Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
15 kB · 397 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398import { useLayoutEffect, useRef } from "react";import { EditorState, Transaction } from "prosemirror-state";import { EditorView } from "prosemirror-view";import type { Node } from "prosemirror-model";import { baseKeymap } from "prosemirror-commands";import { keymap } from "prosemirror-keymap";import { ySyncPlugin, ySyncPluginKey } from "y-prosemirror";import { Replicache } from "replicache";import { produce } from "immer";
import { schema } from "./schema";import { UndoManager } from "src/undoManager";import { TextBlockKeymap } from "./keymap";import { inputrules } from "./inputRules";import { highlightSelectionPlugin } from "./plugins";import { autolink } from "./autolink-plugin";import { useEditorStates } from "src/state/useEditorState";import { useEntity, useReplicache, type ReplicacheMutators,} from "src/replicache";import { useHandlePaste } from "./useHandlePaste";import { BlockProps } from "../Block";import { useEntitySetContext } from "components/EntitySetProvider";import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils";import { useFootnotePopoverStore } from "components/Footnotes/FootnotePopover";import { useLinkPopoverStore } from "components/LinkPopover";import { useEditorCommentSheetStore } from "components/EditorComments/editorCommentStores";import { useEditorCommentPopoverStore } from "components/EditorComments/EditorCommentPopover";import { commentDraftPlugin } from "./commentDraftPlugin";import { stripCommentMarks } from "./stripCommentMarks";import { useCollabText } from "./useCollabText";
// The comment anchor under an event's target and its comment IDs; null when// there is none. Anchors can carry several IDs since overlapping comments// share a mark.function commentAnchor(event: Event) { let anchor = (event.target as HTMLElement | null)?.closest( ".comment-anchor[data-comment-id]", ) as HTMLElement | null; if (!anchor) return null; let commentIDs = anchor .getAttribute("data-comment-id")! .split(" ") .filter(Boolean); if (commentIDs.length === 0) return null; return { anchor, commentIDs };}
export function useMountProsemirror({ props, openMentionAutocomplete,}: { props: BlockProps; openMentionAutocomplete: () => void;}) { let { entityID } = props; let rep = useReplicache(); let mountRef = useRef<HTMLPreElement | null>(null); const repRef = useRef<Replicache<ReplicacheMutators> | null>(null); let { yText: value, cursorPlugin, overlay } = useCollabText(entityID); let entity_set = useEntitySetContext(); let alignment = useEntity(entityID, "block/text-alignment")?.data.value || "left"; let propsRef = useRef({ ...props, entity_set, alignment }); let handlePaste = useHandlePaste(entityID, propsRef);
propsRef.current = { ...props, entity_set, alignment }; repRef.current = rep.rep;
useLayoutEffect(() => { if (!mountRef.current) return;
const km = TextBlockKeymap( propsRef, repRef, rep.undoManager, openMentionAutocomplete, ); const editor = EditorState.create({ schema: schema, plugins: [ ySyncPlugin(value), cursorPlugin, keymap(km), inputrules(propsRef, repRef, openMentionAutocomplete), keymap(baseKeymap), highlightSelectionPlugin, commentDraftPlugin, autolink({ type: schema.marks.link, shouldAutoLink: () => true, defaultProtocol: "https", }), ], });
const view = new EditorView( { mount: mountRef.current }, { state: editor, handlePaste, transformCopied: stripCommentMarks, transformPasted: stripCommentMarks, handleDOMEvents: { // cmd/ctrl+click opens links in a new tab. Handled on the native // click event rather than in handleClickOn: popup blockers trust // window.open from a click handler, and ProseMirror cancels its // click handling when the mouse moves >4px between down and up. click: (_view, event) => { if (!(event.metaKey || event.ctrlKey)) { let target = commentAnchor(event); // Read-only viewers don't see comments, so anchors are inert if (target && propsRef.current.entity_set.permissions.write) { let { anchor, commentIDs } = target; let isDesktop = window.matchMedia( "(min-width: 1280px)", ).matches; if (!isDesktop) { // On mobile, show a popover with an excerpt and a button // that opens the thread in the slide-in sheet let store = useEditorCommentPopoverStore.getState(); if (store.commentIDs?.join(" ") === commentIDs.join(" ")) { store.close(); } else { store.open(commentIDs, anchor); } event.preventDefault(); return true; } // On desktop canvas pages there's no side column, so open the // sheet directly; on doc pages the side column thread expands // on hover, so the click just places the cursor. if (propsRef.current.pageType === "canvas") { useEditorCommentSheetStore .getState() .openSheet(propsRef.current.parent, commentIDs[0]); event.preventDefault(); return true; } return false; } return false; } let anchor = (event.target as HTMLElement | null)?.closest("a"); let href = anchor?.getAttribute("href"); if (!href) return false; window.open(href, "_blank", "noopener,noreferrer"); event.preventDefault(); return true; }, }, handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { if (!direct) return;
// Check for footnote inline nodes if (node?.type === schema.nodes.footnote) { let footnoteID = node.attrs.footnoteEntityID; let supEl = _event.target as HTMLElement; let sup = supEl.closest(".footnote-ref") as HTMLElement | null; if (!sup) return;
// On mobile/tablet or canvas, show popover let isDesktop = window.matchMedia("(min-width: 1280px)").matches; let isCanvas = propsRef.current.pageType === "canvas"; if (!isDesktop || isCanvas) { let store = useFootnotePopoverStore.getState(); if (store.activeFootnoteID === footnoteID) { store.close(); } else { store.open(footnoteID, sup); } return; }
// On desktop, prefer the side column editor if visible let sideColumn = document.querySelector(".footnote-side-column"); let editor = sideColumn?.querySelector( `[data-footnote-editor="${footnoteID}"]`, ) as HTMLElement | null; // Fall back to the bottom section if (!editor) { editor = document.querySelector( `[data-footnote-editor="${footnoteID}"]`, ) as HTMLElement | null; } if (editor) { editor.scrollIntoView({ behavior: "smooth", block: "nearest" }); let pm = editor.querySelector( ".ProseMirror", ) as HTMLElement | null; if (pm) { setTimeout(() => pm!.focus(), 100); } } return; }
// Check for didMention inline nodes if (node?.type === schema.nodes.didMention) { window.open( didToBlueskyUrl(node.attrs.did), "_blank", "noopener,noreferrer", ); return; }
// Check for atMention inline nodes if (node?.type === schema.nodes.atMention) { if (node.attrs.href) window.open(node.attrs.href, "_blank", "noopener,noreferrer"); else { const url = atUriToUrl(node.attrs.atURI); window.open(url, "_blank", "noopener,noreferrer"); } return; }
if (node.nodeSize - 2 <= _pos) return; const nodeAt1 = node.nodeAt(_pos - 1); const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); let linkMark = nodeAt1?.marks.find((f) => f.type === schema.marks.link) || nodeAt2?.marks.find((f) => f.type === schema.marks.link); if (linkMark) { // cmd/ctrl+click opens in a new tab via the click DOM handler // above — don't open the edit popover or let ProseMirror treat // it as a select-node click. if (_event.metaKey || _event.ctrlKey) return true; let anchor = (_event.target as HTMLElement).closest( "a", ) as HTMLElement | null; if (anchor) { useLinkPopoverStore .getState() .open(linkMark.attrs.href, anchor, entityID); } return; } }, dispatchTransaction, }, );
const unsubscribe = useEditorStates.subscribe( (s) => s.editorStates[entityID], (editorState) => { if (editorState?.initial) return; if (editorState?.editor) editorState.view?.updateState(editorState.editor); }, );
let editorState = useEditorStates.getState().editorStates[entityID]; if (editorState?.editor && !editorState.initial) editorState.view?.updateState(editorState.editor);
return () => { unsubscribe(); view.destroy(); useEditorStates.setState((s) => ({ ...s, editorStates: { ...s.editorStates, [entityID]: undefined, }, })); };
function dispatchTransaction(this: EditorView, tr: any) { useEditorStates.setState((s) => { let oldEditorState = this.state; let newState = this.state.apply(tr); let docHasChanges = tr.steps.length !== 0 || tr.docChanged; // Changes synced in from a peer over yjs carry the ySync change origin. // The peer that made the edit already fired the orphan-diff mutations // below, so skip them here to avoid deleting the same footnote/comment // twice from every client. let isRemoteChange = !!tr.getMeta(ySyncPluginKey)?.isChangeOrigin;
// Diff for removed/added footnote nodes if (docHasChanges && !isRemoteChange) { let oldFootnotes = new Set<string>(); let newFootnotes = new Set<string>(); oldEditorState.doc.descendants((n) => { if (n.type.name === "footnote") oldFootnotes.add(n.attrs.footnoteEntityID); }); newState.doc.descendants((n) => { if (n.type.name === "footnote") newFootnotes.add(n.attrs.footnoteEntityID); }); // Removed footnotes for (let id of oldFootnotes) { if (!newFootnotes.has(id)) { repRef.current?.mutate.deleteFootnote({ footnoteEntityID: id, blockID: entityID, }); } }
// Diff comment marks: deleting all the commented text deletes the // comment, like removing a footnote node deletes the footnote let collectCommentIDs = (doc: Node, into: Set<string>) => { doc.descendants((n) => { for (let m of n.marks) if (m.type.name === "comment") for (let id of ((m.attrs.commentID as string) || "").split( " ", )) { if (id) into.add(id); } }); }; let oldComments = new Set<string>(); let newComments = new Set<string>(); collectCommentIDs(oldEditorState.doc, oldComments); collectCommentIDs(newState.doc, newComments); // Resolving a thread strips its anchor mark locally, so this also // fires for a just-resolved comment on the resolver's own client; // deleteEditorComment is idempotent, so the redundant delete is harmless. for (let id of oldComments) { if (!newComments.has(id)) { repRef.current?.mutate.deleteEditorComment({ commentEntityID: id, blockID: entityID, }); } } }
// Handle undo/redo history with timeout-based grouping let isBulkOp = tr.getMeta("bulkOp"); let setState = (s: EditorState) => () => useEditorStates.setState( produce((draft) => { let view = draft.editorStates[entityID]?.view; if (!view?.hasFocus() && !isBulkOp) view?.focus(); draft.editorStates[entityID]!.editor = s; }), );
trackUndoRedo( tr, rep.undoManager, entityID, setState(oldEditorState), setState(newState), );
return { editorStates: { ...s.editorStates, [entityID]: { editor: newState, view: this as unknown as EditorView, initial: false, keymap: km, }, }, }; }); } // `parent` is intentionally NOT a dependency: indent/outdent change a // block's parent but keep the same entityID, and rebuilding the EditorView // on reparent destroyed the caret (dropping focus to <body> on every // structural edit and its undo/redo). The view reads parent via propsRef. }, [entityID, value, cursorPlugin, handlePaste, rep]); return { mountRef, overlay };}
export function trackUndoRedo( tr: Transaction, undoManager: UndoManager, coalesceKey: string, undo: () => void, redo: () => void,) { let addToHistory = tr.getMeta("addToHistory"); let isBulkOp = tr.getMeta("bulkOp"); // externalUndoGroup: the caller already holds a command group open across its // async mutations (e.g. the Enter handler grouping a block split with the new // block's creation), so just add to it. bulkOp likewise manages its own // grouping. Otherwise coalesce consecutive text edits into one undo step // (the undo manager owns the coalescing window, so a command can flush it and // never merge with adjacent typing). let skipGroupManagement = isBulkOp || tr.getMeta("externalUndoGroup"); let docHasChanges = tr.steps.length !== 0 || tr.docChanged;
if (addToHistory !== false && docHasChanges) { if (skipGroupManagement) undoManager.add({ undo, redo }); else undoManager.addGrouped({ undo, redo }, coalesceKey); }}