a tool for shared writing and social publishing
at feature/recommend 802 lines 28 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"; 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}