a tool for shared writing and social publishing
1import { useLayoutEffect, useRef } from "react";
2import { EditorState, TextSelection } from "prosemirror-state";
3import { EditorView } from "prosemirror-view";
4import { baseKeymap, toggleMark } from "prosemirror-commands";
5import { keymap } from "prosemirror-keymap";
6import { ySyncPlugin } from "y-prosemirror";
7import { schema } from "components/Blocks/TextBlock/schema";
8import { useReplicache } from "src/replicache";
9import { autolink } from "components/Blocks/TextBlock/autolink-plugin";
10import { betterIsUrl } from "src/utils/isURL";
11import {
12 useYJSValue,
13 trackUndoRedo,
14} from "components/Blocks/TextBlock/mountProsemirror";
15import { DeleteTiny } from "components/Icons/DeleteTiny";
16import { FootnoteItemLayout } from "./FootnoteItemLayout";
17import { useEditorStates } from "src/state/useEditorState";
18import { useUIState } from "src/useUIState";
19import { useFootnoteContext } from "./FootnoteContext";
20
21export function FootnoteEditor(props: {
22 footnoteEntityID: string;
23 index: number;
24 editable: boolean;
25 onDelete?: () => void;
26 autoFocus?: boolean;
27}) {
28 let mountRef = useRef<HTMLDivElement | null>(null);
29 let rep = useReplicache();
30 let value = useYJSValue(props.footnoteEntityID);
31 let actionTimeout = useRef<number | null>(null);
32 let { pageID } = useFootnoteContext();
33
34 useLayoutEffect(() => {
35 if (!mountRef.current || !value) return;
36
37 let plugins = [
38 ySyncPlugin(value),
39 keymap({
40 "Meta-b": toggleMark(schema.marks.strong),
41 "Ctrl-b": toggleMark(schema.marks.strong),
42 "Meta-u": toggleMark(schema.marks.underline),
43 "Ctrl-u": toggleMark(schema.marks.underline),
44 "Meta-i": toggleMark(schema.marks.em),
45 "Ctrl-i": toggleMark(schema.marks.em),
46 "Shift-Enter": (state, dispatch) => {
47 let hardBreak = schema.nodes.hard_break.create();
48 if (dispatch) {
49 dispatch(state.tr.replaceSelectionWith(hardBreak).scrollIntoView());
50 }
51 return true;
52 },
53 Enter: (_state, _dispatch, view) => {
54 view?.dom.blur();
55 return true;
56 },
57 }),
58 keymap(baseKeymap),
59 autolink({
60 type: schema.marks.link,
61 shouldAutoLink: () => true,
62 defaultProtocol: "https",
63 }),
64 ];
65
66 let state = EditorState.create({ schema, plugins });
67 let view = new EditorView(
68 { mount: mountRef.current },
69 {
70 state,
71 editable: () => props.editable,
72 handlePaste: (view, e) => {
73 let text = e.clipboardData?.getData("text");
74 if (text && betterIsUrl(text)) {
75 let selection = view.state.selection as TextSelection;
76 let tr = view.state.tr;
77 let { from, to } = selection;
78 if (selection.empty) {
79 tr.insertText(text, selection.from);
80 tr.addMark(
81 from,
82 from + text.length,
83 schema.marks.link.create({ href: text }),
84 );
85 } else {
86 tr.addMark(from, to, schema.marks.link.create({ href: text }));
87 }
88 view.dispatch(tr);
89 return true;
90 }
91 },
92 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => {
93 if (!direct) return;
94 if (node.nodeSize - 2 <= _pos) return;
95 const nodeAt1 = node.nodeAt(_pos - 1);
96 const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0));
97 let linkMark =
98 nodeAt1?.marks.find((f) => f.type === schema.marks.link) ||
99 nodeAt2?.marks.find((f) => f.type === schema.marks.link);
100 if (linkMark) {
101 window.open(linkMark.attrs.href, "_blank");
102 return;
103 }
104 },
105 dispatchTransaction(this: EditorView, tr) {
106 let oldState = this.state;
107 let newState = this.state.apply(tr);
108 this.updateState(newState);
109
110 useEditorStates.setState((s) => ({
111 editorStates: {
112 ...s.editorStates,
113 [props.footnoteEntityID]: {
114 editor: newState,
115 view: this,
116 },
117 },
118 }));
119
120 trackUndoRedo(
121 tr,
122 rep.undoManager,
123 actionTimeout,
124 () => {
125 this.focus();
126 this.updateState(oldState);
127 },
128 () => {
129 this.focus();
130 this.updateState(newState);
131 },
132 );
133 },
134 },
135 );
136
137 // Register editor state
138 useEditorStates.setState((s) => ({
139 editorStates: {
140 ...s.editorStates,
141 [props.footnoteEntityID]: {
142 editor: view.state,
143 view,
144 },
145 },
146 }));
147
148 // Subscribe to external state changes (e.g. link toolbar)
149 let unsubscribe = useEditorStates.subscribe((s) => {
150 let editorState = s.editorStates[props.footnoteEntityID];
151 if (editorState?.editor)
152 editorState.view?.updateState(editorState.editor);
153 });
154
155 // Set focusedEntity on focus
156 let handleFocus = () => {
157 useUIState.setState({
158 focusedEntity: {
159 entityType: "footnote",
160 entityID: props.footnoteEntityID,
161 parent: pageID,
162 },
163 });
164 };
165 view.dom.addEventListener("focus", handleFocus);
166
167 if (props.autoFocus) {
168 setTimeout(() => view.focus(), 50);
169 }
170
171 return () => {
172 unsubscribe();
173 view.dom.removeEventListener("focus", handleFocus);
174 view.destroy();
175 useEditorStates.setState((s) => {
176 let { [props.footnoteEntityID]: _, ...rest } = s.editorStates;
177 return { editorStates: rest };
178 });
179 };
180 }, [
181 props.footnoteEntityID,
182 value,
183 props.editable,
184 props.autoFocus,
185 rep.undoManager,
186 pageID,
187 ]);
188
189 return (
190 <div data-footnote-editor={props.footnoteEntityID}>
191 <FootnoteItemLayout
192 index={props.index}
193 indexAction={() => {
194 let pm = mountRef.current?.querySelector(
195 ".ProseMirror",
196 ) as HTMLElement | null;
197 if (pm) {
198 pm.focus();
199 }
200 }}
201 trailing={
202 props.editable && props.onDelete ? (
203 <FootnoteDeleteButton
204 footnoteEntityID={props.footnoteEntityID}
205 onDelete={props.onDelete}
206 />
207 ) : undefined
208 }
209 >
210 <div ref={mountRef} className="outline-hidden" />
211 </FootnoteItemLayout>
212 </div>
213 );
214}
215
216function FootnoteDeleteButton(props: {
217 footnoteEntityID: string;
218 onDelete: () => void;
219}) {
220 let isActive = useUIState(
221 (s) =>
222 s.focusedEntity?.entityType === "footnote" &&
223 s.focusedEntity.entityID === props.footnoteEntityID,
224 );
225
226 return (
227 <button
228 className={`shrink-0 mt-0.5 text-tertiary hover:text-accent-contrast transition-opacity ${isActive ? "opacity-100" : "opacity-0"}`}
229 onClick={props.onDelete}
230 title="Delete footnote"
231 >
232 <DeleteTiny />
233 </button>
234 );
235}