import { useLayoutEffect, useRef } from "react"; import { EditorState, TextSelection } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { baseKeymap, toggleMark } from "prosemirror-commands"; import { keymap } from "prosemirror-keymap"; import { ySyncPlugin } from "y-prosemirror"; import { schema } from "components/Blocks/TextBlock/schema"; import { formattingKeymap } from "src/utils/prosemirror/formattingKeymap"; import { footnoteInputRules } from "components/Blocks/TextBlock/inputRules"; import { highlightSelectionPlugin } from "components/Blocks/TextBlock/plugins"; import { stripCommentMarks } from "components/Blocks/TextBlock/stripCommentMarks"; import { useReplicache, useEntity } from "src/replicache"; import { scanIndex } from "src/replicache/utils"; import { focusBlock } from "src/utils/focusBlock"; import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; import { betterIsUrl } from "src/utils/isURL"; import { trackUndoRedo } from "components/Blocks/TextBlock/mountProsemirror"; import { useCollabText } from "components/Blocks/TextBlock/useCollabText"; import { useStaleClient } from "components/Blocks/TextBlock/schemaVersion"; import { RenderYJSFragment } from "components/Blocks/TextBlock/RenderYJSFragment"; import { DeleteTiny } from "components/Icons/DeleteTiny"; import { FootnoteItemLayout } from "./FootnoteItemLayout"; import { useEditorStates } from "src/state/useEditorState"; import { useUIState } from "src/useUIState"; import { useFootnoteContext } from "./FootnoteContext"; export function FootnoteEditor(props: { footnoteEntityID: string; index: number; editable: boolean; onDelete?: () => void; autoFocus?: boolean; }) { // Read-only viewers don't need a live ProseMirror instance (with its yjs // doc, realtime registration, and remote-cursor overlay) per footnote — // render the stored content statically, the same way RenderedTextBlock does // for non-editable text blocks. Stale clients (see TextBlock/schemaVersion) // also render statically. let stale = useStaleClient((s) => s.stale); if (!props.editable || stale) return ( ); return ; } function RenderedFootnote(props: { footnoteEntityID: string; index: number }) { let content = useEntity(props.footnoteEntityID, "block/text"); return (
{content ? ( ) : ( Empty footnote )}
); } function EditableFootnote(props: { footnoteEntityID: string; index: number; editable: boolean; onDelete?: () => void; autoFocus?: boolean; }) { let mountRef = useRef(null); let rep = useReplicache(); let { yText: value, cursorPlugin, overlay, } = useCollabText(props.footnoteEntityID); let { pageID, footnotes } = useFootnoteContext(); // Read through a ref so the keymap (created once per mount) sees the current // footnote list without remounting the editor whenever it changes. let footnotesRef = useRef(footnotes); footnotesRef.current = footnotes; useLayoutEffect(() => { if (!mountRef.current || !value) return; let plugins = [ ySyncPlugin(value), cursorPlugin, keymap({ ...formattingKeymap(schema.marks), "Ctrl-Meta-h": (...args) => toggleMark(schema.marks.highlight, { color: useUIState.getState().lastUsedHighlight, })(...args), "Shift-Enter": (state, dispatch) => { let hardBreak = schema.nodes.hard_break.create(); if (dispatch) { dispatch(state.tr.replaceSelectionWith(hardBreak).scrollIntoView()); } return true; }, Enter: (_state, _dispatch, view) => { view?.dom.blur(); return true; }, ArrowLeft: (state, _dispatch, view) => { if (!state.selection.empty || state.selection.anchor > 1) return false; let fn = footnotesRef.current.find( (f) => f.footnoteEntityID === props.footnoteEntityID, ); if (!fn || !rep.rep) return false; let blockID = fn.blockID; view?.dom.blur(); rep.rep .query(async (tx) => { let scan = scanIndex(tx); return (await scan.eav(blockID, "block/type"))[0]?.data.value; }) .then((type) => { if (!type) return; focusBlock( { entityID: blockID, type, parent: pageID }, { type: "afterFootnote", footnoteEntityID: props.footnoteEntityID, }, ); }); return true; }, }), footnoteInputRules(), keymap(baseKeymap), highlightSelectionPlugin, autolink({ type: schema.marks.link, shouldAutoLink: () => true, defaultProtocol: "https", }), ]; let state = EditorState.create({ schema, plugins }); let view = new EditorView( { mount: mountRef.current }, { state, editable: () => props.editable, transformPasted: stripCommentMarks, handlePaste: (view, e) => { let text = e.clipboardData?.getData("text"); if (text && betterIsUrl(text)) { let selection = view.state.selection as TextSelection; let tr = view.state.tr; let { from, to } = selection; if (selection.empty) { tr.insertText(text, selection.from); tr.addMark( from, from + text.length, schema.marks.link.create({ href: text }), ); } else { tr.addMark(from, to, schema.marks.link.create({ href: text })); } view.dispatch(tr); return true; } }, handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { if (!direct) 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) { window.open(linkMark.attrs.href, "_blank"); return; } }, dispatchTransaction(this: EditorView, tr) { let oldState = this.state; let newState = this.state.apply(tr); this.updateState(newState); useEditorStates.setState((s) => ({ editorStates: { ...s.editorStates, [props.footnoteEntityID]: { editor: newState, view: this, }, }, })); trackUndoRedo( tr, rep.undoManager, props.footnoteEntityID, () => { this.focus(); this.updateState(oldState); }, () => { this.focus(); this.updateState(newState); }, ); }, }, ); // Register editor state useEditorStates.setState((s) => ({ editorStates: { ...s.editorStates, [props.footnoteEntityID]: { editor: view.state, view, }, }, })); // Subscribe to external state changes (e.g. link toolbar). Selector form: // a plain listener would fire on every editorStates write anywhere (every // keystroke in every block) and force a redundant ProseMirror reconcile // per mounted footnote editor. let unsubscribe = useEditorStates.subscribe( (s) => s.editorStates[props.footnoteEntityID], (editorState) => { if (editorState?.editor) editorState.view?.updateState(editorState.editor); }, ); // Set focusedEntity on focus let handleFocus = () => { useUIState.setState({ focusedEntity: { entityType: "footnote", entityID: props.footnoteEntityID, parent: pageID, }, }); }; view.dom.addEventListener("focus", handleFocus); if (props.autoFocus) { setTimeout(() => view.focus(), 50); } return () => { unsubscribe(); view.dom.removeEventListener("focus", handleFocus); view.destroy(); useEditorStates.setState((s) => { let { [props.footnoteEntityID]: _, ...rest } = s.editorStates; return { editorStates: rest }; }); }; }, [ props.footnoteEntityID, value, cursorPlugin, props.editable, props.autoFocus, rep.undoManager, pageID, ]); return (
{ let pm = mountRef.current?.querySelector( ".ProseMirror", ) as HTMLElement | null; if (pm) { pm.focus(); } }} trailing={ props.editable && props.onDelete ? ( ) : undefined } > {overlay}
); } function FootnoteDeleteButton(props: { footnoteEntityID: string; onDelete: () => void; }) { let isActive = useUIState( (s) => s.focusedEntity?.entityType === "footnote" && s.focusedEntity.entityID === props.footnoteEntityID, ); return ( ); }