@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.
@@ -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
+ }