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