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