Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
28 kB · 828 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829import { 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 { getBlocksWithType } 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<Replicache<ReplicacheMutators> | 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) { useUIState .getState() .setSelectedBlocks([propsRef.current, propsRef.current.nextBlock]); useUIState.getState().setFocusedBlock({ entityType: "block", entityID: propsRef.current.nextBlock.value, 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) { useUIState .getState() .setSelectedBlocks([ propsRef.current, propsRef.current.previousBlock, ]); useUIState.getState().setFocusedBlock({ entityType: "block", entityID: propsRef.current.previousBlock.value, 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<Replicache<ReplicacheMutators> | 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<Replicache<ReplicacheMutators> | 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<Replicache<ReplicacheMutators> | 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<unknown>[] = []; let mutate = (p: Promise<unknown> | 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.value : propsRef.current.listData.parent || propsRef.current.parent, after: propsRef.current.previousBlock?.listData?.path.find( (f) => f.depth === depth, )?.entity || propsRef.current.previousBlock?.value || 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( { value: 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.value ] : 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.value, }), ); 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 = { value: 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.value]; 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.value, { editor: newState }); }
return finish(true); };
const shifttab = ( propsRef: RefObject<BlockProps & { entity_set: { set: string } }>, repRef: RefObject<Replicache<ReplicacheMutators> | 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; };
const enter = ( propsRef: PropsRef, repRef: RefObject<Replicache<ReplicacheMutators> | 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; } // 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( { value: newEntityID, parent: propsRef.current.parent, type: "text", }, { type: "start" }, ); }; run(); };
asyncRun() .then(() => { useUIState.getState().setSelectedBlock({ value: 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<BlockProps & { entity_set: { set: string } }>, repRef: RefObject<Replicache<ReplicacheMutators> | null>, ) => ( state: EditorState, dispatch?: (tr: Transaction) => void, view?: EditorView, ) => { repRef.current?.mutate.toggleTodoState({ entityID: propsRef.current.entityID, }); return true; };
const metaA = ( propsRef: RefObject<BlockProps & { entity_set: { set: string } }>, repRef: RefObject<Replicache<ReplicacheMutators> | 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(); repRef.current?.query(async (tx) => { let allBlocks = (await getBlocksWithType(tx, propsRef.current.parent)) || []; useUIState.setState({ selectedBlocks: allBlocks.map((b) => ({ value: b.value, parent: propsRef.current.parent, })), }); }); return true; } };