forked from
leaflet.pub/leaflet
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 if (node.nodeSize - 2 <= _pos) return;
84
85 // Check for marks at the clicked position
86 const nodeAt1 = node.nodeAt(_pos - 1);
87 const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0));
88
89 // Check for link marks
90 let linkMark = nodeAt1?.marks.find((f) => f.type === schema.marks.link) ||
91 nodeAt2?.marks.find((f) => f.type === schema.marks.link);
92 if (linkMark) {
93 window.open(linkMark.attrs.href, "_blank");
94 return;
95 }
96
97 // Check for didMention inline nodes
98 if (nodeAt1?.type === schema.nodes.didMention) {
99 window.open(didToBlueskyUrl(nodeAt1.attrs.did), "_blank", "noopener,noreferrer");
100 return;
101 }
102 if (nodeAt2?.type === schema.nodes.didMention) {
103 window.open(didToBlueskyUrl(nodeAt2.attrs.did), "_blank", "noopener,noreferrer");
104 return;
105 }
106
107 // Check for atMention inline nodes
108 if (nodeAt1?.type === schema.nodes.atMention) {
109 const url = atUriToUrl(nodeAt1.attrs.atURI);
110 window.open(url, "_blank", "noopener,noreferrer");
111 return;
112 }
113 if (nodeAt2?.type === schema.nodes.atMention) {
114 const url = atUriToUrl(nodeAt2.attrs.atURI);
115 window.open(url, "_blank", "noopener,noreferrer");
116 return;
117 }
118 },
119 dispatchTransaction,
120 },
121 );
122
123 const unsubscribe = useEditorStates.subscribe((s) => {
124 let editorState = s.editorStates[entityID];
125 if (editorState?.initial) return;
126 if (editorState?.editor)
127 editorState.view?.updateState(editorState.editor);
128 });
129
130 let editorState = useEditorStates.getState().editorStates[entityID];
131 if (editorState?.editor && !editorState.initial)
132 editorState.view?.updateState(editorState.editor);
133
134 return () => {
135 unsubscribe();
136 view.destroy();
137 useEditorStates.setState((s) => ({
138 ...s,
139 editorStates: {
140 ...s.editorStates,
141 [entityID]: undefined,
142 },
143 }));
144 };
145
146 function dispatchTransaction(this: EditorView, tr: any) {
147 useEditorStates.setState((s) => {
148 let oldEditorState = this.state;
149 let newState = this.state.apply(tr);
150 let addToHistory = tr.getMeta("addToHistory");
151 let isBulkOp = tr.getMeta("bulkOp");
152 let docHasChanges = tr.steps.length !== 0 || tr.docChanged;
153
154 // Handle undo/redo history with timeout-based grouping
155 if (addToHistory !== false && docHasChanges) {
156 if (actionTimeout.current) window.clearTimeout(actionTimeout.current);
157 else if (!isBulkOp) rep.undoManager.startGroup();
158
159 if (!isBulkOp) {
160 actionTimeout.current = window.setTimeout(() => {
161 rep.undoManager.endGroup();
162 actionTimeout.current = null;
163 }, 200);
164 }
165
166 let setState = (s: EditorState) => () =>
167 useEditorStates.setState(
168 produce((draft) => {
169 let view = draft.editorStates[entityID]?.view;
170 if (!view?.hasFocus() && !isBulkOp) view?.focus();
171 draft.editorStates[entityID]!.editor = s;
172 }),
173 );
174
175 rep.undoManager.add({
176 redo: setState(newState),
177 undo: setState(oldEditorState),
178 });
179 }
180
181 return {
182 editorStates: {
183 ...s.editorStates,
184 [entityID]: {
185 editor: newState,
186 view: this as unknown as EditorView,
187 initial: false,
188 keymap: km,
189 },
190 },
191 };
192 });
193 }
194 }, [entityID, parent, value, handlePaste, rep]);
195 return { mountRef, actionTimeout };
196}
197
198function useYJSValue(entityID: string) {
199 const [ydoc] = useState(new Y.Doc());
200 const docStateFromReplicache = useEntity(entityID, "block/text");
201 let rep = useReplicache();
202 const [yText] = useState(ydoc.getXmlFragment("prosemirror"));
203
204 if (docStateFromReplicache) {
205 const update = base64.toByteArray(docStateFromReplicache.data.value);
206 Y.applyUpdate(ydoc, update);
207 }
208
209 useEffect(() => {
210 if (!rep.rep) return;
211 let timeout = null as null | number;
212 const updateReplicache = async () => {
213 const update = Y.encodeStateAsUpdate(ydoc);
214 await rep.rep?.mutate.assertFact({
215 //These undos are handled above in the Prosemirror context
216 ignoreUndo: true,
217 entity: entityID,
218 attribute: "block/text",
219 data: {
220 value: base64.fromByteArray(update),
221 type: "text",
222 },
223 });
224 };
225 const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => {
226 if (!transaction.origin) return;
227 if (timeout) clearTimeout(timeout);
228 timeout = window.setTimeout(async () => {
229 updateReplicache();
230 }, 300);
231 };
232
233 yText.observeDeep(f);
234 return () => {
235 yText.unobserveDeep(f);
236 };
237 }, [yText, entityID, rep, ydoc]);
238 return yText;
239}