@atom.io/template-react-realtime-text-editor 0.0.11 → 0.0.13

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/src/main.tsx CHANGED
@@ -1,24 +1,45 @@
1
1
  import "./globals.css"
2
2
 
3
- import { RealtimeProvider } from "atom.io/realtime-react"
4
- import { StrictMode } from "react"
3
+ import { Silo } from "atom.io"
4
+ import { StoreProvider } from "@atom.io/react"
5
5
  import { createRoot } from "react-dom/client"
6
6
  import { io } from "socket.io-client"
7
7
 
8
+ import { createMarkdownCollaborationClient } from "./collaboration-client.ts"
8
9
  import { MarkdownWorkspace } from "./MarkdownWorkspace.tsx"
9
- import { getBrowserSession, realtimeAuth } from "./session.ts"
10
+ import { getBrowserSession } from "./session.ts"
11
+ import { createMarkdownWorkspaceState } from "./workspace-state.ts"
10
12
 
11
13
  const session = getBrowserSession()
14
+ const silo = new Silo({
15
+ isProduction: import.meta.env.PROD,
16
+ lifespan: `ephemeral`,
17
+ name: `markdown-client:${session.clientId}`,
18
+ })
12
19
  const socket = io(window.location.origin, {
13
- auth: realtimeAuth(session.identity, session.clientId),
20
+ auth: { actor: session.identity.id, session: session.clientId },
14
21
  reconnection: true,
15
22
  reconnectionDelayMax: 4_000,
16
23
  })
24
+ const client = await createMarkdownCollaborationClient({
25
+ identity: session.identity,
26
+ sessionId: session.clientId,
27
+ silo,
28
+ socket,
29
+ })
30
+ const workspace = createMarkdownWorkspaceState({ client, silo })
31
+ window.addEventListener(
32
+ `pagehide`,
33
+ () => {
34
+ workspace[Symbol.dispose]()
35
+ client[Symbol.dispose]()
36
+ socket.close()
37
+ },
38
+ { once: true },
39
+ )
17
40
 
18
41
  createRoot(document.getElementById(`root`)!).render(
19
- <StrictMode>
20
- <RealtimeProvider socket={socket}>
21
- <MarkdownWorkspace {...session} />
22
- </RealtimeProvider>
23
- </StrictMode>,
42
+ <StoreProvider store={silo.store}>
43
+ <MarkdownWorkspace client={client} state={workspace} />
44
+ </StoreProvider>,
24
45
  )
@@ -0,0 +1,35 @@
1
+ import type {
2
+ MosaicTextIndexRange,
3
+ MosaicTextRelativePosition,
4
+ } from "@atom.io/realtime"
5
+
6
+ export const MARKDOWN_EVENTS = {
7
+ command: `markdown-domain:command`,
8
+ materialize: `markdown-domain:materialize`,
9
+ positionAtOffset: `markdown-domain:position-at-offset`,
10
+ resolvePosition: `markdown-domain:resolve-position`,
11
+ } as const
12
+
13
+ export type MarkdownAcknowledgement<Value> =
14
+ | { readonly ok: true; readonly value: Value }
15
+ | { readonly ok: false; readonly reason: string }
16
+
17
+ export type MarkdownEditCommand = {
18
+ readonly anchor: MosaicTextRelativePosition
19
+ readonly gestureId: string
20
+ readonly head: MosaicTextRelativePosition
21
+ readonly sequence: number
22
+ readonly text: string
23
+ readonly type: `replace`
24
+ }
25
+
26
+ export type MarkdownImportCommand = {
27
+ readonly gestureId: string
28
+ readonly sequence: number
29
+ readonly text: string
30
+ readonly type: `import`
31
+ }
32
+
33
+ export type MarkdownCommand = MarkdownEditCommand | MarkdownImportCommand
34
+
35
+ export type MarkdownViewport = MosaicTextIndexRange
@@ -0,0 +1,53 @@
1
+ import type { MosaicTextIndexRange } from "@atom.io/realtime"
2
+
3
+ export type MarkdownVirtualMetrics = {
4
+ readonly averageUtf16UnitsPerRow: number
5
+ readonly overscanRows: number
6
+ readonly rowHeight: number
7
+ readonly totalUtf16Units: number
8
+ }
9
+
10
+ export type MarkdownVirtualWindow = {
11
+ readonly bottomSpacer: number
12
+ readonly range: MosaicTextIndexRange
13
+ readonly topSpacer: number
14
+ }
15
+
16
+ /** Map a scroll viewport to a bounded logical range without a segment manifest. */
17
+ export function markdownVirtualWindow(
18
+ viewport: { readonly height: number; readonly scrollTop: number },
19
+ metrics: MarkdownVirtualMetrics,
20
+ ): MarkdownVirtualWindow {
21
+ for (const [name, value] of Object.entries({ ...viewport, ...metrics })) {
22
+ if (!Number.isFinite(value) || value < 0) {
23
+ throw new RangeError(`${name} must be a finite non-negative number.`)
24
+ }
25
+ }
26
+ if (metrics.averageUtf16UnitsPerRow < 1 || metrics.rowHeight < 1) {
27
+ throw new RangeError(`Virtual row estimates must be positive.`)
28
+ }
29
+ const totalRows = Math.max(
30
+ 1,
31
+ Math.ceil(metrics.totalUtf16Units / metrics.averageUtf16UnitsPerRow),
32
+ )
33
+ const firstVisibleRow = Math.floor(viewport.scrollTop / metrics.rowHeight)
34
+ const visibleRows = Math.max(1, Math.ceil(viewport.height / metrics.rowHeight))
35
+ const firstRow = Math.max(0, firstVisibleRow - metrics.overscanRows)
36
+ const lastRow = Math.min(
37
+ totalRows,
38
+ firstVisibleRow + visibleRows + metrics.overscanRows,
39
+ )
40
+ const start = Math.min(
41
+ metrics.totalUtf16Units,
42
+ Math.floor(firstRow * metrics.averageUtf16UnitsPerRow),
43
+ )
44
+ const end = Math.min(
45
+ metrics.totalUtf16Units,
46
+ Math.max(start, Math.ceil(lastRow * metrics.averageUtf16UnitsPerRow)),
47
+ )
48
+ return {
49
+ bottomSpacer: Math.max(0, (totalRows - lastRow) * metrics.rowHeight),
50
+ range: { end, kind: `utf16-range`, start },
51
+ topSpacer: firstRow * metrics.rowHeight,
52
+ }
53
+ }
@@ -0,0 +1,275 @@
1
+ import type { ReadonlyPureSelectorToken, RegularAtomToken, Silo } from "atom.io"
2
+ import type { MosaicTextSelection } from "@atom.io/realtime"
3
+ import {
4
+ createMosaicTextRangeController,
5
+ type MosaicTextRangeController,
6
+ type MosaicTextRangeView,
7
+ } from "@atom.io/realtime/client"
8
+
9
+ import type {
10
+ MarkdownClientStatus,
11
+ MarkdownCollaborationClient,
12
+ } from "./collaboration-client.ts"
13
+ import type { MarkdownPresence } from "./document-domain.ts"
14
+ import {
15
+ IncrementalMarkdownParser,
16
+ type MarkdownParseInstrumentation,
17
+ type MarkdownSemanticBlock,
18
+ } from "./incremental-markdown.ts"
19
+ import {
20
+ markdownVirtualWindow,
21
+ type MarkdownVirtualWindow,
22
+ } from "./virtualization.ts"
23
+
24
+ export type MarkdownScrollViewport = {
25
+ readonly height: number
26
+ readonly scrollTop: number
27
+ }
28
+
29
+ export type MarkdownWorkspacePeer = {
30
+ readonly id: string
31
+ readonly selection: MosaicTextSelection | null
32
+ readonly value: MarkdownPresence
33
+ }
34
+
35
+ export type MarkdownWorkspaceState = Disposable & {
36
+ readonly parse: RegularAtomToken<readonly MarkdownSemanticBlock[]>
37
+ readonly parseMetrics: RegularAtomToken<MarkdownParseInstrumentation>
38
+ readonly peers: ReadonlyPureSelectorToken<readonly MarkdownWorkspacePeer[]>
39
+ readonly presence: RegularAtomToken<readonly MarkdownPresence[]>
40
+ readonly problem: RegularAtomToken<string | null>
41
+ readonly readProblem: RegularAtomToken<string | null>
42
+ readonly scroll: RegularAtomToken<MarkdownScrollViewport>
43
+ readonly status: RegularAtomToken<MarkdownClientStatus>
44
+ readonly totalLength: RegularAtomToken<number>
45
+ readonly view: RegularAtomToken<MosaicTextRangeView>
46
+ readonly window: ReadonlyPureSelectorToken<MarkdownVirtualWindow>
47
+ flushDeferredScroll(hasLocalDraft: boolean): void
48
+ publishSelection(selection: MosaicTextSelection): Promise<void>
49
+ publishViewport(viewport: MosaicTextSelection): Promise<void>
50
+ refreshLength(): Promise<number | null>
51
+ replace(
52
+ input: Parameters<MarkdownCollaborationClient[`replace`]>[0],
53
+ ): Promise<void>
54
+ reportEditorError(error: unknown): void
55
+ setDocumentLength(length: number): void
56
+ setScroll(viewport: MarkdownScrollViewport, defer: boolean): void
57
+ }
58
+
59
+ export const EMPTY_PARSE_METRICS: MarkdownParseInstrumentation = {
60
+ canceled: false,
61
+ elapsedMs: 0,
62
+ parsedBlocks: 0,
63
+ reusedBlocks: 0,
64
+ scannedUtf16Units: 0,
65
+ stableBoundaryIndex: null,
66
+ }
67
+
68
+ const activePresence = (
69
+ client: MarkdownCollaborationClient,
70
+ ): MarkdownPresence[] =>
71
+ client.presence.state.presence.flatMap((envelope) =>
72
+ envelope.kind === `update` && envelope.address.member === `collaborator`
73
+ ? [envelope.value as MarkdownPresence]
74
+ : [],
75
+ )
76
+
77
+ /** Store-owned application state around the renderer-neutral text controllers. */
78
+ export function createMarkdownWorkspaceState(options: {
79
+ readonly client: MarkdownCollaborationClient
80
+ readonly silo: Silo
81
+ }): MarkdownWorkspaceState {
82
+ const { client, silo } = options
83
+ const key = `markdown-workspace:${client.sessionId}`
84
+ const statusAtom = silo.atom<MarkdownClientStatus>({
85
+ default: client.status(),
86
+ // eslint-disable-next-line atom.io/naming-convention -- A shared Store needs one workspace namespace per realtime session.
87
+ key: `${key}:status`,
88
+ })
89
+ const presenceAtom = silo.atom<readonly MarkdownPresence[]>({
90
+ default: activePresence(client),
91
+ // eslint-disable-next-line atom.io/naming-convention -- A shared Store needs one workspace namespace per realtime session.
92
+ key: `${key}:presence`,
93
+ })
94
+ const totalLengthAtom = silo.atom<number>({
95
+ default: 0,
96
+ // eslint-disable-next-line atom.io/naming-convention -- A shared Store needs one workspace namespace per realtime session.
97
+ key: `${key}:totalLength`,
98
+ })
99
+ const scrollAtom = silo.atom<MarkdownScrollViewport>({
100
+ default: { height: 560, scrollTop: 0 },
101
+ // eslint-disable-next-line atom.io/naming-convention -- A shared Store needs one workspace namespace per realtime session.
102
+ key: `${key}:scroll`,
103
+ })
104
+ const parseAtom = silo.atom<readonly MarkdownSemanticBlock[]>({
105
+ default: [],
106
+ // eslint-disable-next-line atom.io/naming-convention -- A shared Store needs one workspace namespace per realtime session.
107
+ key: `${key}:parse`,
108
+ })
109
+ const parseMetricsAtom = silo.atom<MarkdownParseInstrumentation>({
110
+ default: EMPTY_PARSE_METRICS,
111
+ // eslint-disable-next-line atom.io/naming-convention -- A shared Store needs one workspace namespace per realtime session.
112
+ key: `${key}:parseMetrics`,
113
+ })
114
+ const readProblemAtom = silo.atom<string | null>({
115
+ default: null,
116
+ // eslint-disable-next-line atom.io/naming-convention -- A shared Store needs one workspace namespace per realtime session.
117
+ key: `${key}:readProblem`,
118
+ })
119
+ const problemAtom = silo.atom<string | null>({
120
+ default: null,
121
+ // eslint-disable-next-line atom.io/naming-convention -- A shared Store needs one workspace namespace per realtime session.
122
+ key: `${key}:problem`,
123
+ })
124
+ const windowSelector = silo.selector<MarkdownVirtualWindow>({
125
+ get: ({ get }) =>
126
+ markdownVirtualWindow(get(scrollAtom), {
127
+ averageUtf16UnitsPerRow: 88,
128
+ overscanRows: 24,
129
+ rowHeight: 24,
130
+ totalUtf16Units: get(totalLengthAtom),
131
+ }),
132
+ // eslint-disable-next-line atom.io/naming-convention -- A shared Store needs one workspace namespace per realtime session.
133
+ key: `${key}:window`,
134
+ })
135
+ const peersSelector = silo.selector<readonly MarkdownWorkspacePeer[]>({
136
+ get: ({ get }) =>
137
+ get(presenceAtom)
138
+ .filter((person) => person.session !== client.sessionId)
139
+ .map((person) => ({
140
+ id: person.session,
141
+ selection: person.selection,
142
+ value: person,
143
+ })),
144
+ // eslint-disable-next-line atom.io/naming-convention -- A shared Store needs one workspace namespace per realtime session.
145
+ key: `${key}:peers`,
146
+ })
147
+ const initialWindow = silo.getState(windowSelector)
148
+ const range: MosaicTextRangeController = createMosaicTextRangeController({
149
+ client: client.projection,
150
+ deferStart: true,
151
+ initialRange: initialWindow.range,
152
+ key: `${key}:text-range`,
153
+ overscan: 2_048,
154
+ silo,
155
+ })
156
+ const parser = new IncrementalMarkdownParser()
157
+ let deferredScroll: MarkdownScrollViewport | null = null
158
+ let disposed = false
159
+
160
+ const refreshLength = async (): Promise<number | null> => {
161
+ try {
162
+ const length = await client.projection.readLength()
163
+ if (disposed) return null
164
+ silo.setState(totalLengthAtom, length)
165
+ silo.setState(readProblemAtom, null)
166
+ if (length === 0) {
167
+ silo.setState(range.view, {
168
+ error: null,
169
+ projection: {
170
+ blocks: [],
171
+ complete: true,
172
+ range: { end: 0, kind: `utf16-range`, start: 0 },
173
+ segments: [],
174
+ text: ``,
175
+ },
176
+ status: `ready`,
177
+ })
178
+ } else {
179
+ range.start()
180
+ }
181
+ return length
182
+ } catch (error) {
183
+ if (!disposed) {
184
+ silo.setState(
185
+ readProblemAtom,
186
+ error instanceof Error ? error.message : String(error),
187
+ )
188
+ }
189
+ return null
190
+ }
191
+ }
192
+ const parseProjection = (view: MosaicTextRangeView): void => {
193
+ if (view.status !== `ready`) return
194
+ void parser.parse(view.projection.blocks).then((result) => {
195
+ if (disposed || result.instrumentation.canceled) return
196
+ silo.setState(parseAtom, result.blocks)
197
+ silo.setState(parseMetricsAtom, result.instrumentation)
198
+ })
199
+ }
200
+ const stopStatus = client.subscribe((next) => {
201
+ silo.setState(statusAtom, next)
202
+ if (next.connection === `live`) void refreshLength()
203
+ })
204
+ const stopPresence = client.presence.subscribe(() => {
205
+ silo.setState(presenceAtom, activePresence(client))
206
+ })
207
+ const stopWindow = silo.subscribe(windowSelector, ({ newValue }) => {
208
+ silo.setState(range.range, newValue.range)
209
+ })
210
+ const stopView = silo.subscribe(range.view, ({ newValue }) => {
211
+ parseProjection(newValue)
212
+ })
213
+ parseProjection(silo.getState(range.view))
214
+ void refreshLength()
215
+
216
+ return {
217
+ parse: parseAtom,
218
+ parseMetrics: parseMetricsAtom,
219
+ peers: peersSelector,
220
+ presence: presenceAtom,
221
+ problem: problemAtom,
222
+ readProblem: readProblemAtom,
223
+ scroll: scrollAtom,
224
+ status: statusAtom,
225
+ totalLength: totalLengthAtom,
226
+ view: range.view,
227
+ window: windowSelector,
228
+ flushDeferredScroll(hasLocalDraft) {
229
+ if (hasLocalDraft || deferredScroll === null) return
230
+ silo.setState(scrollAtom, deferredScroll)
231
+ deferredScroll = null
232
+ },
233
+ publishSelection(selection) {
234
+ return client.publishPresence({
235
+ color: client.identity.color,
236
+ name: client.identity.name,
237
+ selection,
238
+ viewport: null,
239
+ })
240
+ },
241
+ publishViewport(viewport) {
242
+ return client.publishPresence({
243
+ color: client.identity.color,
244
+ name: client.identity.name,
245
+ selection: null,
246
+ viewport,
247
+ })
248
+ },
249
+ refreshLength,
250
+ replace: (input) => client.replace(input),
251
+ reportEditorError(error) {
252
+ silo.setState(
253
+ problemAtom,
254
+ error instanceof Error ? error.message : String(error),
255
+ )
256
+ },
257
+ setDocumentLength(length) {
258
+ silo.setState(totalLengthAtom, length)
259
+ },
260
+ setScroll(viewport, defer) {
261
+ if (defer) deferredScroll = viewport
262
+ else silo.setState(scrollAtom, viewport)
263
+ },
264
+ [Symbol.dispose]() {
265
+ if (disposed) return
266
+ disposed = true
267
+ stopStatus()
268
+ stopPresence()
269
+ stopWindow()
270
+ stopView()
271
+ parser.cancel()
272
+ range[Symbol.dispose]()
273
+ },
274
+ }
275
+ }