@atom.io/template-react-realtime-text-editor 0.0.11 → 0.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/INNOVATIONS.md +242 -113
- package/README.md +75 -39
- package/eslint.config.ts +1 -1
- package/index.html +1 -0
- package/node/http-server.ts +81 -0
- package/node/server.ts +12 -60
- package/node/service.ts +310 -0
- package/package.json +30 -21
- package/playwright.config.ts +34 -0
- package/src/LexicalMarkdownEditor.css +88 -0
- package/src/LexicalMarkdownEditor.module.css +8 -0
- package/src/LexicalMarkdownEditor.tsx +43 -0
- package/src/MarkdownWorkspace.module.css +154 -98
- package/src/MarkdownWorkspace.tsx +163 -296
- package/src/collaboration-client.ts +238 -0
- package/src/document-domain.ts +169 -0
- package/src/globals.css +9 -2
- package/src/incremental-markdown.ts +279 -0
- package/src/main.tsx +30 -9
- package/src/protocol.ts +35 -0
- package/src/virtualization.ts +53 -0
- package/src/workspace-state.ts +275 -0
- package/tests/browser/editor-conformance.spec.ts +806 -0
- package/tests/collaboration.test.tsx +465 -213
- package/tests/incremental-markdown.test.tsx +101 -0
- package/tsconfig.app.json +1 -1
- package/tsconfig.node.json +8 -2
- package/tsconfig.test.json +1 -1
- package/vite.config.ts +13 -9
- package/node/mosaic-atom.ts +0 -88
- package/src/collaboration/mosaic.ts +0 -46
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { Silo } from "atom.io"
|
|
2
|
+
import type {
|
|
3
|
+
MosaicTextIndexLookup,
|
|
4
|
+
MosaicTextSelection,
|
|
5
|
+
} from "@atom.io/realtime"
|
|
6
|
+
import {
|
|
7
|
+
createMosaicDomainHistoryClient,
|
|
8
|
+
createMosaicDomainHistorySocketTransport,
|
|
9
|
+
createMosaicDomainPresenceClient,
|
|
10
|
+
createMosaicDomainPresenceSocketTransport,
|
|
11
|
+
createMosaicDomainResidencyClient,
|
|
12
|
+
createMosaicDomainResidencySocketTransport,
|
|
13
|
+
createMosaicDomainSessionClient,
|
|
14
|
+
createMosaicTextProjectionClient,
|
|
15
|
+
type MosaicDomainHistoryClient,
|
|
16
|
+
type MosaicDomainPresenceClient,
|
|
17
|
+
type MosaicDomainResidencyClient,
|
|
18
|
+
type MosaicDomainSessionClientState,
|
|
19
|
+
type MosaicTextProjectionClient,
|
|
20
|
+
type MosaicTextLogicalEdit,
|
|
21
|
+
} from "@atom.io/realtime/client"
|
|
22
|
+
import type { Socket } from "socket.io-client"
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
activateMarkdownDocumentDomain,
|
|
26
|
+
markdownPresenceKey,
|
|
27
|
+
type MarkdownDocumentDomain,
|
|
28
|
+
type MarkdownPresence,
|
|
29
|
+
} from "./document-domain.ts"
|
|
30
|
+
import type { Identity } from "./identities.ts"
|
|
31
|
+
import {
|
|
32
|
+
MARKDOWN_EVENTS,
|
|
33
|
+
type MarkdownAcknowledgement,
|
|
34
|
+
type MarkdownCommand,
|
|
35
|
+
} from "./protocol.ts"
|
|
36
|
+
|
|
37
|
+
type MarkdownRange = { end: number; kind: `utf16-range`; start: number }
|
|
38
|
+
export type MarkdownClientStatus = MosaicDomainSessionClientState
|
|
39
|
+
|
|
40
|
+
export type MarkdownCollaborationClient = Disposable & {
|
|
41
|
+
readonly domain: MarkdownDocumentDomain
|
|
42
|
+
readonly history: MosaicDomainHistoryClient
|
|
43
|
+
readonly identity: Identity
|
|
44
|
+
readonly presence: MosaicDomainPresenceClient
|
|
45
|
+
readonly projection: MosaicTextProjectionClient<
|
|
46
|
+
MarkdownDocumentDomain[`identity`],
|
|
47
|
+
MarkdownRange
|
|
48
|
+
>
|
|
49
|
+
publishPresence(
|
|
50
|
+
value: Omit<MarkdownPresence, `actor` | `session`>,
|
|
51
|
+
): Promise<void>
|
|
52
|
+
redo(): Promise<boolean>
|
|
53
|
+
replace(input: {
|
|
54
|
+
readonly selection: MosaicTextSelection
|
|
55
|
+
readonly text: string
|
|
56
|
+
}): Promise<void>
|
|
57
|
+
readonly residency: MosaicDomainResidencyClient<
|
|
58
|
+
MarkdownDocumentDomain[`identity`],
|
|
59
|
+
MarkdownRange
|
|
60
|
+
>
|
|
61
|
+
readonly sessionId: string
|
|
62
|
+
status(): MarkdownClientStatus
|
|
63
|
+
subscribe(listener: (status: MarkdownClientStatus) => void): () => void
|
|
64
|
+
undo(): Promise<boolean>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const request = <Value>(
|
|
68
|
+
socket: Socket,
|
|
69
|
+
event: string,
|
|
70
|
+
...parameters: readonly unknown[]
|
|
71
|
+
): Promise<Value> =>
|
|
72
|
+
new Promise((resolve, reject) => {
|
|
73
|
+
if (!socket.connected) {
|
|
74
|
+
reject(new Error(`offline`))
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
let settled = false
|
|
78
|
+
const finish = (result: () => void): void => {
|
|
79
|
+
if (settled) return
|
|
80
|
+
settled = true
|
|
81
|
+
socket.off(`disconnect`, disconnected)
|
|
82
|
+
result()
|
|
83
|
+
}
|
|
84
|
+
const disconnected = (): void => {
|
|
85
|
+
finish(() => {
|
|
86
|
+
reject(new DOMException(`Socket disconnected`, `AbortError`))
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
socket.once(`disconnect`, disconnected)
|
|
90
|
+
socket.emit(
|
|
91
|
+
event,
|
|
92
|
+
...parameters,
|
|
93
|
+
(acknowledgement: MarkdownAcknowledgement<Value>) => {
|
|
94
|
+
finish(() => {
|
|
95
|
+
if (acknowledgement.ok) resolve(acknowledgement.value)
|
|
96
|
+
else reject(new Error(acknowledgement.reason))
|
|
97
|
+
})
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
export async function createMarkdownCollaborationClient(options: {
|
|
103
|
+
readonly identity: Identity
|
|
104
|
+
readonly presenceRenewalMs?: number
|
|
105
|
+
readonly sessionId: string
|
|
106
|
+
readonly silo: Pick<Silo, `install` | `store`>
|
|
107
|
+
readonly socket: Socket
|
|
108
|
+
}): Promise<MarkdownCollaborationClient> {
|
|
109
|
+
const { identity, sessionId, socket } = options
|
|
110
|
+
const presenceRenewalMs = options.presenceRenewalMs ?? 5_000
|
|
111
|
+
if (!Number.isSafeInteger(presenceRenewalMs) || presenceRenewalMs < 1) {
|
|
112
|
+
throw new Error(`Presence renewal must be a positive integer.`)
|
|
113
|
+
}
|
|
114
|
+
const domain = await activateMarkdownDocumentDomain({ silo: options.silo })
|
|
115
|
+
const residencyTransport = createMosaicDomainResidencySocketTransport<
|
|
116
|
+
MarkdownDocumentDomain[`identity`],
|
|
117
|
+
MarkdownRange
|
|
118
|
+
>(socket, {
|
|
119
|
+
idSource: () => `${sessionId}:residency:${crypto.randomUUID()}`,
|
|
120
|
+
})
|
|
121
|
+
const residency = createMosaicDomainResidencyClient({
|
|
122
|
+
actor: identity.id,
|
|
123
|
+
domain,
|
|
124
|
+
maxResidentBytes: 4 * 1024 * 1024,
|
|
125
|
+
maxResidentMembers: 128,
|
|
126
|
+
session: sessionId,
|
|
127
|
+
transport: residencyTransport,
|
|
128
|
+
})
|
|
129
|
+
const projection = createMosaicTextProjectionClient({
|
|
130
|
+
actor: identity.id,
|
|
131
|
+
domainKey: `markdown-body`,
|
|
132
|
+
evictReleased: true,
|
|
133
|
+
materialize: () => request(socket, MARKDOWN_EVENTS.materialize),
|
|
134
|
+
maximumActiveRanges: 8,
|
|
135
|
+
maximumRangeUtf16Units: 65_536,
|
|
136
|
+
planEdit(_edit: MosaicTextLogicalEdit) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Markdown commands are authoritatively composed with index maintenance.`,
|
|
139
|
+
)
|
|
140
|
+
},
|
|
141
|
+
positionAtOffset: (offset) =>
|
|
142
|
+
request<MosaicTextIndexLookup>(
|
|
143
|
+
socket,
|
|
144
|
+
MARKDOWN_EVENTS.positionAtOffset,
|
|
145
|
+
offset,
|
|
146
|
+
),
|
|
147
|
+
rangeMember: `indexMembers`,
|
|
148
|
+
rangeMemberLimit: 64,
|
|
149
|
+
residency,
|
|
150
|
+
resolvePosition: (position) =>
|
|
151
|
+
request(socket, MARKDOWN_EVENTS.resolvePosition, position),
|
|
152
|
+
rootAddress: domain.address(`indexRoot`),
|
|
153
|
+
session: sessionId,
|
|
154
|
+
})
|
|
155
|
+
const presenceTransport = createMosaicDomainPresenceSocketTransport(socket, {
|
|
156
|
+
idSource: () => `${sessionId}:presence:${crypto.randomUUID()}`,
|
|
157
|
+
})
|
|
158
|
+
const presence = createMosaicDomainPresenceClient({
|
|
159
|
+
domain,
|
|
160
|
+
renewalMs: presenceRenewalMs,
|
|
161
|
+
session: sessionId,
|
|
162
|
+
transport: presenceTransport,
|
|
163
|
+
})
|
|
164
|
+
const historyTransport = createMosaicDomainHistorySocketTransport(socket, {
|
|
165
|
+
idSource: () => `${sessionId}:history:${crypto.randomUUID()}`,
|
|
166
|
+
})
|
|
167
|
+
const history = createMosaicDomainHistoryClient({
|
|
168
|
+
actor: identity.id,
|
|
169
|
+
onObserverError: (error) => {
|
|
170
|
+
domain.store.logger.error(
|
|
171
|
+
`🐞`,
|
|
172
|
+
`unknown`,
|
|
173
|
+
sessionId,
|
|
174
|
+
`A Mosaic Domain history client listener threw.`,
|
|
175
|
+
error,
|
|
176
|
+
)
|
|
177
|
+
},
|
|
178
|
+
session: sessionId,
|
|
179
|
+
transport: historyTransport,
|
|
180
|
+
})
|
|
181
|
+
const session = await createMosaicDomainSessionClient<MarkdownCommand>({
|
|
182
|
+
history,
|
|
183
|
+
presence,
|
|
184
|
+
residency,
|
|
185
|
+
send: (command) => request(socket, MARKDOWN_EVENTS.command, command),
|
|
186
|
+
socket,
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
domain,
|
|
191
|
+
history,
|
|
192
|
+
identity,
|
|
193
|
+
presence,
|
|
194
|
+
projection,
|
|
195
|
+
publishPresence(value) {
|
|
196
|
+
const latest = structuredClone({
|
|
197
|
+
...value,
|
|
198
|
+
actor: identity.id,
|
|
199
|
+
session: sessionId,
|
|
200
|
+
})
|
|
201
|
+
return presence.publish(
|
|
202
|
+
domain.address(`collaborator`, markdownPresenceKey(latest)),
|
|
203
|
+
latest,
|
|
204
|
+
)
|
|
205
|
+
},
|
|
206
|
+
redo: () => session.history(`redo`),
|
|
207
|
+
replace({ selection, text }) {
|
|
208
|
+
return session.submit((sequence) => ({
|
|
209
|
+
anchor: selection.anchor,
|
|
210
|
+
gestureId: `${identity.id}:${sessionId}:edit:${sequence}`,
|
|
211
|
+
head: selection.head,
|
|
212
|
+
sequence,
|
|
213
|
+
text,
|
|
214
|
+
type: `replace`,
|
|
215
|
+
}))
|
|
216
|
+
},
|
|
217
|
+
residency,
|
|
218
|
+
sessionId,
|
|
219
|
+
status: session.state,
|
|
220
|
+
subscribe: session.subscribe,
|
|
221
|
+
undo: () => session.history(`undo`),
|
|
222
|
+
[Symbol.dispose]() {
|
|
223
|
+
session[Symbol.dispose]()
|
|
224
|
+
presence[Symbol.dispose]()
|
|
225
|
+
presenceTransport[Symbol.dispose]()
|
|
226
|
+
history[Symbol.dispose]()
|
|
227
|
+
historyTransport[Symbol.dispose]()
|
|
228
|
+
void projection
|
|
229
|
+
.dispose()
|
|
230
|
+
.catch(() => undefined)
|
|
231
|
+
.then(() => residency.dispose())
|
|
232
|
+
.catch(() => undefined)
|
|
233
|
+
.finally(() => {
|
|
234
|
+
domain[Symbol.dispose]()
|
|
235
|
+
})
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { Silo } from "atom.io"
|
|
2
|
+
import { atom, atomFamily, mutableAtom } from "atom.io"
|
|
3
|
+
import {
|
|
4
|
+
createMosaicTextDomainModels,
|
|
5
|
+
mosaicDomain,
|
|
6
|
+
mosaicText,
|
|
7
|
+
type MosaicTextIndexMember,
|
|
8
|
+
type MosaicTextIndexRoot,
|
|
9
|
+
type MosaicTextSelection,
|
|
10
|
+
} from "@atom.io/realtime"
|
|
11
|
+
import { z } from "zod"
|
|
12
|
+
|
|
13
|
+
export const MARKDOWN_INDEX_OPTIONS = {
|
|
14
|
+
maximumAliasGenerations: 4,
|
|
15
|
+
maximumAliasTargets: 8,
|
|
16
|
+
maximumChildrenPerNode: 32,
|
|
17
|
+
maximumFragmentsPerLeaf: 64,
|
|
18
|
+
maximumLeafGraphemes: 65_536,
|
|
19
|
+
maximumLeafUtf16Units: 65_536,
|
|
20
|
+
minimumChildrenPerNode: 8,
|
|
21
|
+
minimumLeafGraphemes: 16_384,
|
|
22
|
+
targetChildrenPerNode: 16,
|
|
23
|
+
targetLeafGraphemes: 32_768,
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
export const MarkdownText = mosaicText({
|
|
27
|
+
maximumDeletionIntervalsPerOperation: 16_384,
|
|
28
|
+
maximumHistoryTargets: 10_000,
|
|
29
|
+
maximumRunGraphemes: 65_536,
|
|
30
|
+
maximumRunUtf16Units: 1_000_000,
|
|
31
|
+
maximumRunsPerOperation: 65_536,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export const {
|
|
35
|
+
emptyIndexRoot: EMPTY_MARKDOWN_INDEX_ROOT,
|
|
36
|
+
indexMemberModel: markdownIndexMemberModel,
|
|
37
|
+
indexMemberSchema: markdownIndexMemberSchema,
|
|
38
|
+
indexRootModel: markdownIndexRootModel,
|
|
39
|
+
indexRootSchema: markdownIndexRootSchema,
|
|
40
|
+
sourceModel: markdownSourceModel,
|
|
41
|
+
sourceSnapshotSchema: markdownSourceSnapshotSchema,
|
|
42
|
+
} = createMosaicTextDomainModels({
|
|
43
|
+
index: MARKDOWN_INDEX_OPTIONS,
|
|
44
|
+
text: MarkdownText,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const id = z.string().min(1).max(512)
|
|
48
|
+
const emptyIndexMember = (memberId: string): MosaicTextIndexMember => ({
|
|
49
|
+
fragments: [],
|
|
50
|
+
id: memberId,
|
|
51
|
+
kind: `leaf`,
|
|
52
|
+
summary: { graphemes: 0, leafCount: 1, lineBreaks: 0, utf16Units: 0 },
|
|
53
|
+
version: 1,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
export const markdownSourceAtom = mutableAtom<InstanceType<typeof MarkdownText>>(
|
|
57
|
+
{ class: MarkdownText, key: `markdownSource` },
|
|
58
|
+
)
|
|
59
|
+
export const markdownIndexRootAtom = atom<MosaicTextIndexRoot>({
|
|
60
|
+
default: EMPTY_MARKDOWN_INDEX_ROOT,
|
|
61
|
+
key: `markdownIndexRoot`,
|
|
62
|
+
})
|
|
63
|
+
export const markdownIndexMemberAtoms = atomFamily<
|
|
64
|
+
MosaicTextIndexMember,
|
|
65
|
+
string
|
|
66
|
+
>({
|
|
67
|
+
default: emptyIndexMember,
|
|
68
|
+
key: `markdownIndexMember`,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
export type MarkdownPresence = {
|
|
72
|
+
readonly actor: string
|
|
73
|
+
readonly color: string
|
|
74
|
+
readonly name: string
|
|
75
|
+
readonly selection: MosaicTextSelection | null
|
|
76
|
+
readonly session: string
|
|
77
|
+
readonly viewport: {
|
|
78
|
+
readonly anchor: MosaicTextSelection[`anchor`]
|
|
79
|
+
readonly head: MosaicTextSelection[`head`]
|
|
80
|
+
} | null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const relativePosition = z
|
|
84
|
+
.object({
|
|
85
|
+
affinity: z.enum([`left`, `right`]),
|
|
86
|
+
offset: z.number().int().nonnegative(),
|
|
87
|
+
runId: id.nullable(),
|
|
88
|
+
})
|
|
89
|
+
.strict()
|
|
90
|
+
|
|
91
|
+
export const markdownPresenceSchema: z.ZodType<MarkdownPresence> = z
|
|
92
|
+
.object({
|
|
93
|
+
actor: id,
|
|
94
|
+
color: z.string().min(1).max(64),
|
|
95
|
+
name: z.string().min(1).max(128),
|
|
96
|
+
selection: z
|
|
97
|
+
.object({ anchor: relativePosition, head: relativePosition })
|
|
98
|
+
.strict()
|
|
99
|
+
.nullable(),
|
|
100
|
+
session: id,
|
|
101
|
+
viewport: z
|
|
102
|
+
.object({ anchor: relativePosition, head: relativePosition })
|
|
103
|
+
.strict()
|
|
104
|
+
.nullable(),
|
|
105
|
+
})
|
|
106
|
+
.strict()
|
|
107
|
+
|
|
108
|
+
export const markdownPresenceAtoms = atomFamily<MarkdownPresence | null, string>(
|
|
109
|
+
{ default: null, key: `markdownPresence` },
|
|
110
|
+
)
|
|
111
|
+
export const markdownPresenceKey = (
|
|
112
|
+
identity: Pick<MarkdownPresence, `actor` | `session`>,
|
|
113
|
+
): string => `${identity.actor}\u0000${identity.session}`
|
|
114
|
+
|
|
115
|
+
export const markdownDocumentDomain = mosaicDomain({
|
|
116
|
+
configSchema: z.object({}).strict(),
|
|
117
|
+
key: `markdown-document`,
|
|
118
|
+
members: {
|
|
119
|
+
collaborator: {
|
|
120
|
+
keySchema: id,
|
|
121
|
+
role: `ephemeral`,
|
|
122
|
+
schema: markdownPresenceSchema,
|
|
123
|
+
token: markdownPresenceAtoms,
|
|
124
|
+
},
|
|
125
|
+
indexMembers: {
|
|
126
|
+
keySchema: id,
|
|
127
|
+
model: markdownIndexMemberModel,
|
|
128
|
+
role: `durable`,
|
|
129
|
+
schema: markdownIndexMemberSchema as never,
|
|
130
|
+
token: markdownIndexMemberAtoms,
|
|
131
|
+
},
|
|
132
|
+
indexRoot: {
|
|
133
|
+
model: markdownIndexRootModel,
|
|
134
|
+
role: `durable`,
|
|
135
|
+
schema: markdownIndexRootSchema,
|
|
136
|
+
token: markdownIndexRootAtom,
|
|
137
|
+
},
|
|
138
|
+
source: {
|
|
139
|
+
model: markdownSourceModel,
|
|
140
|
+
role: `durable`,
|
|
141
|
+
schema: markdownSourceSnapshotSchema,
|
|
142
|
+
token: markdownSourceAtom,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
version: 1,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const MARKDOWN_DOMAIN_TOKENS = [
|
|
149
|
+
markdownSourceAtom,
|
|
150
|
+
markdownIndexRootAtom,
|
|
151
|
+
markdownIndexMemberAtoms,
|
|
152
|
+
markdownPresenceAtoms,
|
|
153
|
+
] as const
|
|
154
|
+
|
|
155
|
+
export function activateMarkdownDocumentDomain(options: {
|
|
156
|
+
readonly instance?: string
|
|
157
|
+
readonly silo: Pick<Silo, `install` | `store`>
|
|
158
|
+
}): ReturnType<typeof markdownDocumentDomain.activate> {
|
|
159
|
+
options.silo.install([...MARKDOWN_DOMAIN_TOKENS])
|
|
160
|
+
return markdownDocumentDomain.activate({
|
|
161
|
+
config: {},
|
|
162
|
+
instance: options.instance ?? `shared-markdown`,
|
|
163
|
+
store: options.silo.store,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type MarkdownDocumentDomain = Awaited<
|
|
168
|
+
ReturnType<typeof activateMarkdownDocumentDomain>
|
|
169
|
+
>
|
package/src/globals.css
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
@import url("https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&family=Newsreader:opsz,wght@6..72,500;6..72,650&display=swap");
|
|
2
2
|
|
|
3
3
|
:root {
|
|
4
|
+
color-scheme: dark light;
|
|
4
5
|
font-family: "Manrope", system-ui, sans-serif;
|
|
5
|
-
color: #
|
|
6
|
-
background: #
|
|
6
|
+
color: #f1edf7;
|
|
7
|
+
background: #121016;
|
|
7
8
|
font-synthesis: none;
|
|
8
9
|
text-rendering: optimizeLegibility;
|
|
10
|
+
|
|
11
|
+
@media (prefers-color-scheme: light) {
|
|
12
|
+
color-scheme: light;
|
|
13
|
+
color: #292936;
|
|
14
|
+
background: #f3f2f7;
|
|
15
|
+
}
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
* {
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import type { MosaicTextRelativePosition } from "@atom.io/realtime"
|
|
2
|
+
|
|
3
|
+
export type MarkdownSourceBlock = {
|
|
4
|
+
readonly anchor: MosaicTextRelativePosition
|
|
5
|
+
readonly end: number
|
|
6
|
+
readonly key: string
|
|
7
|
+
readonly start: number
|
|
8
|
+
readonly text: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type MarkdownSemanticBlock = MarkdownSourceBlock & {
|
|
12
|
+
readonly kind: `blockquote` | `code` | `heading` | `list-item` | `paragraph`
|
|
13
|
+
readonly level?: number
|
|
14
|
+
readonly ordered?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type MarkdownParseInstrumentation = {
|
|
18
|
+
readonly canceled: boolean
|
|
19
|
+
readonly elapsedMs: number
|
|
20
|
+
readonly parsedBlocks: number
|
|
21
|
+
readonly scannedUtf16Units: number
|
|
22
|
+
readonly stableBoundaryIndex: number | null
|
|
23
|
+
readonly reusedBlocks: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type MarkdownParseResult = {
|
|
27
|
+
readonly blocks: readonly MarkdownSemanticBlock[]
|
|
28
|
+
readonly instrumentation: MarkdownParseInstrumentation
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type FenceState = {
|
|
32
|
+
readonly anchor: MosaicTextRelativePosition
|
|
33
|
+
readonly end: number
|
|
34
|
+
readonly key: string
|
|
35
|
+
readonly language: string
|
|
36
|
+
readonly start: number
|
|
37
|
+
readonly text: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type ParseState = { readonly fence: FenceState | null }
|
|
41
|
+
|
|
42
|
+
type CacheEntry = {
|
|
43
|
+
readonly input: ParseState
|
|
44
|
+
readonly output: ParseState
|
|
45
|
+
readonly semantic: readonly MarkdownSemanticBlock[]
|
|
46
|
+
readonly source: MarkdownSourceBlock
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const samePosition = (
|
|
50
|
+
left: MosaicTextRelativePosition,
|
|
51
|
+
right: MosaicTextRelativePosition,
|
|
52
|
+
): boolean =>
|
|
53
|
+
left.affinity === right.affinity &&
|
|
54
|
+
left.offset === right.offset &&
|
|
55
|
+
left.runId === right.runId
|
|
56
|
+
|
|
57
|
+
const sameFence = (left: FenceState | null, right: FenceState | null): boolean =>
|
|
58
|
+
left === right ||
|
|
59
|
+
(left !== null &&
|
|
60
|
+
right !== null &&
|
|
61
|
+
left.end === right.end &&
|
|
62
|
+
left.key === right.key &&
|
|
63
|
+
left.language === right.language &&
|
|
64
|
+
left.start === right.start &&
|
|
65
|
+
left.text === right.text &&
|
|
66
|
+
samePosition(left.anchor, right.anchor))
|
|
67
|
+
|
|
68
|
+
const sameState = (left: ParseState, right: ParseState): boolean =>
|
|
69
|
+
sameFence(left.fence, right.fence)
|
|
70
|
+
|
|
71
|
+
const sameSource = (
|
|
72
|
+
left: MarkdownSourceBlock,
|
|
73
|
+
right: MarkdownSourceBlock,
|
|
74
|
+
): boolean =>
|
|
75
|
+
left.key === right.key &&
|
|
76
|
+
left.start === right.start &&
|
|
77
|
+
left.end === right.end &&
|
|
78
|
+
left.text === right.text &&
|
|
79
|
+
samePosition(left.anchor, right.anchor)
|
|
80
|
+
|
|
81
|
+
const classify = (source: MarkdownSourceBlock): MarkdownSemanticBlock => {
|
|
82
|
+
const heading = /^(#{1,6})\s+(.*)$/u.exec(source.text)
|
|
83
|
+
if (heading) {
|
|
84
|
+
return {
|
|
85
|
+
...source,
|
|
86
|
+
kind: `heading`,
|
|
87
|
+
level: heading[1].length,
|
|
88
|
+
text: heading[2],
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const ordered = /^\d+[.)]\s+(.*)$/u.exec(source.text)
|
|
92
|
+
if (ordered) {
|
|
93
|
+
return { ...source, kind: `list-item`, ordered: true, text: ordered[1] }
|
|
94
|
+
}
|
|
95
|
+
const unordered = /^[-*+]\s+(.*)$/u.exec(source.text)
|
|
96
|
+
if (unordered) {
|
|
97
|
+
return { ...source, kind: `list-item`, ordered: false, text: unordered[1] }
|
|
98
|
+
}
|
|
99
|
+
if (source.text.startsWith(`> `)) {
|
|
100
|
+
return { ...source, kind: `blockquote`, text: source.text.slice(2) }
|
|
101
|
+
}
|
|
102
|
+
return { ...source, kind: `paragraph` }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseOne(
|
|
106
|
+
source: MarkdownSourceBlock,
|
|
107
|
+
input: ParseState,
|
|
108
|
+
): { readonly output: ParseState; readonly semantic: MarkdownSemanticBlock[] } {
|
|
109
|
+
const fenceMarker = /^```([^\n]*)$/u.exec(source.text.trimEnd())
|
|
110
|
+
if (input.fence === null) {
|
|
111
|
+
if (fenceMarker === null) {
|
|
112
|
+
return {
|
|
113
|
+
output: input,
|
|
114
|
+
semantic: source.text.trim() === `` ? [] : [classify(source)],
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
output: {
|
|
119
|
+
fence: {
|
|
120
|
+
anchor: source.anchor,
|
|
121
|
+
end: source.end,
|
|
122
|
+
key: source.key,
|
|
123
|
+
language: fenceMarker[1].trim(),
|
|
124
|
+
start: source.start,
|
|
125
|
+
text: ``,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
semantic: [],
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (fenceMarker !== null) {
|
|
132
|
+
return {
|
|
133
|
+
output: { fence: null },
|
|
134
|
+
semantic: [
|
|
135
|
+
{
|
|
136
|
+
anchor: input.fence.anchor,
|
|
137
|
+
end: source.end,
|
|
138
|
+
key: input.fence.key,
|
|
139
|
+
kind: `code`,
|
|
140
|
+
start: input.fence.start,
|
|
141
|
+
text: input.fence.text,
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
output: {
|
|
148
|
+
fence: {
|
|
149
|
+
...input.fence,
|
|
150
|
+
end: source.end,
|
|
151
|
+
text:
|
|
152
|
+
input.fence.text === ``
|
|
153
|
+
? source.text
|
|
154
|
+
: `${input.fence.text}\n${source.text}`,
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
semantic: [],
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const turn = (): Promise<void> =>
|
|
162
|
+
new Promise((resolve) => setTimeout(resolve, 0))
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Incremental block parser with explicit cancelation and stable-boundary reuse.
|
|
166
|
+
* It is renderer-neutral; the React viewport may be replaced without moving
|
|
167
|
+
* parser state into component instances.
|
|
168
|
+
*/
|
|
169
|
+
export class IncrementalMarkdownParser {
|
|
170
|
+
#cache: CacheEntry[] = []
|
|
171
|
+
#generation: AbortController | null = null
|
|
172
|
+
|
|
173
|
+
public cancel(): void {
|
|
174
|
+
this.#generation?.abort()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
public async parse(
|
|
178
|
+
source: readonly MarkdownSourceBlock[],
|
|
179
|
+
options: {
|
|
180
|
+
readonly signal?: AbortSignal
|
|
181
|
+
readonly yieldAfterUtf16Units?: number
|
|
182
|
+
} = {},
|
|
183
|
+
): Promise<MarkdownParseResult> {
|
|
184
|
+
this.cancel()
|
|
185
|
+
const generation = new AbortController()
|
|
186
|
+
this.#generation = generation
|
|
187
|
+
const abort = (): void => {
|
|
188
|
+
generation.abort()
|
|
189
|
+
}
|
|
190
|
+
if (options.signal?.aborted) abort()
|
|
191
|
+
options.signal?.addEventListener(`abort`, abort, { once: true })
|
|
192
|
+
const started = performance.now()
|
|
193
|
+
const yieldAfter = options.yieldAfterUtf16Units ?? 16_384
|
|
194
|
+
let sinceYield = 0
|
|
195
|
+
let scannedUtf16Units = 0
|
|
196
|
+
let parsedBlocks = 0
|
|
197
|
+
let reusedBlocks = 0
|
|
198
|
+
let stableBoundaryIndex: number | null = null
|
|
199
|
+
let propagating = false
|
|
200
|
+
const next: CacheEntry[] = []
|
|
201
|
+
const semantic: MarkdownSemanticBlock[] = []
|
|
202
|
+
let state: ParseState = { fence: null }
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
// Even the first bounded slice begins outside the browser input turn.
|
|
206
|
+
await turn()
|
|
207
|
+
for (let index = 0; index < source.length; index++) {
|
|
208
|
+
if (generation.signal.aborted) throw generation.signal.reason
|
|
209
|
+
const item = source[index]
|
|
210
|
+
const cached = this.#cache[index]
|
|
211
|
+
if (
|
|
212
|
+
cached !== undefined &&
|
|
213
|
+
sameSource(cached.source, item) &&
|
|
214
|
+
sameState(cached.input, state)
|
|
215
|
+
) {
|
|
216
|
+
next.push(cached)
|
|
217
|
+
semantic.push(...cached.semantic)
|
|
218
|
+
state = cached.output
|
|
219
|
+
reusedBlocks++
|
|
220
|
+
if (propagating) {
|
|
221
|
+
stableBoundaryIndex = index
|
|
222
|
+
propagating = false
|
|
223
|
+
}
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
propagating = true
|
|
227
|
+
stableBoundaryIndex = null
|
|
228
|
+
const parsed = parseOne(item, state)
|
|
229
|
+
const entry = {
|
|
230
|
+
input: state,
|
|
231
|
+
output: parsed.output,
|
|
232
|
+
semantic: parsed.semantic,
|
|
233
|
+
source: item,
|
|
234
|
+
}
|
|
235
|
+
next.push(entry)
|
|
236
|
+
semantic.push(...parsed.semantic)
|
|
237
|
+
state = parsed.output
|
|
238
|
+
parsedBlocks++
|
|
239
|
+
scannedUtf16Units += item.text.length
|
|
240
|
+
sinceYield += item.text.length
|
|
241
|
+
if (sinceYield >= yieldAfter) {
|
|
242
|
+
sinceYield = 0
|
|
243
|
+
await turn()
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (state.fence !== null) {
|
|
247
|
+
semantic.push({ ...state.fence, kind: `code` })
|
|
248
|
+
}
|
|
249
|
+
if (!generation.signal.aborted) this.#cache = next
|
|
250
|
+
return {
|
|
251
|
+
blocks: semantic,
|
|
252
|
+
instrumentation: {
|
|
253
|
+
canceled: false,
|
|
254
|
+
elapsedMs: performance.now() - started,
|
|
255
|
+
parsedBlocks,
|
|
256
|
+
reusedBlocks,
|
|
257
|
+
scannedUtf16Units,
|
|
258
|
+
stableBoundaryIndex,
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (!generation.signal.aborted) throw error
|
|
263
|
+
return {
|
|
264
|
+
blocks: [],
|
|
265
|
+
instrumentation: {
|
|
266
|
+
canceled: true,
|
|
267
|
+
elapsedMs: performance.now() - started,
|
|
268
|
+
parsedBlocks,
|
|
269
|
+
reusedBlocks,
|
|
270
|
+
scannedUtf16Units,
|
|
271
|
+
stableBoundaryIndex,
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
} finally {
|
|
275
|
+
options.signal?.removeEventListener(`abort`, abort)
|
|
276
|
+
if (this.#generation === generation) this.#generation = null
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|