@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.
@@ -1,46 +1,39 @@
1
- import type { MosaicTextSelection } from "atom.io/realtime"
2
- import { useO } from "atom.io/react"
3
- import { useMosaic } from "atom.io/realtime-react"
1
+ import { useO } from "@atom.io/react"
2
+ import { useMosaicTextEditor } from "@atom.io/realtime/react"
4
3
  import {
5
- Fragment,
6
4
  createElement,
7
5
  type CSSProperties,
6
+ Fragment,
8
7
  type ReactElement,
9
8
  type ReactNode,
10
- useEffect,
11
- useLayoutEffect,
12
- useMemo,
13
- useRef,
14
- useState,
15
9
  } from "react"
16
10
 
17
- import {
18
- lineAndColumnAt,
19
- type Markdown,
20
- markdownAtom,
21
- markdownCharacterCountSelector,
22
- markdownWordCountSelector,
23
- type MarkdownPresence,
24
- } from "./collaboration/mosaic.ts"
11
+ import type {
12
+ MarkdownClientStatus,
13
+ MarkdownCollaborationClient,
14
+ } from "./collaboration-client.ts"
15
+ import type { MarkdownSemanticBlock } from "./incremental-markdown.ts"
25
16
  import { SIMULATED_IDENTITIES } from "./identities.ts"
26
- import type { Identity } from "./identities.ts"
27
- import { switchBrowserIdentity } from "./session.ts"
17
+ import {
18
+ LexicalMarkdownEditor,
19
+ type RenderedCollaboratorSelection,
20
+ } from "./LexicalMarkdownEditor.tsx"
21
+ import type { MarkdownWorkspaceState } from "./workspace-state.ts"
28
22
  import css from "./MarkdownWorkspace.module.css"
23
+ import { switchBrowserIdentity } from "./session.ts"
29
24
 
30
25
  type MarkdownWorkspaceProps = {
31
- clientId: string
32
- identity: Identity
26
+ readonly client: MarkdownCollaborationClient
27
+ readonly state: MarkdownWorkspaceState
33
28
  }
34
-
35
- type CaretStyle = CSSProperties &
36
- Record<`--caret-column` | `--caret-line` | `--person-color`, number | string>
29
+ type PersonStyle = CSSProperties & Record<`--person-color`, string>
37
30
 
38
31
  function safeHref(candidate: string): string | undefined {
39
- return /^(https?:\/\/|mailto:|#)/.test(candidate) ? candidate : undefined
32
+ return /^(https?:\/\/|mailto:|#)/u.test(candidate) ? candidate : undefined
40
33
  }
41
34
 
42
35
  function renderInline(text: string, keyPrefix: string): ReactNode[] {
43
- const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g
36
+ const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/gu
44
37
  const nodes: ReactNode[] = []
45
38
  let cursor = 0
46
39
  for (const match of text.matchAll(pattern)) {
@@ -55,7 +48,7 @@ function renderInline(text: string, keyPrefix: string): ReactNode[] {
55
48
  } else if (token.startsWith(`*`)) {
56
49
  nodes.push(<em key={key}>{token.slice(1, -1)}</em>)
57
50
  } else {
58
- const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token)
51
+ const link = /^\[([^\]]+)\]\(([^)]+)\)$/u.exec(token)
59
52
  const href = safeHref(link?.[2] ?? ``)
60
53
  nodes.push(
61
54
  href ? (
@@ -73,171 +66,77 @@ function renderInline(text: string, keyPrefix: string): ReactNode[] {
73
66
  return nodes
74
67
  }
75
68
 
76
- function startsBlock(line: string): boolean {
77
- return /^(#{1,6} |```|> |[-*] |\d+\. )/.test(line)
78
- }
79
-
80
- function renderMarkdown(markdown: string): ReactNode[] {
81
- const lines = markdown.split(`\n`)
82
- const blocks: ReactNode[] = []
83
- let index = 0
84
- while (index < lines.length) {
85
- const line = lines[index]
86
- if (line.trim() === ``) {
87
- index++
88
- continue
89
- }
90
- if (line.startsWith(`\`\`\``)) {
91
- const language = line.slice(3).trim()
92
- const code: string[] = []
93
- index++
94
- while (index < lines.length && !lines[index].startsWith(`\`\`\``)) {
95
- code.push(lines[index])
96
- index++
97
- }
98
- index++
99
- blocks.push(
100
- <pre key={`code:${index}`} data-language={language || undefined}>
101
- <code>{code.join(`\n`)}</code>
102
- </pre>,
69
+ function renderSemantic(block: MarkdownSemanticBlock): ReactElement {
70
+ const content = renderInline(block.text, block.key)
71
+ switch (block.kind) {
72
+ case `heading`:
73
+ return createElement(`h${block.level ?? 2}`, { key: block.key }, content)
74
+ case `blockquote`:
75
+ return <blockquote key={block.key}>{content}</blockquote>
76
+ case `code`:
77
+ return (
78
+ <pre key={block.key}>
79
+ <code>{block.text}</code>
80
+ </pre>
103
81
  )
104
- continue
105
- }
106
- const heading = /^(#{1,6}) (.*)$/.exec(line)
107
- if (heading) {
108
- blocks.push(
109
- createElement(
110
- `h${heading[1].length}`,
111
- { key: `heading:${index}` },
112
- renderInline(heading[2], `heading:${index}`),
113
- ),
114
- )
115
- index++
116
- continue
117
- }
118
- if (/^[-*] /.test(line)) {
119
- const items: ReactNode[] = []
120
- while (index < lines.length && /^[-*] /.test(lines[index])) {
121
- items.push(
122
- <li key={`item:${index}`}>
123
- {renderInline(lines[index].slice(2), `item:${index}`)}
124
- </li>,
125
- )
126
- index++
127
- }
128
- blocks.push(<ul key={`list:${index}`}>{items}</ul>)
129
- continue
130
- }
131
- if (/^\d+\. /.test(line)) {
132
- const items: ReactNode[] = []
133
- while (index < lines.length && /^\d+\. /.test(lines[index])) {
134
- items.push(
135
- <li key={`item:${index}`}>
136
- {renderInline(lines[index].replace(/^\d+\. /, ``), `item:${index}`)}
137
- </li>,
138
- )
139
- index++
140
- }
141
- blocks.push(<ol key={`list:${index}`}>{items}</ol>)
142
- continue
143
- }
144
- if (line.startsWith(`> `)) {
145
- blocks.push(
146
- <blockquote key={`quote:${index}`}>
147
- {renderInline(line.slice(2), `quote:${index}`)}
148
- </blockquote>,
149
- )
150
- index++
151
- continue
152
- }
153
-
154
- const paragraph = [line]
155
- index++
156
- while (
157
- index < lines.length &&
158
- lines[index].trim() !== `` &&
159
- !startsBlock(lines[index])
160
- ) {
161
- paragraph.push(lines[index])
162
- index++
163
- }
164
- blocks.push(
165
- <p key={`paragraph:${index}`}>
166
- {renderInline(paragraph.join(` `), `paragraph:${index}`)}
167
- </p>,
168
- )
82
+ case `list-item`:
83
+ return <p key={block.key}>• {content}</p>
84
+ case `paragraph`:
85
+ return <p key={block.key}>{content}</p>
169
86
  }
170
- return blocks
171
87
  }
172
88
 
173
- function getStatusLabel(status: string, pending: number): string {
174
- if (status === `offline`) return `Offline · changes stay queued`
175
- if (status === `recovering` || status === `syncing`) {
176
- return `Catching up…`
177
- }
178
- if (status === `rejected`) return `Collaboration needs attention`
89
+ function statusLabel(
90
+ status: MarkdownClientStatus,
91
+ hasLocalDraft: boolean,
92
+ ): string {
93
+ if (status.connection === `offline`) return `Offline · draft stays local`
94
+ if (status.connection === `recovering`) return `Resnapshotting working set…`
95
+ if (status.connection === `connecting`) return `Connecting…`
96
+ const pending = Math.max(status.pending, hasLocalDraft ? 1 : 0)
179
97
  return pending === 0
180
98
  ? `All changes saved`
181
- : `Saving ${pending} change${pending === 1 ? `` : `s`}…`
99
+ : `Saving ${pending} gesture${pending === 1 ? `` : `s`}…`
182
100
  }
183
101
 
184
102
  export function MarkdownWorkspace({
185
- clientId,
186
- identity,
103
+ client,
104
+ state,
187
105
  }: MarkdownWorkspaceProps): ReactElement {
188
- const mosaic = useMosaic<InstanceType<typeof Markdown>, MarkdownPresence>(
189
- markdownAtom,
190
- { actor: identity.id, session: clientId },
191
- )
192
- const document = useO(markdownAtom)
193
- const characterCount = useO(markdownCharacterCountSelector)
194
- const wordCount = useO(markdownWordCountSelector)
195
- const markdown = document.text
196
- const history = document.historyFor(identity.id)
197
- const presence = mosaic.presence.map(({ actor, presence: peer, session }) => ({
198
- ...peer,
199
- clientId: session,
200
- id: actor,
201
- }))
202
- const editorRef = useRef<HTMLTextAreaElement | null>(null)
203
- const selectionRef = useRef<MosaicTextSelection | null>(null)
204
- const [scroll, setScroll] = useState({ left: 0, top: 0 })
205
- const preview = useMemo(() => renderMarkdown(markdown), [markdown])
206
- const collaborators = presence.filter(({ clientId: id }) => id !== clientId)
207
- const pending = mosaic.pending.length
208
-
209
- const publishSelection = (selection: MosaicTextSelection | null): void => {
210
- mosaic.publishPresence({
211
- color: identity.color,
212
- lastActiveAt: Date.now(),
213
- name: identity.name,
214
- selection,
215
- })
216
- }
217
-
218
- useEffect(() => {
219
- if (mosaic.status === `live`) publishSelection(selectionRef.current)
220
- }, [mosaic.status])
106
+ const status = useO(state.status)
107
+ const presence = useO(state.presence)
108
+ const totalLength = useO(state.totalLength)
109
+ const parse = useO(state.parse)
110
+ const parseMetrics = useO(state.parseMetrics)
111
+ const readProblem = useO(state.readProblem)
112
+ const problem = useO(state.problem)
113
+ const window = useO(state.window)
114
+ const view = useO(state.view)
115
+ const peers = useO(state.peers)
116
+ const incomingProjection = view.status === `ready` ? view.projection : null
117
+ const editor = useMosaicTextEditor({
118
+ client: client.projection,
119
+ connected: status.connection === `live`,
120
+ documentLength: totalLength,
121
+ onDocumentLength: state.setDocumentLength,
122
+ onError: state.reportEditorError,
123
+ onLocalDraftChange: state.flushDeferredScroll,
124
+ peers,
125
+ projection: incomingProjection,
126
+ publishSelection: state.publishSelection,
127
+ publishViewport: state.publishViewport,
128
+ replace: state.replace,
129
+ })
130
+ const projection = editor.projection
221
131
 
222
- useLayoutEffect(() => {
223
- const editor = editorRef.current
224
- const selection = selectionRef.current
225
- if (!editor || !selection || globalThis.document.activeElement !== editor) {
226
- return
227
- }
228
- editor.setSelectionRange(
229
- document.resolvePosition(selection.anchor),
230
- document.resolvePosition(selection.head),
231
- )
232
- }, [markdown])
233
-
234
- const rememberSelection = (editor: HTMLTextAreaElement): void => {
235
- selectionRef.current = document.selectionFromOffsets(
236
- editor.selectionStart,
237
- editor.selectionEnd,
238
- )
239
- publishSelection(selectionRef.current)
240
- }
132
+ const collaboratorSelections: readonly RenderedCollaboratorSelection[] =
133
+ editor.remoteSelections.map(({ end, id, start, value: person }) => ({
134
+ color: person.color,
135
+ end,
136
+ name: person.name,
137
+ session: id,
138
+ start,
139
+ }))
241
140
 
242
141
  return (
243
142
  <markdown-workspace className={css.class}>
@@ -251,34 +150,24 @@ export function MarkdownWorkspace({
251
150
  </brand-lockup>
252
151
  <document-title>
253
152
  <strong>Launch field notes</strong>
254
- <span>{getStatusLabel(mosaic.status, pending)}</span>
153
+ <span>{statusLabel(status, editor.hasLocalDraft)}</span>
255
154
  </document-title>
256
155
  <toolbar-actions>
257
- <button
258
- type="button"
259
- disabled={history.undo.length === 0}
260
- onClick={() => mosaic.change({ type: `undo` })}
261
- title="Undo my last change"
262
- >
156
+ <button type="button" onClick={() => void client.undo()}>
263
157
  ↶ <span>Undo mine</span>
264
158
  </button>
265
- <button
266
- type="button"
267
- disabled={history.redo.length === 0}
268
- onClick={() => mosaic.change({ type: `redo` })}
269
- title="Redo my last change"
270
- >
159
+ <button type="button" onClick={() => void client.redo()}>
271
160
  ↷ <span>Redo</span>
272
161
  </button>
273
162
  <label>
274
163
  <avatar-dot
275
- style={{ "--person-color": identity.color } as CSSProperties}
164
+ style={{ "--person-color": client.identity.color } as PersonStyle}
276
165
  >
277
- {identity.name[0]}
166
+ {client.identity.name[0]}
278
167
  </avatar-dot>
279
168
  <select
280
169
  aria-label="Simulated identity"
281
- value={identity.id}
170
+ value={client.identity.id}
282
171
  onChange={(event) => {
283
172
  switchBrowserIdentity(event.target.value)
284
173
  }}
@@ -297,127 +186,105 @@ export function MarkdownWorkspace({
297
186
  <pane-heading>
298
187
  <label htmlFor="markdown-source">Markdown</label>
299
188
  <span>
300
- {characterCount.toLocaleString()} characters ·{` `}
301
- {wordCount.toLocaleString()} words
189
+ {totalLength.toLocaleString()} UTF-16 units · bounded viewport
302
190
  </span>
303
191
  </pane-heading>
304
- <editor-surface>
305
- <textarea
306
- id="markdown-source"
307
- ref={editorRef}
308
- aria-label="Shared markdown source"
309
- value={markdown}
310
- disabled={
311
- mosaic.status === `recovering` || mosaic.status === `syncing`
192
+ <editor-surface
193
+ onScroll={(event) => {
194
+ const element = event.currentTarget
195
+ const nextScroll = {
196
+ height: element.clientHeight,
197
+ scrollTop: element.scrollTop,
312
198
  }
313
- onChange={(event) => {
314
- const { selectionStart, selectionEnd, value } =
315
- event.currentTarget
316
- mosaic.change({ text: value, type: `replace-text` })
317
- selectionRef.current = document.selectionFromOffsets(
318
- selectionStart,
319
- selectionEnd,
320
- )
321
- publishSelection(selectionRef.current)
322
- }}
323
- onKeyDown={(event) => {
324
- if (!(event.metaKey || event.ctrlKey) || event.key !== `z`)
325
- return
326
- event.preventDefault()
327
- mosaic.change({ type: event.shiftKey ? `redo` : `undo` })
328
- }}
329
- onScroll={(event) => {
330
- setScroll({
331
- left: event.currentTarget.scrollLeft,
332
- top: event.currentTarget.scrollTop,
333
- })
334
- }}
335
- onSelect={(event) => {
336
- rememberSelection(event.currentTarget)
337
- }}
338
- spellCheck
199
+ state.setScroll(nextScroll, editor.hasLocalDraft)
200
+ }}
201
+ >
202
+ <virtual-spacer
203
+ aria-hidden="true"
204
+ style={{ height: window.topSpacer }}
205
+ />
206
+ {view.status === `loading` ? <p>Hydrating viewport…</p> : null}
207
+ {view.status === `error` ? (
208
+ <p role="status">
209
+ {status.connection === `offline`
210
+ ? `Waiting for the realtime backend…`
211
+ : `Restoring the shared viewport…`}
212
+ </p>
213
+ ) : null}
214
+ {projection ? (
215
+ <LexicalMarkdownEditor
216
+ onDirty={editor.onDirty}
217
+ onError={state.reportEditorError}
218
+ onRedo={() => void client.redo()}
219
+ onSelectionChange={editor.onSelectionChange}
220
+ onUndo={() => void client.undo()}
221
+ onValueChange={editor.onValueChange}
222
+ selections={collaboratorSelections}
223
+ selection={editor.selection}
224
+ value={editor.text}
225
+ />
226
+ ) : null}
227
+ <virtual-spacer
228
+ aria-hidden="true"
229
+ style={{ height: window.bottomSpacer }}
339
230
  />
340
- <caret-layer aria-hidden="true">
341
- {collaborators.map((person) => {
342
- const relative = person.selection?.head
343
- if (!relative) return null
344
- const offset = document.resolvePosition(relative)
345
- const { line, column } = lineAndColumnAt(markdown, offset)
346
- const style: CaretStyle = {
347
- "--caret-column": column,
348
- "--caret-line": line,
349
- "--person-color": person.color,
350
- translate: `${-scroll.left}px ${-scroll.top}px`,
351
- }
352
- return (
353
- <remote-caret key={person.clientId} style={style}>
354
- <span>{person.name.split(` `)[0]}</span>
355
- </remote-caret>
356
- )
357
- })}
358
- </caret-layer>
359
231
  </editor-surface>
360
232
  </editor-pane>
361
233
  <preview-pane>
362
234
  <pane-heading>
363
235
  <strong>Preview</strong>
364
- <span>React-rendered · safe HTML</span>
236
+ <span>
237
+ {parseMetrics.parsedBlocks} parsed · {parseMetrics.reusedBlocks}
238
+ {` `}
239
+ reused
240
+ </span>
365
241
  </pane-heading>
366
- <article>{preview}</article>
242
+ <article>{parse.map(renderSemantic)}</article>
367
243
  </preview-pane>
368
244
  <presence-rail>
369
- <strong>In this document</strong>
245
+ <strong>In this viewport</strong>
370
246
  <ul>
371
- {presence.map((person) => {
372
- const offset = person.selection
373
- ? document.resolvePosition(person.selection.head)
374
- : null
375
- const line =
376
- offset === null
377
- ? null
378
- : lineAndColumnAt(markdown, offset).line + 1
379
- return (
380
- <li
381
- key={person.clientId}
382
- data-self={person.clientId === clientId}
247
+ {presence.map((person) => (
248
+ <li
249
+ key={`${person.actor}:${person.session}`}
250
+ data-self={person.session === client.sessionId}
251
+ >
252
+ <avatar-dot
253
+ style={{ "--person-color": person.color } as PersonStyle}
383
254
  >
384
- <avatar-dot
385
- style={{ "--person-color": person.color } as CSSProperties}
386
- >
387
- {person.name[0]}
388
- </avatar-dot>
389
- <person-label>
390
- <strong>{person.name}</strong>
391
- <span>
392
- {person.clientId === clientId
393
- ? `You · editing now`
394
- : line
395
- ? `Editing near line ${line}`
396
- : `Viewing`}
397
- </span>
398
- </person-label>
399
- </li>
400
- )
401
- })}
255
+ {person.name[0]}
256
+ </avatar-dot>
257
+ <person-label>
258
+ <strong>{person.name}</strong>
259
+ <span>
260
+ {person.session === client.sessionId
261
+ ? `You · local input`
262
+ : person.selection
263
+ ? `Editing a logical position`
264
+ : `Viewing another range`}
265
+ </span>
266
+ </person-label>
267
+ </li>
268
+ ))}
402
269
  </ul>
403
270
  <aside>
404
- <strong>Undo without collisions</strong>
271
+ <strong>Bounded by design</strong>
405
272
  <p>
406
- Your history removes only characters and deletion marks you
407
- authored. Everyone else’s work stays put.
273
+ Only this source and preview window is resident. Presence and
274
+ selective history use logical run positions across splits.
408
275
  </p>
409
276
  </aside>
410
277
  </presence-rail>
411
278
  </main>
412
279
  <footer>
413
- <span data-status={mosaic.status} />
414
- <strong>
415
- {mosaic.status === `live`
416
- ? `Realtime connected`
417
- : getStatusLabel(mosaic.status, pending)}
418
- </strong>
419
- <small>Revision {mosaic.revision}</small>
420
- {mosaic.problem ? <output>{mosaic.problem.reason}</output> : null}
280
+ <span data-status={status.connection} />
281
+ <strong>{statusLabel(status, editor.hasLocalDraft)}</strong>
282
+ <small>
283
+ Resident {client.residency.state.residentMemberCount} members
284
+ </small>
285
+ {(problem ?? readProblem ?? status.reason) ? (
286
+ <output>{problem ?? readProblem ?? status.reason}</output>
287
+ ) : null}
421
288
  </footer>
422
289
  </markdown-workspace>
423
290
  )