@atom.io/template-preact-svg-editor 0.1.0 → 0.1.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/CHANGELOG.md +14 -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,401 @@
|
|
|
1
|
+
import {
|
|
2
|
+
findState,
|
|
3
|
+
getState,
|
|
4
|
+
runTransaction,
|
|
5
|
+
setState,
|
|
6
|
+
subscribe,
|
|
7
|
+
} from "atom.io"
|
|
8
|
+
import { beforeEach, describe, expect, test, vi } from "vitest"
|
|
9
|
+
|
|
10
|
+
import { parsePreactLogo } from "./preact-logo.ts"
|
|
11
|
+
import {
|
|
12
|
+
materializeSvgOrder,
|
|
13
|
+
readSvgRegister,
|
|
14
|
+
reduceSvgRegister,
|
|
15
|
+
} from "./svg-convergence.ts"
|
|
16
|
+
import {
|
|
17
|
+
activeDragAtom,
|
|
18
|
+
beginSvgDrag,
|
|
19
|
+
createSvgGestureClock,
|
|
20
|
+
deletePathTransaction,
|
|
21
|
+
deleteSubpathTransaction,
|
|
22
|
+
dragPresenceAtoms,
|
|
23
|
+
edgeAtoms,
|
|
24
|
+
finishSvgDrag,
|
|
25
|
+
insertPathTransaction,
|
|
26
|
+
insertSubpathTransaction,
|
|
27
|
+
nodeAtoms,
|
|
28
|
+
pathDrawSelectors,
|
|
29
|
+
pathOrderAtom,
|
|
30
|
+
pointerCaptureAtom,
|
|
31
|
+
previewSvgDrag,
|
|
32
|
+
projectedNodeSelectors,
|
|
33
|
+
reorderSubpathTransaction,
|
|
34
|
+
replaceSvgDrawing,
|
|
35
|
+
splitSubpathTransaction,
|
|
36
|
+
structureViolationsSelector,
|
|
37
|
+
subpathAtoms,
|
|
38
|
+
subpathOrderAtoms,
|
|
39
|
+
svgDragPresenceKey,
|
|
40
|
+
type SvgDrawingFixture,
|
|
41
|
+
svgOperationId,
|
|
42
|
+
} from "./svg-editor-state.ts"
|
|
43
|
+
|
|
44
|
+
const clock = createSvgGestureClock({
|
|
45
|
+
actor: `test-actor`,
|
|
46
|
+
initialLogicalTime: 10_000,
|
|
47
|
+
session: `fixture`,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const drawing: SvgDrawingFixture = {
|
|
51
|
+
paths: [
|
|
52
|
+
{
|
|
53
|
+
id: `path-a`,
|
|
54
|
+
subpaths: [
|
|
55
|
+
{ edge: { kind: `move` }, id: `a0`, node: { x: 0, y: 0 } },
|
|
56
|
+
{ edge: { kind: `line` }, id: `a1`, node: { x: 10, y: 10 } },
|
|
57
|
+
{
|
|
58
|
+
edge: { c: { x: 12, y: 12 }, kind: `cubic`, s: { x: 18, y: 18 } },
|
|
59
|
+
id: `a2`,
|
|
60
|
+
node: { x: 20, y: 20 },
|
|
61
|
+
},
|
|
62
|
+
{ edge: { kind: `close` }, id: `a3`, node: null },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
vi.spyOn(console, `error`).mockImplementation(() => {})
|
|
70
|
+
vi.spyOn(console, `warn`).mockImplementation(() => {})
|
|
71
|
+
finishSvgDrag({ commit: false })
|
|
72
|
+
replaceSvgDrawing(drawing, clock.begin())
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe(`SVG editor state`, () => {
|
|
76
|
+
test(`gesture clocks reject ambiguous identities and invalid initial time`, () => {
|
|
77
|
+
expect(() =>
|
|
78
|
+
createSvgGestureClock({ actor: ``, session: `fixture` }),
|
|
79
|
+
).toThrow(`identities must be nonempty`)
|
|
80
|
+
expect(() =>
|
|
81
|
+
createSvgGestureClock({
|
|
82
|
+
actor: `actor`,
|
|
83
|
+
initialLogicalTime: Number.NaN,
|
|
84
|
+
session: `fixture`,
|
|
85
|
+
}),
|
|
86
|
+
).toThrow(`nonnegative integer`)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test(`imports a complete graph and derives SVG without a renderer registry`, () => {
|
|
90
|
+
expect(getState(structureViolationsSelector)).toEqual([])
|
|
91
|
+
expect(
|
|
92
|
+
materializeSvgOrder(getState(pathOrderAtom)).map(({ value }) => value),
|
|
93
|
+
).toEqual([`path-a`])
|
|
94
|
+
expect(getState(pathDrawSelectors, `path-a`)).toBe(
|
|
95
|
+
`M 0 0 L 10 10 C 12 12 18 18 20 20 Z`,
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test(`path and subpath lifecycle settles as complete transactions`, () => {
|
|
100
|
+
const insertPathGesture = clock.begin()
|
|
101
|
+
runTransaction(
|
|
102
|
+
insertPathTransaction,
|
|
103
|
+
insertPathGesture.id,
|
|
104
|
+
)({
|
|
105
|
+
gesture: insertPathGesture,
|
|
106
|
+
index: 1,
|
|
107
|
+
pathId: `path-b`,
|
|
108
|
+
})
|
|
109
|
+
const insertSubpathGesture = clock.begin()
|
|
110
|
+
runTransaction(
|
|
111
|
+
insertSubpathTransaction,
|
|
112
|
+
insertSubpathGesture.id,
|
|
113
|
+
)({
|
|
114
|
+
edge: { kind: `move` },
|
|
115
|
+
gesture: insertSubpathGesture,
|
|
116
|
+
index: 0,
|
|
117
|
+
node: { x: 100, y: 100 },
|
|
118
|
+
pathId: `path-b`,
|
|
119
|
+
subpathId: `b0`,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
expect(getState(pathDrawSelectors, `path-b`)).toBe(`M 100 100`)
|
|
123
|
+
expect(getState(structureViolationsSelector)).toEqual([])
|
|
124
|
+
|
|
125
|
+
const deletePathGesture = clock.begin()
|
|
126
|
+
runTransaction(
|
|
127
|
+
deletePathTransaction,
|
|
128
|
+
deletePathGesture.id,
|
|
129
|
+
)({
|
|
130
|
+
gesture: deletePathGesture,
|
|
131
|
+
pathId: `path-b`,
|
|
132
|
+
})
|
|
133
|
+
expect(
|
|
134
|
+
materializeSvgOrder(getState(pathOrderAtom)).map(({ value }) => value),
|
|
135
|
+
).toEqual([`path-a`])
|
|
136
|
+
expect(getState(structureViolationsSelector)).toEqual([])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test(`split, reorder, and delete preserve structural invariants atomically`, () => {
|
|
140
|
+
const observedViolations: unknown[] = []
|
|
141
|
+
const unsubscribe = subscribe(
|
|
142
|
+
structureViolationsSelector,
|
|
143
|
+
({ newValue }) => observedViolations.push(newValue),
|
|
144
|
+
`structure-test`,
|
|
145
|
+
)
|
|
146
|
+
const splitGesture = clock.begin()
|
|
147
|
+
runTransaction(
|
|
148
|
+
splitSubpathTransaction,
|
|
149
|
+
splitGesture.id,
|
|
150
|
+
)({
|
|
151
|
+
continuationEdge: { kind: `line` },
|
|
152
|
+
gesture: splitGesture,
|
|
153
|
+
inserted: {
|
|
154
|
+
edge: { kind: `line` },
|
|
155
|
+
node: { x: 5, y: 5 },
|
|
156
|
+
subpathId: `split`,
|
|
157
|
+
},
|
|
158
|
+
pathId: `path-a`,
|
|
159
|
+
targetSubpathId: `a1`,
|
|
160
|
+
})
|
|
161
|
+
expect(getState(structureViolationsSelector)).toEqual([])
|
|
162
|
+
expect(
|
|
163
|
+
materializeSvgOrder(getState(subpathOrderAtoms, `path-a`)).map(
|
|
164
|
+
({ value }) => value,
|
|
165
|
+
),
|
|
166
|
+
).toEqual([`a0`, `split`, `a1`, `a2`, `a3`])
|
|
167
|
+
|
|
168
|
+
const reorderGesture = clock.begin()
|
|
169
|
+
runTransaction(
|
|
170
|
+
reorderSubpathTransaction,
|
|
171
|
+
reorderGesture.id,
|
|
172
|
+
)({
|
|
173
|
+
gesture: reorderGesture,
|
|
174
|
+
index: 3,
|
|
175
|
+
pathId: `path-a`,
|
|
176
|
+
subpathId: `split`,
|
|
177
|
+
})
|
|
178
|
+
const deleteGesture = clock.begin()
|
|
179
|
+
runTransaction(
|
|
180
|
+
deleteSubpathTransaction,
|
|
181
|
+
deleteGesture.id,
|
|
182
|
+
)({
|
|
183
|
+
gesture: deleteGesture,
|
|
184
|
+
pathId: `path-a`,
|
|
185
|
+
subpathId: `a1`,
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
expect(getState(structureViolationsSelector)).toEqual([])
|
|
189
|
+
expect(
|
|
190
|
+
materializeSvgOrder(getState(subpathOrderAtoms, `path-a`)).map(
|
|
191
|
+
({ value }) => value,
|
|
192
|
+
),
|
|
193
|
+
).toEqual([`a0`, `a2`, `split`, `a3`])
|
|
194
|
+
expect(
|
|
195
|
+
observedViolations.every(
|
|
196
|
+
(violations) => Array.isArray(violations) && violations.length === 0,
|
|
197
|
+
),
|
|
198
|
+
).toBe(true)
|
|
199
|
+
unsubscribe()
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test(`split rejects an incompatible continuation without partial settlement`, () => {
|
|
203
|
+
const before = getState(pathDrawSelectors, `path-a`)
|
|
204
|
+
const gesture = clock.begin()
|
|
205
|
+
|
|
206
|
+
expect(() => {
|
|
207
|
+
runTransaction(
|
|
208
|
+
splitSubpathTransaction,
|
|
209
|
+
gesture.id,
|
|
210
|
+
)({
|
|
211
|
+
continuationEdge: { kind: `close` },
|
|
212
|
+
gesture,
|
|
213
|
+
inserted: {
|
|
214
|
+
edge: { kind: `line` },
|
|
215
|
+
node: { x: 5, y: 5 },
|
|
216
|
+
subpathId: `invalid-split`,
|
|
217
|
+
},
|
|
218
|
+
pathId: `path-a`,
|
|
219
|
+
targetSubpathId: `a1`,
|
|
220
|
+
})
|
|
221
|
+
}).toThrow(`Only a close edge may have a null SVG node`)
|
|
222
|
+
expect(getState(pathDrawSelectors, `path-a`)).toBe(before)
|
|
223
|
+
expect(getState(structureViolationsSelector)).toEqual([])
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test(`the invariant selector detects a mismatched subpath identity`, () => {
|
|
227
|
+
const gesture = clock.begin()
|
|
228
|
+
setState(
|
|
229
|
+
subpathAtoms,
|
|
230
|
+
`a1`,
|
|
231
|
+
reduceSvgRegister(getState(subpathAtoms, `a1`), {
|
|
232
|
+
id: svgOperationId(gesture, 0),
|
|
233
|
+
value: { id: `another-subpath`, pathId: `path-a` },
|
|
234
|
+
}),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
expect(getState(structureViolationsSelector)).toContainEqual({
|
|
238
|
+
code: `missing-subpath`,
|
|
239
|
+
pathId: `path-a`,
|
|
240
|
+
subpathId: `a1`,
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test(`a rejected import leaves the previous drawing intact`, () => {
|
|
245
|
+
const before = getState(pathDrawSelectors, `path-a`)
|
|
246
|
+
expect(() => {
|
|
247
|
+
replaceSvgDrawing(
|
|
248
|
+
{
|
|
249
|
+
paths: [
|
|
250
|
+
{
|
|
251
|
+
id: `broken`,
|
|
252
|
+
subpaths: [{ edge: { kind: `line` }, id: `bad`, node: null }],
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
},
|
|
256
|
+
clock.begin(),
|
|
257
|
+
)
|
|
258
|
+
}).toThrow(`Only a close edge`)
|
|
259
|
+
expect(getState(pathDrawSelectors, `path-a`)).toBe(before)
|
|
260
|
+
expect(getState(structureViolationsSelector)).toEqual([])
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test(`drag previews stay ephemeral and settle once at pointer-up`, () => {
|
|
264
|
+
const durableBefore = readSvgRegister(getState(nodeAtoms, `a1`))
|
|
265
|
+
const durableUpdates = vi.fn()
|
|
266
|
+
const unsubscribe = subscribe(
|
|
267
|
+
findState(nodeAtoms, `a1`),
|
|
268
|
+
durableUpdates,
|
|
269
|
+
`durable-node-test`,
|
|
270
|
+
)
|
|
271
|
+
const gesture = clock.begin()
|
|
272
|
+
const element = {} as Element
|
|
273
|
+
beginSvgDrag({
|
|
274
|
+
element,
|
|
275
|
+
gesture,
|
|
276
|
+
point: durableBefore!,
|
|
277
|
+
pointerId: 7,
|
|
278
|
+
target: { kind: `node`, subpathId: `a1` },
|
|
279
|
+
})
|
|
280
|
+
previewSvgDrag({ x: 30, y: 40 })
|
|
281
|
+
previewSvgDrag({ x: 50, y: 60 })
|
|
282
|
+
|
|
283
|
+
expect(readSvgRegister(getState(nodeAtoms, `a1`))).toEqual(durableBefore)
|
|
284
|
+
expect(getState(projectedNodeSelectors, `a1`)).toEqual({ x: 50, y: 60 })
|
|
285
|
+
expect(
|
|
286
|
+
getState(dragPresenceAtoms, svgDragPresenceKey(gesture)),
|
|
287
|
+
).toMatchObject({
|
|
288
|
+
gestureId: gesture.id,
|
|
289
|
+
sequence: 2,
|
|
290
|
+
target: { kind: `node`, subpathId: `a1` },
|
|
291
|
+
})
|
|
292
|
+
expect(getState(pointerCaptureAtom)).toEqual({ element, pointerId: 7 })
|
|
293
|
+
|
|
294
|
+
finishSvgDrag({ commit: true })
|
|
295
|
+
|
|
296
|
+
expect(readSvgRegister(getState(nodeAtoms, `a1`))).toEqual({ x: 50, y: 60 })
|
|
297
|
+
expect(durableUpdates).toHaveBeenCalledTimes(1)
|
|
298
|
+
expect(getState(activeDragAtom)).toBeNull()
|
|
299
|
+
expect(getState(dragPresenceAtoms, svgDragPresenceKey(gesture))).toBeNull()
|
|
300
|
+
expect(getState(pointerCaptureAtom)).toBeNull()
|
|
301
|
+
unsubscribe()
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test(`canceling a drag drops presence without a durable write`, () => {
|
|
305
|
+
const before = readSvgRegister(getState(edgeAtoms, `a2`))
|
|
306
|
+
const gesture = clock.begin()
|
|
307
|
+
beginSvgDrag({
|
|
308
|
+
element: {} as Element,
|
|
309
|
+
gesture,
|
|
310
|
+
point: { x: 18, y: 18 },
|
|
311
|
+
pointerId: 9,
|
|
312
|
+
target: { control: `s`, kind: `edge-control`, subpathId: `a2` },
|
|
313
|
+
})
|
|
314
|
+
previewSvgDrag({ x: 90, y: 91 })
|
|
315
|
+
finishSvgDrag({ commit: false })
|
|
316
|
+
|
|
317
|
+
expect(readSvgRegister(getState(edgeAtoms, `a2`))).toEqual(before)
|
|
318
|
+
expect(getState(dragPresenceAtoms, svgDragPresenceKey(gesture))).toBeNull()
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
test(`presence and cleanup distinguish concurrent sessions for one actor`, () => {
|
|
322
|
+
const local = clock.begin()
|
|
323
|
+
const peer = createSvgGestureClock({
|
|
324
|
+
actor: local.actor,
|
|
325
|
+
initialLogicalTime: local.logicalTime,
|
|
326
|
+
session: `peer-session`,
|
|
327
|
+
}).begin()
|
|
328
|
+
setState(dragPresenceAtoms, svgDragPresenceKey(peer), {
|
|
329
|
+
actor: peer.actor,
|
|
330
|
+
gestureId: peer.id,
|
|
331
|
+
point: { x: 70, y: 80 },
|
|
332
|
+
sequence: 1,
|
|
333
|
+
session: peer.session,
|
|
334
|
+
target: { kind: `node`, subpathId: `a1` },
|
|
335
|
+
})
|
|
336
|
+
beginSvgDrag({
|
|
337
|
+
element: {} as Element,
|
|
338
|
+
gesture: local,
|
|
339
|
+
point: { x: 10, y: 10 },
|
|
340
|
+
pointerId: 10,
|
|
341
|
+
target: { kind: `node`, subpathId: `a1` },
|
|
342
|
+
})
|
|
343
|
+
previewSvgDrag({ x: 30, y: 40 })
|
|
344
|
+
finishSvgDrag({ commit: false })
|
|
345
|
+
|
|
346
|
+
expect(getState(dragPresenceAtoms, svgDragPresenceKey(local))).toBeNull()
|
|
347
|
+
expect(getState(dragPresenceAtoms, svgDragPresenceKey(peer))).toMatchObject({
|
|
348
|
+
point: { x: 70, y: 80 },
|
|
349
|
+
session: `peer-session`,
|
|
350
|
+
})
|
|
351
|
+
setState(dragPresenceAtoms, svgDragPresenceKey(peer), null)
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
test(`a rejected drag commit still releases local gesture state`, () => {
|
|
355
|
+
const dragGesture = clock.begin()
|
|
356
|
+
beginSvgDrag({
|
|
357
|
+
element: {} as Element,
|
|
358
|
+
gesture: dragGesture,
|
|
359
|
+
point: { x: 10, y: 10 },
|
|
360
|
+
pointerId: 11,
|
|
361
|
+
target: { kind: `node`, subpathId: `a1` },
|
|
362
|
+
})
|
|
363
|
+
previewSvgDrag({ x: 30, y: 40 })
|
|
364
|
+
const deleteGesture = clock.begin()
|
|
365
|
+
runTransaction(
|
|
366
|
+
deleteSubpathTransaction,
|
|
367
|
+
deleteGesture.id,
|
|
368
|
+
)({
|
|
369
|
+
gesture: deleteGesture,
|
|
370
|
+
pathId: `path-a`,
|
|
371
|
+
subpathId: `a1`,
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
expect(() => {
|
|
375
|
+
finishSvgDrag({ commit: true })
|
|
376
|
+
}).toThrow(`Cannot move a missing or closing SVG node`)
|
|
377
|
+
expect(getState(activeDragAtom)).toBeNull()
|
|
378
|
+
expect(getState(pointerCaptureAtom)).toBeNull()
|
|
379
|
+
expect(
|
|
380
|
+
getState(dragPresenceAtoms, svgDragPresenceKey(dragGesture)),
|
|
381
|
+
).toBeNull()
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
test(`the bundled parser expands repeated commands into complete members`, () => {
|
|
385
|
+
const parsed = parsePreactLogo(
|
|
386
|
+
`<svg><path d="m0 0l10 0 0 10z"></path></svg>`,
|
|
387
|
+
)
|
|
388
|
+
expect(parsed.paths[0].subpaths).toEqual([
|
|
389
|
+
{ edge: { kind: `move` }, id: `subpath0`, node: { x: 0, y: 0 } },
|
|
390
|
+
{ edge: { kind: `line` }, id: `subpath1`, node: { x: 10, y: 0 } },
|
|
391
|
+
{ edge: { kind: `line` }, id: `subpath2`, node: { x: 10, y: 10 } },
|
|
392
|
+
{ edge: { kind: `close` }, id: `subpath3`, node: null },
|
|
393
|
+
])
|
|
394
|
+
expect(() =>
|
|
395
|
+
parsePreactLogo(`<svg><path d="M0 0H10 20"></path></svg>`),
|
|
396
|
+
).toThrow(`Unsupported SVG path command H`)
|
|
397
|
+
expect(() => parsePreactLogo(`<svg><path d="M0 0L"></path></svg>`)).toThrow(
|
|
398
|
+
`SVG path command L is incomplete`,
|
|
399
|
+
)
|
|
400
|
+
})
|
|
401
|
+
})
|