a tool for shared writing and social publishing
at feature/set-page-width 717 lines 25 kB view raw
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}