a tool for shared writing and social publishing
1import { useLayoutEffect, useRef, useEffect, useState } from "react";
2import { EditorState } 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 { TextBlockKeymap } from "./keymap";
14import { inputrules } from "./inputRules";
15import { highlightSelectionPlugin } from "./plugins";
16import { autolink } from "./autolink-plugin";
17import { useEditorStates } from "src/state/useEditorState";
18import {
19 useEntity,
20 useReplicache,
21 type ReplicacheMutators,
22} from "src/replicache";
23import { useHandlePaste } from "./useHandlePaste";
24import { BlockProps } from "../Block";
25import { useEntitySetContext } from "components/EntitySetProvider";
26import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils";
27
28export function useMountProsemirror({
29 props,
30 openMentionAutocomplete,
31}: {
32 props: BlockProps;
33 openMentionAutocomplete: () => void;
34}) {
35 let { entityID, parent } = props;
36 let rep = useReplicache();
37 let mountRef = useRef<HTMLPreElement | null>(null);
38 const repRef = useRef<Replicache<ReplicacheMutators> | null>(null);
39 let value = useYJSValue(entityID);
40 let entity_set = useEntitySetContext();
41 let alignment =
42 useEntity(entityID, "block/text-alignment")?.data.value || "left";
43 let propsRef = useRef({ ...props, entity_set, alignment });
44 let handlePaste = useHandlePaste(entityID, propsRef);
45
46 const actionTimeout = useRef<number | null>(null);
47
48 propsRef.current = { ...props, entity_set, alignment };
49 repRef.current = rep.rep;
50
51 useLayoutEffect(() => {
52 if (!mountRef.current) return;
53
54 const km = TextBlockKeymap(
55 propsRef,
56 repRef,
57 rep.undoManager,
58 openMentionAutocomplete,
59 );
60 const editor = EditorState.create({
61 schema: schema,
62 plugins: [
63 ySyncPlugin(value),
64 keymap(km),
65 inputrules(propsRef, repRef, openMentionAutocomplete),
66 keymap(baseKeymap),
67 highlightSelectionPlugin,
68 autolink({
69 type: schema.marks.link,
70 shouldAutoLink: () => true,
71 defaultProtocol: "https",
72 }),
73 ],
74 });
75
76 const view = new EditorView(
77 { mount: mountRef.current },
78 {
79 state: editor,
80 handlePaste,
81 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => {
82 if (!direct) return;
83
84 // Check for didMention inline nodes
85 if (node?.type === schema.nodes.didMention) {
86 window.open(
87 didToBlueskyUrl(node.attrs.did),
88 "_blank",
89 "noopener,noreferrer",
90 );
91 return;
92 }
93
94 // Check for atMention inline nodes
95 if (node?.type === schema.nodes.atMention) {
96 const url = atUriToUrl(node.attrs.atURI);
97 window.open(url, "_blank", "noopener,noreferrer");
98 return;
99 }
100 if (node.nodeSize - 2 <= _pos) return;
101
102 // Check for marks at the clicked position
103 const nodeAt1 = node.nodeAt(_pos - 1);
104 const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0));
105
106 // Check for link marks
107 let linkMark =
108 nodeAt1?.marks.find((f) => f.type === schema.marks.link) ||
109 nodeAt2?.marks.find((f) => f.type === schema.marks.link);
110 if (linkMark) {
111 window.open(linkMark.attrs.href, "_blank");
112 return;
113 }
114 },
115 dispatchTransaction,
116 },
117 );
118
119 const unsubscribe = useEditorStates.subscribe((s) => {
120 let editorState = s.editorStates[entityID];
121 if (editorState?.initial) return;
122 if (editorState?.editor)
123 editorState.view?.updateState(editorState.editor);
124 });
125
126 let editorState = useEditorStates.getState().editorStates[entityID];
127 if (editorState?.editor && !editorState.initial)
128 editorState.view?.updateState(editorState.editor);
129
130 return () => {
131 unsubscribe();
132 view.destroy();
133 useEditorStates.setState((s) => ({
134 ...s,
135 editorStates: {
136 ...s.editorStates,
137 [entityID]: undefined,
138 },
139 }));
140 };
141
142 function dispatchTransaction(this: EditorView, tr: any) {
143 useEditorStates.setState((s) => {
144 let oldEditorState = this.state;
145 let newState = this.state.apply(tr);
146 let addToHistory = tr.getMeta("addToHistory");
147 let isBulkOp = tr.getMeta("bulkOp");
148 let docHasChanges = tr.steps.length !== 0 || tr.docChanged;
149
150 // Handle undo/redo history with timeout-based grouping
151 if (addToHistory !== false && docHasChanges) {
152 if (actionTimeout.current) window.clearTimeout(actionTimeout.current);
153 else if (!isBulkOp) rep.undoManager.startGroup();
154
155 if (!isBulkOp) {
156 actionTimeout.current = window.setTimeout(() => {
157 rep.undoManager.endGroup();
158 actionTimeout.current = null;
159 }, 200);
160 }
161
162 let setState = (s: EditorState) => () =>
163 useEditorStates.setState(
164 produce((draft) => {
165 let view = draft.editorStates[entityID]?.view;
166 if (!view?.hasFocus() && !isBulkOp) view?.focus();
167 draft.editorStates[entityID]!.editor = s;
168 }),
169 );
170
171 rep.undoManager.add({
172 redo: setState(newState),
173 undo: setState(oldEditorState),
174 });
175 }
176
177 return {
178 editorStates: {
179 ...s.editorStates,
180 [entityID]: {
181 editor: newState,
182 view: this as unknown as EditorView,
183 initial: false,
184 keymap: km,
185 },
186 },
187 };
188 });
189 }
190 }, [entityID, parent, value, handlePaste, rep]);
191 return { mountRef, actionTimeout };
192}
193
194function useYJSValue(entityID: string) {
195 const [ydoc] = useState(new Y.Doc());
196 const docStateFromReplicache = useEntity(entityID, "block/text");
197 let rep = useReplicache();
198 const [yText] = useState(ydoc.getXmlFragment("prosemirror"));
199
200 if (docStateFromReplicache) {
201 const update = base64.toByteArray(docStateFromReplicache.data.value);
202 Y.applyUpdate(ydoc, update);
203 }
204
205 useEffect(() => {
206 if (!rep.rep) return;
207 let timeout = null as null | number;
208 const updateReplicache = async () => {
209 const update = Y.encodeStateAsUpdate(ydoc);
210 await rep.rep?.mutate.assertFact({
211 //These undos are handled above in the Prosemirror context
212 ignoreUndo: true,
213 entity: entityID,
214 attribute: "block/text",
215 data: {
216 value: base64.fromByteArray(update),
217 type: "text",
218 },
219 });
220 };
221 const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => {
222 if (!transaction.origin) return;
223 if (timeout) clearTimeout(timeout);
224 timeout = window.setTimeout(async () => {
225 updateReplicache();
226 }, 300);
227 };
228
229 yText.observeDeep(f);
230 return () => {
231 yText.unobserveDeep(f);
232 };
233 }, [yText, entityID, rep, ydoc]);
234 return yText;
235}