@atom.io/template-preact-svg-editor 0.1.0 → 0.1.2
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 +23 -0
- package/COLLABORATION.md +132 -0
- package/README.md +43 -10
- package/eslint.config.ts +1 -1
- package/package.json +30 -16
- package/src/BezierPlayground.tsx +179 -320
- package/src/preact-logo.ts +155 -0
- package/src/svg-contour.test.ts +89 -0
- package/src/svg-contour.ts +579 -0
- package/src/svg-convergence.test.ts +264 -0
- package/src/svg-convergence.ts +566 -0
- package/src/svg-domain-realtime.test.ts +369 -0
- package/src/svg-domain.test.ts +618 -0
- package/src/svg-domain.ts +939 -0
- package/src/svg-editor-state.test.ts +401 -0
- package/src/svg-editor-state.ts +1102 -0
- package/vite.config.ts +1 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MosaicAcceptedDomainBatchEnvelope,
|
|
3
|
+
MosaicDomainBatchProposal,
|
|
4
|
+
} from "@atom.io/realtime"
|
|
5
|
+
import {
|
|
6
|
+
createMosaicDomainBatchClient,
|
|
7
|
+
createMosaicDomainPresenceClient,
|
|
8
|
+
createMosaicDomainPresenceSocketTransport,
|
|
9
|
+
type MosaicDomainBatchClientTransport,
|
|
10
|
+
} from "@atom.io/realtime/client"
|
|
11
|
+
import {
|
|
12
|
+
bindMosaicDomainPresenceServerSocket,
|
|
13
|
+
createMosaicDomainBatchServer,
|
|
14
|
+
createMosaicDomainPresenceServer,
|
|
15
|
+
type MosaicDomainBatchConnection,
|
|
16
|
+
} from "@atom.io/realtime/server"
|
|
17
|
+
import {
|
|
18
|
+
headless,
|
|
19
|
+
type HeadlessRealtimeTestClient,
|
|
20
|
+
} from "@atom.io/realtime/testing/headless"
|
|
21
|
+
import type { Silo } from "atom.io"
|
|
22
|
+
import { afterEach, describe, expect, test } from "vitest"
|
|
23
|
+
|
|
24
|
+
import { readSvgRegister } from "./svg-convergence.ts"
|
|
25
|
+
import {
|
|
26
|
+
activateSvgDesignDomain,
|
|
27
|
+
createSvgDomainEditor,
|
|
28
|
+
type SvgDesignDomain,
|
|
29
|
+
} from "./svg-domain.ts"
|
|
30
|
+
import {
|
|
31
|
+
createSvgGestureClock,
|
|
32
|
+
dragPresenceAtoms,
|
|
33
|
+
nodeAtoms,
|
|
34
|
+
structureViolationsSelector,
|
|
35
|
+
svgDragPresenceKey,
|
|
36
|
+
type SvgDrawingFixture,
|
|
37
|
+
} from "./svg-editor-state.ts"
|
|
38
|
+
|
|
39
|
+
const ACCEPTED = `svg-domain:batch:accepted`
|
|
40
|
+
const PROPOSE = `svg-domain:batch:propose`
|
|
41
|
+
const RECOVER = `svg-domain:batch:recover`
|
|
42
|
+
|
|
43
|
+
const drawing: SvgDrawingFixture = {
|
|
44
|
+
paths: [
|
|
45
|
+
{
|
|
46
|
+
id: `path-a`,
|
|
47
|
+
subpaths: [
|
|
48
|
+
{ edge: { kind: `move` }, id: `a0`, node: { x: 0, y: 0 } },
|
|
49
|
+
{ edge: { kind: `line` }, id: `a1`, node: { x: 10, y: 10 } },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const teardowns: Array<() => Promise<void>> = []
|
|
56
|
+
|
|
57
|
+
afterEach(async () => {
|
|
58
|
+
for (const teardown of teardowns.splice(0).reverse()) await teardown()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
function batchSocketTransport(
|
|
62
|
+
harness: HeadlessRealtimeTestClient,
|
|
63
|
+
): MosaicDomainBatchClientTransport {
|
|
64
|
+
return {
|
|
65
|
+
propose(batch) {
|
|
66
|
+
if (!harness.socket.connected) return Promise.reject(new Error(`offline`))
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
harness.socket.emit(PROPOSE, batch, resolve)
|
|
69
|
+
})
|
|
70
|
+
},
|
|
71
|
+
recover(afterRevision = 0) {
|
|
72
|
+
if (!harness.socket.connected) return Promise.reject(new Error(`offline`))
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
harness.socket.emit(RECOVER, afterRevision, resolve)
|
|
75
|
+
})
|
|
76
|
+
},
|
|
77
|
+
subscribe(listener) {
|
|
78
|
+
harness.socket.on(ACCEPTED, listener)
|
|
79
|
+
return () => harness.socket.off(ACCEPTED, listener)
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function realtimeFixture(
|
|
85
|
+
options: { readonly duplicateDelivery?: boolean } = {},
|
|
86
|
+
) {
|
|
87
|
+
let denyEdges = false
|
|
88
|
+
let serverDomainPromise: Promise<SvgDesignDomain> | undefined
|
|
89
|
+
let batchServerPromise:
|
|
90
|
+
| Promise<ReturnType<typeof createMosaicDomainBatchServer>>
|
|
91
|
+
| undefined
|
|
92
|
+
let presenceServerPromise:
|
|
93
|
+
| Promise<ReturnType<typeof createMosaicDomainPresenceServer>>
|
|
94
|
+
| undefined
|
|
95
|
+
let serverSilo: Silo | undefined
|
|
96
|
+
const scenario = headless({
|
|
97
|
+
scenarioId: `svg-domain-realtime`,
|
|
98
|
+
server: (tools) => {
|
|
99
|
+
serverSilo ??= tools.silo
|
|
100
|
+
serverDomainPromise ??= activateSvgDesignDomain({
|
|
101
|
+
instance: `drawing`,
|
|
102
|
+
silo: tools.silo,
|
|
103
|
+
})
|
|
104
|
+
batchServerPromise ??= serverDomainPromise.then((domain) =>
|
|
105
|
+
createMosaicDomainBatchServer({
|
|
106
|
+
authorize: ({ batch }) =>
|
|
107
|
+
!denyEdges ||
|
|
108
|
+
batch.operations.every(({ address }) => address.member !== `edges`),
|
|
109
|
+
domain,
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
presenceServerPromise ??= serverDomainPromise.then((domain) =>
|
|
113
|
+
createMosaicDomainPresenceServer({ domain }),
|
|
114
|
+
)
|
|
115
|
+
const batchConnection = batchServerPromise.then((server) =>
|
|
116
|
+
server.connect({ actor: tools.userKey, session: tools.sessionId }),
|
|
117
|
+
)
|
|
118
|
+
const presenceConnection = presenceServerPromise.then((server) =>
|
|
119
|
+
server.connect({ actor: tools.userKey, session: tools.sessionId }),
|
|
120
|
+
)
|
|
121
|
+
let unsubscribeBatch: () => void = () => undefined
|
|
122
|
+
let unbindPresence: () => Promise<void> = () => Promise.resolve()
|
|
123
|
+
void tools.work.track(
|
|
124
|
+
batchConnection.then((connection) => {
|
|
125
|
+
unsubscribeBatch = connection.subscribe((accepted) => {
|
|
126
|
+
tools.socket.emit(ACCEPTED, accepted)
|
|
127
|
+
if (options.duplicateDelivery === true) {
|
|
128
|
+
tools.socket.emit(ACCEPTED, accepted)
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
}),
|
|
132
|
+
`bind SVG batch broadcast`,
|
|
133
|
+
)
|
|
134
|
+
void tools.work.track(
|
|
135
|
+
presenceConnection.then((connection) => {
|
|
136
|
+
unbindPresence = bindMosaicDomainPresenceServerSocket(
|
|
137
|
+
connection,
|
|
138
|
+
tools.socket as never,
|
|
139
|
+
tools.work,
|
|
140
|
+
)
|
|
141
|
+
}),
|
|
142
|
+
`bind SVG presence`,
|
|
143
|
+
)
|
|
144
|
+
tools.socket.on(
|
|
145
|
+
PROPOSE,
|
|
146
|
+
(
|
|
147
|
+
batch: MosaicDomainBatchProposal,
|
|
148
|
+
respond: (
|
|
149
|
+
result: Awaited<ReturnType<MosaicDomainBatchConnection[`propose`]>>,
|
|
150
|
+
) => void,
|
|
151
|
+
) => {
|
|
152
|
+
void tools.work
|
|
153
|
+
.track(
|
|
154
|
+
batchConnection.then((connection) => connection.propose(batch)),
|
|
155
|
+
`propose SVG batch`,
|
|
156
|
+
)
|
|
157
|
+
.then(respond)
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
tools.socket.on(
|
|
161
|
+
RECOVER,
|
|
162
|
+
(
|
|
163
|
+
afterRevision: number,
|
|
164
|
+
respond: (recovery: {
|
|
165
|
+
headRevision: number
|
|
166
|
+
tail: readonly MosaicAcceptedDomainBatchEnvelope[]
|
|
167
|
+
}) => void,
|
|
168
|
+
) => {
|
|
169
|
+
void tools.work
|
|
170
|
+
.track(
|
|
171
|
+
batchConnection.then((connection) =>
|
|
172
|
+
connection.recover(afterRevision),
|
|
173
|
+
),
|
|
174
|
+
`recover SVG batches`,
|
|
175
|
+
)
|
|
176
|
+
.then(respond)
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
return () => {
|
|
180
|
+
unsubscribeBatch()
|
|
181
|
+
void unbindPresence()
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
async function createPeer(name: string) {
|
|
187
|
+
const harness = scenario.createClient({ name })
|
|
188
|
+
await scenario.waitForIdle()
|
|
189
|
+
const domain = await activateSvgDesignDomain({
|
|
190
|
+
instance: `drawing`,
|
|
191
|
+
silo: harness.silo,
|
|
192
|
+
})
|
|
193
|
+
const batch = createMosaicDomainBatchClient({
|
|
194
|
+
actor: harness.userKey,
|
|
195
|
+
domain,
|
|
196
|
+
session: harness.sessionId,
|
|
197
|
+
transport: batchSocketTransport(harness),
|
|
198
|
+
})
|
|
199
|
+
const presenceTransport = createMosaicDomainPresenceSocketTransport(
|
|
200
|
+
harness.socket,
|
|
201
|
+
{
|
|
202
|
+
idSource: (() => {
|
|
203
|
+
let sequence = 0
|
|
204
|
+
return () => `${harness.sessionId}:presence:${sequence++}`
|
|
205
|
+
})(),
|
|
206
|
+
},
|
|
207
|
+
)
|
|
208
|
+
const presence = createMosaicDomainPresenceClient({
|
|
209
|
+
domain,
|
|
210
|
+
session: harness.sessionId,
|
|
211
|
+
transport: presenceTransport,
|
|
212
|
+
})
|
|
213
|
+
await Promise.all([batch.start(), presence.start()])
|
|
214
|
+
const editor = createSvgDomainEditor({
|
|
215
|
+
batch,
|
|
216
|
+
domain,
|
|
217
|
+
presence,
|
|
218
|
+
state: {
|
|
219
|
+
getState: harness.silo.getState,
|
|
220
|
+
setState: harness.silo.setState,
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
return {
|
|
224
|
+
batch,
|
|
225
|
+
clock: createSvgGestureClock({
|
|
226
|
+
actor: harness.userKey,
|
|
227
|
+
session: harness.sessionId,
|
|
228
|
+
}),
|
|
229
|
+
domain,
|
|
230
|
+
editor,
|
|
231
|
+
harness,
|
|
232
|
+
presence,
|
|
233
|
+
presenceTransport,
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const teardown = async (): Promise<void> => {
|
|
238
|
+
await scenario.teardown()
|
|
239
|
+
;(await batchServerPromise)?.dispose()
|
|
240
|
+
;(await presenceServerPromise)?.[Symbol.dispose]()
|
|
241
|
+
;(await serverDomainPromise)?.[Symbol.dispose]()
|
|
242
|
+
}
|
|
243
|
+
teardowns.push(teardown)
|
|
244
|
+
return {
|
|
245
|
+
createPeer,
|
|
246
|
+
denyEdges: () => {
|
|
247
|
+
denyEdges = true
|
|
248
|
+
},
|
|
249
|
+
scenario,
|
|
250
|
+
server: async () => ({
|
|
251
|
+
batch: await batchServerPromise!,
|
|
252
|
+
domain: await serverDomainPromise!,
|
|
253
|
+
silo: serverSilo!,
|
|
254
|
+
}),
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
describe(`SVG Domain over realtime-testing`, () => {
|
|
259
|
+
test(`real clients tolerate duplicate delivery and deterministically settle contention`, async () => {
|
|
260
|
+
const room = realtimeFixture({ duplicateDelivery: true })
|
|
261
|
+
const [alice, bob] = await Promise.all([
|
|
262
|
+
room.createPeer(`alice`),
|
|
263
|
+
room.createPeer(`bob`),
|
|
264
|
+
])
|
|
265
|
+
await alice.editor.replaceDrawing({ drawing, gesture: alice.clock.begin() })
|
|
266
|
+
await bob.batch.flush()
|
|
267
|
+
alice.clock.observe(10)
|
|
268
|
+
bob.clock.observe(10)
|
|
269
|
+
await Promise.all([
|
|
270
|
+
alice.editor.commitGeometry({
|
|
271
|
+
gesture: alice.clock.begin(),
|
|
272
|
+
point: { x: 31, y: 32 },
|
|
273
|
+
target: { kind: `node`, subpathId: `a0` },
|
|
274
|
+
}),
|
|
275
|
+
bob.editor.commitGeometry({
|
|
276
|
+
gesture: bob.clock.begin(),
|
|
277
|
+
point: { x: 41, y: 42 },
|
|
278
|
+
target: { kind: `node`, subpathId: `a0` },
|
|
279
|
+
}),
|
|
280
|
+
])
|
|
281
|
+
await Promise.all([alice.batch.flush(), bob.batch.flush()])
|
|
282
|
+
const server = await room.server()
|
|
283
|
+
const values = [server.silo, alice.harness.silo, bob.harness.silo].map(
|
|
284
|
+
(silo) => readSvgRegister(silo.getState(nodeAtoms, `a0`)),
|
|
285
|
+
)
|
|
286
|
+
expect(values[0]).toEqual(values[1])
|
|
287
|
+
expect(values[1]).toEqual(values[2])
|
|
288
|
+
expect(server.batch.revision).toBe(3)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test(`a real final-member rejection exposes no partial structural state`, async () => {
|
|
292
|
+
const room = realtimeFixture()
|
|
293
|
+
const alice = await room.createPeer(`alice`)
|
|
294
|
+
await alice.editor.replaceDrawing({ drawing, gesture: alice.clock.begin() })
|
|
295
|
+
room.denyEdges()
|
|
296
|
+
await expect(
|
|
297
|
+
alice.editor.insertSubpath({
|
|
298
|
+
edge: { kind: `line` },
|
|
299
|
+
gesture: alice.clock.begin(),
|
|
300
|
+
index: 2,
|
|
301
|
+
node: { x: 20, y: 20 },
|
|
302
|
+
pathId: `path-a`,
|
|
303
|
+
subpathId: `denied`,
|
|
304
|
+
}),
|
|
305
|
+
).rejects.toThrow(`not authorized`)
|
|
306
|
+
expect(alice.harness.silo.getState(structureViolationsSelector)).toEqual([])
|
|
307
|
+
const server = await room.server()
|
|
308
|
+
expect(server.silo.getState(structureViolationsSelector)).toEqual([])
|
|
309
|
+
expect(server.batch.revision).toBe(1)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test(`a disconnected real client replays one coalesced geometry batch`, async () => {
|
|
313
|
+
const room = realtimeFixture()
|
|
314
|
+
const [alice, bob] = await Promise.all([
|
|
315
|
+
room.createPeer(`alice`),
|
|
316
|
+
room.createPeer(`bob`),
|
|
317
|
+
])
|
|
318
|
+
await alice.editor.replaceDrawing({ drawing, gesture: alice.clock.begin() })
|
|
319
|
+
await bob.batch.flush()
|
|
320
|
+
alice.harness.socket.disconnect()
|
|
321
|
+
await alice.editor.commitGeometry({
|
|
322
|
+
gesture: alice.clock.begin(),
|
|
323
|
+
point: { x: 70, y: 80 },
|
|
324
|
+
target: { kind: `node`, subpathId: `a0` },
|
|
325
|
+
})
|
|
326
|
+
expect(alice.batch.state.status).toBe(`offline`)
|
|
327
|
+
alice.harness.socket.connect()
|
|
328
|
+
await room.scenario.waitForIdle()
|
|
329
|
+
await alice.batch.flush()
|
|
330
|
+
await bob.batch.flush()
|
|
331
|
+
expect(readSvgRegister(bob.harness.silo.getState(nodeAtoms, `a0`))).toEqual({
|
|
332
|
+
x: 70,
|
|
333
|
+
y: 80,
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
test(`disconnect during drag expires only that actor-session preview`, async () => {
|
|
338
|
+
const room = realtimeFixture()
|
|
339
|
+
const [alice, bob] = await Promise.all([
|
|
340
|
+
room.createPeer(`alice`),
|
|
341
|
+
room.createPeer(`bob`),
|
|
342
|
+
])
|
|
343
|
+
await alice.editor.replaceDrawing({ drawing, gesture: alice.clock.begin() })
|
|
344
|
+
await bob.batch.flush()
|
|
345
|
+
const gesture = alice.clock.begin()
|
|
346
|
+
await alice.editor.beginDrag({
|
|
347
|
+
element: {} as Element,
|
|
348
|
+
gesture,
|
|
349
|
+
point: { x: 15, y: 16 },
|
|
350
|
+
pointerId: 1,
|
|
351
|
+
target: { kind: `node`, subpathId: `a0` },
|
|
352
|
+
})
|
|
353
|
+
await room.scenario.waitForIdle()
|
|
354
|
+
await bob.presence.flush()
|
|
355
|
+
const key = svgDragPresenceKey(gesture)
|
|
356
|
+
expect(bob.harness.silo.getState(dragPresenceAtoms, key)).toMatchObject({
|
|
357
|
+
point: { x: 15, y: 16 },
|
|
358
|
+
})
|
|
359
|
+
alice.harness.socket.disconnect()
|
|
360
|
+
await bob.harness.waitForIdle()
|
|
361
|
+
await bob.presence.flush()
|
|
362
|
+
expect(bob.harness.silo.getState(dragPresenceAtoms, key)).toBeNull()
|
|
363
|
+
const server = await room.server()
|
|
364
|
+
expect(readSvgRegister(server.silo.getState(nodeAtoms, `a0`))).toEqual({
|
|
365
|
+
x: 0,
|
|
366
|
+
y: 0,
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
})
|