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