tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
291
fork
atom
a tool for shared writing and social publishing
291
fork
atom
overview
issues
27
pulls
pipelines
server render text blocks!!
awarm.space
2 years ago
248ec3a7
fb54cbe9
+245
-82
9 changed files
expand all
collapse all
unified
split
app
[doc_id]
page.tsx
layout.tsx
components
InitialPageLoadProvider.tsx
RenderYJSFragment.tsx
TextBlock.tsx
instrumentation.ts
package-lock.json
package.json
replicache
index.tsx
+14
-3
app/[doc_id]/page.tsx
···
1
1
-
import { ReplicacheProvider } from "../../replicache";
1
1
+
import { createClient } from "@supabase/supabase-js";
2
2
+
import { Fact, ReplicacheProvider } from "../../replicache";
3
3
+
import { Database } from "../../supabase/database.types";
2
4
import { AddBlock, Blocks } from "./Blocks";
5
5
+
import { Attributes } from "../../replicache/attributes";
3
6
4
7
export const preferredRegion = ["sfo1"];
5
8
export const dynamic = "force-dynamic";
6
9
7
7
-
export default function DocumentPage(props: { params: { doc_id: string } }) {
10
10
+
let supabase = createClient<Database>(
11
11
+
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
12
12
+
process.env.SUPABASE_SERVICE_ROLE_KEY as string,
13
13
+
);
14
14
+
export default async function DocumentPage(props: {
15
15
+
params: { doc_id: string };
16
16
+
}) {
17
17
+
let { data } = await supabase.rpc("get_facts", { root: props.params.doc_id });
18
18
+
let initialFacts = (data as unknown as Fact<keyof typeof Attributes>[]) || [];
8
19
return (
9
9
-
<ReplicacheProvider name={props.params.doc_id}>
20
20
+
<ReplicacheProvider name={props.params.doc_id} initialFacts={initialFacts}>
10
21
<div className="text-blue-400">doc_id: {props.params.doc_id}</div>
11
22
<AddBlock entityID={props.params.doc_id} />
12
23
<Blocks entityID={props.params.doc_id} />
+4
-1
app/layout.tsx
···
1
1
+
import { InitialPageLoad } from "../components/InitialPageLoadProvider";
1
2
import "./globals.css";
2
3
import localFont from "next/font/local";
3
4
···
21
22
}) {
22
23
return (
23
24
<html lang="en" className={`${quattro.variable}`}>
24
24
-
<body>{children}</body>
25
25
+
<body>
26
26
+
<InitialPageLoad>{children}</InitialPageLoad>
27
27
+
</body>
25
28
</html>
26
29
);
27
30
}
+13
components/InitialPageLoadProvider.tsx
···
1
1
+
"use client";
2
2
+
import { useEffect } from "react";
3
3
+
import { create } from "zustand";
4
4
+
5
5
+
export const useInitialPageLoad = create(() => false);
6
6
+
export function InitialPageLoad(props: { children: React.ReactNode }) {
7
7
+
useEffect(() => {
8
8
+
setTimeout(() => {
9
9
+
useInitialPageLoad.setState(() => true);
10
10
+
}, 80);
11
11
+
}, []);
12
12
+
return <>{props.children}</>;
13
13
+
}
+59
components/RenderYJSFragment.tsx
···
1
1
+
import { XmlElement, XmlHook, XmlText } from "yjs";
2
2
+
import { nodes, marks } from "prosemirror-schema-basic";
3
3
+
export function RenderYJSFragment({
4
4
+
node,
5
5
+
}: {
6
6
+
node: XmlElement | XmlText | XmlHook;
7
7
+
}) {
8
8
+
if (node.constructor === XmlElement) {
9
9
+
switch (node.nodeName as keyof typeof nodes) {
10
10
+
case "paragraph": {
11
11
+
let children = node.toArray();
12
12
+
return (
13
13
+
<p>
14
14
+
{children.length === 0 ? (
15
15
+
<br />
16
16
+
) : (
17
17
+
node
18
18
+
.toArray()
19
19
+
.map((f, index) => <RenderYJSFragment node={f} key={index} />)
20
20
+
)}
21
21
+
</p>
22
22
+
);
23
23
+
}
24
24
+
case "hard_break":
25
25
+
return <br />;
26
26
+
default:
27
27
+
return null;
28
28
+
}
29
29
+
}
30
30
+
if (node.constructor === XmlText) {
31
31
+
return (
32
32
+
<>
33
33
+
{(node.toDelta() as Delta[]).map((d, index) => {
34
34
+
return (
35
35
+
<span key={index} className={attributesToClassName(d)}>
36
36
+
{d.insert}
37
37
+
</span>
38
38
+
);
39
39
+
})}
40
40
+
</>
41
41
+
);
42
42
+
}
43
43
+
return null;
44
44
+
}
45
45
+
46
46
+
type Delta = {
47
47
+
insert: string;
48
48
+
attributes?: {
49
49
+
strong?: {};
50
50
+
em?: {};
51
51
+
};
52
52
+
};
53
53
+
54
54
+
function attributesToClassName(d: Delta) {
55
55
+
let className = "";
56
56
+
if (d.attributes?.strong) className += "font-bold ";
57
57
+
if (d.attributes?.em) className += "italic";
58
58
+
return className;
59
59
+
}
+126
-72
components/TextBlock.tsx
···
3
3
import { baseKeymap, toggleMark } from "prosemirror-commands";
4
4
import { keymap } from "prosemirror-keymap";
5
5
import * as Y from "yjs";
6
6
-
import { ProseMirror } from "@nytimes/react-prosemirror";
6
6
+
import { ProseMirror, useEditorState } from "@nytimes/react-prosemirror";
7
7
import * as base64 from "base64-js";
8
8
import { useReplicache, useEntity, ReplicacheMutators } from "../replicache";
9
9
···
13
13
import { Replicache } from "replicache";
14
14
import { generateKeyBetween } from "fractional-indexing";
15
15
import { create } from "zustand";
16
16
+
import { RenderYJSFragment } from "./RenderYJSFragment";
17
17
+
import { useInitialPageLoad } from "./InitialPageLoadProvider";
16
18
17
19
let useEditorStates = create(
18
20
() =>
19
19
-
({}) as { [entity: string]: { editor: InstanceType<typeof EditorState> } },
21
21
+
({}) as {
22
22
+
[entity: string]:
23
23
+
| { editor: InstanceType<typeof EditorState> }
24
24
+
| undefined;
25
25
+
},
20
26
);
21
27
22
28
export function TextBlock(props: {
···
26
32
previousBlock: { value: string; position: string } | null;
27
33
nextPosition: string | null;
28
34
}) {
35
35
+
let initialized = useInitialPageLoad();
36
36
+
return (
37
37
+
<>
38
38
+
{!initialized && <RenderedTextBlock entityID={props.entityID} />}
39
39
+
<div className={`${!initialized ? "hidden" : ""}`}>
40
40
+
<BaseTextBlock {...props} />
41
41
+
</div>
42
42
+
</>
43
43
+
);
44
44
+
}
45
45
+
46
46
+
function RenderedTextBlock(props: { entityID: string }) {
47
47
+
let { initialFacts } = useReplicache();
48
48
+
let initialFact = initialFacts.find(
49
49
+
(f) => f.entity === props.entityID && f.attribute === "block/text",
50
50
+
);
51
51
+
if (!initialFact) return <pre className="min-h-6" />;
52
52
+
let doc = new Y.Doc();
53
53
+
const update = base64.toByteArray(initialFact.data.value);
54
54
+
Y.applyUpdate(doc, update);
55
55
+
return (
56
56
+
<pre className="w-full whitespace-pre-wrap outline-none min-h-6">
57
57
+
{doc
58
58
+
.getXmlElement("prosemirror")
59
59
+
.toArray()
60
60
+
.map((node, index) => (
61
61
+
<RenderYJSFragment key={index} node={node} />
62
62
+
))}
63
63
+
</pre>
64
64
+
);
65
65
+
}
66
66
+
export function BaseTextBlock(props: {
67
67
+
entityID: string;
68
68
+
parent: string;
69
69
+
position: string;
70
70
+
previousBlock: { value: string; position: string } | null;
71
71
+
nextPosition: string | null;
72
72
+
}) {
29
73
const [mount, setMount] = useState<HTMLElement | null>(null);
30
74
let value = useYJSValue(props.entityID);
31
75
let repRef = useRef<null | Replicache<ReplicacheMutators>>(null);
···
37
81
useEffect(() => {
38
82
repRef.current = rep.rep;
39
83
}, [rep?.rep]);
40
40
-
let [editorState, setEditorState] = useState(
41
41
-
EditorState.create({
42
42
-
schema,
43
43
-
plugins: [
44
44
-
ySyncPlugin(value),
45
45
-
keymap({
46
46
-
"Meta-b": toggleMark(schema.marks.strong),
47
47
-
"Meta-i": toggleMark(schema.marks.em),
48
48
-
Backspace: (state) => {
49
49
-
if (state.doc.textContent.length === 0) {
50
50
-
repRef.current?.mutate.removeBlock({
51
51
-
blockEntity: props.entityID,
52
52
-
});
53
53
-
if (propsRef.current.previousBlock) {
54
54
-
let prevBlock = propsRef.current.previousBlock.value;
55
55
-
document
56
56
-
.getElementById(elementId.block(prevBlock).text)
57
57
-
?.focus();
58
58
-
let previousBlockEditor =
59
59
-
useEditorStates.getState()[prevBlock]?.editor;
60
60
-
if (previousBlockEditor) {
61
61
-
let tr = previousBlockEditor.tr;
62
62
-
let endPos = tr.doc.content.size;
63
84
64
64
-
let newState = previousBlockEditor.apply(
65
65
-
tr.setSelection(
66
66
-
TextSelection.create(tr.doc, endPos - 1, endPos - 1),
67
67
-
),
68
68
-
);
69
69
-
useEditorStates.setState((s) => ({
70
70
-
...s,
71
71
-
[prevBlock]: { editor: newState },
72
72
-
}));
73
73
-
}
74
74
-
}
75
75
-
}
76
76
-
return false;
77
77
-
},
78
78
-
"Shift-Enter": () => {
79
79
-
let newEntityID = crypto.randomUUID();
80
80
-
repRef.current?.mutate.addBlock({
81
81
-
newEntityID,
82
82
-
parent: props.parent,
83
83
-
position: generateKeyBetween(
84
84
-
propsRef.current.position,
85
85
-
propsRef.current.nextPosition,
86
86
-
),
87
87
-
});
88
88
-
setTimeout(() => {
89
89
-
document
90
90
-
.getElementById(elementId.block(newEntityID).text)
91
91
-
?.focus();
92
92
-
}, 100);
93
93
-
return true;
94
94
-
},
95
95
-
}),
96
96
-
keymap(baseKeymap),
97
97
-
],
98
98
-
}),
99
99
-
);
85
85
+
let editorState = useEditorStates((s) => s[props.entityID])?.editor;
100
86
useEffect(() => {
101
101
-
useEditorStates.setState((s) => {
102
102
-
return { ...s, [props.entityID]: { editor: editorState } };
103
103
-
});
104
104
-
}, [editorState, props.entityID]);
87
87
+
if (!editorState)
88
88
+
useEditorStates.setState((s) => ({
89
89
+
...s,
90
90
+
[props.entityID]: {
91
91
+
editor: EditorState.create({
92
92
+
schema,
93
93
+
plugins: [
94
94
+
ySyncPlugin(value),
95
95
+
keymap({
96
96
+
"Meta-b": toggleMark(schema.marks.strong),
97
97
+
"Meta-i": toggleMark(schema.marks.em),
98
98
+
Backspace: (state) => {
99
99
+
if (state.doc.textContent.length === 0) {
100
100
+
repRef.current?.mutate.removeBlock({
101
101
+
blockEntity: props.entityID,
102
102
+
});
103
103
+
if (propsRef.current.previousBlock) {
104
104
+
let prevBlock = propsRef.current.previousBlock.value;
105
105
+
document
106
106
+
.getElementById(elementId.block(prevBlock).text)
107
107
+
?.focus();
108
108
+
let previousBlockEditor =
109
109
+
useEditorStates.getState()[prevBlock]?.editor;
110
110
+
if (previousBlockEditor) {
111
111
+
let tr = previousBlockEditor.tr;
112
112
+
let endPos = tr.doc.content.size;
105
113
106
106
-
let editorStateFromZustand = useEditorStates((s) => s[props.entityID]);
107
107
-
useEffect(() => {
108
108
-
if (editorStateFromZustand) setEditorState(editorStateFromZustand.editor);
109
109
-
}, [editorStateFromZustand]);
114
114
+
let newState = previousBlockEditor.apply(
115
115
+
tr.setSelection(
116
116
+
TextSelection.create(
117
117
+
tr.doc,
118
118
+
endPos - 1,
119
119
+
endPos - 1,
120
120
+
),
121
121
+
),
122
122
+
);
123
123
+
useEditorStates.setState((s) => ({
124
124
+
...s,
125
125
+
[prevBlock]: { editor: newState },
126
126
+
}));
127
127
+
}
128
128
+
}
129
129
+
}
130
130
+
return false;
131
131
+
},
132
132
+
"Shift-Enter": () => {
133
133
+
let newEntityID = crypto.randomUUID();
134
134
+
repRef.current?.mutate.addBlock({
135
135
+
newEntityID,
136
136
+
parent: props.parent,
137
137
+
position: generateKeyBetween(
138
138
+
propsRef.current.position,
139
139
+
propsRef.current.nextPosition,
140
140
+
),
141
141
+
});
142
142
+
setTimeout(() => {
143
143
+
document
144
144
+
.getElementById(elementId.block(newEntityID).text)
145
145
+
?.focus();
146
146
+
}, 10);
147
147
+
return true;
148
148
+
},
149
149
+
}),
150
150
+
keymap(baseKeymap),
151
151
+
],
152
152
+
}),
153
153
+
},
154
154
+
}));
155
155
+
}, [editorState, props.entityID, props.parent, value]);
156
156
+
if (!editorState) return null;
110
157
111
158
return (
112
159
<ProseMirror
113
160
mount={mount}
114
161
state={editorState}
115
162
dispatchTransaction={(tr) => {
116
116
-
setEditorState((s) => s.apply(tr));
163
163
+
useEditorStates.setState((s) => {
164
164
+
let existingState = s[props.entityID]?.editor;
165
165
+
if (!existingState) return s;
166
166
+
return {
167
167
+
...s,
168
168
+
[props.entityID]: { editor: existingState.apply(tr) },
169
169
+
};
170
170
+
});
117
171
}}
118
172
>
119
173
<pre
···
135
189
136
190
if (docStateFromReplicache) {
137
191
const update = base64.toByteArray(docStateFromReplicache.data.value);
138
138
-
Y.applyUpdateV2(ydoc, update);
192
192
+
Y.applyUpdate(ydoc, update);
139
193
}
140
194
141
195
useEffect(() => {
142
196
if (!rep.rep) return;
143
197
const f = async () => {
144
144
-
const update = Y.encodeStateAsUpdateV2(ydoc);
198
198
+
const update = Y.encodeStateAsUpdate(ydoc);
145
199
await rep.rep?.mutate.assertFact({
146
200
entity: entityID,
147
201
attribute: "block/text",
+4
-1
instrumentation.ts
···
1
1
export async function register() {
2
2
-
if (process.env.NEXT_RUNTIME === "nodejs") {
2
2
+
if (
3
3
+
process.env.NEXT_RUNTIME === "nodejs" &&
4
4
+
process.env.NODE_ENV === "production"
5
5
+
) {
3
6
const { BaselimeSDK, VercelPlugin, BetterHttpInstrumentation } =
4
7
//@ts-ignore
5
8
await import("@baselime/node-opentelemetry");
+11
-1
package-lock.json
···
32
32
"react-dom": "^18.3.1",
33
33
"react-use-measure": "^2.1.1",
34
34
"replicache": "^14.2.2",
35
35
+
"replicache-react": "^5.0.1",
35
36
"y-prosemirror": "^1.2.5",
36
37
"yjs": "^13.6.15",
37
38
"zustand": "^4.5.2"
···
47
48
"prettier": "3.2.5",
48
49
"supabase": "^1.167.4",
49
50
"tailwindcss": "^3.4.3",
50
50
-
"typescript": "5.4.5",
51
51
+
"typescript": "^5.4.5",
51
52
"wrangler": "^3.56.0"
52
53
}
53
54
},
···
11421
11422
},
11422
11423
"engines": {
11423
11424
"node": ">=14.8.0"
11425
11425
+
}
11426
11426
+
},
11427
11427
+
"node_modules/replicache-react": {
11428
11428
+
"version": "5.0.1",
11429
11429
+
"resolved": "https://registry.npmjs.org/replicache-react/-/replicache-react-5.0.1.tgz",
11430
11430
+
"integrity": "sha512-xwiVdoANIX9oagqK8sT/txx9ltfEmkKgbJivdVvyz13bj5PAT+b8o9xuMtY4X1bJgu+9mn04muWPKtPv9MV/ZA==",
11431
11431
+
"peerDependencies": {
11432
11432
+
"react": ">=16.0 <19.0",
11433
11433
+
"react-dom": ">=16.0 <19.0"
11424
11434
}
11425
11435
},
11426
11436
"node_modules/require-directory": {
+2
-1
package.json
···
34
34
"react-dom": "^18.3.1",
35
35
"react-use-measure": "^2.1.1",
36
36
"replicache": "^14.2.2",
37
37
+
"replicache-react": "^5.0.1",
37
38
"y-prosemirror": "^1.2.5",
38
39
"yjs": "^13.6.15",
39
40
"zustand": "^4.5.2"
···
49
50
"prettier": "3.2.5",
50
51
"supabase": "^1.167.4",
51
52
"tailwindcss": "^3.4.3",
52
52
-
"typescript": "5.4.5",
53
53
+
"typescript": "^5.4.5",
53
54
"wrangler": "^3.56.0"
54
55
}
55
56
}
+12
-3
replicache/index.tsx
···
28
28
29
29
let ReplicacheContext = createContext({
30
30
rep: null as null | Replicache<ReplicacheMutators>,
31
31
+
initialFacts: [] as Fact<keyof typeof Attributes>[],
31
32
});
32
33
export function useReplicache() {
33
34
return useContext(ReplicacheContext);
···
39
40
) => Promise<void>;
40
41
};
41
42
export function ReplicacheProvider(props: {
43
43
+
initialFacts: Fact<keyof typeof Attributes>[];
42
44
name: string;
43
45
children: React.ReactNode;
44
46
}) {
···
97
99
};
98
100
}, [props.name]);
99
101
return (
100
100
-
<ReplicacheContext.Provider value={{ rep }}>
102
102
+
<ReplicacheContext.Provider
103
103
+
value={{ rep, initialFacts: props.initialFacts }}
104
104
+
>
101
105
{props.children}
102
106
</ReplicacheContext.Provider>
103
107
);
···
111
115
entity: string,
112
116
attribute: A,
113
117
): CardinalityResult<A> {
114
114
-
let [data, setData] = useState<DeepReadonlyObject<Fact<A>[]>>([]);
115
115
-
let { rep } = useReplicache();
118
118
+
let { rep, initialFacts } = useReplicache();
119
119
+
let fallbackData = initialFacts.filter(
120
120
+
(f) => f.entity === entity && f.attribute === attribute,
121
121
+
);
122
122
+
let [data, setData] = useState<DeepReadonlyObject<Fact<A>[]>>(
123
123
+
fallbackData as DeepReadonlyObject<Fact<A>>[],
124
124
+
);
116
125
useEffect(() => {
117
126
if (!rep) return;
118
127
return rep.subscribe(