@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,566 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
export type SvgOrderRank = {
|
|
4
|
+
readonly denominator: string
|
|
5
|
+
readonly numerator: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type SvgOrderOperation = {
|
|
9
|
+
readonly actor?: string | undefined
|
|
10
|
+
readonly entryId: string
|
|
11
|
+
readonly id: string
|
|
12
|
+
readonly present: boolean
|
|
13
|
+
readonly rank: SvgOrderRank
|
|
14
|
+
readonly value: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type SvgHistoryOperation = {
|
|
18
|
+
readonly actor: string
|
|
19
|
+
readonly id: string
|
|
20
|
+
readonly mode: `redo` | `undo`
|
|
21
|
+
readonly targetOperationIds: readonly string[]
|
|
22
|
+
readonly type: `history`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type SvgOrderModelOperation = SvgHistoryOperation | SvgOrderOperation
|
|
26
|
+
|
|
27
|
+
export type SvgOrderState = {
|
|
28
|
+
readonly history?: Readonly<Record<string, SvgHistoryOperation>> | undefined
|
|
29
|
+
readonly operations: Readonly<Record<string, SvgOrderOperation>>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type SvgOrderedEntry = {
|
|
33
|
+
readonly entryId: string
|
|
34
|
+
readonly rank: SvgOrderRank
|
|
35
|
+
readonly value: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type SvgRegisterOperation<Value> = {
|
|
39
|
+
readonly actor?: string | undefined
|
|
40
|
+
readonly id: string
|
|
41
|
+
readonly value: Value
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type SvgRegisterModelOperation<Value> =
|
|
45
|
+
| SvgHistoryOperation
|
|
46
|
+
| SvgRegisterOperation<Value>
|
|
47
|
+
|
|
48
|
+
export type SvgRegisterState<Value> = {
|
|
49
|
+
readonly history?: Readonly<Record<string, SvgHistoryOperation>> | undefined
|
|
50
|
+
readonly operations: Readonly<Record<string, SvgRegisterOperation<Value>>>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const integerStringSchema = z.string().regex(/^-?(?:0|[1-9]\d*)$/u)
|
|
54
|
+
const positiveIntegerStringSchema = z.string().regex(/^[1-9]\d*$/u)
|
|
55
|
+
|
|
56
|
+
export const svgOrderRankSchema: z.ZodType<SvgOrderRank> = z
|
|
57
|
+
.object({
|
|
58
|
+
denominator: positiveIntegerStringSchema,
|
|
59
|
+
numerator: integerStringSchema,
|
|
60
|
+
})
|
|
61
|
+
.strict()
|
|
62
|
+
|
|
63
|
+
export const svgOrderOperationSchema: z.ZodType<SvgOrderOperation> = z
|
|
64
|
+
.object({
|
|
65
|
+
actor: z.string().min(1).optional(),
|
|
66
|
+
entryId: z.string().min(1),
|
|
67
|
+
id: z.string().min(1),
|
|
68
|
+
present: z.boolean(),
|
|
69
|
+
rank: svgOrderRankSchema,
|
|
70
|
+
value: z.string().min(1),
|
|
71
|
+
})
|
|
72
|
+
.strict()
|
|
73
|
+
|
|
74
|
+
export const svgHistoryOperationSchema: z.ZodType<SvgHistoryOperation> = z
|
|
75
|
+
.object({
|
|
76
|
+
actor: z.string().min(1),
|
|
77
|
+
id: z.string().min(1),
|
|
78
|
+
mode: z.enum([`redo`, `undo`]),
|
|
79
|
+
targetOperationIds: z.array(z.string().min(1)).min(1),
|
|
80
|
+
type: z.literal(`history`),
|
|
81
|
+
})
|
|
82
|
+
.strict()
|
|
83
|
+
.refine(
|
|
84
|
+
({ targetOperationIds }) =>
|
|
85
|
+
new Set(targetOperationIds).size === targetOperationIds.length,
|
|
86
|
+
{ message: `SVG history targets must be unique` },
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
export const svgOrderModelOperationSchema: z.ZodType<SvgOrderModelOperation> =
|
|
90
|
+
z.union([svgHistoryOperationSchema, svgOrderOperationSchema])
|
|
91
|
+
|
|
92
|
+
export const svgOrderStateSchema: z.ZodType<SvgOrderState> = z
|
|
93
|
+
.object({
|
|
94
|
+
history: z.record(z.string(), svgHistoryOperationSchema).optional(),
|
|
95
|
+
operations: z.record(z.string(), svgOrderOperationSchema),
|
|
96
|
+
})
|
|
97
|
+
.strict()
|
|
98
|
+
.refine(
|
|
99
|
+
({ operations }) =>
|
|
100
|
+
Object.entries(operations).every(([id, operation]) => id === operation.id),
|
|
101
|
+
{ message: `SVG order operation map keys must match operation IDs` },
|
|
102
|
+
)
|
|
103
|
+
.refine(
|
|
104
|
+
({ history }) =>
|
|
105
|
+
Object.entries(history ?? {}).every(
|
|
106
|
+
([id, operation]) => id === operation.id,
|
|
107
|
+
),
|
|
108
|
+
{ message: `SVG order history map keys must match operation IDs` },
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
export function svgRegisterOperationSchema<Value>(
|
|
112
|
+
valueSchema: z.ZodType<Value>,
|
|
113
|
+
): z.ZodType<SvgRegisterOperation<Value>> {
|
|
114
|
+
return z
|
|
115
|
+
.object({
|
|
116
|
+
actor: z.string().min(1).optional(),
|
|
117
|
+
id: z.string().min(1),
|
|
118
|
+
value: valueSchema,
|
|
119
|
+
})
|
|
120
|
+
.strict()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function svgRegisterModelOperationSchema<Value>(
|
|
124
|
+
valueSchema: z.ZodType<Value>,
|
|
125
|
+
): z.ZodType<SvgRegisterModelOperation<Value>> {
|
|
126
|
+
return z.union([
|
|
127
|
+
svgHistoryOperationSchema,
|
|
128
|
+
svgRegisterOperationSchema(valueSchema),
|
|
129
|
+
])
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function svgRegisterStateSchema<Value>(
|
|
133
|
+
valueSchema: z.ZodType<Value>,
|
|
134
|
+
): z.ZodType<SvgRegisterState<Value>> {
|
|
135
|
+
return z
|
|
136
|
+
.object({
|
|
137
|
+
history: z.record(z.string(), svgHistoryOperationSchema).optional(),
|
|
138
|
+
operations: z.record(z.string(), svgRegisterOperationSchema(valueSchema)),
|
|
139
|
+
})
|
|
140
|
+
.strict()
|
|
141
|
+
.refine(
|
|
142
|
+
({ operations }) =>
|
|
143
|
+
Object.entries(operations).every(
|
|
144
|
+
([id, operation]) => id === operation.id,
|
|
145
|
+
),
|
|
146
|
+
{ message: `SVG register operation map keys must match operation IDs` },
|
|
147
|
+
)
|
|
148
|
+
.refine(
|
|
149
|
+
({ history }) =>
|
|
150
|
+
Object.entries(history ?? {}).every(
|
|
151
|
+
([id, operation]) => id === operation.id,
|
|
152
|
+
),
|
|
153
|
+
{ message: `SVG register history map keys must match operation IDs` },
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const EMPTY_SVG_ORDER: SvgOrderState = { operations: {} }
|
|
158
|
+
|
|
159
|
+
export function emptySvgRegister<Value>(): SvgRegisterState<Value> {
|
|
160
|
+
return { operations: {} }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Locale-independent ordering used by every SVG convergence decision. */
|
|
164
|
+
export function compareSvgIds(left: string, right: string): number {
|
|
165
|
+
return left < right ? -1 : left > right ? 1 : 0
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function greatestCommonDivisor(left: bigint, right: bigint): bigint {
|
|
169
|
+
let a = left < 0n ? -left : left
|
|
170
|
+
let b = right < 0n ? -right : right
|
|
171
|
+
while (b !== 0n) {
|
|
172
|
+
const remainder = a % b
|
|
173
|
+
a = b
|
|
174
|
+
b = remainder
|
|
175
|
+
}
|
|
176
|
+
return a === 0n ? 1n : a
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function normalizeRank(numerator: bigint, denominator: bigint): SvgOrderRank {
|
|
180
|
+
if (denominator <= 0n) {
|
|
181
|
+
throw new Error(`An SVG order rank denominator must be positive`)
|
|
182
|
+
}
|
|
183
|
+
const divisor = greatestCommonDivisor(numerator, denominator)
|
|
184
|
+
return {
|
|
185
|
+
denominator: (denominator / divisor).toString(),
|
|
186
|
+
numerator: (numerator / divisor).toString(),
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Produce a stable rational rank strictly between the supplied neighbors. */
|
|
191
|
+
export function rankSvgOrderBetween(
|
|
192
|
+
left: SvgOrderRank | null,
|
|
193
|
+
right: SvgOrderRank | null,
|
|
194
|
+
): SvgOrderRank {
|
|
195
|
+
if (left === null && right === null) return normalizeRank(0n, 1n)
|
|
196
|
+
if (left === null) {
|
|
197
|
+
const numerator = BigInt(right!.numerator)
|
|
198
|
+
const denominator = BigInt(right!.denominator)
|
|
199
|
+
return normalizeRank(numerator - denominator, denominator)
|
|
200
|
+
}
|
|
201
|
+
if (right === null) {
|
|
202
|
+
const numerator = BigInt(left.numerator)
|
|
203
|
+
const denominator = BigInt(left.denominator)
|
|
204
|
+
return normalizeRank(numerator + denominator, denominator)
|
|
205
|
+
}
|
|
206
|
+
if (compareSvgOrderRanks(left, right) >= 0) {
|
|
207
|
+
throw new Error(`SVG order neighbors must be strictly increasing`)
|
|
208
|
+
}
|
|
209
|
+
return normalizeRank(
|
|
210
|
+
BigInt(left.numerator) + BigInt(right.numerator),
|
|
211
|
+
BigInt(left.denominator) + BigInt(right.denominator),
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function compareSvgOrderRanks(
|
|
216
|
+
left: SvgOrderRank,
|
|
217
|
+
right: SvgOrderRank,
|
|
218
|
+
): number {
|
|
219
|
+
const difference =
|
|
220
|
+
BigInt(left.numerator) * BigInt(right.denominator) -
|
|
221
|
+
BigInt(right.numerator) * BigInt(left.denominator)
|
|
222
|
+
return difference < 0n ? -1 : difference > 0n ? 1 : 0
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function operationsMatch(
|
|
226
|
+
left: SvgOrderOperation,
|
|
227
|
+
right: SvgOrderOperation,
|
|
228
|
+
): boolean {
|
|
229
|
+
return (
|
|
230
|
+
left.entryId === right.entryId &&
|
|
231
|
+
left.actor === right.actor &&
|
|
232
|
+
left.id === right.id &&
|
|
233
|
+
left.present === right.present &&
|
|
234
|
+
left.rank.denominator === right.rank.denominator &&
|
|
235
|
+
left.rank.numerator === right.rank.numerator &&
|
|
236
|
+
left.value === right.value
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function historyMatches(
|
|
241
|
+
left: SvgHistoryOperation,
|
|
242
|
+
right: SvgHistoryOperation,
|
|
243
|
+
): boolean {
|
|
244
|
+
const rightTargets = new Set(right.targetOperationIds)
|
|
245
|
+
return (
|
|
246
|
+
left.actor === right.actor &&
|
|
247
|
+
left.id === right.id &&
|
|
248
|
+
left.mode === right.mode &&
|
|
249
|
+
left.targetOperationIds.length === right.targetOperationIds.length &&
|
|
250
|
+
left.targetOperationIds.every((target) => rightTargets.has(target))
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function jsonValuesMatch(left: unknown, right: unknown): boolean {
|
|
255
|
+
if (Object.is(left, right)) return true
|
|
256
|
+
if (typeof left !== `object` || left === null) return false
|
|
257
|
+
if (typeof right !== `object` || right === null) return false
|
|
258
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
259
|
+
if (!Array.isArray(left) || !Array.isArray(right)) return false
|
|
260
|
+
return (
|
|
261
|
+
left.length === right.length &&
|
|
262
|
+
left.every((value, index) => jsonValuesMatch(value, right[index]))
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
const leftRecord = left as Record<string, unknown>
|
|
266
|
+
const rightRecord = right as Record<string, unknown>
|
|
267
|
+
const leftKeys = Object.keys(leftRecord)
|
|
268
|
+
const rightKeys = Object.keys(rightRecord)
|
|
269
|
+
return (
|
|
270
|
+
leftKeys.length === rightKeys.length &&
|
|
271
|
+
leftKeys.every(
|
|
272
|
+
(key) =>
|
|
273
|
+
Object.hasOwn(rightRecord, key) &&
|
|
274
|
+
jsonValuesMatch(leftRecord[key], rightRecord[key]),
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function activeSvgOperations<
|
|
280
|
+
Operation extends { actor?: string | undefined; id: string },
|
|
281
|
+
>(
|
|
282
|
+
operations: Iterable<Operation>,
|
|
283
|
+
history: Iterable<SvgHistoryOperation>,
|
|
284
|
+
): readonly Operation[] {
|
|
285
|
+
const changes = [...operations]
|
|
286
|
+
const byId = new Map(changes.map((operation) => [operation.id, operation]))
|
|
287
|
+
const counts = new Map<string, { redo: number; undo: number }>()
|
|
288
|
+
for (const operation of history) {
|
|
289
|
+
for (const targetId of operation.targetOperationIds) {
|
|
290
|
+
const target = byId.get(targetId)
|
|
291
|
+
if (target?.actor !== operation.actor || target === undefined) {
|
|
292
|
+
throw new Error(`SVG history targeted an unknown or foreign operation`)
|
|
293
|
+
}
|
|
294
|
+
const count = counts.get(targetId) ?? { redo: 0, undo: 0 }
|
|
295
|
+
count[operation.mode]++
|
|
296
|
+
counts.set(targetId, count)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return changes.filter((operation) => {
|
|
300
|
+
const count = counts.get(operation.id) ?? { redo: 0, undo: 0 }
|
|
301
|
+
if (count.undo < count.redo || count.undo > count.redo + 1) {
|
|
302
|
+
throw new Error(`SVG history modes do not form an alternating cursor`)
|
|
303
|
+
}
|
|
304
|
+
return count.undo === count.redo
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Accumulate an idempotent order operation. Materialization is independent of
|
|
310
|
+
* delivery order, making this reducer suitable for MOS-11 member registration.
|
|
311
|
+
*/
|
|
312
|
+
export function reduceSvgOrder(
|
|
313
|
+
state: SvgOrderState,
|
|
314
|
+
operation: SvgOrderModelOperation,
|
|
315
|
+
): SvgOrderState {
|
|
316
|
+
const normalized = svgOrderModelOperationSchema.parse(operation)
|
|
317
|
+
if (`type` in normalized) {
|
|
318
|
+
const previous = state.history?.[normalized.id]
|
|
319
|
+
if (previous !== undefined) {
|
|
320
|
+
if (!historyMatches(previous, normalized)) {
|
|
321
|
+
throw new Error(`SVG order history ID collision: ${normalized.id}`)
|
|
322
|
+
}
|
|
323
|
+
return state
|
|
324
|
+
}
|
|
325
|
+
const next = {
|
|
326
|
+
history: { ...state.history, [normalized.id]: normalized },
|
|
327
|
+
operations: state.operations,
|
|
328
|
+
}
|
|
329
|
+
activeSvgOperations(
|
|
330
|
+
Object.values(next.operations),
|
|
331
|
+
Object.values(next.history),
|
|
332
|
+
)
|
|
333
|
+
return next
|
|
334
|
+
}
|
|
335
|
+
const previous = state.operations[normalized.id]
|
|
336
|
+
if (previous !== undefined) {
|
|
337
|
+
if (!operationsMatch(previous, normalized)) {
|
|
338
|
+
throw new Error(`SVG order operation ID collision: ${normalized.id}`)
|
|
339
|
+
}
|
|
340
|
+
return state
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
...(state.history === undefined ? {} : { history: state.history }),
|
|
344
|
+
operations: { ...state.operations, [normalized.id]: normalized },
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function latestSvgOrderOperations(
|
|
349
|
+
state: SvgOrderState,
|
|
350
|
+
): ReadonlyMap<string, SvgOrderOperation> {
|
|
351
|
+
const latest = new Map<string, SvgOrderOperation>()
|
|
352
|
+
for (const operation of activeSvgOperations(
|
|
353
|
+
Object.values(state.operations),
|
|
354
|
+
Object.values(state.history ?? {}),
|
|
355
|
+
)) {
|
|
356
|
+
const previous = latest.get(operation.entryId)
|
|
357
|
+
if (previous === undefined || compareSvgIds(previous.id, operation.id) < 0) {
|
|
358
|
+
latest.set(operation.entryId, operation)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return latest
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Materialize the current sequence without turning it into a parallel registry. */
|
|
365
|
+
export function materializeSvgOrder(
|
|
366
|
+
state: SvgOrderState,
|
|
367
|
+
): readonly SvgOrderedEntry[] {
|
|
368
|
+
return [...latestSvgOrderOperations(state).values()]
|
|
369
|
+
.filter(({ present }) => present)
|
|
370
|
+
.map(({ entryId, rank, value }) => ({ entryId, rank, value }))
|
|
371
|
+
.sort((left, right) => {
|
|
372
|
+
const byRank = compareSvgOrderRanks(left.rank, right.rank)
|
|
373
|
+
return byRank === 0 ? compareSvgIds(left.entryId, right.entryId) : byRank
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function placeSvgOrderEntry(
|
|
378
|
+
state: SvgOrderState,
|
|
379
|
+
options: {
|
|
380
|
+
readonly entryId: string
|
|
381
|
+
readonly id: string
|
|
382
|
+
readonly index: number
|
|
383
|
+
readonly value: string
|
|
384
|
+
},
|
|
385
|
+
): SvgOrderState {
|
|
386
|
+
const withoutEntry = materializeSvgOrder(state).filter(
|
|
387
|
+
({ entryId }) => entryId !== options.entryId,
|
|
388
|
+
)
|
|
389
|
+
const index = Math.max(0, Math.min(options.index, withoutEntry.length))
|
|
390
|
+
return reduceSvgOrder(state, {
|
|
391
|
+
entryId: options.entryId,
|
|
392
|
+
id: options.id,
|
|
393
|
+
present: true,
|
|
394
|
+
rank: rankSvgOrderBetween(
|
|
395
|
+
withoutEntry[index - 1]?.rank ?? null,
|
|
396
|
+
withoutEntry[index]?.rank ?? null,
|
|
397
|
+
),
|
|
398
|
+
value: options.value,
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function removeSvgOrderEntry(
|
|
403
|
+
state: SvgOrderState,
|
|
404
|
+
entryId: string,
|
|
405
|
+
id: string,
|
|
406
|
+
): SvgOrderState {
|
|
407
|
+
const current = latestSvgOrderOperations(state).get(entryId)
|
|
408
|
+
if (current === undefined) return state
|
|
409
|
+
return reduceSvgOrder(state, { ...current, id, present: false })
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* A deterministic last-operation-wins register with delivery-order-independent
|
|
414
|
+
* duplicate protection and model-owned actor-selective compensation.
|
|
415
|
+
*/
|
|
416
|
+
export function reduceSvgRegister<Value>(
|
|
417
|
+
state: SvgRegisterState<Value>,
|
|
418
|
+
operation: SvgRegisterModelOperation<Value>,
|
|
419
|
+
): SvgRegisterState<Value> {
|
|
420
|
+
if (`type` in operation) {
|
|
421
|
+
const normalized = svgHistoryOperationSchema.parse(operation)
|
|
422
|
+
const previous = state.history?.[normalized.id]
|
|
423
|
+
if (previous !== undefined) {
|
|
424
|
+
if (!historyMatches(previous, normalized)) {
|
|
425
|
+
throw new Error(`SVG register history ID collision: ${normalized.id}`)
|
|
426
|
+
}
|
|
427
|
+
return state
|
|
428
|
+
}
|
|
429
|
+
const next = {
|
|
430
|
+
history: { ...state.history, [normalized.id]: normalized },
|
|
431
|
+
operations: state.operations,
|
|
432
|
+
}
|
|
433
|
+
activeSvgOperations(
|
|
434
|
+
Object.values(next.operations),
|
|
435
|
+
Object.values(next.history),
|
|
436
|
+
)
|
|
437
|
+
return next
|
|
438
|
+
}
|
|
439
|
+
const previous = state.operations[operation.id]
|
|
440
|
+
if (previous === undefined) {
|
|
441
|
+
const next = structuredClone(operation)
|
|
442
|
+
return {
|
|
443
|
+
...(state.history === undefined ? {} : { history: state.history }),
|
|
444
|
+
operations: { ...state.operations, [next.id]: next },
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (
|
|
448
|
+
previous.actor !== operation.actor ||
|
|
449
|
+
previous.id !== operation.id ||
|
|
450
|
+
!jsonValuesMatch(previous.value, operation.value)
|
|
451
|
+
) {
|
|
452
|
+
throw new Error(`SVG register operation ID collision: ${operation.id}`)
|
|
453
|
+
}
|
|
454
|
+
return state
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function readSvgRegister<Value>(
|
|
458
|
+
state: SvgRegisterState<Value>,
|
|
459
|
+
): Value | undefined {
|
|
460
|
+
let latest: SvgRegisterOperation<Value> | undefined
|
|
461
|
+
for (const operation of activeSvgOperations(
|
|
462
|
+
Object.values(state.operations),
|
|
463
|
+
Object.values(state.history ?? {}),
|
|
464
|
+
)) {
|
|
465
|
+
if (latest === undefined || compareSvgIds(latest.id, operation.id) < 0) {
|
|
466
|
+
latest = operation
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return latest?.value
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function compactedHistory(
|
|
473
|
+
operations: Readonly<
|
|
474
|
+
Record<string, { readonly actor?: string | undefined; readonly id: string }>
|
|
475
|
+
>,
|
|
476
|
+
history: Readonly<Record<string, SvgHistoryOperation>> | undefined,
|
|
477
|
+
retainedOperationIds: ReadonlySet<string>,
|
|
478
|
+
throughRevision: number,
|
|
479
|
+
): Readonly<Record<string, SvgHistoryOperation>> | undefined {
|
|
480
|
+
const active = new Set(
|
|
481
|
+
activeSvgOperations(
|
|
482
|
+
Object.values(operations),
|
|
483
|
+
Object.values(history ?? {}),
|
|
484
|
+
).map(({ id }) => id),
|
|
485
|
+
)
|
|
486
|
+
const compacted: Record<string, SvgHistoryOperation> = {}
|
|
487
|
+
for (const targetId of [...retainedOperationIds].sort()) {
|
|
488
|
+
const target = operations[targetId]
|
|
489
|
+
if (target === undefined || active.has(targetId)) continue
|
|
490
|
+
if (target.actor === undefined) {
|
|
491
|
+
throw new Error(`SVG history cannot compact an unauthored operation`)
|
|
492
|
+
}
|
|
493
|
+
const id = `svg-history-cut:${throughRevision.toString()}:${targetId}`
|
|
494
|
+
compacted[id] = {
|
|
495
|
+
actor: target.actor,
|
|
496
|
+
id,
|
|
497
|
+
mode: `undo`,
|
|
498
|
+
targetOperationIds: [targetId],
|
|
499
|
+
type: `history`,
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return Object.keys(compacted).length === 0 ? undefined : compacted
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Fold retired register receipts while preserving visible and protected state. */
|
|
506
|
+
export function compactSvgRegisterHistory<Value>(
|
|
507
|
+
state: SvgRegisterState<Value>,
|
|
508
|
+
context: {
|
|
509
|
+
readonly retainedOperationIds: ReadonlySet<string>
|
|
510
|
+
readonly throughRevision: number
|
|
511
|
+
},
|
|
512
|
+
): SvgRegisterState<Value> {
|
|
513
|
+
const active = activeSvgOperations(
|
|
514
|
+
Object.values(state.operations),
|
|
515
|
+
Object.values(state.history ?? {}),
|
|
516
|
+
)
|
|
517
|
+
const latest = active.reduce<SvgRegisterOperation<Value> | undefined>(
|
|
518
|
+
(current, operation) =>
|
|
519
|
+
current === undefined || compareSvgIds(current.id, operation.id) < 0
|
|
520
|
+
? operation
|
|
521
|
+
: current,
|
|
522
|
+
undefined,
|
|
523
|
+
)
|
|
524
|
+
const keep = new Set(context.retainedOperationIds)
|
|
525
|
+
if (latest !== undefined) keep.add(latest.id)
|
|
526
|
+
const operations = Object.fromEntries(
|
|
527
|
+
Object.entries(state.operations).filter(([id]) => keep.has(id)),
|
|
528
|
+
)
|
|
529
|
+
const history = compactedHistory(
|
|
530
|
+
state.operations,
|
|
531
|
+
state.history,
|
|
532
|
+
context.retainedOperationIds,
|
|
533
|
+
context.throughRevision,
|
|
534
|
+
)
|
|
535
|
+
return {
|
|
536
|
+
...(history === undefined ? {} : { history }),
|
|
537
|
+
operations,
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Fold retired ordering receipts to one visible decision per resource. */
|
|
542
|
+
export function compactSvgOrderHistory(
|
|
543
|
+
state: SvgOrderState,
|
|
544
|
+
context: {
|
|
545
|
+
readonly retainedOperationIds: ReadonlySet<string>
|
|
546
|
+
readonly throughRevision: number
|
|
547
|
+
},
|
|
548
|
+
): SvgOrderState {
|
|
549
|
+
const keep = new Set(context.retainedOperationIds)
|
|
550
|
+
for (const operation of latestSvgOrderOperations(state).values()) {
|
|
551
|
+
keep.add(operation.id)
|
|
552
|
+
}
|
|
553
|
+
const operations = Object.fromEntries(
|
|
554
|
+
Object.entries(state.operations).filter(([id]) => keep.has(id)),
|
|
555
|
+
)
|
|
556
|
+
const history = compactedHistory(
|
|
557
|
+
state.operations,
|
|
558
|
+
state.history,
|
|
559
|
+
context.retainedOperationIds,
|
|
560
|
+
context.throughRevision,
|
|
561
|
+
)
|
|
562
|
+
return {
|
|
563
|
+
...(history === undefined ? {} : { history }),
|
|
564
|
+
operations,
|
|
565
|
+
}
|
|
566
|
+
}
|