@atom.io/template-preact-svg-editor 0.0.93 → 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 +20 -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,264 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
compactSvgOrderHistory,
|
|
5
|
+
compactSvgRegisterHistory,
|
|
6
|
+
EMPTY_SVG_ORDER,
|
|
7
|
+
emptySvgRegister,
|
|
8
|
+
materializeSvgOrder,
|
|
9
|
+
rankSvgOrderBetween,
|
|
10
|
+
readSvgRegister,
|
|
11
|
+
reduceSvgOrder,
|
|
12
|
+
reduceSvgRegister,
|
|
13
|
+
type SvgOrderOperation,
|
|
14
|
+
} from "./svg-convergence.ts"
|
|
15
|
+
|
|
16
|
+
describe(`SVG member convergence`, () => {
|
|
17
|
+
test(`order members converge under contention, reordering, and duplicates`, () => {
|
|
18
|
+
const middle = rankSvgOrderBetween(null, null)
|
|
19
|
+
const before = rankSvgOrderBetween(null, middle)
|
|
20
|
+
const operations: SvgOrderOperation[] = [
|
|
21
|
+
{
|
|
22
|
+
entryId: `a`,
|
|
23
|
+
id: `0001:alice:insert-a`,
|
|
24
|
+
present: true,
|
|
25
|
+
rank: middle,
|
|
26
|
+
value: `path-a`,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
entryId: `b`,
|
|
30
|
+
id: `0001:bob:insert-b`,
|
|
31
|
+
present: true,
|
|
32
|
+
rank: middle,
|
|
33
|
+
value: `path-b`,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
entryId: `a`,
|
|
37
|
+
id: `0002:alice:move-a`,
|
|
38
|
+
present: true,
|
|
39
|
+
rank: before,
|
|
40
|
+
value: `path-a`,
|
|
41
|
+
},
|
|
42
|
+
]
|
|
43
|
+
const apply = (delivery: readonly SvgOrderOperation[]) =>
|
|
44
|
+
delivery.reduce(reduceSvgOrder, EMPTY_SVG_ORDER)
|
|
45
|
+
const forward = apply([...operations, operations[1]])
|
|
46
|
+
const reverse = apply([...operations].reverse())
|
|
47
|
+
|
|
48
|
+
expect(materializeSvgOrder(forward)).toEqual(materializeSvgOrder(reverse))
|
|
49
|
+
expect(materializeSvgOrder(forward).map(({ value }) => value)).toEqual([
|
|
50
|
+
`path-a`,
|
|
51
|
+
`path-b`,
|
|
52
|
+
])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test(`same-node contention is deterministic without a mutex`, () => {
|
|
56
|
+
const alice = { id: `0007:alice:drag`, value: { x: 10, y: 20 } }
|
|
57
|
+
const bob = { id: `0007:bob:drag`, value: { x: 30, y: 40 } }
|
|
58
|
+
const first = reduceSvgRegister(
|
|
59
|
+
reduceSvgRegister(emptySvgRegister(), alice),
|
|
60
|
+
bob,
|
|
61
|
+
)
|
|
62
|
+
const second = reduceSvgRegister(
|
|
63
|
+
reduceSvgRegister(emptySvgRegister(), bob),
|
|
64
|
+
alice,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
expect(first).toEqual(second)
|
|
68
|
+
expect(readSvgRegister(first)).toEqual({ x: 30, y: 40 })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test(`operation ID collisions fail closed`, () => {
|
|
72
|
+
const initial = reduceSvgRegister(emptySvgRegister(), {
|
|
73
|
+
id: `same`,
|
|
74
|
+
value: { x: 1, y: 1 },
|
|
75
|
+
})
|
|
76
|
+
expect(() =>
|
|
77
|
+
reduceSvgRegister(initial, { id: `same`, value: { x: 2, y: 2 } }),
|
|
78
|
+
).toThrow(`operation ID collision`)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test(`operation duplicates ignore JSON object insertion order`, () => {
|
|
82
|
+
const initial = reduceSvgRegister(emptySvgRegister(), {
|
|
83
|
+
id: `same`,
|
|
84
|
+
value: { point: { x: 1, y: 2 }, selected: true },
|
|
85
|
+
})
|
|
86
|
+
expect(
|
|
87
|
+
reduceSvgRegister(initial, {
|
|
88
|
+
id: `same`,
|
|
89
|
+
value: { selected: true, point: { y: 2, x: 1 } },
|
|
90
|
+
}),
|
|
91
|
+
).toBe(initial)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test(`superseded operation ID collisions fail in every delivery order`, () => {
|
|
95
|
+
const old = { id: `0001`, value: { x: 1, y: 1 } }
|
|
96
|
+
const latest = { id: `0002`, value: { x: 2, y: 2 } }
|
|
97
|
+
const collision = { id: old.id, value: { x: 3, y: 3 } }
|
|
98
|
+
const apply = (operations: readonly (typeof old)[]) =>
|
|
99
|
+
operations.reduce(reduceSvgRegister, emptySvgRegister())
|
|
100
|
+
|
|
101
|
+
expect(() => apply([old, latest, collision])).toThrow(
|
|
102
|
+
`operation ID collision`,
|
|
103
|
+
)
|
|
104
|
+
expect(() => apply([latest, old, collision])).toThrow(
|
|
105
|
+
`operation ID collision`,
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test(`compensation target sets are idempotent regardless of wire order`, () => {
|
|
110
|
+
const firstOrder: SvgOrderOperation = {
|
|
111
|
+
actor: `alice`,
|
|
112
|
+
entryId: `a`,
|
|
113
|
+
id: `first`,
|
|
114
|
+
present: false,
|
|
115
|
+
rank: rankSvgOrderBetween(null, null),
|
|
116
|
+
value: `path-a`,
|
|
117
|
+
}
|
|
118
|
+
const orderOperation = {
|
|
119
|
+
actor: `alice`,
|
|
120
|
+
id: `undo`,
|
|
121
|
+
mode: `undo` as const,
|
|
122
|
+
targetOperationIds: [`first`, `second`],
|
|
123
|
+
type: `history` as const,
|
|
124
|
+
}
|
|
125
|
+
const orderChanges = reduceSvgOrder(
|
|
126
|
+
reduceSvgOrder(EMPTY_SVG_ORDER, firstOrder),
|
|
127
|
+
{ ...firstOrder, id: `second` },
|
|
128
|
+
)
|
|
129
|
+
const order = reduceSvgOrder(orderChanges, orderOperation)
|
|
130
|
+
expect(
|
|
131
|
+
reduceSvgOrder(order, {
|
|
132
|
+
...orderOperation,
|
|
133
|
+
targetOperationIds: [`second`, `first`],
|
|
134
|
+
}),
|
|
135
|
+
).toBe(order)
|
|
136
|
+
|
|
137
|
+
const registerChanges = reduceSvgRegister(
|
|
138
|
+
reduceSvgRegister(emptySvgRegister(), {
|
|
139
|
+
actor: `alice`,
|
|
140
|
+
id: `first`,
|
|
141
|
+
value: { x: 0, y: 1 },
|
|
142
|
+
}),
|
|
143
|
+
{ actor: `alice`, id: `second`, value: { x: 1, y: 2 } },
|
|
144
|
+
)
|
|
145
|
+
const registerOperation = {
|
|
146
|
+
actor: `alice`,
|
|
147
|
+
id: `undo`,
|
|
148
|
+
mode: `undo` as const,
|
|
149
|
+
targetOperationIds: [`first`, `second`],
|
|
150
|
+
type: `history` as const,
|
|
151
|
+
}
|
|
152
|
+
const register = reduceSvgRegister(registerChanges, registerOperation)
|
|
153
|
+
expect(
|
|
154
|
+
reduceSvgRegister(register, {
|
|
155
|
+
...registerOperation,
|
|
156
|
+
targetOperationIds: [`second`, `first`],
|
|
157
|
+
}),
|
|
158
|
+
).toBe(register)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test(`a malformed or foreign compensation target fails closed`, () => {
|
|
162
|
+
expect(() =>
|
|
163
|
+
reduceSvgRegister(emptySvgRegister(), {
|
|
164
|
+
actor: `alice`,
|
|
165
|
+
id: `undo`,
|
|
166
|
+
mode: `undo`,
|
|
167
|
+
targetOperationIds: [`missing`],
|
|
168
|
+
type: `history`,
|
|
169
|
+
}),
|
|
170
|
+
).toThrow(`unknown or foreign`)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test(`compaction survives restart and preserves protected redo targets`, () => {
|
|
174
|
+
const registerChanges = [
|
|
175
|
+
{ actor: `alice`, id: `0001`, value: { x: 1, y: 1 } },
|
|
176
|
+
{ actor: `alice`, id: `0002`, value: { x: 2, y: 2 } },
|
|
177
|
+
{ actor: `alice`, id: `0003`, value: { x: 3, y: 3 } },
|
|
178
|
+
].reduce(reduceSvgRegister, emptySvgRegister<{ x: number; y: number }>())
|
|
179
|
+
const undoneRegister = reduceSvgRegister(registerChanges, {
|
|
180
|
+
actor: `alice`,
|
|
181
|
+
id: `undo-0003`,
|
|
182
|
+
mode: `undo`,
|
|
183
|
+
targetOperationIds: [`0003`],
|
|
184
|
+
type: `history`,
|
|
185
|
+
})
|
|
186
|
+
const compactedRegister = compactSvgRegisterHistory(undoneRegister, {
|
|
187
|
+
retainedOperationIds: new Set([`0003`]),
|
|
188
|
+
throughRevision: 9,
|
|
189
|
+
})
|
|
190
|
+
const restartedRegister = structuredClone(compactedRegister)
|
|
191
|
+
expect(Object.keys(restartedRegister.operations)).toEqual([`0002`, `0003`])
|
|
192
|
+
expect(Object.keys(restartedRegister.history ?? {})).toEqual([
|
|
193
|
+
`svg-history-cut:9:0003`,
|
|
194
|
+
])
|
|
195
|
+
expect(readSvgRegister(restartedRegister)).toEqual({ x: 2, y: 2 })
|
|
196
|
+
expect(
|
|
197
|
+
readSvgRegister(
|
|
198
|
+
reduceSvgRegister(restartedRegister, {
|
|
199
|
+
actor: `alice`,
|
|
200
|
+
id: `redo-0003`,
|
|
201
|
+
mode: `redo`,
|
|
202
|
+
targetOperationIds: [`0003`],
|
|
203
|
+
type: `history`,
|
|
204
|
+
}),
|
|
205
|
+
),
|
|
206
|
+
).toEqual({ x: 3, y: 3 })
|
|
207
|
+
|
|
208
|
+
const rank = rankSvgOrderBetween(null, null)
|
|
209
|
+
const orderChanges = [
|
|
210
|
+
{
|
|
211
|
+
actor: `alice`,
|
|
212
|
+
entryId: `path`,
|
|
213
|
+
id: `0001`,
|
|
214
|
+
present: true,
|
|
215
|
+
rank,
|
|
216
|
+
value: `old`,
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
actor: `alice`,
|
|
220
|
+
entryId: `path`,
|
|
221
|
+
id: `0002`,
|
|
222
|
+
present: true,
|
|
223
|
+
rank,
|
|
224
|
+
value: `middle`,
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
actor: `alice`,
|
|
228
|
+
entryId: `path`,
|
|
229
|
+
id: `0003`,
|
|
230
|
+
present: true,
|
|
231
|
+
rank,
|
|
232
|
+
value: `latest`,
|
|
233
|
+
},
|
|
234
|
+
].reduce(reduceSvgOrder, EMPTY_SVG_ORDER)
|
|
235
|
+
const undoneOrder = reduceSvgOrder(orderChanges, {
|
|
236
|
+
actor: `alice`,
|
|
237
|
+
id: `undo-order-0003`,
|
|
238
|
+
mode: `undo`,
|
|
239
|
+
targetOperationIds: [`0003`],
|
|
240
|
+
type: `history`,
|
|
241
|
+
})
|
|
242
|
+
const restartedOrder = structuredClone(
|
|
243
|
+
compactSvgOrderHistory(undoneOrder, {
|
|
244
|
+
retainedOperationIds: new Set([`0003`]),
|
|
245
|
+
throughRevision: 9,
|
|
246
|
+
}),
|
|
247
|
+
)
|
|
248
|
+
expect(Object.keys(restartedOrder.operations)).toEqual([`0002`, `0003`])
|
|
249
|
+
expect(
|
|
250
|
+
materializeSvgOrder(restartedOrder).map(({ value }) => value),
|
|
251
|
+
).toEqual([`middle`])
|
|
252
|
+
expect(
|
|
253
|
+
materializeSvgOrder(
|
|
254
|
+
reduceSvgOrder(restartedOrder, {
|
|
255
|
+
actor: `alice`,
|
|
256
|
+
id: `redo-order-0003`,
|
|
257
|
+
mode: `redo`,
|
|
258
|
+
targetOperationIds: [`0003`],
|
|
259
|
+
type: `history`,
|
|
260
|
+
}),
|
|
261
|
+
).map(({ value }) => value),
|
|
262
|
+
).toEqual([`latest`])
|
|
263
|
+
})
|
|
264
|
+
})
|