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