a tool for shared writing and social publishing
at feature/footnotes 691 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 let tr = state.tr; 396 let newContent = tr.doc.slice(state.selection.anchor); 397 tr.delete(state.selection.anchor, state.doc.content.size); 398 dispatch?.(tr); 399 400 let newEntityID = v7(); 401 let position: string; 402 let asyncRun = async () => { 403 let blockType = 404 propsRef.current.type === "heading" && state.selection.anchor <= 2 405 ? ("heading" as const) 406 : ("text" as const); 407 if (propsRef.current.pageType === "canvas") { 408 let el = document.getElementById( 409 elementId.block(propsRef.current.entityID).container, 410 ); 411 let [position] = 412 (await repRef.current?.query((tx) => 413 scanIndex(tx).vae(propsRef.current.entityID, "canvas/block"), 414 )) || []; 415 if (!position || !el) return; 416 417 let box = el.getBoundingClientRect(); 418 419 await repRef.current?.mutate.addCanvasBlock({ 420 newEntityID, 421 factID: v7(), 422 permission_set: propsRef.current.entity_set.set, 423 parent: propsRef.current.parent, 424 type: blockType, 425 position: { 426 x: position.data.position.x, 427 y: position.data.position.y + box.height, 428 }, 429 }); 430 if (propsRef.current.listData) { 431 await repRef.current?.mutate.assertFact({ 432 entity: newEntityID, 433 attribute: "block/is-list", 434 data: { type: "boolean", value: true }, 435 }); 436 // Copy list style for canvas blocks 437 let listStyle = await repRef.current?.query((tx) => 438 scanIndex(tx).eav(propsRef.current.entityID, "block/list-style"), 439 ); 440 if (listStyle?.[0]) { 441 await repRef.current?.mutate.assertFact({ 442 entity: newEntityID, 443 attribute: "block/list-style", 444 data: { 445 type: "list-style-union", 446 value: listStyle[0].data.value, 447 }, 448 }); 449 } 450 } 451 return; 452 } 453 if (propsRef.current.listData) { 454 if (state.doc.content.size <= 2) { 455 return shifttab(propsRef, repRef)(); 456 } 457 let createChild = 458 propsRef.current.nextBlock?.listData && 459 propsRef.current.nextBlock.listData.depth > 460 propsRef.current.listData.depth && 461 state.selection.anchor === state.doc.content.size - 1 && 462 !useUIState 463 .getState() 464 .foldedBlocks.includes(propsRef.current.entityID); 465 466 if (!createChild) { 467 //get this items next sibling 468 let parent = propsRef.current.listData.parent; 469 let siblings = ( 470 (await repRef.current?.query((tx) => 471 scanIndex(tx).eav(parent, "card/block"), 472 )) || [] 473 ).sort((a, b) => (a.data.position > b.data.position ? 1 : -1)); 474 let index = siblings.findIndex( 475 (sib) => sib.data.value === propsRef.current.entityID, 476 ); 477 position = generateKeyBetween( 478 propsRef.current.position, 479 siblings[index + 1]?.data.position || null, 480 ); 481 } else { 482 //Get this blocks children and get the first one 483 let children = ( 484 (await repRef.current?.query((tx) => 485 scanIndex(tx).eav(propsRef.current.entityID, "card/block"), 486 )) || [] 487 ).sort((a, b) => (a.data.position > b.data.position ? 1 : -1)); 488 position = generateKeyBetween( 489 createChild ? null : propsRef.current.position, 490 children[0]?.data.position || null, 491 ); 492 } 493 await repRef.current?.mutate.addBlock({ 494 newEntityID, 495 factID: v7(), 496 permission_set: propsRef.current.entity_set.set, 497 parent: createChild 498 ? propsRef.current.entityID 499 : propsRef.current.listData.parent, 500 type: blockType, 501 position, 502 }); 503 if ( 504 !createChild && 505 (!useUIState 506 .getState() 507 .foldedBlocks.includes(propsRef.current.entityID) || 508 state.selection.anchor === 1) 509 ) { 510 await repRef.current?.mutate.moveChildren({ 511 oldParent: propsRef.current.entityID, 512 newParent: newEntityID, 513 after: null, 514 }); 515 } 516 await repRef.current?.mutate.assertFact({ 517 entity: newEntityID, 518 attribute: "block/is-list", 519 data: { type: "boolean", value: true }, 520 }); 521 // Copy list style (ordered/unordered) to new list item 522 let listStyle = await repRef.current?.query((tx) => 523 scanIndex(tx).eav(propsRef.current.entityID, "block/list-style"), 524 ); 525 if (listStyle?.[0]) { 526 await repRef.current?.mutate.assertFact({ 527 entity: newEntityID, 528 attribute: "block/list-style", 529 data: { 530 type: "list-style-union", 531 value: listStyle[0].data.value, 532 }, 533 }); 534 } 535 let checked = await repRef.current?.query((tx) => 536 scanIndex(tx).eav(propsRef.current.entityID, "block/check-list"), 537 ); 538 if (checked?.[0]) 539 await repRef.current?.mutate.assertFact({ 540 entity: newEntityID, 541 attribute: "block/check-list", 542 data: { 543 type: "boolean", 544 value: 545 state.selection.anchor === 1 ? checked?.[0].data.value : false, 546 }, 547 }); 548 } 549 // if the block is not a list, add a new text block after it 550 if (!propsRef.current.listData) { 551 position = generateKeyBetween( 552 propsRef.current.position, 553 propsRef.current.nextPosition, 554 ); 555 await repRef.current?.mutate.addBlock({ 556 newEntityID, 557 factID: v7(), 558 permission_set: propsRef.current.entity_set.set, 559 parent: propsRef.current.parent, 560 type: blockType, 561 position, 562 }); 563 } 564 // if you are are the beginning of a heading, move the heading level to the new block 565 if (blockType === "heading") { 566 await repRef.current?.mutate.assertFact({ 567 entity: propsRef.current.entityID, 568 attribute: "block/type", 569 data: { type: "block-type-union", value: "text" }, 570 }); 571 let [headingLevel] = 572 (await repRef.current?.query((tx) => 573 scanIndex(tx).eav(propsRef.current.entityID, "block/heading-level"), 574 )) || []; 575 await repRef.current?.mutate.assertFact({ 576 entity: newEntityID, 577 attribute: "block/heading-level", 578 data: { type: "number", value: headingLevel.data.value || 0 }, 579 }); 580 } 581 if (propsRef.current.alignment !== "left") { 582 await repRef.current?.mutate.assertFact({ 583 entity: newEntityID, 584 attribute: "block/text-alignment", 585 data: { 586 type: "text-alignment-type-union", 587 value: propsRef.current.alignment, 588 }, 589 }); 590 } 591 let [textSize] = 592 (await repRef.current?.query((tx) => 593 scanIndex(tx).eav(propsRef.current.entityID, "block/text-size"), 594 )) || []; 595 if (textSize) { 596 await repRef.current?.mutate.assertFact({ 597 entity: newEntityID, 598 attribute: "block/text-size", 599 data: { 600 type: "text-size-union", 601 value: textSize.data.value, 602 }, 603 }); 604 } 605 }; 606 asyncRun().then(() => { 607 useUIState.getState().setSelectedBlock({ 608 value: newEntityID, 609 parent: propsRef.current.parent, 610 }); 611 612 setTimeout(() => { 613 let block = useEditorStates.getState().editorStates[newEntityID]; 614 if (block) { 615 let tr = block.editor.tr; 616 if (newContent.content.size > 2) { 617 tr.replaceWith(0, tr.doc.content.size, newContent.content); 618 tr.setSelection(TextSelection.create(tr.doc, 0)); 619 let newState = block.editor.apply(tr); 620 setEditorState(newEntityID, { 621 editor: newState, 622 }); 623 } 624 focusBlock( 625 { 626 value: newEntityID, 627 parent: propsRef.current.parent, 628 type: "text", 629 }, 630 { type: "start" }, 631 ); 632 } 633 }, 10); 634 }); 635 636 // if you are in the middle of a text block, split the block 637 return true; 638 }; 639 640const CtrlEnter = 641 ( 642 propsRef: RefObject<BlockProps & { entity_set: { set: string } }>, 643 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 644 ) => 645 ( 646 state: EditorState, 647 dispatch?: (tr: Transaction) => void, 648 view?: EditorView, 649 ) => { 650 repRef.current?.mutate.toggleTodoState({ 651 entityID: propsRef.current.entityID, 652 }); 653 return true; 654 }; 655 656const metaA = 657 ( 658 propsRef: RefObject<BlockProps & { entity_set: { set: string } }>, 659 repRef: RefObject<Replicache<ReplicacheMutators> | null>, 660 ) => 661 ( 662 state: EditorState, 663 dispatch: ((tr: Transaction) => void) | undefined, 664 view: EditorView | undefined, 665 ) => { 666 const { from, to } = state.selection; 667 // Check if the entire content of the blockk is selected 668 const isFullySelected = from === 0 && to === state.doc.content.size; 669 670 if (!isFullySelected) { 671 // If the entire block is selected, we don't need to do anything 672 return false; 673 } else { 674 // Remove the selection 675 view?.dispatch( 676 state.tr.setSelection(TextSelection.create(state.doc, from)), 677 ); 678 view?.dom.blur(); 679 repRef.current?.query(async (tx) => { 680 let allBlocks = 681 (await getBlocksWithType(tx, propsRef.current.parent)) || []; 682 useUIState.setState({ 683 selectedBlocks: allBlocks.map((b) => ({ 684 value: b.value, 685 parent: propsRef.current.parent, 686 })), 687 }); 688 }); 689 return true; 690 } 691 };