import { BlockProps } from "../Block"; import { focusBlock } from "src/utils/focusBlock"; import { EditorView } from "prosemirror-view"; import { generateKeyBetween } from "fractional-indexing"; import { baseKeymap, setBlockType, toggleMark } from "prosemirror-commands"; import { formattingKeymap } from "src/utils/prosemirror/formattingKeymap"; import { keymap } from "prosemirror-keymap"; import { Command, EditorState, TextSelection, Transaction, } from "prosemirror-state"; import { RefObject } from "react"; import { Replicache } from "replicache"; import type { Fact, ReplicacheMutators } from "src/replicache"; import { elementId } from "src/utils/elementId"; import { schema } from "./schema"; import { useUIState } from "src/useUIState"; import { setEditorState, useEditorStates } from "src/state/useEditorState"; import { focusPage } from "src/utils/focusPage"; import { v7 } from "uuid"; import { scanIndex } from "src/replicache/utils"; import { indent, outdent } from "src/utils/list-operations"; import { getPageBlocks } from "src/replicache/getBlocks"; import { isTextBlock } from "src/utils/isTextBlock"; import { UndoManager } from "src/undoManager"; type PropsRef = RefObject< BlockProps & { entity_set: { set: string }; alignment: Fact<"block/text-alignment">["data"]["value"]; } >; export const TextBlockKeymap = ( propsRef: PropsRef, repRef: RefObject | null>, um: UndoManager, openMentionAutocomplete: () => void, ) => ({ ...formattingKeymap(schema.marks), "Ctrl-Meta-h": (...args) => { return toggleMark(schema.marks.highlight, { color: useUIState.getState().lastUsedHighlight, })(...args); }, "Ctrl-a": metaA(propsRef, repRef), "Meta-a": metaA(propsRef, repRef), Escape: (_state, _dispatch, view) => { view?.dom.blur(); useUIState.setState(() => ({ focusedEntity: { entityType: "page", entityID: propsRef.current.parent, }, selectedBlocks: [], })); return false; }, "Shift-ArrowDown": (state, _dispatch, view) => { if ( state.doc.content.size - 1 === state.selection.from || state.doc.content.size - 1 === state.selection.to ) { if (propsRef.current.nextBlock) { let next = propsRef.current.nextBlock; useUIState.setState({ selectedBlocks: [ { entityID: propsRef.current.entityID, parent: propsRef.current.parent }, { entityID: next.entityID, parent: next.parent }, ], focusedEntity: { entityType: "block", entityID: next.entityID, parent: propsRef.current.parent, }, }); document.getSelection()?.removeAllRanges(); view?.dom.blur(); return true; } } return false; }, "Shift-ArrowUp": (state, _dispatch, view) => { if (state.selection.from <= 1 || state.selection.to <= 1) { if (propsRef.current.previousBlock) { let previous = propsRef.current.previousBlock; useUIState.setState({ selectedBlocks: [ { entityID: propsRef.current.entityID, parent: propsRef.current.parent }, { entityID: previous.entityID, parent: previous.parent }, ], focusedEntity: { entityType: "block", entityID: previous.entityID, parent: propsRef.current.parent, }, }); document.getSelection()?.removeAllRanges(); view?.dom.blur(); return true; } } return false; }, "Ctrl-k": moveCursorUp(propsRef, repRef, true), ArrowUp: moveCursorUp(propsRef, repRef), "Ctrl-j": moveCursorDown(propsRef, repRef, true), ArrowDown: moveCursorDown(propsRef, repRef), ArrowLeft: (state, dispatch, view) => { if (!state.selection.empty) return false; if (skipFootnote(state, dispatch, "before")) return true; if (state.selection.anchor > 1) return false; if (clearStoredMarks(state, dispatch)) return true; let block = propsRef.current.previousBlock; if (block) { view?.dom.blur(); focusBlock(block, { type: "end" }); } return true; }, ArrowRight: (state, dispatch, view) => { if (!state.selection.empty) return false; if (skipFootnote(state, dispatch, "after")) return true; if (state.doc.content.size - state.selection.anchor > 1) return false; if (clearStoredMarks(state, dispatch)) return true; let block = propsRef.current.nextBlock; if (block) { view?.dom.blur(); focusBlock(block, { type: "start" }); } return true; }, Backspace: (state, dispatch, view) => backspace(propsRef, repRef, um)(state, dispatch, view), "Shift-Backspace": backspace(propsRef, repRef, um), Enter: (state, dispatch, view) => enter(propsRef, repRef, um)(state, dispatch, view), "Shift-Enter": (state, dispatch, view) => { // Insert a hard break let hardBreak = schema.nodes.hard_break.create(); if (dispatch) { dispatch(state.tr.replaceSelectionWith(hardBreak).scrollIntoView()); } return true; }, "Ctrl-Enter": CtrlEnter(propsRef, repRef), "Meta-Enter": CtrlEnter(propsRef, repRef), }) as { [key: string]: Command }; const skipFootnote = ( state: EditorState, dispatch: ((tr: Transaction) => void) | undefined, side: "before" | "after", ) => { let node = side === "before" ? state.selection.$from.nodeBefore : state.selection.$from.nodeAfter; if (node?.type !== schema.nodes.footnote) return false; let delta = side === "before" ? -node.nodeSize : node.nodeSize; if (dispatch) dispatch( state.tr.setSelection( TextSelection.create(state.doc, state.selection.from + delta), ), ); return true; }; const clearStoredMarks = ( state: EditorState, dispatch: ((tr: Transaction) => void) | undefined, ) => { let marks = state.storedMarks ?? state.selection.$from.marks(); if (marks.length === 0) return false; if (dispatch) dispatch(state.tr.setStoredMarks([])); return true; }; const moveCursorDown = ( propsRef: PropsRef, repRef: RefObject | null>, jumpToNextBlock: boolean = false, ) => ( state: EditorState, dispatch?: (tr: Transaction) => void, view?: EditorView, ) => { if (!view) return true; if (state.doc.textContent.startsWith("/")) return true; if (useUIState.getState().selectedBlocks.length > 1) return true; if (view.state.selection.from !== view.state.selection.to) return false; const viewClientRect = view.dom.getBoundingClientRect(); const coords = view.coordsAtPos(view.state.selection.anchor); let isBottom = viewClientRect.bottom - coords.bottom < 12; if (isBottom || jumpToNextBlock) { let block = propsRef.current.nextBlock; if (block) { view.dom.blur(); focusBlock(block, { left: coords.left, type: "top" }); return true; } return false || jumpToNextBlock; } return false; }; const moveCursorUp = ( propsRef: PropsRef, repRef: RefObject | null>, jumpToNextBlock: boolean = false, ) => ( state: EditorState, dispatch?: (tr: Transaction) => void, view?: EditorView, ) => { if (!view) return false; if (state.doc.textContent.startsWith("/")) return true; if (useUIState.getState().selectedBlocks.length > 1) return true; if (view.state.selection.from !== view.state.selection.to) return false; const viewClientRect = view.dom.getBoundingClientRect(); const coords = view.coordsAtPos(view.state.selection.anchor); if (coords.top - viewClientRect.top < 12 || jumpToNextBlock) { let block = propsRef.current.previousBlock; if (block) { view.dom.blur(); focusBlock(block, { left: coords.left, type: "bottom" }); return true; } return false || jumpToNextBlock; } return false; }; const backspace = ( propsRef: PropsRef, repRef: RefObject | null>, um: UndoManager, ) => ( state: EditorState, dispatch?: (tr: Transaction) => void, view?: EditorView, ) => { // A single backspace can fire several mutations (e.g. reparenting a list // item's children and then removing the block). Each mutation's // undoManager.add runs async — after this returns — so collect the mutation // promises and hold an undo group open until they all settle, making the // whole backspace undo as one step. The group opens lazily on the first // mutation; finish() closes it (or is a no-op when nothing was mutated). let opened = false; let pending: Promise[] = []; let mutate = (p: Promise | undefined) => { if (!opened) { um.startGroup(); opened = true; } if (p) pending.push(p); }; let finish = (result: boolean) => { if (opened) Promise.all(pending).finally(() => um.endGroup()); return result; }; // if multiple blocks are selected, don't do anything (handled in SelectionManager) if (useUIState.getState().selectedBlocks.length > 1) { return finish(false); } // if you are selecting text within a block, don't do anything (handled by proseMirror) if (state.selection.anchor > 1 || state.selection.content().size > 0) { return finish(false); } // if you are in a list... if (propsRef.current.listData) { // ...and the item is a checklist item, remove the checklist attribute if (propsRef.current.listData.checklist) { mutate( repRef.current?.mutate.retractAttribute({ entity: propsRef.current.entityID, attribute: "block/check-list", }), ); return finish(true); } // ...move the child list items to next eligible parent (?) let depth = propsRef.current.listData.depth; mutate( repRef.current?.mutate.moveChildren({ oldParent: propsRef.current.entityID, newParent: propsRef.current.previousBlock?.listData ? propsRef.current.previousBlock.entityID : propsRef.current.listData.parent || propsRef.current.parent, after: propsRef.current.previousBlock?.listData?.path.find( (f) => f.depth === depth, )?.entity || propsRef.current.previousBlock?.entityID || null, }), ); } // if this is the first block and is it a list, remove list attribute if (!propsRef.current.previousBlock) { if (propsRef.current.listData) { mutate( repRef.current?.mutate.retractAttribute({ entity: propsRef.current.entityID, attribute: "block/is-list", }), ); return finish(true); } // If the block is a heading, convert it to a text block if (propsRef.current.type === "heading") { mutate( repRef.current?.mutate.assertFact({ entity: propsRef.current.entityID, attribute: "block/type", data: { type: "block-type-union", value: "text" }, }), ); setTimeout( () => focusBlock( { entityID: propsRef.current.entityID, type: "text", parent: propsRef.current.parent, }, { type: "start" }, ), 10, ); return finish(false); } if (propsRef.current.pageType === "canvas") { mutate( repRef.current?.mutate.removeBlock({ blockEntity: propsRef.current.entityID, }), ); } return finish(true); } let block = !!propsRef.current.previousBlock ? useEditorStates.getState().editorStates[ propsRef.current.previousBlock.entityID ] : null; if ( block && propsRef.current.previousBlock && block.editor.doc.textContent.length === 0 && !propsRef.current.previousBlock?.listData ) { mutate( repRef.current?.mutate.removeBlock({ blockEntity: propsRef.current.previousBlock.entityID, }), ); return finish(true); } if (state.doc.textContent.length === 0) { mutate( repRef.current?.mutate.removeBlock({ blockEntity: propsRef.current.entityID, }), ); if (propsRef.current.previousBlock) { focusBlock(propsRef.current.previousBlock, { type: "end" }); } else { useUIState.getState().setFocusedBlock({ entityType: "page", entityID: propsRef.current.parent, }); } return finish(true); } if ( propsRef.current.previousBlock && !isTextBlock[propsRef.current.previousBlock?.type] ) { focusBlock(propsRef.current.previousBlock, { type: "end" }); view?.dom.blur(); return finish(true); } if (!block || !propsRef.current.previousBlock) return finish(false); let mergedBlock = propsRef.current.previousBlock; let removedBlock = { entityID: propsRef.current.entityID, type: propsRef.current.type, parent: propsRef.current.parent, }; mutate( repRef.current?.mutate.removeBlock({ blockEntity: propsRef.current.entityID, }), ); // Undo recreates the removed block (via removeBlock's undo) but leaves the // caret in the block we merged into; restore it to the start of the // recreated block so undo lands where the backspace began. Added before the // text-merge entry so it runs last on undo — after the block exists again. // // The recreated block's editor mounts with an empty doc and only loads its // text once the restored block/text fact lands and ySync inserts it. Setting // the caret to the start before that insertion lets ySync map the caret to // after the inserted text (the end), so poll until the editor has mounted // and its content has loaded before placing the caret at the start. um.add({ undo: () => { let attempts = 0; let run = () => { let editor = useEditorStates.getState().editorStates[removedBlock.entityID]; if (!editor?.view || editor.editor.doc.content.size <= 2) { if (attempts++ < 50) setTimeout(run, 10); return; } focusBlock(removedBlock, { type: "start" }); }; run(); }, redo: () => {}, }); let tr = block.editor.tr; block.view?.focus(); let firstChild = state.doc.content.firstChild?.content; if (firstChild) { tr.insert(tr.doc.content.size - 1, firstChild); tr.setSelection( TextSelection.create( tr.doc, tr.doc.content.size - firstChild?.size - 1, ), ); } // Dispatch through the previous block's view rather than applying the state // directly so its dispatchTransaction records the merged text via // trackUndoRedo; externalUndoGroup folds that entry into this backspace's // undo group. Applying directly left the merged text with no undo entry, so // it survived the undo. Fall back to a direct apply if the view is absent. tr.setMeta("externalUndoGroup", true); if (block.view) { block.view.dispatch(tr); } else { let newState = block.editor.apply(tr); setEditorState(mergedBlock.entityID, { editor: newState }); } return finish(true); }; const shifttab = ( propsRef: RefObject, repRef: RefObject | null>, ) => async () => { if (useUIState.getState().selectedBlocks.length > 1) return false; if (!repRef.current) return false; if (!repRef.current) return false; let { foldedBlocks, toggleFold } = useUIState.getState(); await outdent(propsRef.current, propsRef.current.previousBlock, repRef.current, { foldedBlocks, toggleFold, }); return true; }; // Enter with the caret at the start of a block inserts the empty block above // the current one, leaving content, caret, and focus untouched. Chained so // that under key-repeat each insertion reads the sibling positions the // previous one committed — otherwise two repeats generate colliding // fractional keys from the same neighbors and sort unpredictably. let insertAboveChain: Promise = Promise.resolve(); const insertEmptyBlockAbove = async ( propsRef: PropsRef, repRef: RefObject | null>, ) => { let rep = repRef.current; if (!rep) return; let listData = propsRef.current.listData; let parent = listData ? listData.parent : propsRef.current.parent; let siblings = ( await rep.query((tx) => scanIndex(tx).eav(parent, "card/block")) ).sort((a, b) => (a.data.position > b.data.position ? 1 : -1)); let index = siblings.findIndex( (sib) => sib.data.value === propsRef.current.entityID, ); let position = generateKeyBetween( siblings[index - 1]?.data.position || null, siblings[index]?.data.position || propsRef.current.position, ); let list; if (listData) { let [listStyle] = await rep.query((tx) => scanIndex(tx).eav(propsRef.current.entityID, "block/list-style"), ); let [checked] = await rep.query((tx) => scanIndex(tx).eav(propsRef.current.entityID, "block/check-list"), ); list = { listStyle: listStyle?.data.value, checklist: checked?.data.value, }; } let newEntityID = v7(); await rep.mutate.addBlock({ newEntityID, factID: v7(), permission_set: propsRef.current.entity_set.set, parent, type: "text", position, list, }); if (propsRef.current.alignment !== "left") { await rep.mutate.assertFact({ entity: newEntityID, attribute: "block/text-alignment", data: { type: "text-alignment-type-union", value: propsRef.current.alignment, }, }); } let [textSize] = await rep.query((tx) => scanIndex(tx).eav(propsRef.current.entityID, "block/text-size"), ); if (textSize) { await rep.mutate.assertFact({ entity: newEntityID, attribute: "block/text-size", data: { type: "text-size-union", value: textSize.data.value }, }); } }; const enter = ( propsRef: PropsRef, repRef: RefObject | null>, um: UndoManager, ) => ( state: EditorState, dispatch?: (tr: Transaction) => void, view?: EditorView, ) => { if (state.doc.textContent.startsWith("/")) return true; // Empty list item: just outdent, don't create a new block if ( propsRef.current.listData && propsRef.current.pageType !== "canvas" && state.doc.content.size <= 2 ) { // The outdent's mutations run async, so keep the undo group open until // they resolve instead of closing it synchronously. um.startGroup(); shifttab(propsRef, repRef)().finally(() => um.endGroup()); return true; } // At the start of a block nothing precedes the caret, so the "split" is // really "insert an empty block above": create it and leave this block's // content, caret, and focus alone. This is what makes holding Enter safe — // the split path below hands focus and content to a block whose editor // hasn't mounted yet, and key-repeat outruns that hand-off; here there is // nothing to hand off, and after any genuine split the caret lands at the // start of the new block so subsequent repeats take this path. The undo // group opens inside the chain thunk so each keypress stays its own step // (overlapping startGroups would merge by depth-counting). if ( state.selection.empty && state.selection.anchor <= 1 && propsRef.current.pageType !== "canvas" ) { insertAboveChain = insertAboveChain.then(async () => { um.startGroup(); try { await insertEmptyBlockAbove(propsRef, repRef); } finally { um.endGroup(); } }); return true; } // Splitting the block to make a new list item / text block runs its // mutations asynchronously (see asyncRun below). Open the undo group now and // close it once those resolve so the split and the new block undo as one // step. The delete transaction is marked externalUndoGroup so trackUndoRedo // adds it to this group instead of starting its own timeout-based group — // unlike bulkOp this still refocuses the block on undo, so the cursor is // restored to the split point. um.startGroup(); let tr = state.tr; let newContent = tr.doc.slice(state.selection.anchor); tr.delete(state.selection.anchor, state.doc.content.size); tr.setMeta("externalUndoGroup", true); dispatch?.(tr); let newEntityID = v7(); let position: string; let asyncRun = async () => { let blockType = propsRef.current.type === "heading" && state.selection.anchor <= 2 ? ("heading" as const) : ("text" as const); if (propsRef.current.pageType === "canvas") { let el = document.getElementById( elementId.block(propsRef.current.entityID).container, ); let [position] = (await repRef.current?.query((tx) => scanIndex(tx).vae(propsRef.current.entityID, "canvas/block"), )) || []; if (!position || !el) return; let box = el.getBoundingClientRect(); await repRef.current?.mutate.addCanvasBlock({ newEntityID, factID: v7(), permission_set: propsRef.current.entity_set.set, parent: propsRef.current.parent, type: blockType, position: { x: position.data.position.x, y: position.data.position.y + box.height, }, }); if (propsRef.current.listData) { await repRef.current?.mutate.assertFact({ entity: newEntityID, attribute: "block/is-list", data: { type: "boolean", value: true }, }); // Copy list style for canvas blocks let listStyle = await repRef.current?.query((tx) => scanIndex(tx).eav(propsRef.current.entityID, "block/list-style"), ); if (listStyle?.[0]) { await repRef.current?.mutate.assertFact({ entity: newEntityID, attribute: "block/list-style", data: { type: "list-style-union", value: listStyle[0].data.value, }, }); } } return; } if (propsRef.current.listData) { let createChild = propsRef.current.nextBlock?.listData && propsRef.current.nextBlock.listData.depth > propsRef.current.listData.depth && state.selection.anchor === state.doc.content.size - 1 && !useUIState .getState() .foldedBlocks.includes(propsRef.current.entityID); if (!createChild) { //get this items next sibling let parent = propsRef.current.listData.parent; let siblings = ( (await repRef.current?.query((tx) => scanIndex(tx).eav(parent, "card/block"), )) || [] ).sort((a, b) => (a.data.position > b.data.position ? 1 : -1)); let index = siblings.findIndex( (sib) => sib.data.value === propsRef.current.entityID, ); // Use the freshly-read sibling position, not the render-lagged // propsRef snapshot, which can collide with the just-created item. position = generateKeyBetween( siblings[index]?.data.position || propsRef.current.position, siblings[index + 1]?.data.position || null, ); } else { //Get this blocks children and get the first one let children = ( (await repRef.current?.query((tx) => scanIndex(tx).eav(propsRef.current.entityID, "card/block"), )) || [] ).sort((a, b) => (a.data.position > b.data.position ? 1 : -1)); position = generateKeyBetween( createChild ? null : propsRef.current.position, children[0]?.data.position || null, ); } let [listStyle] = (await repRef.current?.query((tx) => scanIndex(tx).eav(propsRef.current.entityID, "block/list-style"), )) || []; let [checked] = (await repRef.current?.query((tx) => scanIndex(tx).eav(propsRef.current.entityID, "block/check-list"), )) || []; await repRef.current?.mutate.addBlock({ newEntityID, factID: v7(), permission_set: propsRef.current.entity_set.set, parent: createChild ? propsRef.current.entityID : propsRef.current.listData.parent, type: blockType, position, list: { listStyle: listStyle?.data.value, checklist: checked ? state.selection.anchor === 1 ? checked.data.value : false : undefined, }, }); if ( !createChild && (!useUIState .getState() .foldedBlocks.includes(propsRef.current.entityID) || state.selection.anchor === 1) ) { await repRef.current?.mutate.moveChildren({ oldParent: propsRef.current.entityID, newParent: newEntityID, after: null, }); } } // if the block is not a list, add a new text block after it if (!propsRef.current.listData) { position = generateKeyBetween( propsRef.current.position, propsRef.current.nextPosition, ); await repRef.current?.mutate.addBlock({ newEntityID, factID: v7(), permission_set: propsRef.current.entity_set.set, parent: propsRef.current.parent, type: blockType, position, }); // Splitting a folded heading would drop the new block into its hidden // section; unfold so the freshly created block stays visible. if ( useUIState.getState().foldedBlocks.includes(propsRef.current.entityID) ) useUIState.getState().toggleFold(propsRef.current.entityID); } // if you are are the beginning of a heading, move the heading level to the new block if (blockType === "heading") { await repRef.current?.mutate.assertFact({ entity: propsRef.current.entityID, attribute: "block/type", data: { type: "block-type-union", value: "text" }, }); let [headingLevel] = (await repRef.current?.query((tx) => scanIndex(tx).eav(propsRef.current.entityID, "block/heading-level"), )) || []; await repRef.current?.mutate.assertFact({ entity: newEntityID, attribute: "block/heading-level", data: { type: "number", value: headingLevel.data.value || 0 }, }); } if (propsRef.current.alignment !== "left") { await repRef.current?.mutate.assertFact({ entity: newEntityID, attribute: "block/text-alignment", data: { type: "text-alignment-type-union", value: propsRef.current.alignment, }, }); } let [textSize] = (await repRef.current?.query((tx) => scanIndex(tx).eav(propsRef.current.entityID, "block/text-size"), )) || []; if (textSize) { await repRef.current?.mutate.assertFact({ entity: newEntityID, attribute: "block/text-size", data: { type: "text-size-union", value: textSize.data.value, }, }); } }; // focusBlock silently no-ops until the new block's editor mounts, so a // fixed timeout could drop focus and send the next keystrokes to the old // block. Retry until it registers. let focusNewBlock = () => { let attempts = 0; let run = () => { let block = useEditorStates.getState().editorStates[newEntityID]; if (!block) { if (attempts++ < 50) setTimeout(run, 10); return; } let tr = block.editor.tr; if (newContent.content.size > 2) { tr.replaceWith(0, tr.doc.content.size, newContent.content); tr.setSelection(TextSelection.create(tr.doc, 0)); let newState = block.editor.apply(tr); setEditorState(newEntityID, { editor: newState, }); } focusBlock( { entityID: newEntityID, parent: propsRef.current.parent, type: "text", }, { type: "start" }, ); }; run(); }; asyncRun() .then(() => { useUIState.getState().setSelectedBlock({ entityID: newEntityID, parent: propsRef.current.parent, }); // The split's tracked delete transaction refocuses the source block on // undo (see externalUndoGroup above), and its redo would leave the // cursor there too. Register a redo, while the group is still open, that // moves the cursor into the new block — matching where the forward Enter // places it. um.add({ undo: () => {}, redo: () => { setTimeout(focusNewBlock, 10); }, }); setTimeout(focusNewBlock, 10); }) .finally(() => um.endGroup()); // if you are in the middle of a text block, split the block return true; }; const CtrlEnter = ( propsRef: RefObject, repRef: RefObject | null>, ) => ( state: EditorState, dispatch?: (tr: Transaction) => void, view?: EditorView, ) => { repRef.current?.mutate.toggleTodoState({ entityID: propsRef.current.entityID, }); return true; }; const metaA = ( propsRef: RefObject, repRef: RefObject | null>, ) => ( state: EditorState, dispatch: ((tr: Transaction) => void) | undefined, view: EditorView | undefined, ) => { const { from, to } = state.selection; // Check if the entire content of the blockk is selected const isFullySelected = from === 0 && to === state.doc.content.size; if (!isFullySelected) { // If the entire block is selected, we don't need to do anything return false; } else { // Remove the selection view?.dispatch( state.tr.setSelection(TextSelection.create(state.doc, from)), ); view?.dom.blur(); if (repRef.current) { let allBlocks = getPageBlocks(repRef.current, propsRef.current.parent); useUIState.setState({ selectedBlocks: allBlocks.map((b) => ({ entityID: b.entityID, parent: propsRef.current.parent, })), }); } return true; } };