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