@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,251 @@
|
|
|
1
|
+
import { act, waitFor } from "@testing-library/react"
|
|
2
|
+
import { useO } from "atom.io/react"
|
|
3
|
+
import type { Socket, UserKey } from "atom.io/realtime"
|
|
4
|
+
import { useMosaic } from "atom.io/realtime-react"
|
|
5
|
+
import { createMosaicServer } from "atom.io/realtime-server"
|
|
6
|
+
import * as RTTest from "atom.io/realtime-testing"
|
|
7
|
+
|
|
8
|
+
import { markdownAtomRegistration } from "../node/mosaic-atom.ts"
|
|
9
|
+
import {
|
|
10
|
+
Markdown,
|
|
11
|
+
markdownAtom,
|
|
12
|
+
markdownWordCountSelector,
|
|
13
|
+
type MarkdownPresence,
|
|
14
|
+
} from "../src/collaboration/mosaic.ts"
|
|
15
|
+
import type { Identity } from "../src/identities.ts"
|
|
16
|
+
import { INITIAL_MARKDOWN } from "../src/initial-markdown.ts"
|
|
17
|
+
|
|
18
|
+
const JANE = { id: `jane`, name: `Jane`, color: `#7057ff` } satisfies Identity
|
|
19
|
+
const DAVE = { id: `dave`, name: `Dave`, color: `#df527c` } satisfies Identity
|
|
20
|
+
|
|
21
|
+
function identityFor(userKey: UserKey): Identity {
|
|
22
|
+
return userKey.includes(`jane`) ? JANE : DAVE
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function testClient(identity: Identity, session: string) {
|
|
26
|
+
return function TestClient() {
|
|
27
|
+
const mosaic = useMosaic<InstanceType<typeof Markdown>, MarkdownPresence>(
|
|
28
|
+
markdownAtom,
|
|
29
|
+
{ actor: identity.id, session },
|
|
30
|
+
)
|
|
31
|
+
const document = useO(markdownAtom)
|
|
32
|
+
const wordCount = useO(markdownWordCountSelector)
|
|
33
|
+
const text = document.text
|
|
34
|
+
const history = document.historyFor(identity.id)
|
|
35
|
+
return (
|
|
36
|
+
<main>
|
|
37
|
+
<output data-testid="text">{text}</output>
|
|
38
|
+
<output data-testid="word-count">{wordCount}</output>
|
|
39
|
+
<output data-testid="status">{mosaic.status}</output>
|
|
40
|
+
<output data-testid="presence">
|
|
41
|
+
{mosaic.presence
|
|
42
|
+
.map(({ actor }) => actor)
|
|
43
|
+
.sort()
|
|
44
|
+
.join(`,`)}
|
|
45
|
+
</output>
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
data-testid="append"
|
|
49
|
+
onClick={() =>
|
|
50
|
+
mosaic.change({
|
|
51
|
+
text: `${text}[${identity.name}]`,
|
|
52
|
+
type: `replace-text`,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
/>
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
data-testid="presence-update"
|
|
59
|
+
onClick={() => {
|
|
60
|
+
mosaic.publishPresence({
|
|
61
|
+
color: identity.color,
|
|
62
|
+
lastActiveAt: 1,
|
|
63
|
+
name: identity.name,
|
|
64
|
+
selection: document.selectionFromOffsets(0, 0),
|
|
65
|
+
})
|
|
66
|
+
}}
|
|
67
|
+
/>
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
data-testid="undo"
|
|
71
|
+
disabled={history.undo.length === 0}
|
|
72
|
+
onClick={() => mosaic.change({ type: `undo` })}
|
|
73
|
+
/>
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
data-testid="redo"
|
|
77
|
+
disabled={history.redo.length === 0}
|
|
78
|
+
onClick={() => mosaic.change({ type: `redo` })}
|
|
79
|
+
/>
|
|
80
|
+
</main>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function scenario() {
|
|
86
|
+
const collaboration = createMosaicServer({
|
|
87
|
+
registrations: [markdownAtomRegistration],
|
|
88
|
+
})
|
|
89
|
+
const room = RTTest.multiClient({
|
|
90
|
+
scenarioId: `mosaic-markdown`,
|
|
91
|
+
server: ({ sessionId, socket, userKey }) => {
|
|
92
|
+
const dispose = collaboration.connect({
|
|
93
|
+
actor: identityFor(userKey).id,
|
|
94
|
+
session: sessionId,
|
|
95
|
+
socket: socket as unknown as Socket,
|
|
96
|
+
})
|
|
97
|
+
return () => {
|
|
98
|
+
void dispose()
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
clients: {
|
|
102
|
+
jane: testClient(JANE, `test:jane`),
|
|
103
|
+
dave: testClient(DAVE, `test:dave`),
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
return {
|
|
107
|
+
...room,
|
|
108
|
+
teardown: async (): Promise<void> => {
|
|
109
|
+
await room.teardown()
|
|
110
|
+
await collaboration.dispose()
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const initJane = (room: ReturnType<typeof scenario>) =>
|
|
116
|
+
room.clients.jane.init({
|
|
117
|
+
sessionId: `test:jane`,
|
|
118
|
+
userKey: `user::jane`,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const initDave = (room: ReturnType<typeof scenario>) =>
|
|
122
|
+
room.clients.dave.init({
|
|
123
|
+
sessionId: `test:dave`,
|
|
124
|
+
userKey: `user::dave`,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
async function expectText(
|
|
128
|
+
client: RTTest.RealtimeTestClient,
|
|
129
|
+
text: string,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
await waitFor(() => {
|
|
132
|
+
expect(client.renderResult.getByTestId(`text`).textContent).toBe(text)
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function expectStatus(
|
|
137
|
+
client: RTTest.RealtimeTestClient,
|
|
138
|
+
status: string,
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
await waitFor(() => {
|
|
141
|
+
expect(client.renderResult.getByTestId(`status`).textContent).toBe(status)
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
describe(`realtime collaborative Markdown`, () => {
|
|
146
|
+
test(`offline concurrent edits rebase and converge for both clients`, async () => {
|
|
147
|
+
const room = scenario()
|
|
148
|
+
const jane = initJane(room)
|
|
149
|
+
const dave = initDave(room)
|
|
150
|
+
await expectText(jane, INITIAL_MARKDOWN)
|
|
151
|
+
await expectText(dave, INITIAL_MARKDOWN)
|
|
152
|
+
|
|
153
|
+
act(() => {
|
|
154
|
+
jane.socket.disconnect()
|
|
155
|
+
dave.socket.disconnect()
|
|
156
|
+
})
|
|
157
|
+
await expectStatus(jane, `offline`)
|
|
158
|
+
await expectStatus(dave, `offline`)
|
|
159
|
+
act(() => {
|
|
160
|
+
jane.renderResult.getByTestId(`append`).click()
|
|
161
|
+
dave.renderResult.getByTestId(`append`).click()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
act(() => {
|
|
165
|
+
jane.socket.connect()
|
|
166
|
+
dave.socket.connect()
|
|
167
|
+
})
|
|
168
|
+
await waitFor(() => {
|
|
169
|
+
const janeText = jane.renderResult.getByTestId(`text`).textContent ?? ``
|
|
170
|
+
const daveText = dave.renderResult.getByTestId(`text`).textContent ?? ``
|
|
171
|
+
expect(janeText).toBe(daveText)
|
|
172
|
+
expect(janeText).toContain(`[Jane]`)
|
|
173
|
+
expect(janeText).toContain(`[Dave]`)
|
|
174
|
+
expect(jane.renderResult.getByTestId(`word-count`).textContent).toBe(
|
|
175
|
+
dave.renderResult.getByTestId(`word-count`).textContent,
|
|
176
|
+
)
|
|
177
|
+
})
|
|
178
|
+
await room.teardown()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test(`one identity's undo preserves a later foreign edit`, async () => {
|
|
182
|
+
const room = scenario()
|
|
183
|
+
const jane = initJane(room)
|
|
184
|
+
const dave = initDave(room)
|
|
185
|
+
await expectStatus(jane, `live`)
|
|
186
|
+
await expectStatus(dave, `live`)
|
|
187
|
+
|
|
188
|
+
act(() => {
|
|
189
|
+
jane.renderResult.getByTestId(`append`).click()
|
|
190
|
+
})
|
|
191
|
+
await waitFor(() => {
|
|
192
|
+
expect(dave.renderResult.getByTestId(`text`).textContent).toContain(
|
|
193
|
+
`[Jane]`,
|
|
194
|
+
)
|
|
195
|
+
})
|
|
196
|
+
act(() => {
|
|
197
|
+
dave.renderResult.getByTestId(`append`).click()
|
|
198
|
+
})
|
|
199
|
+
await waitFor(() => {
|
|
200
|
+
expect(jane.renderResult.getByTestId(`text`).textContent).toContain(
|
|
201
|
+
`[Dave]`,
|
|
202
|
+
)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
act(() => {
|
|
206
|
+
jane.renderResult.getByTestId(`undo`).click()
|
|
207
|
+
})
|
|
208
|
+
await waitFor(() => {
|
|
209
|
+
const text = jane.renderResult.getByTestId(`text`).textContent ?? ``
|
|
210
|
+
expect(text).not.toContain(`[Jane]`)
|
|
211
|
+
expect(text).toContain(`[Dave]`)
|
|
212
|
+
expect(dave.renderResult.getByTestId(`text`).textContent).toBe(text)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
act(() => {
|
|
216
|
+
jane.renderResult.getByTestId(`redo`).click()
|
|
217
|
+
})
|
|
218
|
+
await waitFor(() => {
|
|
219
|
+
const text = jane.renderResult.getByTestId(`text`).textContent ?? ``
|
|
220
|
+
expect(text).toContain(`[Jane]`)
|
|
221
|
+
expect(text).toContain(`[Dave]`)
|
|
222
|
+
expect(dave.renderResult.getByTestId(`text`).textContent).toBe(text)
|
|
223
|
+
})
|
|
224
|
+
await room.teardown()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test(`presence is model-aware and removed on disconnect`, async () => {
|
|
228
|
+
const room = scenario()
|
|
229
|
+
const jane = initJane(room)
|
|
230
|
+
const dave = initDave(room)
|
|
231
|
+
await expectStatus(jane, `live`)
|
|
232
|
+
await expectStatus(dave, `live`)
|
|
233
|
+
act(() => {
|
|
234
|
+
jane.renderResult.getByTestId(`presence-update`).click()
|
|
235
|
+
dave.renderResult.getByTestId(`presence-update`).click()
|
|
236
|
+
})
|
|
237
|
+
await waitFor(() => {
|
|
238
|
+
expect(jane.renderResult.getByTestId(`presence`).textContent).toBe(
|
|
239
|
+
`dave,jane`,
|
|
240
|
+
)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
act(() => {
|
|
244
|
+
dave.socket.disconnect()
|
|
245
|
+
})
|
|
246
|
+
await waitFor(() => {
|
|
247
|
+
expect(jane.renderResult.getByTestId(`presence`).textContent).toBe(`jane`)
|
|
248
|
+
})
|
|
249
|
+
await room.teardown()
|
|
250
|
+
})
|
|
251
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
4
|
+
"target": "ES2024",
|
|
5
|
+
"useDefineForClassFields": true,
|
|
6
|
+
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
|
7
|
+
"module": "ESNext",
|
|
8
|
+
"types": ["vite/client", "lasertag/css-modules", "lasertag/react-jsx"],
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"moduleResolution": "bundler",
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"verbatimModuleSyntax": true,
|
|
13
|
+
"moduleDetection": "force",
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"jsx": "react-jsx",
|
|
16
|
+
"strict": true,
|
|
17
|
+
"noUnusedLocals": true,
|
|
18
|
+
"noUnusedParameters": true,
|
|
19
|
+
"erasableSyntaxOnly": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedSideEffectImports": true
|
|
22
|
+
},
|
|
23
|
+
"include": ["src"]
|
|
24
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
4
|
+
"target": "ES2024",
|
|
5
|
+
"lib": ["ES2024"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"moduleResolution": "bundler",
|
|
10
|
+
"allowImportingTsExtensions": true,
|
|
11
|
+
"verbatimModuleSyntax": true,
|
|
12
|
+
"moduleDetection": "force",
|
|
13
|
+
"noEmit": true,
|
|
14
|
+
"strict": true,
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"noUnusedParameters": true,
|
|
17
|
+
"erasableSyntaxOnly": true,
|
|
18
|
+
"noFallthroughCasesInSwitch": true,
|
|
19
|
+
"noUncheckedSideEffectImports": true
|
|
20
|
+
},
|
|
21
|
+
"include": ["node", "vite.config.ts", "vitest.config.ts", "eslint.config.ts"]
|
|
22
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.app.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo",
|
|
5
|
+
"types": [
|
|
6
|
+
"node",
|
|
7
|
+
"vitest/globals",
|
|
8
|
+
"lasertag/css-modules",
|
|
9
|
+
"lasertag/react-jsx"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"include": ["node/mosaic-atom.ts", "src", "tests"]
|
|
13
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import react from "@vitejs/plugin-react"
|
|
2
|
+
import { defineConfig } from "vite"
|
|
3
|
+
|
|
4
|
+
const BACKEND_URL = `http://localhost:3000`
|
|
5
|
+
|
|
6
|
+
const proxyBackend = () => ({
|
|
7
|
+
[`/health`]: {
|
|
8
|
+
changeOrigin: true,
|
|
9
|
+
target: BACKEND_URL,
|
|
10
|
+
},
|
|
11
|
+
[`/socket.io`]: {
|
|
12
|
+
changeOrigin: true,
|
|
13
|
+
target: BACKEND_URL,
|
|
14
|
+
ws: true,
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export default defineConfig({
|
|
19
|
+
plugins: [react()],
|
|
20
|
+
preview: { proxy: proxyBackend() },
|
|
21
|
+
server: { proxy: proxyBackend() },
|
|
22
|
+
})
|