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