@atom.io/template-react-realtime-text-editor 0.0.1
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/.turbo/turbo-build.log +10 -0
- package/CHANGELOG.md +5 -0
- package/INNOVATIONS.md +113 -0
- package/LICENSE +201 -0
- package/README.md +48 -0
- package/_gitignore +5 -0
- package/dist/assets/index-DGIOrh2O.css +1 -0
- package/dist/index.html +17 -0
- package/eslint.config.ts +39 -0
- package/index.html +16 -0
- package/mise.toml +1 -0
- package/node/mosaic-atom.ts +88 -0
- package/node/server.ts +67 -0
- package/package.json +53 -0
- package/src/MarkdownWorkspace.module.css +539 -0
- package/src/MarkdownWorkspace.tsx +424 -0
- package/src/collaboration/mosaic.ts +46 -0
- package/src/globals.css +38 -0
- package/src/identities.ts +23 -0
- package/src/initial-markdown.ts +22 -0
- package/src/main.tsx +24 -0
- package/src/session.ts +29 -0
- package/src/vite-env.d.ts +4 -0
- package/tests/collaboration.test.tsx +251 -0
- package/tsconfig.app.json +24 -0
- package/tsconfig.json +8 -0
- package/tsconfig.node.json +22 -0
- package/tsconfig.test.json +13 -0
- package/vite.config.ts +22 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import type { MosaicTextSelection } from "atom.io/realtime"
|
|
2
|
+
import { useO } from "atom.io/react"
|
|
3
|
+
import { useMosaic } from "atom.io/realtime-react"
|
|
4
|
+
import {
|
|
5
|
+
Fragment,
|
|
6
|
+
createElement,
|
|
7
|
+
type CSSProperties,
|
|
8
|
+
type ReactElement,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
useEffect,
|
|
11
|
+
useLayoutEffect,
|
|
12
|
+
useMemo,
|
|
13
|
+
useRef,
|
|
14
|
+
useState,
|
|
15
|
+
} from "react"
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
lineAndColumnAt,
|
|
19
|
+
Markdown,
|
|
20
|
+
markdownAtom,
|
|
21
|
+
markdownCharacterCountSelector,
|
|
22
|
+
markdownWordCountSelector,
|
|
23
|
+
type MarkdownPresence,
|
|
24
|
+
} from "./collaboration/mosaic.ts"
|
|
25
|
+
import { SIMULATED_IDENTITIES } from "./identities.ts"
|
|
26
|
+
import type { Identity } from "./identities.ts"
|
|
27
|
+
import { switchBrowserIdentity } from "./session.ts"
|
|
28
|
+
import css from "./MarkdownWorkspace.module.css"
|
|
29
|
+
|
|
30
|
+
type MarkdownWorkspaceProps = {
|
|
31
|
+
clientId: string
|
|
32
|
+
identity: Identity
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type CaretStyle = CSSProperties &
|
|
36
|
+
Record<`--caret-column` | `--caret-line` | `--person-color`, number | string>
|
|
37
|
+
|
|
38
|
+
function safeHref(candidate: string): string | undefined {
|
|
39
|
+
return /^(https?:\/\/|mailto:|#)/.test(candidate) ? candidate : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function renderInline(text: string, keyPrefix: string): ReactNode[] {
|
|
43
|
+
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g
|
|
44
|
+
const nodes: ReactNode[] = []
|
|
45
|
+
let cursor = 0
|
|
46
|
+
for (const match of text.matchAll(pattern)) {
|
|
47
|
+
const index = match.index
|
|
48
|
+
if (index > cursor) nodes.push(text.slice(cursor, index))
|
|
49
|
+
const token = match[0]
|
|
50
|
+
const key = `${keyPrefix}:${index}`
|
|
51
|
+
if (token.startsWith(`\``)) {
|
|
52
|
+
nodes.push(<code key={key}>{token.slice(1, -1)}</code>)
|
|
53
|
+
} else if (token.startsWith(`**`)) {
|
|
54
|
+
nodes.push(<strong key={key}>{token.slice(2, -2)}</strong>)
|
|
55
|
+
} else if (token.startsWith(`*`)) {
|
|
56
|
+
nodes.push(<em key={key}>{token.slice(1, -1)}</em>)
|
|
57
|
+
} else {
|
|
58
|
+
const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token)
|
|
59
|
+
const href = safeHref(link?.[2] ?? ``)
|
|
60
|
+
nodes.push(
|
|
61
|
+
href ? (
|
|
62
|
+
<a key={key} href={href} rel="noreferrer">
|
|
63
|
+
{link?.[1]}
|
|
64
|
+
</a>
|
|
65
|
+
) : (
|
|
66
|
+
<Fragment key={key}>{token}</Fragment>
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
cursor = index + token.length
|
|
71
|
+
}
|
|
72
|
+
if (cursor < text.length) nodes.push(text.slice(cursor))
|
|
73
|
+
return nodes
|
|
74
|
+
}
|
|
75
|
+
|
|
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>,
|
|
103
|
+
)
|
|
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
|
+
)
|
|
169
|
+
}
|
|
170
|
+
return blocks
|
|
171
|
+
}
|
|
172
|
+
|
|
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`
|
|
179
|
+
return pending === 0
|
|
180
|
+
? `All changes saved`
|
|
181
|
+
: `Saving ${pending} change${pending === 1 ? `` : `s`}…`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function MarkdownWorkspace({
|
|
185
|
+
clientId,
|
|
186
|
+
identity,
|
|
187
|
+
}: 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])
|
|
221
|
+
|
|
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
|
+
}
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<markdown-workspace className={css.class}>
|
|
244
|
+
<header>
|
|
245
|
+
<brand-lockup>
|
|
246
|
+
<mark>M</mark>
|
|
247
|
+
<label-set>
|
|
248
|
+
<strong>Mosaic</strong>
|
|
249
|
+
<span>Shared markdown</span>
|
|
250
|
+
</label-set>
|
|
251
|
+
</brand-lockup>
|
|
252
|
+
<document-title>
|
|
253
|
+
<strong>Launch field notes</strong>
|
|
254
|
+
<span>{getStatusLabel(mosaic.status, pending)}</span>
|
|
255
|
+
</document-title>
|
|
256
|
+
<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
|
+
>
|
|
263
|
+
↶ <span>Undo mine</span>
|
|
264
|
+
</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
|
+
>
|
|
271
|
+
↷ <span>Redo</span>
|
|
272
|
+
</button>
|
|
273
|
+
<label>
|
|
274
|
+
<avatar-dot
|
|
275
|
+
style={{ "--person-color": identity.color } as CSSProperties}
|
|
276
|
+
>
|
|
277
|
+
{identity.name[0]}
|
|
278
|
+
</avatar-dot>
|
|
279
|
+
<select
|
|
280
|
+
aria-label="Simulated identity"
|
|
281
|
+
value={identity.id}
|
|
282
|
+
onChange={(event) => {
|
|
283
|
+
switchBrowserIdentity(event.target.value)
|
|
284
|
+
}}
|
|
285
|
+
>
|
|
286
|
+
{SIMULATED_IDENTITIES.map((person) => (
|
|
287
|
+
<option key={person.id} value={person.id}>
|
|
288
|
+
{person.name}
|
|
289
|
+
</option>
|
|
290
|
+
))}
|
|
291
|
+
</select>
|
|
292
|
+
</label>
|
|
293
|
+
</toolbar-actions>
|
|
294
|
+
</header>
|
|
295
|
+
<main>
|
|
296
|
+
<editor-pane>
|
|
297
|
+
<pane-heading>
|
|
298
|
+
<label htmlFor="markdown-source">Markdown</label>
|
|
299
|
+
<span>
|
|
300
|
+
{characterCount.toLocaleString()} characters ·{` `}
|
|
301
|
+
{wordCount.toLocaleString()} words
|
|
302
|
+
</span>
|
|
303
|
+
</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`
|
|
312
|
+
}
|
|
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
|
|
339
|
+
/>
|
|
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
|
+
</editor-surface>
|
|
360
|
+
</editor-pane>
|
|
361
|
+
<preview-pane>
|
|
362
|
+
<pane-heading>
|
|
363
|
+
<strong>Preview</strong>
|
|
364
|
+
<span>React-rendered · safe HTML</span>
|
|
365
|
+
</pane-heading>
|
|
366
|
+
<article>{preview}</article>
|
|
367
|
+
</preview-pane>
|
|
368
|
+
<presence-rail>
|
|
369
|
+
<strong>In this document</strong>
|
|
370
|
+
<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}
|
|
383
|
+
>
|
|
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
|
+
})}
|
|
402
|
+
</ul>
|
|
403
|
+
<aside>
|
|
404
|
+
<strong>Undo without collisions</strong>
|
|
405
|
+
<p>
|
|
406
|
+
Your history removes only characters and deletion marks you
|
|
407
|
+
authored. Everyone else’s work stays put.
|
|
408
|
+
</p>
|
|
409
|
+
</aside>
|
|
410
|
+
</presence-rail>
|
|
411
|
+
</main>
|
|
412
|
+
<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}
|
|
421
|
+
</footer>
|
|
422
|
+
</markdown-workspace>
|
|
423
|
+
)
|
|
424
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { mutableAtom, selector } from "atom.io"
|
|
2
|
+
import type { MosaicTextSelection } from "atom.io/realtime"
|
|
3
|
+
import { mosaicText } from "atom.io/realtime"
|
|
4
|
+
|
|
5
|
+
import { INITIAL_MARKDOWN } from "../initial-markdown.ts"
|
|
6
|
+
|
|
7
|
+
export type MarkdownPresence = {
|
|
8
|
+
readonly color: string
|
|
9
|
+
readonly lastActiveAt: number
|
|
10
|
+
readonly name: string
|
|
11
|
+
readonly selection: MosaicTextSelection | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const Markdown = mosaicText({
|
|
15
|
+
initialText: INITIAL_MARKDOWN,
|
|
16
|
+
maximumGraphemes: 200_000,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
/** The collaborative document is an ordinary mutable atom. */
|
|
20
|
+
export const markdownAtom = mutableAtom<InstanceType<typeof Markdown>>({
|
|
21
|
+
key: `markdown`,
|
|
22
|
+
class: Markdown,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
/** Mosaic views participate in the state graph like every other atom value. */
|
|
26
|
+
export const markdownCharacterCountSelector = selector<number>({
|
|
27
|
+
key: `markdownCharacterCount`,
|
|
28
|
+
get: ({ get }) => get(markdownAtom).length,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export const markdownWordCountSelector = selector<number>({
|
|
32
|
+
key: `markdownWordCount`,
|
|
33
|
+
get: ({ get }) => {
|
|
34
|
+
const text = get(markdownAtom).text.trim()
|
|
35
|
+
return text === `` ? 0 : text.split(/\s+/u).length
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export function lineAndColumnAt(
|
|
40
|
+
text: string,
|
|
41
|
+
offset: number,
|
|
42
|
+
): { column: number; line: number } {
|
|
43
|
+
const before = text.slice(0, offset)
|
|
44
|
+
const lines = before.split(`\n`)
|
|
45
|
+
return { column: lines.at(-1)?.length ?? 0, line: lines.length - 1 }
|
|
46
|
+
}
|
package/src/globals.css
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
|
|
3
|
+
:root {
|
|
4
|
+
font-family: "Manrope", system-ui, sans-serif;
|
|
5
|
+
color: #292936;
|
|
6
|
+
background: #f3f2f7;
|
|
7
|
+
font-synthesis: none;
|
|
8
|
+
text-rendering: optimizeLegibility;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
* {
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
html,
|
|
16
|
+
body,
|
|
17
|
+
#root {
|
|
18
|
+
min-width: 320px;
|
|
19
|
+
min-height: 100%;
|
|
20
|
+
margin: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
button,
|
|
24
|
+
input,
|
|
25
|
+
select,
|
|
26
|
+
textarea {
|
|
27
|
+
font: inherit;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
button,
|
|
31
|
+
select {
|
|
32
|
+
cursor: pointer;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
button:disabled,
|
|
36
|
+
textarea:disabled {
|
|
37
|
+
cursor: not-allowed;
|
|
38
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type Identity = {
|
|
2
|
+
readonly color: string
|
|
3
|
+
readonly id: string
|
|
4
|
+
readonly name: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const SIMULATED_IDENTITIES = [
|
|
8
|
+
{ id: `maya`, name: `Maya Chen`, color: `#7c5cff` },
|
|
9
|
+
{ id: `theo`, name: `Theo Brooks`, color: `#eb5e8d` },
|
|
10
|
+
{ id: `samira`, name: `Samira Okafor`, color: `#1aa981` },
|
|
11
|
+
{ id: `noah`, name: `Noah Williams`, color: `#e8892f` },
|
|
12
|
+
] as const satisfies readonly Identity[]
|
|
13
|
+
|
|
14
|
+
export function identityById(id: string | null | undefined): Identity {
|
|
15
|
+
return (
|
|
16
|
+
SIMULATED_IDENTITIES.find((identity) => identity.id === id) ??
|
|
17
|
+
SIMULATED_IDENTITIES[0]
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function identityFromUserKey(userKey: string): Identity {
|
|
22
|
+
return identityById(userKey.replace(/^user::/, ``).split(`::`)[0])
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const INITIAL_MARKDOWN = `# Field notes for the launch
|
|
2
|
+
|
|
3
|
+
Welcome to **Mosaic**, a realtime markdown room.
|
|
4
|
+
|
|
5
|
+
## What we know
|
|
6
|
+
|
|
7
|
+
- Everyone edits the same convergent document.
|
|
8
|
+
- Presence is live and selections survive nearby edits.
|
|
9
|
+
- Your undo only reverses _your_ last writing gesture.
|
|
10
|
+
|
|
11
|
+
> Open this page in another tab and choose a different simulated teammate.
|
|
12
|
+
|
|
13
|
+
## Ship list
|
|
14
|
+
|
|
15
|
+
1. Tighten the opening story
|
|
16
|
+
2. Add rollout owners
|
|
17
|
+
3. Celebrate the tiny details
|
|
18
|
+
|
|
19
|
+
\`\`\`ts
|
|
20
|
+
const collaboration = "optimistic, ordered, and kind"
|
|
21
|
+
\`\`\`
|
|
22
|
+
`
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import "./globals.css"
|
|
2
|
+
|
|
3
|
+
import { RealtimeProvider } from "atom.io/realtime-react"
|
|
4
|
+
import { StrictMode } from "react"
|
|
5
|
+
import { createRoot } from "react-dom/client"
|
|
6
|
+
import { io } from "socket.io-client"
|
|
7
|
+
|
|
8
|
+
import { MarkdownWorkspace } from "./MarkdownWorkspace.tsx"
|
|
9
|
+
import { getBrowserSession, realtimeAuth } from "./session.ts"
|
|
10
|
+
|
|
11
|
+
const session = getBrowserSession()
|
|
12
|
+
const socket = io(window.location.origin, {
|
|
13
|
+
auth: realtimeAuth(session.identity, session.clientId),
|
|
14
|
+
reconnection: true,
|
|
15
|
+
reconnectionDelayMax: 4_000,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
createRoot(document.getElementById(`root`)!).render(
|
|
19
|
+
<StrictMode>
|
|
20
|
+
<RealtimeProvider socket={socket}>
|
|
21
|
+
<MarkdownWorkspace {...session} />
|
|
22
|
+
</RealtimeProvider>
|
|
23
|
+
</StrictMode>,
|
|
24
|
+
)
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { identityById } from "./identities.ts"
|
|
2
|
+
|
|
3
|
+
import type { Identity } from "./identities.ts"
|
|
4
|
+
|
|
5
|
+
const IDENTITY_KEY = `markdown-collab-identity`
|
|
6
|
+
|
|
7
|
+
export function getBrowserSession(): { clientId: string; identity: Identity } {
|
|
8
|
+
const queryIdentity = new URL(window.location.href).searchParams.get(`as`)
|
|
9
|
+
const storedIdentity = window.localStorage.getItem(IDENTITY_KEY)
|
|
10
|
+
const identity = identityById(queryIdentity ?? storedIdentity)
|
|
11
|
+
window.localStorage.setItem(IDENTITY_KEY, identity.id)
|
|
12
|
+
|
|
13
|
+
const clientId = `browser:${window.crypto.randomUUID()}`
|
|
14
|
+
return { clientId, identity }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function realtimeAuth(
|
|
18
|
+
identity: Identity,
|
|
19
|
+
clientId: string,
|
|
20
|
+
): { sessionId: string; userId: string } {
|
|
21
|
+
return { sessionId: clientId, userId: identity.id }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function switchBrowserIdentity(identityId: string): void {
|
|
25
|
+
window.localStorage.setItem(IDENTITY_KEY, identityId)
|
|
26
|
+
const url = new URL(window.location.href)
|
|
27
|
+
url.searchParams.set(`as`, identityId)
|
|
28
|
+
window.location.assign(url)
|
|
29
|
+
}
|