a tool for shared writing and social publishing
1import { useLayoutEffect, useRef, useEffect, useState } from "react";
2import { EditorState, Transaction } from "prosemirror-state";
3import { EditorView } from "prosemirror-view";
4import { baseKeymap } from "prosemirror-commands";
5import { keymap } from "prosemirror-keymap";
6import { ySyncPlugin } from "y-prosemirror";
7import * as Y from "yjs";
8import * as base64 from "base64-js";
9import { Replicache } from "replicache";
10import { produce } from "immer";
11
12import { schema } from "./schema";
13import { UndoManager } from "src/undoManager";
14import { TextBlockKeymap } from "./keymap";
15import { inputrules } from "./inputRules";
16import { highlightSelectionPlugin } from "./plugins";
17import { autolink } from "./autolink-plugin";
18import { useEditorStates } from "src/state/useEditorState";
19import {
20 useEntity,
21 useReplicache,
22 type ReplicacheMutators,
23} from "src/replicache";
24import { useHandlePaste } from "./useHandlePaste";
25import { BlockProps } from "../Block";
26import { useEntitySetContext } from "components/EntitySetProvider";
27import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils";
28import { useFootnotePopoverStore } from "components/Footnotes/FootnotePopover";
29
30export function useMountProsemirror({
31 props,
32 openMentionAutocomplete,
33}: {
34 props: BlockProps;
35 openMentionAutocomplete: () => void;
36}) {
37 let { entityID, parent } = props;
38 let rep = useReplicache();
39 let mountRef = useRef<HTMLPreElement | null>(null);
40 const repRef = useRef<Replicache<ReplicacheMutators> | null>(null);
41 let value = useYJSValue(entityID);
42 let entity_set = useEntitySetContext();
43 let alignment =
44 useEntity(entityID, "block/text-alignment")?.data.value || "left";
45 let propsRef = useRef({ ...props, entity_set, alignment });
46 let handlePaste = useHandlePaste(entityID, propsRef);
47
48 const actionTimeout = useRef<number | null>(null);
49
50 propsRef.current = { ...props, entity_set, alignment };
51 repRef.current = rep.rep;
52
53 useLayoutEffect(() => {
54 if (!mountRef.current) return;
55
56 const km = TextBlockKeymap(
57 propsRef,
58 repRef,
59 rep.undoManager,
60 openMentionAutocomplete,
61 );
62 const editor = EditorState.create({
63 schema: schema,
64 plugins: [
65 ySyncPlugin(value),
66 keymap(km),
67 inputrules(propsRef, repRef, openMentionAutocomplete),
68 keymap(baseKeymap),
69 highlightSelectionPlugin,
70 autolink({
71 type: schema.marks.link,
72 shouldAutoLink: () => true,
73 defaultProtocol: "https",
74 }),
75 ],
76 });
77
78 const view = new EditorView(
79 { mount: mountRef.current },
80 {
81 state: editor,
82 handlePaste,
83 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => {
84 if (!direct) return;
85
86 // Check for footnote inline nodes
87 if (node?.type === schema.nodes.footnote) {
88 let footnoteID = node.attrs.footnoteEntityID;
89 let supEl = _event.target as HTMLElement;
90 let sup = supEl.closest(".footnote-ref") as HTMLElement | null;
91 if (!sup) return;
92
93 // On mobile/tablet or canvas, show popover
94 let isDesktop = window.matchMedia("(min-width: 1280px)").matches;
95 let isCanvas = propsRef.current.pageType === "canvas";
96 if (!isDesktop || isCanvas) {
97 let store = useFootnotePopoverStore.getState();
98 if (store.activeFootnoteID === footnoteID) {
99 store.close();
100 } else {
101 store.open(footnoteID, sup);
102 }
103 return;
104 }
105
106 // On desktop, prefer the side column editor if visible
107 let sideColumn = document.querySelector(".footnote-side-column");
108 let editor = sideColumn?.querySelector(
109 `[data-footnote-editor="${footnoteID}"]`,
110 ) as HTMLElement | null;
111 // Fall back to the bottom section
112 if (!editor) {
113 editor = document.querySelector(
114 `[data-footnote-editor="${footnoteID}"]`,
115 ) as HTMLElement | null;
116 }
117 if (editor) {
118 editor.scrollIntoView({ behavior: "smooth", block: "nearest" });
119 let pm = editor.querySelector(
120 ".ProseMirror",
121 ) as HTMLElement | null;
122 if (pm) {
123 setTimeout(() => pm!.focus(), 100);
124 }
125 }
126 return;
127 }
128
129 // Check for didMention inline nodes
130 if (node?.type === schema.nodes.didMention) {
131 window.open(
132 didToBlueskyUrl(node.attrs.did),
133 "_blank",
134 "noopener,noreferrer",
135 );
136 return;
137 }
138
139 // Check for atMention inline nodes
140 if (node?.type === schema.nodes.atMention) {
141 const url = atUriToUrl(node.attrs.atURI);
142 window.open(url, "_blank", "noopener,noreferrer");
143 return;
144 }
145 if (node.nodeSize - 2 <= _pos) return;
146
147 // Check for marks at the clicked position
148 const nodeAt1 = node.nodeAt(_pos - 1);
149 const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0));
150
151 // Check for link marks
152 let linkMark =
153 nodeAt1?.marks.find((f) => f.type === schema.marks.link) ||
154 nodeAt2?.marks.find((f) => f.type === schema.marks.link);
155 if (linkMark) {
156 window.open(linkMark.attrs.href, "_blank");
157 return;
158 }
159 },
160 dispatchTransaction,
161 },
162 );
163
164 const unsubscribe = useEditorStates.subscribe((s) => {
165 let editorState = s.editorStates[entityID];
166 if (editorState?.initial) return;
167 if (editorState?.editor)
168 editorState.view?.updateState(editorState.editor);
169 });
170
171 let editorState = useEditorStates.getState().editorStates[entityID];
172 if (editorState?.editor && !editorState.initial)
173 editorState.view?.updateState(editorState.editor);
174
175 return () => {
176 unsubscribe();
177 view.destroy();
178 useEditorStates.setState((s) => ({
179 ...s,
180 editorStates: {
181 ...s.editorStates,
182 [entityID]: undefined,
183 },
184 }));
185 };
186
187 function dispatchTransaction(this: EditorView, tr: any) {
188 useEditorStates.setState((s) => {
189 let oldEditorState = this.state;
190 let newState = this.state.apply(tr);
191 let docHasChanges = tr.steps.length !== 0 || tr.docChanged;
192
193 // Diff for removed/added footnote nodes
194 if (docHasChanges) {
195 let oldFootnotes = new Set<string>();
196 let newFootnotes = new Set<string>();
197 oldEditorState.doc.descendants((n) => {
198 if (n.type.name === "footnote")
199 oldFootnotes.add(n.attrs.footnoteEntityID);
200 });
201 newState.doc.descendants((n) => {
202 if (n.type.name === "footnote")
203 newFootnotes.add(n.attrs.footnoteEntityID);
204 });
205 // Removed footnotes
206 for (let id of oldFootnotes) {
207 if (!newFootnotes.has(id)) {
208 repRef.current?.mutate.deleteFootnote({
209 footnoteEntityID: id,
210 blockID: entityID,
211 });
212 }
213 }
214 }
215
216 // Handle undo/redo history with timeout-based grouping
217 let isBulkOp = tr.getMeta("bulkOp");
218 let setState = (s: EditorState) => () =>
219 useEditorStates.setState(
220 produce((draft) => {
221 let view = draft.editorStates[entityID]?.view;
222 if (!view?.hasFocus() && !isBulkOp) view?.focus();
223 draft.editorStates[entityID]!.editor = s;
224 }),
225 );
226
227 trackUndoRedo(
228 tr,
229 rep.undoManager,
230 actionTimeout,
231 setState(oldEditorState),
232 setState(newState),
233 );
234
235 return {
236 editorStates: {
237 ...s.editorStates,
238 [entityID]: {
239 editor: newState,
240 view: this as unknown as EditorView,
241 initial: false,
242 keymap: km,
243 },
244 },
245 };
246 });
247 }
248 }, [entityID, parent, value, handlePaste, rep]);
249 return { mountRef, actionTimeout };
250}
251
252export function trackUndoRedo(
253 tr: Transaction,
254 undoManager: UndoManager,
255 actionTimeout: { current: number | null },
256 undo: () => void,
257 redo: () => void,
258) {
259 let addToHistory = tr.getMeta("addToHistory");
260 let isBulkOp = tr.getMeta("bulkOp");
261 let docHasChanges = tr.steps.length !== 0 || tr.docChanged;
262
263 if (addToHistory !== false && docHasChanges) {
264 if (actionTimeout.current) window.clearTimeout(actionTimeout.current);
265 else if (!isBulkOp) undoManager.startGroup();
266
267 if (!isBulkOp) {
268 actionTimeout.current = window.setTimeout(() => {
269 undoManager.endGroup();
270 actionTimeout.current = null;
271 }, 200);
272 }
273
274 undoManager.add({ undo, redo });
275 }
276}
277
278export function useYJSValue(entityID: string) {
279 const [ydoc] = useState(new Y.Doc());
280 const docStateFromReplicache = useEntity(entityID, "block/text");
281 let rep = useReplicache();
282 const [yText] = useState(ydoc.getXmlFragment("prosemirror"));
283
284 if (docStateFromReplicache) {
285 const update = base64.toByteArray(docStateFromReplicache.data.value);
286 Y.applyUpdate(ydoc, update);
287 }
288
289 useEffect(() => {
290 if (!rep.rep) return;
291 let timeout = null as null | number;
292 const updateReplicache = async () => {
293 const update = Y.encodeStateAsUpdate(ydoc);
294 await rep.rep?.mutate.assertFact({
295 //These undos are handled above in the Prosemirror context
296 ignoreUndo: true,
297 entity: entityID,
298 attribute: "block/text",
299 data: {
300 value: base64.fromByteArray(update),
301 type: "text",
302 },
303 });
304 };
305 const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => {
306 if (!transaction.origin) return;
307 if (timeout) clearTimeout(timeout);
308 timeout = window.setTimeout(async () => {
309 updateReplicache();
310 }, 300);
311 };
312
313 yText.observeDeep(f);
314 return () => {
315 yText.unobserveDeep(f);
316 };
317 }, [yText, entityID, rep, ydoc]);
318 return yText;
319}