a tool for shared writing and social publishing
at main 787 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/replicache/getBlocks"; 10import { indent, outdentFull, multiSelectOutdent } 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 490 if (e.shiftKey) { 491 let { foldedBlocks, toggleFold } = useUIState.getState(); 492 await multiSelectOutdent(sortedSelection, siblings, rep, { foldedBlocks, toggleFold }); 493 } else { 494 for (let i = 0; i < siblings.length; i++) { 495 let block = siblings[i]; 496 if (!sortedSelection.find((s) => s.value === block.value)) 497 continue; 498 if ( 499 sortedSelection.find((s) => s.value === block.listData?.parent) 500 ) 501 continue; 502 let parentoffset = 1; 503 let previousBlock = siblings[i - parentoffset]; 504 while ( 505 previousBlock && 506 sortedSelection.find((s) => previousBlock.value === s.value) 507 ) { 508 parentoffset += 1; 509 previousBlock = siblings[i - parentoffset]; 510 } 511 if (!block.listData || !previousBlock.listData) continue; 512 let { foldedBlocks, toggleFold } = useUIState.getState(); 513 514 await indent(block, previousBlock, rep, { foldedBlocks, toggleFold }); 515 } 516 } 517 } 518 if (e.key === "ArrowDown") { 519 let [sortedSelection, siblings] = await getSortedSelectionBound(); 520 let focusedBlock = useUIState.getState().focusedEntity; 521 if (!e.shiftKey) { 522 if (sortedSelection.length === 1) return; 523 let lastBlock = sortedSelection[sortedSelection.length - 1]; 524 if (!lastBlock) return; 525 let type = await rep?.query((tx) => 526 scanIndex(tx).eav(lastBlock.value, "block/type"), 527 ); 528 if (!type?.[0]) return; 529 useUIState.getState().setSelectedBlock(lastBlock); 530 focusBlock( 531 { ...lastBlock, type: type[0].data.value }, 532 { type: "end" }, 533 ); 534 } 535 if (e.shiftKey) { 536 if (e.defaultPrevented) return; 537 if ( 538 sortedSelection.length <= 1 || 539 !focusedBlock || 540 focusedBlock.entityType === "page" 541 ) 542 return; 543 let b = focusedBlock; 544 let focusedBlockIndex = sortedSelection.findIndex( 545 (s) => s.value == b.entityID, 546 ); 547 if (focusedBlockIndex === sortedSelection.length - 1) { 548 let index = siblings.findIndex((s) => s.value === b.entityID); 549 let nextSelectedBlock = siblings[index + 1]; 550 if (!nextSelectedBlock) return; 551 useUIState.getState().addBlockToSelection({ 552 ...nextSelectedBlock, 553 }); 554 555 scrollIntoViewIfNeeded( 556 document.getElementById( 557 elementId.block(nextSelectedBlock.value).container, 558 ), 559 false, 560 ); 561 useUIState.getState().setFocusedBlock({ 562 entityType: "block", 563 parent: nextSelectedBlock.parent, 564 entityID: nextSelectedBlock.value, 565 }); 566 } else { 567 let nextBlock = sortedSelection[1]; 568 useUIState 569 .getState() 570 .removeBlockFromSelection({ value: b.entityID }); 571 scrollIntoViewIfNeeded( 572 document.getElementById( 573 elementId.block(nextBlock.value).container, 574 ), 575 false, 576 ); 577 useUIState.getState().setFocusedBlock({ 578 entityType: "block", 579 parent: b.parent, 580 entityID: nextBlock.value, 581 }); 582 if (sortedSelection.length === 2) { 583 useEditorStates 584 .getState() 585 .editorStates[nextBlock.value]?.view?.focus(); 586 } 587 } 588 } 589 } 590 if ((e.key === "c" || e.key === "x") && (e.metaKey || e.ctrlKey)) { 591 if (!rep) return; 592 if (e.shiftKey || (e.metaKey && e.ctrlKey)) return; 593 let [, , selectionWithFoldedChildren] = 594 await getSortedSelectionBound(); 595 if (!selectionWithFoldedChildren) return; 596 let el = document.activeElement as HTMLElement; 597 if ( 598 el?.tagName === "LABEL" || 599 el?.tagName === "INPUT" || 600 el?.tagName === "TEXTAREA" 601 ) { 602 return; 603 } 604 605 if ( 606 el.contentEditable === "true" && 607 selectionWithFoldedChildren.length <= 1 608 ) 609 return; 610 e.preventDefault(); 611 await copySelection(rep, selectionWithFoldedChildren); 612 if (e.key === "x") deleteBlocks(); 613 } 614 }); 615 window.addEventListener("keydown", listener); 616 return () => { 617 removeListener(); 618 window.removeEventListener("keydown", listener); 619 }; 620 }, [moreThanOneSelected, rep, entity_set.permissions.write]); 621 622 let [mouseDown, setMouseDown] = useState(false); 623 let initialContentEditableParent = useRef<null | Node>(null); 624 let savedSelection = useRef<SavedRange[] | null>(undefined); 625 useEffect(() => { 626 if (isMobile) return; 627 if (!entity_set.permissions.write) return; 628 let mouseDownListener = (e: MouseEvent) => { 629 if ((e.target as Element).getAttribute("data-draggable")) return; 630 let contentEditableParent = getContentEditableParent(e.target as Node); 631 if (contentEditableParent) { 632 setMouseDown(true); 633 let entityID = (contentEditableParent as Element).getAttribute( 634 "data-entityid", 635 ); 636 useSelectingMouse.setState({ start: entityID }); 637 } 638 initialContentEditableParent.current = contentEditableParent; 639 }; 640 let mouseUpListener = (e: MouseEvent) => { 641 savedSelection.current = null; 642 if ( 643 initialContentEditableParent.current && 644 !(e.target as Element).getAttribute("data-draggable") && 645 getContentEditableParent(e.target as Node) !== 646 initialContentEditableParent.current 647 ) { 648 setTimeout(() => { 649 window.getSelection()?.removeAllRanges(); 650 }, 5); 651 } 652 initialContentEditableParent.current = null; 653 useSelectingMouse.setState({ start: null }); 654 setMouseDown(false); 655 }; 656 window.addEventListener("mousedown", mouseDownListener); 657 window.addEventListener("mouseup", mouseUpListener); 658 return () => { 659 window.removeEventListener("mousedown", mouseDownListener); 660 window.removeEventListener("mouseup", mouseUpListener); 661 }; 662 }, [entity_set.permissions.write, isMobile]); 663 useEffect(() => { 664 if (!mouseDown) return; 665 if (isMobile) return; 666 let mouseMoveListener = (e: MouseEvent) => { 667 if (e.buttons !== 1) return; 668 if (initialContentEditableParent.current) { 669 if ( 670 initialContentEditableParent.current === 671 getContentEditableParent(e.target as Node) 672 ) { 673 if (savedSelection.current) { 674 restoreSelection(savedSelection.current); 675 } 676 savedSelection.current = null; 677 return; 678 } 679 if (!savedSelection.current) savedSelection.current = saveSelection(); 680 window.getSelection()?.removeAllRanges(); 681 } 682 }; 683 window.addEventListener("mousemove", mouseMoveListener); 684 return () => { 685 window.removeEventListener("mousemove", mouseMoveListener); 686 }; 687 }, [mouseDown, isMobile]); 688 return null; 689} 690 691type SavedRange = { 692 startContainer: Node; 693 startOffset: number; 694 endContainer: Node; 695 endOffset: number; 696 direction: "forward" | "backward"; 697}; 698function saveSelection() { 699 let selection = window.getSelection(); 700 if (selection && selection.rangeCount > 0) { 701 let ranges: SavedRange[] = []; 702 for (let i = 0; i < selection.rangeCount; i++) { 703 let range = selection.getRangeAt(i); 704 ranges.push({ 705 startContainer: range.startContainer, 706 startOffset: range.startOffset, 707 endContainer: range.endContainer, 708 endOffset: range.endOffset, 709 direction: 710 selection.anchorNode === range.startContainer && 711 selection.anchorOffset === range.startOffset 712 ? "forward" 713 : "backward", 714 }); 715 } 716 return ranges; 717 } 718 return []; 719} 720 721function restoreSelection(savedRanges: SavedRange[]) { 722 if (savedRanges && savedRanges.length > 0) { 723 let selection = window.getSelection(); 724 if (!selection) return; 725 selection.removeAllRanges(); 726 for (let i = 0; i < savedRanges.length; i++) { 727 let range = document.createRange(); 728 range.setStart(savedRanges[i].startContainer, savedRanges[i].startOffset); 729 range.setEnd(savedRanges[i].endContainer, savedRanges[i].endOffset); 730 731 selection.addRange(range); 732 733 // If the direction is backward, collapse the selection to the end and then extend it backward 734 if (savedRanges[i].direction === "backward") { 735 selection.collapseToEnd(); 736 selection.extend( 737 savedRanges[i].startContainer, 738 savedRanges[i].startOffset, 739 ); 740 } 741 } 742 } 743} 744 745function getContentEditableParent(e: Node | null): Node | null { 746 let element: Node | null = e; 747 while (element && element !== document) { 748 if ( 749 (element as HTMLElement).contentEditable === "true" || 750 (element as HTMLElement).getAttribute("data-editable-block") 751 ) { 752 return element; 753 } 754 element = element.parentNode; 755 } 756 return null; 757} 758 759function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) { 760 let everyBlockHasMark = blocks.reduce((acc, block) => { 761 let editor = useEditorStates.getState().editorStates[block]; 762 if (!editor) return acc; 763 let { view } = editor; 764 let from = 0; 765 let to = view.state.doc.content.size; 766 let hasMarkInRange = view.state.doc.rangeHasMark(from, to, mark); 767 return acc && hasMarkInRange; 768 }, true); 769 for (let block of blocks) { 770 let editor = useEditorStates.getState().editorStates[block]; 771 if (!editor) return; 772 let { view } = editor; 773 let tr = view.state.tr; 774 775 let from = 0; 776 let to = view.state.doc.content.size; 777 778 tr.setMeta("bulkOp", true); 779 if (everyBlockHasMark) { 780 tr.removeMark(from, to, mark); 781 } else { 782 tr.addMark(from, to, mark.create(attrs)); 783 } 784 785 view.dispatch(tr); 786 } 787}