@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.
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ // eslint-disable-next-line quotes
4
+ declare module "*.css"