a tool for shared writing and social publishing
1"use client";
2import { useEffect, useRef, useState } from "react";
3import { create } from "zustand";
4import { ReplicacheMutators, useReplicache } from "src/replicache";
5import { useUIState } from "src/useUIState";
6import { scanIndex } from "src/replicache/utils";
7import { focusBlock } from "src/utils/focusBlock";
8import { useEditorStates } from "src/state/useEditorState";
9import { useEntitySetContext } from "./EntitySetProvider";
10import { getBlocksWithType } from "src/hooks/queries/useBlocks";
11import { v7 } from "uuid";
12import { indent, outdent, outdentFull } from "src/utils/list-operations";
13import { addShortcut, Shortcut } from "src/shortcuts";
14import { htmlToMarkdown } from "src/htmlMarkdownParsers";
15import { elementId } from "src/utils/elementId";
16import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded";
17import { copySelection } from "src/utils/copySelection";
18import { isTextBlock } from "src/utils/isTextBlock";
19import { useIsMobile } from "src/hooks/isMobile";
20import { deleteBlock } from "./Blocks/DeleteBlock";
21import { Replicache } from "replicache";
22import { schema } from "./Blocks/TextBlock/schema";
23import { TextSelection } from "prosemirror-state";
24import { MarkType } from "prosemirror-model";
25export const useSelectingMouse = create(() => ({
26 start: null as null | string,
27}));
28
29//How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges?
30// How does this relate to *when dragging* ?
31
32export function SelectionManager() {
33 let moreThanOneSelected = useUIState((s) => s.selectedBlocks.length > 1);
34 let entity_set = useEntitySetContext();
35 let { rep, undoManager } = useReplicache();
36 let isMobile = useIsMobile();
37 useEffect(() => {
38 if (!entity_set.permissions.write || !rep) return;
39 const getSortedSelectionBound = getSortedSelection.bind(null, rep);
40 let shortcuts: Shortcut[] = [
41 {
42 metaKey: true,
43 key: "ArrowUp",
44 handler: async () => {
45 let [firstBlock] =
46 (await rep?.query((tx) =>
47 getBlocksWithType(
48 tx,
49 useUIState.getState().selectedBlocks[0].parent,
50 ),
51 )) || [];
52 if (firstBlock) focusBlock(firstBlock, { type: "start" });
53 },
54 },
55 {
56 metaKey: true,
57 key: "ArrowDown",
58 handler: async () => {
59 let blocks =
60 (await rep?.query((tx) =>
61 getBlocksWithType(
62 tx,
63 useUIState.getState().selectedBlocks[0].parent,
64 ),
65 )) || [];
66 let folded = useUIState.getState().foldedBlocks;
67 blocks = blocks.filter(
68 (f) =>
69 !f.listData ||
70 !f.listData.path.find(
71 (path) =>
72 folded.includes(path.entity) && f.value !== path.entity,
73 ),
74 );
75 let lastBlock = blocks[blocks.length - 1];
76 if (lastBlock) focusBlock(lastBlock, { type: "end" });
77 },
78 },
79 {
80 metaKey: true,
81 altKey: true,
82 key: ["l", "¬"],
83 handler: async () => {
84 let [sortedBlocks, siblings] = await getSortedSelectionBound();
85 for (let block of sortedBlocks) {
86 if (!block.listData) {
87 await rep?.mutate.assertFact({
88 entity: block.value,
89 attribute: "block/is-list",
90 data: { type: "boolean", value: true },
91 });
92 } else {
93 outdentFull(block, rep);
94 }
95 }
96 },
97 },
98 {
99 metaKey: true,
100 shift: true,
101 key: ["ArrowDown", "J"],
102 handler: async () => {
103 let [sortedBlocks, siblings] = await getSortedSelectionBound();
104 let block = sortedBlocks[0];
105 let nextBlock = siblings
106 .slice(siblings.findIndex((s) => s.value === block.value) + 1)
107 .find(
108 (f) =>
109 f.listData &&
110 block.listData &&
111 !f.listData.path.find((f) => f.entity === block.value),
112 );
113 if (
114 nextBlock?.listData &&
115 block.listData &&
116 nextBlock.listData.depth === block.listData.depth - 1
117 ) {
118 if (useUIState.getState().foldedBlocks.includes(nextBlock.value))
119 useUIState.getState().toggleFold(nextBlock.value);
120 await rep?.mutate.moveBlock({
121 block: block.value,
122 oldParent: block.listData?.parent,
123 newParent: nextBlock.value,
124 position: { type: "first" },
125 });
126 } else {
127 await rep?.mutate.moveBlockDown({
128 entityID: block.value,
129 parent: block.listData?.parent || block.parent,
130 });
131 }
132 },
133 },
134 {
135 metaKey: true,
136 shift: true,
137 key: ["ArrowUp", "K"],
138 handler: async () => {
139 let [sortedBlocks, siblings] = await getSortedSelectionBound();
140 let block = sortedBlocks[0];
141 let previousBlock =
142 siblings?.[siblings.findIndex((s) => s.value === block.value) - 1];
143 if (previousBlock.value === block.listData?.parent) {
144 previousBlock =
145 siblings?.[
146 siblings.findIndex((s) => s.value === block.value) - 2
147 ];
148 }
149
150 if (
151 previousBlock?.listData &&
152 block.listData &&
153 block.listData.depth > 1 &&
154 !previousBlock.listData.path.find(
155 (f) => f.entity === block.listData?.parent,
156 )
157 ) {
158 let depth = block.listData.depth;
159 let newParent = previousBlock.listData.path.find(
160 (f) => f.depth === depth - 1,
161 );
162 if (!newParent) return;
163 if (useUIState.getState().foldedBlocks.includes(newParent.entity))
164 useUIState.getState().toggleFold(newParent.entity);
165 rep?.mutate.moveBlock({
166 block: block.value,
167 oldParent: block.listData?.parent,
168 newParent: newParent.entity,
169 position: { type: "end" },
170 });
171 } else {
172 rep?.mutate.moveBlockUp({
173 entityID: block.value,
174 parent: block.listData?.parent || block.parent,
175 });
176 }
177 },
178 },
179
180 {
181 metaKey: true,
182 shift: true,
183 key: "Enter",
184 handler: async () => {
185 let [sortedBlocks, siblings] = await getSortedSelectionBound();
186 if (!sortedBlocks[0].listData) return;
187 useUIState.getState().toggleFold(sortedBlocks[0].value);
188 },
189 },
190 ];
191 if (moreThanOneSelected)
192 shortcuts = shortcuts.concat([
193 {
194 metaKey: true,
195 key: "u",
196 handler: async () => {
197 let [sortedBlocks] = await getSortedSelectionBound();
198 toggleMarkInBlocks(
199 sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
200 schema.marks.underline,
201 );
202 },
203 },
204 {
205 metaKey: true,
206 key: "i",
207 handler: async () => {
208 let [sortedBlocks] = await getSortedSelectionBound();
209 toggleMarkInBlocks(
210 sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
211 schema.marks.em,
212 );
213 },
214 },
215 {
216 metaKey: true,
217 key: "b",
218 handler: async () => {
219 let [sortedBlocks] = await getSortedSelectionBound();
220 toggleMarkInBlocks(
221 sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
222 schema.marks.strong,
223 );
224 },
225 },
226 {
227 metaAndCtrl: true,
228 key: "h",
229 handler: async () => {
230 let [sortedBlocks] = await getSortedSelectionBound();
231 toggleMarkInBlocks(
232 sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
233 schema.marks.highlight,
234 {
235 color: useUIState.getState().lastUsedHighlight,
236 },
237 );
238 },
239 },
240 {
241 metaAndCtrl: true,
242 key: "x",
243 handler: async () => {
244 let [sortedBlocks] = await getSortedSelectionBound();
245 toggleMarkInBlocks(
246 sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
247 schema.marks.strikethrough,
248 );
249 },
250 },
251 ]);
252 let removeListener = addShortcut(
253 shortcuts.map((shortcut) => ({
254 ...shortcut,
255 handler: () => undoManager.withUndoGroup(() => shortcut.handler()),
256 })),
257 );
258 let listener = async (e: KeyboardEvent) =>
259 undoManager.withUndoGroup(async () => {
260 //used here and in cut
261 const deleteBlocks = async () => {
262 if (!entity_set.permissions.write) return;
263 if (moreThanOneSelected) {
264 e.preventDefault();
265 let [sortedBlocks, siblings] = await getSortedSelectionBound();
266 let selectedBlocks = useUIState.getState().selectedBlocks;
267 let firstBlock = sortedBlocks[0];
268
269 await rep?.mutate.removeBlock(
270 selectedBlocks.map((block) => ({ blockEntity: block.value })),
271 );
272 useUIState.getState().closePage(selectedBlocks.map((b) => b.value));
273
274 let nextBlock =
275 siblings?.[
276 siblings.findIndex((s) => s.value === firstBlock.value) - 1
277 ];
278 if (nextBlock) {
279 useUIState.getState().setSelectedBlock({
280 value: nextBlock.value,
281 parent: nextBlock.parent,
282 });
283 let type = await rep?.query((tx) =>
284 scanIndex(tx).eav(nextBlock.value, "block/type"),
285 );
286 if (!type?.[0]) return;
287 if (
288 type[0]?.data.value === "text" ||
289 type[0]?.data.value === "heading"
290 )
291 focusBlock(
292 {
293 value: nextBlock.value,
294 type: "text",
295 parent: nextBlock.parent,
296 },
297 { type: "end" },
298 );
299 }
300 }
301 };
302 if (e.key === "Backspace" || e.key === "Delete") {
303 deleteBlocks();
304 }
305 if (e.key === "ArrowUp") {
306 let [sortedBlocks, siblings] = await getSortedSelectionBound();
307 let focusedBlock = useUIState.getState().focusedEntity;
308 if (!e.shiftKey && !e.ctrlKey) {
309 if (e.defaultPrevented) return;
310 if (sortedBlocks.length === 1) return;
311 let firstBlock = sortedBlocks[0];
312 if (!firstBlock) return;
313 let type = await rep?.query((tx) =>
314 scanIndex(tx).eav(firstBlock.value, "block/type"),
315 );
316 if (!type?.[0]) return;
317 useUIState.getState().setSelectedBlock(firstBlock);
318 focusBlock(
319 { ...firstBlock, type: type[0].data.value },
320 { type: "start" },
321 );
322 } else {
323 if (e.defaultPrevented) return;
324 if (
325 sortedBlocks.length <= 1 ||
326 !focusedBlock ||
327 focusedBlock.entityType === "page"
328 )
329 return;
330 let b = focusedBlock;
331 let focusedBlockIndex = sortedBlocks.findIndex(
332 (s) => s.value == b.entityID,
333 );
334 if (focusedBlockIndex === 0) {
335 let index = siblings.findIndex((s) => s.value === b.entityID);
336 let nextSelectedBlock = siblings[index - 1];
337 if (!nextSelectedBlock) return;
338
339 scrollIntoViewIfNeeded(
340 document.getElementById(
341 elementId.block(nextSelectedBlock.value).container,
342 ),
343 false,
344 );
345 useUIState.getState().addBlockToSelection({
346 ...nextSelectedBlock,
347 });
348 useUIState.getState().setFocusedBlock({
349 entityType: "block",
350 parent: nextSelectedBlock.parent,
351 entityID: nextSelectedBlock.value,
352 });
353 } else {
354 let nextBlock = sortedBlocks[sortedBlocks.length - 2];
355 useUIState.getState().setFocusedBlock({
356 entityType: "block",
357 parent: b.parent,
358 entityID: nextBlock.value,
359 });
360 scrollIntoViewIfNeeded(
361 document.getElementById(
362 elementId.block(nextBlock.value).container,
363 ),
364 false,
365 );
366 if (sortedBlocks.length === 2) {
367 useEditorStates
368 .getState()
369 .editorStates[nextBlock.value]?.view?.focus();
370 }
371 useUIState
372 .getState()
373 .removeBlockFromSelection(sortedBlocks[focusedBlockIndex]);
374 }
375 }
376 }
377 if (e.key === "ArrowLeft") {
378 let [sortedSelection, siblings] = await getSortedSelectionBound();
379 if (sortedSelection.length === 1) return;
380 let firstBlock = sortedSelection[0];
381 if (!firstBlock) return;
382 let type = await rep?.query((tx) =>
383 scanIndex(tx).eav(firstBlock.value, "block/type"),
384 );
385 if (!type?.[0]) return;
386 useUIState.getState().setSelectedBlock(firstBlock);
387 focusBlock(
388 { ...firstBlock, type: type[0].data.value },
389 { type: "start" },
390 );
391 }
392 if (e.key === "ArrowRight") {
393 let [sortedSelection, siblings] = await getSortedSelectionBound();
394 if (sortedSelection.length === 1) return;
395 let lastBlock = sortedSelection[sortedSelection.length - 1];
396 if (!lastBlock) return;
397 let type = await rep?.query((tx) =>
398 scanIndex(tx).eav(lastBlock.value, "block/type"),
399 );
400 if (!type?.[0]) return;
401 useUIState.getState().setSelectedBlock(lastBlock);
402 focusBlock(
403 { ...lastBlock, type: type[0].data.value },
404 { type: "end" },
405 );
406 }
407 if (e.key === "Tab") {
408 let [sortedSelection, siblings] = await getSortedSelectionBound();
409 if (sortedSelection.length <= 1) return;
410 e.preventDefault();
411 if (e.shiftKey) {
412 for (let i = siblings.length - 1; i >= 0; i--) {
413 let block = siblings[i];
414 if (!sortedSelection.find((s) => s.value === block.value))
415 continue;
416 if (
417 sortedSelection.find((s) => s.value === block.listData?.parent)
418 )
419 continue;
420 let parentoffset = 1;
421 let previousBlock = siblings[i - parentoffset];
422 while (
423 previousBlock &&
424 sortedSelection.find((s) => previousBlock.value === s.value)
425 ) {
426 parentoffset += 1;
427 previousBlock = siblings[i - parentoffset];
428 }
429 if (!block.listData || !previousBlock.listData) continue;
430 outdent(block, previousBlock, rep);
431 }
432 } else {
433 for (let i = 0; i < siblings.length; i++) {
434 let block = siblings[i];
435 if (!sortedSelection.find((s) => s.value === block.value))
436 continue;
437 if (
438 sortedSelection.find((s) => s.value === block.listData?.parent)
439 )
440 continue;
441 let parentoffset = 1;
442 let previousBlock = siblings[i - parentoffset];
443 while (
444 previousBlock &&
445 sortedSelection.find((s) => previousBlock.value === s.value)
446 ) {
447 parentoffset += 1;
448 previousBlock = siblings[i - parentoffset];
449 }
450 if (!block.listData || !previousBlock.listData) continue;
451 indent(block, previousBlock, rep);
452 }
453 }
454 }
455 if (e.key === "ArrowDown") {
456 let [sortedSelection, siblings] = await getSortedSelectionBound();
457 let focusedBlock = useUIState.getState().focusedEntity;
458 if (!e.shiftKey) {
459 if (sortedSelection.length === 1) return;
460 let lastBlock = sortedSelection[sortedSelection.length - 1];
461 if (!lastBlock) return;
462 let type = await rep?.query((tx) =>
463 scanIndex(tx).eav(lastBlock.value, "block/type"),
464 );
465 if (!type?.[0]) return;
466 useUIState.getState().setSelectedBlock(lastBlock);
467 focusBlock(
468 { ...lastBlock, type: type[0].data.value },
469 { type: "end" },
470 );
471 }
472 if (e.shiftKey) {
473 if (e.defaultPrevented) return;
474 if (
475 sortedSelection.length <= 1 ||
476 !focusedBlock ||
477 focusedBlock.entityType === "page"
478 )
479 return;
480 let b = focusedBlock;
481 let focusedBlockIndex = sortedSelection.findIndex(
482 (s) => s.value == b.entityID,
483 );
484 if (focusedBlockIndex === sortedSelection.length - 1) {
485 let index = siblings.findIndex((s) => s.value === b.entityID);
486 let nextSelectedBlock = siblings[index + 1];
487 if (!nextSelectedBlock) return;
488 useUIState.getState().addBlockToSelection({
489 ...nextSelectedBlock,
490 });
491
492 scrollIntoViewIfNeeded(
493 document.getElementById(
494 elementId.block(nextSelectedBlock.value).container,
495 ),
496 false,
497 );
498 useUIState.getState().setFocusedBlock({
499 entityType: "block",
500 parent: nextSelectedBlock.parent,
501 entityID: nextSelectedBlock.value,
502 });
503 } else {
504 let nextBlock = sortedSelection[1];
505 useUIState
506 .getState()
507 .removeBlockFromSelection({ value: b.entityID });
508 scrollIntoViewIfNeeded(
509 document.getElementById(
510 elementId.block(nextBlock.value).container,
511 ),
512 false,
513 );
514 useUIState.getState().setFocusedBlock({
515 entityType: "block",
516 parent: b.parent,
517 entityID: nextBlock.value,
518 });
519 if (sortedSelection.length === 2) {
520 useEditorStates
521 .getState()
522 .editorStates[nextBlock.value]?.view?.focus();
523 }
524 }
525 }
526 }
527 if ((e.key === "c" || e.key === "x") && (e.metaKey || e.ctrlKey)) {
528 if (!rep) return;
529 if (e.shiftKey || (e.metaKey && e.ctrlKey)) return;
530 let [, , selectionWithFoldedChildren] =
531 await getSortedSelectionBound();
532 if (!selectionWithFoldedChildren) return;
533 let el = document.activeElement as HTMLElement;
534 if (
535 el?.tagName === "LABEL" ||
536 el?.tagName === "INPUT" ||
537 el?.tagName === "TEXTAREA"
538 ) {
539 return;
540 }
541
542 if (
543 el.contentEditable === "true" &&
544 selectionWithFoldedChildren.length <= 1
545 )
546 return;
547 e.preventDefault();
548 await copySelection(rep, selectionWithFoldedChildren);
549 if (e.key === "x") deleteBlocks();
550 }
551 });
552 window.addEventListener("keydown", listener);
553 return () => {
554 removeListener();
555 window.removeEventListener("keydown", listener);
556 };
557 }, [moreThanOneSelected, rep, entity_set.permissions.write]);
558
559 let [mouseDown, setMouseDown] = useState(false);
560 let initialContentEditableParent = useRef<null | Node>(null);
561 let savedSelection = useRef<SavedRange[] | null>(undefined);
562 useEffect(() => {
563 if (isMobile) return;
564 if (!entity_set.permissions.write) return;
565 let mouseDownListener = (e: MouseEvent) => {
566 if ((e.target as Element).getAttribute("data-draggable")) return;
567 let contentEditableParent = getContentEditableParent(e.target as Node);
568 if (contentEditableParent) {
569 setMouseDown(true);
570 let entityID = (contentEditableParent as Element).getAttribute(
571 "data-entityid",
572 );
573 useSelectingMouse.setState({ start: entityID });
574 }
575 initialContentEditableParent.current = contentEditableParent;
576 };
577 let mouseUpListener = (e: MouseEvent) => {
578 savedSelection.current = null;
579 if (
580 initialContentEditableParent.current &&
581 !(e.target as Element).getAttribute("data-draggable") &&
582 getContentEditableParent(e.target as Node) !==
583 initialContentEditableParent.current
584 ) {
585 setTimeout(() => {
586 window.getSelection()?.removeAllRanges();
587 }, 5);
588 }
589 initialContentEditableParent.current = null;
590 useSelectingMouse.setState({ start: null });
591 setMouseDown(false);
592 };
593 window.addEventListener("mousedown", mouseDownListener);
594 window.addEventListener("mouseup", mouseUpListener);
595 return () => {
596 window.removeEventListener("mousedown", mouseDownListener);
597 window.removeEventListener("mouseup", mouseUpListener);
598 };
599 }, [entity_set.permissions.write, isMobile]);
600 useEffect(() => {
601 if (!mouseDown) return;
602 if (isMobile) return;
603 let mouseMoveListener = (e: MouseEvent) => {
604 if (e.buttons !== 1) return;
605 if (initialContentEditableParent.current) {
606 if (
607 initialContentEditableParent.current ===
608 getContentEditableParent(e.target as Node)
609 ) {
610 if (savedSelection.current) {
611 restoreSelection(savedSelection.current);
612 }
613 savedSelection.current = null;
614 return;
615 }
616 if (!savedSelection.current) savedSelection.current = saveSelection();
617 window.getSelection()?.removeAllRanges();
618 }
619 };
620 window.addEventListener("mousemove", mouseMoveListener);
621 return () => {
622 window.removeEventListener("mousemove", mouseMoveListener);
623 };
624 }, [mouseDown, isMobile]);
625 return null;
626}
627
628type SavedRange = {
629 startContainer: Node;
630 startOffset: number;
631 endContainer: Node;
632 endOffset: number;
633 direction: "forward" | "backward";
634};
635export function saveSelection() {
636 let selection = window.getSelection();
637 if (selection && selection.rangeCount > 0) {
638 let ranges: SavedRange[] = [];
639 for (let i = 0; i < selection.rangeCount; i++) {
640 let range = selection.getRangeAt(i);
641 ranges.push({
642 startContainer: range.startContainer,
643 startOffset: range.startOffset,
644 endContainer: range.endContainer,
645 endOffset: range.endOffset,
646 direction:
647 selection.anchorNode === range.startContainer &&
648 selection.anchorOffset === range.startOffset
649 ? "forward"
650 : "backward",
651 });
652 }
653 return ranges;
654 }
655 return [];
656}
657
658export function restoreSelection(savedRanges: SavedRange[]) {
659 if (savedRanges && savedRanges.length > 0) {
660 let selection = window.getSelection();
661 if (!selection) return;
662 selection.removeAllRanges();
663 for (let i = 0; i < savedRanges.length; i++) {
664 let range = document.createRange();
665 range.setStart(savedRanges[i].startContainer, savedRanges[i].startOffset);
666 range.setEnd(savedRanges[i].endContainer, savedRanges[i].endOffset);
667
668 selection.addRange(range);
669
670 // If the direction is backward, collapse the selection to the end and then extend it backward
671 if (savedRanges[i].direction === "backward") {
672 selection.collapseToEnd();
673 selection.extend(
674 savedRanges[i].startContainer,
675 savedRanges[i].startOffset,
676 );
677 }
678 }
679 }
680}
681
682function getContentEditableParent(e: Node | null): Node | null {
683 let element: Node | null = e;
684 while (element && element !== document) {
685 if (
686 (element as HTMLElement).contentEditable === "true" ||
687 (element as HTMLElement).getAttribute("data-editable-block")
688 ) {
689 return element;
690 }
691 element = element.parentNode;
692 }
693 return null;
694}
695
696export const getSortedSelection = async (
697 rep: Replicache<ReplicacheMutators>,
698) => {
699 let selectedBlocks = useUIState.getState().selectedBlocks;
700 let foldedBlocks = useUIState.getState().foldedBlocks;
701 if (!selectedBlocks[0]) return [[], []];
702 let siblings =
703 (await rep?.query((tx) =>
704 getBlocksWithType(tx, selectedBlocks[0].parent),
705 )) || [];
706 let sortedBlocks = siblings.filter((s) => {
707 let selected = selectedBlocks.find((sb) => sb.value === s.value);
708 return selected;
709 });
710 let sortedBlocksWithChildren = siblings.filter((s) => {
711 let selected = selectedBlocks.find((sb) => sb.value === s.value);
712 if (s.listData && !selected) {
713 //Select the children of folded list blocks (in order to copy them)
714 return s.listData.path.find(
715 (p) =>
716 selectedBlocks.find((sb) => sb.value === p.entity) &&
717 foldedBlocks.includes(p.entity),
718 );
719 }
720 return selected;
721 });
722 return [
723 sortedBlocks,
724 siblings.filter(
725 (f) =>
726 !f.listData ||
727 !f.listData.path.find(
728 (p) => foldedBlocks.includes(p.entity) && p.entity !== f.value,
729 ),
730 ),
731 sortedBlocksWithChildren,
732 ];
733};
734
735function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) {
736 let everyBlockHasMark = blocks.reduce((acc, block) => {
737 let editor = useEditorStates.getState().editorStates[block];
738 if (!editor) return acc;
739 let { view } = editor;
740 let from = 0;
741 let to = view.state.doc.content.size;
742 let hasMarkInRange = view.state.doc.rangeHasMark(from, to, mark);
743 return acc && hasMarkInRange;
744 }, true);
745 for (let block of blocks) {
746 let editor = useEditorStates.getState().editorStates[block];
747 if (!editor) return;
748 let { view } = editor;
749 let tr = view.state.tr;
750
751 let from = 0;
752 let to = view.state.doc.content.size;
753
754 tr.setMeta("bulkOp", true);
755 if (everyBlockHasMark) {
756 tr.removeMark(from, to, mark);
757 } else {
758 tr.addMark(from, to, mark.create(attrs));
759 }
760
761 view.dispatch(tr);
762 }
763}