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