tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
289
fork
atom
a tool for shared writing and social publishing
289
fork
atom
overview
issues
28
pulls
pipelines
extract out prosemirror mounting logic from textblock
awarm.space
4 months ago
046e4251
d2d722bd
+223
-201
4 changed files
expand all
collapse all
unified
split
components
Blocks
TextBlock
index.tsx
mountProsemirror.ts
package-lock.json
package.json
+18
-196
components/Blocks/TextBlock/index.tsx
···
1
-
import { useRef, useEffect, useState, useLayoutEffect } from "react";
2
import { elementId } from "src/utils/elementId";
3
-
import { baseKeymap } from "prosemirror-commands";
4
-
import { keymap } from "prosemirror-keymap";
5
-
import * as Y from "yjs";
6
-
import * as base64 from "base64-js";
7
-
import { useReplicache, useEntity, ReplicacheMutators } from "src/replicache";
8
import { isVisible } from "src/utils/isVisible";
9
-
10
import { EditorState, TextSelection } from "prosemirror-state";
11
-
import { EditorView } from "prosemirror-view";
12
-
13
-
import { ySyncPlugin } from "y-prosemirror";
14
-
import { Replicache } from "replicache";
15
import { RenderYJSFragment } from "./RenderYJSFragment";
16
import { useInitialPageLoad } from "components/InitialPageLoadProvider";
17
import { BlockProps } from "../Block";
18
import { focusBlock } from "src/utils/focusBlock";
19
-
import { TextBlockKeymap } from "./keymap";
20
-
import { multiBlockSchema, schema } from "./schema";
21
import { useUIState } from "src/useUIState";
22
import { addBlueskyPostBlock, addLinkBlock } from "src/utils/addLinkBlock";
23
import { BlockCommandBar } from "components/Blocks/BlockCommandBar";
24
import { useEditorStates } from "src/state/useEditorState";
25
import { useEntitySetContext } from "components/EntitySetProvider";
26
-
import { useHandlePaste } from "./useHandlePaste";
27
-
import { highlightSelectionPlugin } from "./plugins";
28
-
import { inputrules } from "./inputRules";
29
-
import { autolink } from "./autolink-plugin";
30
import { TooltipButton } from "components/Buttons";
31
import { blockCommands } from "../BlockCommands";
32
import { betterIsUrl } from "src/utils/isURL";
···
37
import { isIOS } from "src/utils/isDevice";
38
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
39
import { DotLoader } from "components/utils/DotLoader";
0
40
41
const HeadingStyle = {
42
1: "text-xl font-bold",
···
177
}
178
179
export function BaseTextBlock(props: BlockProps & { className?: string }) {
180
-
let mountRef = useRef<HTMLPreElement | null>(null);
181
-
let actionTimeout = useRef<number | null>(null);
182
-
let repRef = useRef<null | Replicache<ReplicacheMutators>>(null);
183
let headingLevel = useEntity(props.entityID, "block/heading-level");
184
-
let entity_set = useEntitySetContext();
185
let alignment =
186
useEntity(props.entityID, "block/text-alignment")?.data.value || "left";
187
-
let propsRef = useRef({ ...props, entity_set, alignment });
188
-
useEffect(() => {
189
-
propsRef.current = { ...props, entity_set, alignment };
190
-
}, [props, entity_set, alignment]);
191
let rep = useReplicache();
192
-
useEffect(() => {
193
-
repRef.current = rep.rep;
194
-
}, [rep?.rep]);
195
196
let selected = useUIState(
197
(s) => !!s.selectedBlocks.find((b) => b.value === props.entityID),
···
204
justify: "text-justify",
205
}[alignment];
206
207
-
let value = useYJSValue(props.entityID);
208
-
209
let editorState = useEditorStates(
210
(s) => s.editorStates[props.entityID],
211
)?.editor;
212
-
let handlePaste = useHandlePaste(props.entityID, propsRef);
213
-
useLayoutEffect(() => {
214
-
if (!mountRef.current) return;
215
-
let km = TextBlockKeymap(propsRef, repRef, rep.undoManager);
216
-
let editor = EditorState.create({
217
-
schema: schema,
218
-
plugins: [
219
-
ySyncPlugin(value),
220
-
keymap(km),
221
-
inputrules(propsRef, repRef),
222
-
keymap(baseKeymap),
223
-
highlightSelectionPlugin,
224
-
autolink({
225
-
type: schema.marks.link,
226
-
shouldAutoLink: () => true,
227
-
defaultProtocol: "https",
228
-
}),
229
-
],
230
-
});
231
232
-
let unsubscribe = useEditorStates.subscribe((s) => {
233
-
let editorState = s.editorStates[props.entityID];
234
-
if (editorState?.initial) return;
235
-
if (editorState?.editor)
236
-
editorState.view?.updateState(editorState.editor);
237
-
});
238
-
let view = new EditorView(
239
-
{ mount: mountRef.current },
240
-
{
241
-
state: editor,
242
-
handlePaste,
243
-
handleClickOn: (view, _pos, node, _nodePos, _event, direct) => {
244
-
if (!direct) return;
245
-
if (node.nodeSize - 2 <= _pos) return;
246
-
let mark =
247
-
node
248
-
.nodeAt(_pos - 1)
249
-
?.marks.find((f) => f.type === schema.marks.link) ||
250
-
node
251
-
.nodeAt(Math.max(_pos - 2, 0))
252
-
?.marks.find((f) => f.type === schema.marks.link);
253
-
if (mark) {
254
-
window.open(mark.attrs.href, "_blank");
255
-
}
256
-
},
257
-
dispatchTransaction(tr) {
258
-
useEditorStates.setState((s) => {
259
-
let oldEditorState = this.state;
260
-
let newState = this.state.apply(tr);
261
-
let addToHistory = tr.getMeta("addToHistory");
262
-
let isBulkOp = tr.getMeta("bulkOp");
263
-
let docHasChanges = tr.steps.length !== 0 || tr.docChanged;
264
-
if (addToHistory !== false && docHasChanges) {
265
-
if (actionTimeout.current) {
266
-
window.clearTimeout(actionTimeout.current);
267
-
} else {
268
-
if (!isBulkOp) rep.undoManager.startGroup();
269
-
}
270
-
271
-
if (!isBulkOp)
272
-
actionTimeout.current = window.setTimeout(() => {
273
-
rep.undoManager.endGroup();
274
-
actionTimeout.current = null;
275
-
}, 200);
276
-
rep.undoManager.add({
277
-
redo: () => {
278
-
useEditorStates.setState((oldState) => {
279
-
let view = oldState.editorStates[props.entityID]?.view;
280
-
if (!view?.hasFocus() && !isBulkOp) view?.focus();
281
-
return {
282
-
editorStates: {
283
-
...oldState.editorStates,
284
-
[props.entityID]: {
285
-
...oldState.editorStates[props.entityID]!,
286
-
editor: newState,
287
-
},
288
-
},
289
-
};
290
-
});
291
-
},
292
-
undo: () => {
293
-
useEditorStates.setState((oldState) => {
294
-
let view = oldState.editorStates[props.entityID]?.view;
295
-
if (!view?.hasFocus() && !isBulkOp) view?.focus();
296
-
return {
297
-
editorStates: {
298
-
...oldState.editorStates,
299
-
[props.entityID]: {
300
-
...oldState.editorStates[props.entityID]!,
301
-
editor: oldEditorState,
302
-
},
303
-
},
304
-
};
305
-
});
306
-
},
307
-
});
308
-
}
309
-
310
-
return {
311
-
editorStates: {
312
-
...s.editorStates,
313
-
[props.entityID]: {
314
-
editor: newState,
315
-
view: this as unknown as EditorView,
316
-
initial: false,
317
-
keymap: km,
318
-
},
319
-
},
320
-
};
321
-
});
322
-
},
323
-
},
324
-
);
325
-
return () => {
326
-
unsubscribe();
327
-
view.destroy();
328
-
useEditorStates.setState((s) => ({
329
-
...s,
330
-
editorStates: {
331
-
...s.editorStates,
332
-
[props.entityID]: undefined,
333
-
},
334
-
}));
335
-
};
336
-
}, [props.entityID, props.parent, value, handlePaste, rep]);
337
338
return (
339
<>
···
586
);
587
};
588
589
-
function useYJSValue(entityID: string) {
590
-
const [ydoc] = useState(new Y.Doc());
591
-
const docStateFromReplicache = useEntity(entityID, "block/text");
592
-
let rep = useReplicache();
593
-
const [yText] = useState(ydoc.getXmlFragment("prosemirror"));
594
-
595
-
if (docStateFromReplicache) {
596
-
const update = base64.toByteArray(docStateFromReplicache.data.value);
597
-
Y.applyUpdate(ydoc, update);
598
-
}
599
-
600
-
useEffect(() => {
601
-
if (!rep.rep) return;
602
-
let timeout = null as null | number;
603
-
const updateReplicache = async () => {
604
-
const update = Y.encodeStateAsUpdate(ydoc);
605
-
await rep.rep?.mutate.assertFact({
606
-
//These undos are handled above in the Prosemirror context
607
-
ignoreUndo: true,
608
-
entity: entityID,
609
-
attribute: "block/text",
610
-
data: {
611
-
value: base64.fromByteArray(update),
612
-
type: "text",
613
-
},
614
-
});
615
-
};
616
-
const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => {
617
-
if (!transaction.origin) return;
618
-
if (timeout) clearTimeout(timeout);
619
-
timeout = window.setTimeout(async () => {
620
-
updateReplicache();
621
-
}, 300);
622
-
};
623
-
624
-
yText.observeDeep(f);
625
-
return () => {
626
-
yText.unobserveDeep(f);
627
-
};
628
-
}, [yText, entityID, rep, ydoc]);
629
-
return yText;
630
-
}
···
1
+
import { useRef, useEffect, useState } from "react";
2
import { elementId } from "src/utils/elementId";
3
+
import { useReplicache, useEntity } from "src/replicache";
0
0
0
0
4
import { isVisible } from "src/utils/isVisible";
0
5
import { EditorState, TextSelection } from "prosemirror-state";
0
0
0
0
6
import { RenderYJSFragment } from "./RenderYJSFragment";
7
import { useInitialPageLoad } from "components/InitialPageLoadProvider";
8
import { BlockProps } from "../Block";
9
import { focusBlock } from "src/utils/focusBlock";
0
0
10
import { useUIState } from "src/useUIState";
11
import { addBlueskyPostBlock, addLinkBlock } from "src/utils/addLinkBlock";
12
import { BlockCommandBar } from "components/Blocks/BlockCommandBar";
13
import { useEditorStates } from "src/state/useEditorState";
14
import { useEntitySetContext } from "components/EntitySetProvider";
0
0
0
0
15
import { TooltipButton } from "components/Buttons";
16
import { blockCommands } from "../BlockCommands";
17
import { betterIsUrl } from "src/utils/isURL";
···
22
import { isIOS } from "src/utils/isDevice";
23
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
24
import { DotLoader } from "components/utils/DotLoader";
25
+
import { useMountProsemirror } from "./mountProsemirror";
26
27
const HeadingStyle = {
28
1: "text-xl font-bold",
···
163
}
164
165
export function BaseTextBlock(props: BlockProps & { className?: string }) {
0
0
0
166
let headingLevel = useEntity(props.entityID, "block/heading-level");
0
167
let alignment =
168
useEntity(props.entityID, "block/text-alignment")?.data.value || "left";
169
+
0
0
0
170
let rep = useReplicache();
0
0
0
171
172
let selected = useUIState(
173
(s) => !!s.selectedBlocks.find((b) => b.value === props.entityID),
···
180
justify: "text-justify",
181
}[alignment];
182
0
0
183
let editorState = useEditorStates(
184
(s) => s.editorStates[props.entityID],
185
)?.editor;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
186
187
+
let { mountRef, actionTimeout } = useMountProsemirror({
188
+
props,
189
+
});
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
190
191
return (
192
<>
···
439
);
440
};
441
442
+
const useMentionState = () => {
443
+
const [editorState, setEditorState] = useState<EditorState | null>(null);
444
+
const [mentionState, setMentionState] = useState<{
445
+
active: boolean;
446
+
range: { from: number; to: number } | null;
447
+
selectedMention: { handle: string; did: string } | null;
448
+
}>({ active: false, range: null, selectedMention: null });
449
+
const mentionStateRef = useRef(mentionState);
450
+
mentionStateRef.current = mentionState;
451
+
return { mentionStateRef };
452
+
};
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
+199
components/Blocks/TextBlock/mountProsemirror.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import { useLayoutEffect, useRef, useEffect, useState } from "react";
2
+
import { EditorState } from "prosemirror-state";
3
+
import { EditorView } from "prosemirror-view";
4
+
import { baseKeymap } from "prosemirror-commands";
5
+
import { keymap } from "prosemirror-keymap";
6
+
import { ySyncPlugin } from "y-prosemirror";
7
+
import * as Y from "yjs";
8
+
import * as base64 from "base64-js";
9
+
import { Replicache } from "replicache";
10
+
import { produce } from "immer";
11
+
12
+
import { schema } from "./schema";
13
+
import { TextBlockKeymap } from "./keymap";
14
+
import { inputrules } from "./inputRules";
15
+
import { highlightSelectionPlugin } from "./plugins";
16
+
import { autolink } from "./autolink-plugin";
17
+
import { useEditorStates } from "src/state/useEditorState";
18
+
import {
19
+
useEntity,
20
+
useReplicache,
21
+
type ReplicacheMutators,
22
+
} from "src/replicache";
23
+
import { useHandlePaste } from "./useHandlePaste";
24
+
import { BlockProps } from "../Block";
25
+
import { useEntitySetContext } from "components/EntitySetProvider";
26
+
27
+
export 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
+
return () => {
95
+
unsubscribe();
96
+
view.destroy();
97
+
useEditorStates.setState((s) => ({
98
+
...s,
99
+
editorStates: {
100
+
...s.editorStates,
101
+
[entityID]: undefined,
102
+
},
103
+
}));
104
+
};
105
+
106
+
function dispatchTransaction(this: EditorView, tr: any) {
107
+
useEditorStates.setState((s) => {
108
+
let oldEditorState = this.state;
109
+
let newState = this.state.apply(tr);
110
+
let addToHistory = tr.getMeta("addToHistory");
111
+
let isBulkOp = tr.getMeta("bulkOp");
112
+
let docHasChanges = tr.steps.length !== 0 || tr.docChanged;
113
+
114
+
// Handle undo/redo history with timeout-based grouping
115
+
if (addToHistory !== false && docHasChanges) {
116
+
if (actionTimeout.current) window.clearTimeout(actionTimeout.current);
117
+
else if (!isBulkOp) rep.undoManager.startGroup();
118
+
119
+
if (!isBulkOp) {
120
+
actionTimeout.current = window.setTimeout(() => {
121
+
rep.undoManager.endGroup();
122
+
actionTimeout.current = null;
123
+
}, 200);
124
+
}
125
+
126
+
let setState = (s: EditorState) => () =>
127
+
useEditorStates.setState(
128
+
produce((draft) => {
129
+
let view = draft.editorStates[entityID]?.view;
130
+
if (!view?.hasFocus() && !isBulkOp) view?.focus();
131
+
draft.editorStates[entityID]!.editor = s;
132
+
}),
133
+
);
134
+
135
+
rep.undoManager.add({
136
+
redo: setState(newState),
137
+
undo: setState(oldEditorState),
138
+
});
139
+
}
140
+
141
+
return {
142
+
editorStates: {
143
+
...s.editorStates,
144
+
[entityID]: {
145
+
editor: newState,
146
+
view: this as unknown as EditorView,
147
+
initial: false,
148
+
keymap: km,
149
+
},
150
+
},
151
+
};
152
+
});
153
+
}
154
+
}, [entityID, parent, value, handlePaste, rep]);
155
+
return { mountRef, actionTimeout };
156
+
}
157
+
158
+
function useYJSValue(entityID: string) {
159
+
const [ydoc] = useState(new Y.Doc());
160
+
const docStateFromReplicache = useEntity(entityID, "block/text");
161
+
let rep = useReplicache();
162
+
const [yText] = useState(ydoc.getXmlFragment("prosemirror"));
163
+
164
+
if (docStateFromReplicache) {
165
+
const update = base64.toByteArray(docStateFromReplicache.data.value);
166
+
Y.applyUpdate(ydoc, update);
167
+
}
168
+
169
+
useEffect(() => {
170
+
if (!rep.rep) return;
171
+
let timeout = null as null | number;
172
+
const updateReplicache = async () => {
173
+
const update = Y.encodeStateAsUpdate(ydoc);
174
+
await rep.rep?.mutate.assertFact({
175
+
//These undos are handled above in the Prosemirror context
176
+
ignoreUndo: true,
177
+
entity: entityID,
178
+
attribute: "block/text",
179
+
data: {
180
+
value: base64.fromByteArray(update),
181
+
type: "text",
182
+
},
183
+
});
184
+
};
185
+
const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => {
186
+
if (!transaction.origin) return;
187
+
if (timeout) clearTimeout(timeout);
188
+
timeout = window.setTimeout(async () => {
189
+
updateReplicache();
190
+
}, 300);
191
+
};
192
+
193
+
yText.observeDeep(f);
194
+
return () => {
195
+
yText.unobserveDeep(f);
196
+
};
197
+
}, [yText, entityID, rep, ydoc]);
198
+
return yText;
199
+
}
+5
-5
package-lock.json
···
44
"feed": "^5.1.0",
45
"fractional-indexing": "^3.2.0",
46
"hono": "^4.7.11",
0
47
"inngest": "^3.40.1",
48
"ioredis": "^5.6.1",
49
"katex": "^0.16.22",
···
10877
}
10878
},
10879
"node_modules/immer": {
10880
-
"version": "10.1.1",
10881
-
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
10882
-
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
10883
-
"optional": true,
10884
-
"peer": true,
10885
"funding": {
10886
"type": "opencollective",
10887
"url": "https://opencollective.com/immer"
···
44
"feed": "^5.1.0",
45
"fractional-indexing": "^3.2.0",
46
"hono": "^4.7.11",
47
+
"immer": "^10.2.0",
48
"inngest": "^3.40.1",
49
"ioredis": "^5.6.1",
50
"katex": "^0.16.22",
···
10878
}
10879
},
10880
"node_modules/immer": {
10881
+
"version": "10.2.0",
10882
+
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
10883
+
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
10884
+
"license": "MIT",
0
10885
"funding": {
10886
"type": "opencollective",
10887
"url": "https://opencollective.com/immer"
+1
package.json
···
54
"feed": "^5.1.0",
55
"fractional-indexing": "^3.2.0",
56
"hono": "^4.7.11",
0
57
"inngest": "^3.40.1",
58
"ioredis": "^5.6.1",
59
"katex": "^0.16.22",
···
54
"feed": "^5.1.0",
55
"fractional-indexing": "^3.2.0",
56
"hono": "^4.7.11",
57
+
"immer": "^10.2.0",
58
"inngest": "^3.40.1",
59
"ioredis": "^5.6.1",
60
"katex": "^0.16.22",