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