@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/CHANGELOG.md +25 -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
|
@@ -1,46 +1,39 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
27
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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:|#)
|
|
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 = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/
|
|
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 = /^\[([^\]]+)\]\(([^)]+)\)
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (status === `
|
|
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}
|
|
99
|
+
: `Saving ${pending} gesture${pending === 1 ? `` : `s`}…`
|
|
182
100
|
}
|
|
183
101
|
|
|
184
102
|
export function MarkdownWorkspace({
|
|
185
|
-
|
|
186
|
-
|
|
103
|
+
client,
|
|
104
|
+
state,
|
|
187
105
|
}: MarkdownWorkspaceProps): ReactElement {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
)
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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>{
|
|
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
|
|
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
|
-
{
|
|
301
|
-
{wordCount.toLocaleString()} words
|
|
189
|
+
{totalLength.toLocaleString()} UTF-16 units · bounded viewport
|
|
302
190
|
</span>
|
|
303
191
|
</pane-heading>
|
|
304
|
-
<editor-surface
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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>
|
|
236
|
+
<span>
|
|
237
|
+
{parseMetrics.parsedBlocks} parsed · {parseMetrics.reusedBlocks}
|
|
238
|
+
{` `}
|
|
239
|
+
reused
|
|
240
|
+
</span>
|
|
365
241
|
</pane-heading>
|
|
366
|
-
<article>{
|
|
242
|
+
<article>{parse.map(renderSemantic)}</article>
|
|
367
243
|
</preview-pane>
|
|
368
244
|
<presence-rail>
|
|
369
|
-
<strong>In this
|
|
245
|
+
<strong>In this viewport</strong>
|
|
370
246
|
<ul>
|
|
371
|
-
{presence.map((person) =>
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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>
|
|
271
|
+
<strong>Bounded by design</strong>
|
|
405
272
|
<p>
|
|
406
|
-
|
|
407
|
-
|
|
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={
|
|
414
|
-
<strong>
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
)
|