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 { stripCommentMarks } from "components/Blocks/TextBlock/stripCommentMarks";
import { useReplicache, useEntity } from "src/replicache";
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 actionTimeout = useRef(null);
let { pageID } = useFootnoteContext();
useLayoutEffect(() => {
if (!mountRef.current || !value) return;
let plugins = [
ySyncPlugin(value),
cursorPlugin,
keymap({
"Meta-b": toggleMark(schema.marks.strong),
"Ctrl-b": toggleMark(schema.marks.strong),
"Meta-u": toggleMark(schema.marks.underline),
"Ctrl-u": toggleMark(schema.marks.underline),
"Meta-i": toggleMark(schema.marks.em),
"Ctrl-i": toggleMark(schema.marks.em),
"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;
},
}),
keymap(baseKeymap),
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,
actionTimeout,
() => {
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)
let unsubscribe = useEditorStates.subscribe((s) => {
let editorState = s.editorStates[props.footnoteEntityID];
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 (
);
}