a tool for shared writing and social publishing
at refactor/domain-management 697 lines 23 kB view raw
1import { BlockProps } from "../Block"; 2import { focusBlock } from "src/utils/focusBlock"; 3import { EditorView } from "prosemirror-view"; 4import { generateKeyBetween } from "fractional-indexing"; 5import { baseKeymap, setBlockType, toggleMark } from "prosemirror-commands"; 6import { keymap } from "prosemirror-keymap"; 7import { 8 Command, 9 EditorState, 10 TextSelection, 11 Transaction, 12} from "prosemirror-state"; 13import { RefObject } from "react"; 14import { Replicache } from "replicache"; 15import type { Fact, ReplicacheMutators } from "src/replicache"; 16import { elementId } from "src/utils/elementId"; 17import { schema } from "./schema"; 18import { useUIState } from "src/useUIState"; 19import { setEditorState, useEditorStates } from "src/state/useEditorState"; 20import { focusPage } from "src/utils/focusPage"; 21import { v7 } from "uuid"; 22import { scanIndex } from "src/replicache/utils"; 23import { indent, outdent } from "src/utils/list-operations"; 24import { getBlocksWithType } from "src/replicache/getBlocks"; 25import { isTextBlock } from "src/utils/isTextBlock"; 26import { UndoManager } from "src/undoManager"; 27type PropsRef = RefObject< 28 BlockProps & { 29 entity_set: { set: string }; 30 alignment: Fact<"block/text-alignment">["data"]["value"]; 31 } 32>; 33export const TextBlockKeymap = ( 34 propsRef: PropsRef, 35 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 36 um: UndoManager, 37 openMentionAutocomplete: () => void, 38) => 39 ({ 40 "Meta-b": toggleMark(schema.marks.strong), 41 "Ctrl-b": toggleMark(schema.marks.strong), 42 "Meta-u": toggleMark(schema.marks.underline), 43 "Ctrl-u": toggleMark(schema.marks.underline), 44 "Meta-i": toggleMark(schema.marks.em), 45 "Ctrl-i": toggleMark(schema.marks.em), 46 "Ctrl-Meta-x": toggleMark(schema.marks.strikethrough), 47 "Ctrl-Meta-h": (...args) => { 48 return toggleMark(schema.marks.highlight, { 49 color: useUIState.getState().lastUsedHighlight, 50 })(...args); 51 }, 52 "Ctrl-a": metaA(propsRef, repRef), 53 "Meta-a": metaA(propsRef, repRef), 54 Escape: (_state, _dispatch, view) => { 55 view?.dom.blur(); 56 useUIState.setState(() => ({ 57 focusedEntity: { 58 entityType: "page", 59 entityID: propsRef.current.parent, 60 }, 61 selectedBlocks: [], 62 })); 63 64 return false; 65 }, 66 "Shift-ArrowDown": (state, _dispatch, view) => { 67 if ( 68 state.doc.content.size - 1 === state.selection.from || 69 state.doc.content.size - 1 === state.selection.to 70 ) { 71 if (propsRef.current.nextBlock) { 72 useUIState 73 .getState() 74 .setSelectedBlocks([propsRef.current, propsRef.current.nextBlock]); 75 useUIState.getState().setFocusedBlock({ 76 entityType: "block", 77 entityID: propsRef.current.nextBlock.value, 78 parent: propsRef.current.parent, 79 }); 80 81 document.getSelection()?.removeAllRanges(); 82 view?.dom.blur(); 83 return true; 84 } 85 } 86 return false; 87 }, 88 "Shift-ArrowUp": (state, _dispatch, view) => { 89 if (state.selection.from <= 1 || state.selection.to <= 1) { 90 if (propsRef.current.previousBlock) { 91 useUIState 92 .getState() 93 .setSelectedBlocks([ 94 propsRef.current, 95 propsRef.current.previousBlock, 96 ]); 97 useUIState.getState().setFocusedBlock({ 98 entityType: "block", 99 entityID: propsRef.current.previousBlock.value, 100 parent: propsRef.current.parent, 101 }); 102 103 document.getSelection()?.removeAllRanges(); 104 view?.dom.blur(); 105 return true; 106 } 107 } 108 return false; 109 }, 110 "Ctrl-k": moveCursorUp(propsRef, repRef, true), 111 ArrowUp: moveCursorUp(propsRef, repRef), 112 "Ctrl-j": moveCursorDown(propsRef, repRef, true), 113 ArrowDown: moveCursorDown(propsRef, repRef), 114 ArrowLeft: (state, tr, view) => { 115 if (state.selection.content().size > 0) return false; 116 if (state.selection.anchor > 1) return false; 117 let block = propsRef.current.previousBlock; 118 if (block) { 119 view?.dom.blur(); 120 focusBlock(block, { type: "end" }); 121 } 122 return true; 123 }, 124 ArrowRight: (state, tr, view) => { 125 if (state.selection.content().size > 0) return false; 126 if (state.doc.content.size - state.selection.anchor > 1) return false; 127 let block = propsRef.current.nextBlock; 128 if (block) { 129 view?.dom.blur(); 130 focusBlock(block, { type: "start" }); 131 } 132 return true; 133 }, 134 Backspace: (state, dispatch, view) => 135 um.withUndoGroup(() => 136 backspace(propsRef, repRef)(state, dispatch, view), 137 ), 138 "Shift-Backspace": backspace(propsRef, repRef), 139 Enter: (state, dispatch, view) => { 140 return um.withUndoGroup(() => { 141 return enter(propsRef, repRef)(state, dispatch, view); 142 }); 143 }, 144 "Shift-Enter": (state, dispatch, view) => { 145 // Insert a hard break 146 let hardBreak = schema.nodes.hard_break.create(); 147 if (dispatch) { 148 dispatch(state.tr.replaceSelectionWith(hardBreak).scrollIntoView()); 149 } 150 return true; 151 }, 152 "Ctrl-Enter": CtrlEnter(propsRef, repRef), 153 "Meta-Enter": CtrlEnter(propsRef, repRef), 154 }) as { [key: string]: Command }; 155 156const moveCursorDown = 157 ( 158 propsRef: PropsRef, 159 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 160 jumpToNextBlock: boolean = false, 161 ) => 162 ( 163 state: EditorState, 164 dispatch?: (tr: Transaction) => void, 165 view?: EditorView, 166 ) => { 167 if (!view) return true; 168 if (state.doc.textContent.startsWith("/")) return true; 169 if (useUIState.getState().selectedBlocks.length > 1) return true; 170 if (view.state.selection.from !== view.state.selection.to) return false; 171 const viewClientRect = view.dom.getBoundingClientRect(); 172 const coords = view.coordsAtPos(view.state.selection.anchor); 173 let isBottom = viewClientRect.bottom - coords.bottom < 12; 174 if (isBottom || jumpToNextBlock) { 175 let block = propsRef.current.nextBlock; 176 if (block) { 177 view.dom.blur(); 178 focusBlock(block, { left: coords.left, type: "top" }); 179 return true; 180 } 181 return false || jumpToNextBlock; 182 } 183 return false; 184 }; 185const moveCursorUp = 186 ( 187 propsRef: PropsRef, 188 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 189 jumpToNextBlock: boolean = false, 190 ) => 191 ( 192 state: EditorState, 193 dispatch?: (tr: Transaction) => void, 194 view?: EditorView, 195 ) => { 196 if (!view) return false; 197 if (state.doc.textContent.startsWith("/")) return true; 198 if (useUIState.getState().selectedBlocks.length > 1) return true; 199 if (view.state.selection.from !== view.state.selection.to) return false; 200 const viewClientRect = view.dom.getBoundingClientRect(); 201 const coords = view.coordsAtPos(view.state.selection.anchor); 202 if (coords.top - viewClientRect.top < 12 || jumpToNextBlock) { 203 let block = propsRef.current.previousBlock; 204 if (block) { 205 view.dom.blur(); 206 focusBlock(block, { left: coords.left, type: "bottom" }); 207 return true; 208 } 209 return false || jumpToNextBlock; 210 } 211 return false; 212 }; 213 214const backspace = 215 ( 216 propsRef: PropsRef, 217 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 218 ) => 219 ( 220 state: EditorState, 221 dispatch?: (tr: Transaction) => void, 222 view?: EditorView, 223 ) => { 224 // if multiple blocks are selected, don't do anything (handled in SelectionManager) 225 if (useUIState.getState().selectedBlocks.length > 1) { 226 return false; 227 } 228 // if you are selecting text within a block, don't do anything (handled by proseMirror) 229 if (state.selection.anchor > 1 || state.selection.content().size > 0) { 230 return false; 231 } 232 // if you are in a list... 233 if (propsRef.current.listData) { 234 // ...and the item is a checklist item, remove the checklist attribute 235 if (propsRef.current.listData.checklist) { 236 repRef.current?.mutate.retractAttribute({ 237 entity: propsRef.current.entityID, 238 attribute: "block/check-list", 239 }); 240 return true; 241 } 242 // ...move the child list items to next eligible parent (?) 243 let depth = propsRef.current.listData.depth; 244 repRef.current?.mutate.moveChildren({ 245 oldParent: propsRef.current.entityID, 246 newParent: propsRef.current.previousBlock?.listData 247 ? propsRef.current.previousBlock.value 248 : propsRef.current.listData.parent || propsRef.current.parent, 249 after: 250 propsRef.current.previousBlock?.listData?.path.find( 251 (f) => f.depth === depth, 252 )?.entity || 253 propsRef.current.previousBlock?.value || 254 null, 255 }); 256 } 257 // if this is the first block and is it a list, remove list attribute 258 if (!propsRef.current.previousBlock) { 259 if (propsRef.current.listData) { 260 repRef.current?.mutate.retractAttribute({ 261 entity: propsRef.current.entityID, 262 attribute: "block/is-list", 263 }); 264 return true; 265 } 266 267 // If the block is a heading, convert it to a text block 268 if (propsRef.current.type === "heading") { 269 repRef.current?.mutate.assertFact({ 270 entity: propsRef.current.entityID, 271 attribute: "block/type", 272 data: { type: "block-type-union", value: "text" }, 273 }); 274 setTimeout( 275 () => 276 focusBlock( 277 { 278 value: propsRef.current.entityID, 279 type: "text", 280 parent: propsRef.current.parent, 281 }, 282 { type: "start" }, 283 ), 284 10, 285 ); 286 287 return false; 288 } 289 290 if (propsRef.current.pageType === "canvas") { 291 repRef.current?.mutate.removeBlock({ 292 blockEntity: propsRef.current.entityID, 293 }); 294 } 295 return true; 296 } 297 298 let block = !!propsRef.current.previousBlock 299 ? useEditorStates.getState().editorStates[ 300 propsRef.current.previousBlock.value 301 ] 302 : null; 303 if ( 304 block && 305 propsRef.current.previousBlock && 306 block.editor.doc.textContent.length === 0 && 307 !propsRef.current.previousBlock?.listData 308 ) { 309 repRef.current?.mutate.removeBlock({ 310 blockEntity: propsRef.current.previousBlock.value, 311 }); 312 return true; 313 } 314 315 if (state.doc.textContent.length === 0) { 316 repRef.current?.mutate.removeBlock({ 317 blockEntity: propsRef.current.entityID, 318 }); 319 if (propsRef.current.previousBlock) { 320 focusBlock(propsRef.current.previousBlock, { type: "end" }); 321 } else { 322 useUIState.getState().setFocusedBlock({ 323 entityType: "page", 324 entityID: propsRef.current.parent, 325 }); 326 } 327 return true; 328 } 329 330 if ( 331 propsRef.current.previousBlock && 332 !isTextBlock[propsRef.current.previousBlock?.type] 333 ) { 334 focusBlock(propsRef.current.previousBlock, { type: "end" }); 335 view?.dom.blur(); 336 return true; 337 } 338 339 if (!block || !propsRef.current.previousBlock) return false; 340 341 repRef.current?.mutate.removeBlock({ 342 blockEntity: propsRef.current.entityID, 343 }); 344 345 let tr = block.editor.tr; 346 347 block.view?.focus(); 348 let firstChild = state.doc.content.firstChild?.content; 349 if (firstChild) { 350 tr.insert(tr.doc.content.size - 1, firstChild); 351 tr.setSelection( 352 TextSelection.create( 353 tr.doc, 354 tr.doc.content.size - firstChild?.size - 1, 355 ), 356 ); 357 } 358 359 let newState = block.editor.apply(tr); 360 setEditorState(propsRef.current.previousBlock.value, { 361 editor: newState, 362 }); 363 364 return true; 365 }; 366 367const shifttab = 368 ( 369 propsRef: RefObject<BlockProps & { entity_set: { set: string } }>, 370 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 371 ) => 372 async () => { 373 if (useUIState.getState().selectedBlocks.length > 1) return false; 374 if (!repRef.current) return false; 375 if (!repRef.current) return false; 376 let { foldedBlocks, toggleFold } = useUIState.getState(); 377 await outdent(propsRef.current, propsRef.current.previousBlock, repRef.current, { 378 foldedBlocks, 379 toggleFold, 380 }); 381 return true; 382 }; 383 384const enter = 385 ( 386 propsRef: PropsRef, 387 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 388 ) => 389 ( 390 state: EditorState, 391 dispatch?: (tr: Transaction) => void, 392 view?: EditorView, 393 ) => { 394 if (state.doc.textContent.startsWith("/")) return true; 395 // Empty list item: just outdent, don't create a new block 396 if ( 397 propsRef.current.listData && 398 propsRef.current.pageType !== "canvas" && 399 state.doc.content.size <= 2 400 ) { 401 shifttab(propsRef, repRef)(); 402 return true; 403 } 404 let tr = state.tr; 405 let newContent = tr.doc.slice(state.selection.anchor); 406 tr.delete(state.selection.anchor, state.doc.content.size); 407 dispatch?.(tr); 408 409 let newEntityID = v7(); 410 let position: string; 411 let asyncRun = async () => { 412 let blockType = 413 propsRef.current.type === "heading" && state.selection.anchor <= 2 414 ? ("heading" as const) 415 : ("text" as const); 416 if (propsRef.current.pageType === "canvas") { 417 let el = document.getElementById( 418 elementId.block(propsRef.current.entityID).container, 419 ); 420 let [position] = 421 (await repRef.current?.query((tx) => 422 scanIndex(tx).vae(propsRef.current.entityID, "canvas/block"), 423 )) || []; 424 if (!position || !el) return; 425 426 let box = el.getBoundingClientRect(); 427 428 await repRef.current?.mutate.addCanvasBlock({ 429 newEntityID, 430 factID: v7(), 431 permission_set: propsRef.current.entity_set.set, 432 parent: propsRef.current.parent, 433 type: blockType, 434 position: { 435 x: position.data.position.x, 436 y: position.data.position.y + box.height, 437 }, 438 }); 439 if (propsRef.current.listData) { 440 await repRef.current?.mutate.assertFact({ 441 entity: newEntityID, 442 attribute: "block/is-list", 443 data: { type: "boolean", value: true }, 444 }); 445 // Copy list style for canvas blocks 446 let listStyle = await repRef.current?.query((tx) => 447 scanIndex(tx).eav(propsRef.current.entityID, "block/list-style"), 448 ); 449 if (listStyle?.[0]) { 450 await repRef.current?.mutate.assertFact({ 451 entity: newEntityID, 452 attribute: "block/list-style", 453 data: { 454 type: "list-style-union", 455 value: listStyle[0].data.value, 456 }, 457 }); 458 } 459 } 460 return; 461 } 462 if (propsRef.current.listData) { 463 let createChild = 464 propsRef.current.nextBlock?.listData && 465 propsRef.current.nextBlock.listData.depth > 466 propsRef.current.listData.depth && 467 state.selection.anchor === state.doc.content.size - 1 && 468 !useUIState 469 .getState() 470 .foldedBlocks.includes(propsRef.current.entityID); 471 472 if (!createChild) { 473 //get this items next sibling 474 let parent = propsRef.current.listData.parent; 475 let siblings = ( 476 (await repRef.current?.query((tx) => 477 scanIndex(tx).eav(parent, "card/block"), 478 )) || [] 479 ).sort((a, b) => (a.data.position > b.data.position ? 1 : -1)); 480 let index = siblings.findIndex( 481 (sib) => sib.data.value === propsRef.current.entityID, 482 ); 483 position = generateKeyBetween( 484 propsRef.current.position, 485 siblings[index + 1]?.data.position || null, 486 ); 487 } else { 488 //Get this blocks children and get the first one 489 let children = ( 490 (await repRef.current?.query((tx) => 491 scanIndex(tx).eav(propsRef.current.entityID, "card/block"), 492 )) || [] 493 ).sort((a, b) => (a.data.position > b.data.position ? 1 : -1)); 494 position = generateKeyBetween( 495 createChild ? null : propsRef.current.position, 496 children[0]?.data.position || null, 497 ); 498 } 499 await repRef.current?.mutate.addBlock({ 500 newEntityID, 501 factID: v7(), 502 permission_set: propsRef.current.entity_set.set, 503 parent: createChild 504 ? propsRef.current.entityID 505 : propsRef.current.listData.parent, 506 type: blockType, 507 position, 508 }); 509 if ( 510 !createChild && 511 (!useUIState 512 .getState() 513 .foldedBlocks.includes(propsRef.current.entityID) || 514 state.selection.anchor === 1) 515 ) { 516 await repRef.current?.mutate.moveChildren({ 517 oldParent: propsRef.current.entityID, 518 newParent: newEntityID, 519 after: null, 520 }); 521 } 522 await repRef.current?.mutate.assertFact({ 523 entity: newEntityID, 524 attribute: "block/is-list", 525 data: { type: "boolean", value: true }, 526 }); 527 // Copy list style (ordered/unordered) to new list item 528 let listStyle = await repRef.current?.query((tx) => 529 scanIndex(tx).eav(propsRef.current.entityID, "block/list-style"), 530 ); 531 if (listStyle?.[0]) { 532 await repRef.current?.mutate.assertFact({ 533 entity: newEntityID, 534 attribute: "block/list-style", 535 data: { 536 type: "list-style-union", 537 value: listStyle[0].data.value, 538 }, 539 }); 540 } 541 let checked = await repRef.current?.query((tx) => 542 scanIndex(tx).eav(propsRef.current.entityID, "block/check-list"), 543 ); 544 if (checked?.[0]) 545 await repRef.current?.mutate.assertFact({ 546 entity: newEntityID, 547 attribute: "block/check-list", 548 data: { 549 type: "boolean", 550 value: 551 state.selection.anchor === 1 ? checked?.[0].data.value : false, 552 }, 553 }); 554 } 555 // if the block is not a list, add a new text block after it 556 if (!propsRef.current.listData) { 557 position = generateKeyBetween( 558 propsRef.current.position, 559 propsRef.current.nextPosition, 560 ); 561 await repRef.current?.mutate.addBlock({ 562 newEntityID, 563 factID: v7(), 564 permission_set: propsRef.current.entity_set.set, 565 parent: propsRef.current.parent, 566 type: blockType, 567 position, 568 }); 569 } 570 // if you are are the beginning of a heading, move the heading level to the new block 571 if (blockType === "heading") { 572 await repRef.current?.mutate.assertFact({ 573 entity: propsRef.current.entityID, 574 attribute: "block/type", 575 data: { type: "block-type-union", value: "text" }, 576 }); 577 let [headingLevel] = 578 (await repRef.current?.query((tx) => 579 scanIndex(tx).eav(propsRef.current.entityID, "block/heading-level"), 580 )) || []; 581 await repRef.current?.mutate.assertFact({ 582 entity: newEntityID, 583 attribute: "block/heading-level", 584 data: { type: "number", value: headingLevel.data.value || 0 }, 585 }); 586 } 587 if (propsRef.current.alignment !== "left") { 588 await repRef.current?.mutate.assertFact({ 589 entity: newEntityID, 590 attribute: "block/text-alignment", 591 data: { 592 type: "text-alignment-type-union", 593 value: propsRef.current.alignment, 594 }, 595 }); 596 } 597 let [textSize] = 598 (await repRef.current?.query((tx) => 599 scanIndex(tx).eav(propsRef.current.entityID, "block/text-size"), 600 )) || []; 601 if (textSize) { 602 await repRef.current?.mutate.assertFact({ 603 entity: newEntityID, 604 attribute: "block/text-size", 605 data: { 606 type: "text-size-union", 607 value: textSize.data.value, 608 }, 609 }); 610 } 611 }; 612 asyncRun().then(() => { 613 useUIState.getState().setSelectedBlock({ 614 value: newEntityID, 615 parent: propsRef.current.parent, 616 }); 617 618 setTimeout(() => { 619 let block = useEditorStates.getState().editorStates[newEntityID]; 620 if (block) { 621 let tr = block.editor.tr; 622 if (newContent.content.size > 2) { 623 tr.replaceWith(0, tr.doc.content.size, newContent.content); 624 tr.setSelection(TextSelection.create(tr.doc, 0)); 625 let newState = block.editor.apply(tr); 626 setEditorState(newEntityID, { 627 editor: newState, 628 }); 629 } 630 focusBlock( 631 { 632 value: newEntityID, 633 parent: propsRef.current.parent, 634 type: "text", 635 }, 636 { type: "start" }, 637 ); 638 } 639 }, 10); 640 }); 641 642 // if you are in the middle of a text block, split the block 643 return true; 644 }; 645 646const CtrlEnter = 647 ( 648 propsRef: RefObject<BlockProps & { entity_set: { set: string } }>, 649 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 650 ) => 651 ( 652 state: EditorState, 653 dispatch?: (tr: Transaction) => void, 654 view?: EditorView, 655 ) => { 656 repRef.current?.mutate.toggleTodoState({ 657 entityID: propsRef.current.entityID, 658 }); 659 return true; 660 }; 661 662const metaA = 663 ( 664 propsRef: RefObject<BlockProps & { entity_set: { set: string } }>, 665 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 666 ) => 667 ( 668 state: EditorState, 669 dispatch: ((tr: Transaction) => void) | undefined, 670 view: EditorView | undefined, 671 ) => { 672 const { from, to } = state.selection; 673 // Check if the entire content of the blockk is selected 674 const isFullySelected = from === 0 && to === state.doc.content.size; 675 676 if (!isFullySelected) { 677 // If the entire block is selected, we don't need to do anything 678 return false; 679 } else { 680 // Remove the selection 681 view?.dispatch( 682 state.tr.setSelection(TextSelection.create(state.doc, from)), 683 ); 684 view?.dom.blur(); 685 repRef.current?.query(async (tx) => { 686 let allBlocks = 687 (await getBlocksWithType(tx, propsRef.current.parent)) || []; 688 useUIState.setState({ 689 selectedBlocks: allBlocks.map((b) => ({ 690 value: b.value, 691 parent: propsRef.current.parent, 692 })), 693 }); 694 }); 695 return true; 696 } 697 };