@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.
@@ -0,0 +1,1102 @@
1
+ import type {
2
+ MosaicDomainHistoryCompactionContext,
3
+ MosaicDomainHistoryOperationClassification,
4
+ } from "@atom.io/realtime"
5
+ import type { TransactionToken } from "atom.io"
6
+ import {
7
+ atom,
8
+ atomFamily,
9
+ getState,
10
+ runTransaction,
11
+ selector,
12
+ selectorFamily,
13
+ setState,
14
+ transaction,
15
+ } from "atom.io"
16
+ import { z } from "zod"
17
+
18
+ import {
19
+ compactSvgOrderHistory,
20
+ compactSvgRegisterHistory,
21
+ EMPTY_SVG_ORDER,
22
+ emptySvgRegister,
23
+ materializeSvgOrder,
24
+ placeSvgOrderEntry,
25
+ readSvgRegister,
26
+ reduceSvgOrder,
27
+ reduceSvgRegister,
28
+ removeSvgOrderEntry,
29
+ type SvgHistoryOperation,
30
+ svgOrderModelOperationSchema,
31
+ type SvgOrderState,
32
+ svgOrderStateSchema,
33
+ svgRegisterModelOperationSchema,
34
+ type SvgRegisterState,
35
+ svgRegisterStateSchema,
36
+ } from "./svg-convergence.ts"
37
+
38
+ export type PointXY = { readonly x: number; readonly y: number }
39
+
40
+ export type SvgEdge =
41
+ | {
42
+ readonly c?: PointXY | undefined
43
+ readonly kind: `cubic`
44
+ readonly s: PointXY
45
+ }
46
+ | { readonly kind: `close` }
47
+ | { readonly kind: `line` | `move` }
48
+
49
+ export type SvgPath = { readonly id: string }
50
+ export type SvgSubpath = { readonly id: string; readonly pathId: string }
51
+
52
+ export type SvgSubpathFixture = {
53
+ readonly edge: SvgEdge
54
+ readonly id: string
55
+ readonly node: PointXY | null
56
+ }
57
+
58
+ export type SvgPathFixture = {
59
+ readonly id: string
60
+ readonly subpaths: readonly SvgSubpathFixture[]
61
+ }
62
+
63
+ export type SvgDrawingFixture = { readonly paths: readonly SvgPathFixture[] }
64
+
65
+ export type SvgGesture = {
66
+ readonly actor: string
67
+ readonly id: string
68
+ readonly logicalTime: number
69
+ readonly session: string
70
+ }
71
+
72
+ export type SvgDragTarget =
73
+ | {
74
+ readonly control: `c` | `s`
75
+ readonly kind: `edge-control`
76
+ readonly subpathId: string
77
+ }
78
+ | { readonly kind: `node`; readonly subpathId: string }
79
+
80
+ export type SvgDragPresence = {
81
+ readonly actor: string
82
+ readonly gestureId: string
83
+ readonly point: PointXY
84
+ readonly sequence: number
85
+ readonly session: string
86
+ readonly target: SvgDragTarget
87
+ }
88
+
89
+ export type SvgCollaborationPresence = {
90
+ readonly activePathId: string | null
91
+ readonly actor: string
92
+ readonly color: string
93
+ readonly name: string
94
+ readonly pointer: PointXY | null
95
+ readonly selectedSubpathId: string | null
96
+ readonly session: string
97
+ }
98
+
99
+ export type SvgActiveDrag = {
100
+ readonly gesture: SvgGesture
101
+ readonly pointerId: number
102
+ readonly target: SvgDragTarget
103
+ }
104
+
105
+ export type SvgWorkspace = {
106
+ readonly activePathId: string | null
107
+ readonly selectedSubpathIds: readonly string[]
108
+ }
109
+
110
+ export type SvgViewport = {
111
+ readonly pan: PointXY
112
+ readonly zoom: number
113
+ }
114
+
115
+ const pointSchema: z.ZodType<PointXY> = z
116
+ .object({ x: z.number().finite(), y: z.number().finite() })
117
+ .strict()
118
+
119
+ const edgeSchema: z.ZodType<SvgEdge> = z.discriminatedUnion(`kind`, [
120
+ z.object({ kind: z.literal(`close`) }).strict(),
121
+ z.object({ kind: z.literal(`line`) }).strict(),
122
+ z.object({ kind: z.literal(`move`) }).strict(),
123
+ z
124
+ .object({
125
+ c: pointSchema.optional(),
126
+ kind: z.literal(`cubic`),
127
+ s: pointSchema,
128
+ })
129
+ .strict(),
130
+ ])
131
+
132
+ const pathSchema: z.ZodType<SvgPath> = z
133
+ .object({ id: z.string().min(1) })
134
+ .strict()
135
+
136
+ const subpathSchema: z.ZodType<SvgSubpath> = z
137
+ .object({ id: z.string().min(1), pathId: z.string().min(1) })
138
+ .strict()
139
+
140
+ export const svgNodeStateSchema = svgRegisterStateSchema(pointSchema.nullable())
141
+ export const svgNodeOperationSchema = svgRegisterModelOperationSchema(
142
+ pointSchema.nullable(),
143
+ )
144
+ export const svgEdgeStateSchema = svgRegisterStateSchema(edgeSchema.nullable())
145
+ export const svgEdgeOperationSchema = svgRegisterModelOperationSchema(
146
+ edgeSchema.nullable(),
147
+ )
148
+ export const svgPathStateSchema = svgRegisterStateSchema(pathSchema.nullable())
149
+ export const svgPathOperationSchema = svgRegisterModelOperationSchema(
150
+ pathSchema.nullable(),
151
+ )
152
+ export const svgSubpathStateSchema = svgRegisterStateSchema(
153
+ subpathSchema.nullable(),
154
+ )
155
+ export const svgSubpathOperationSchema = svgRegisterModelOperationSchema(
156
+ subpathSchema.nullable(),
157
+ )
158
+
159
+ /** Durable members remain ordinary Atom.io atoms and atom families. */
160
+ export const pathOrderAtom = atom<SvgOrderState>({
161
+ key: `pathOrder`,
162
+ default: EMPTY_SVG_ORDER,
163
+ })
164
+ export const pathAtoms = atomFamily<SvgRegisterState<SvgPath | null>, string>({
165
+ key: `path`,
166
+ default: emptySvgRegister(),
167
+ })
168
+ export const subpathOrderAtoms = atomFamily<SvgOrderState, string>({
169
+ key: `subpathOrder`,
170
+ default: EMPTY_SVG_ORDER,
171
+ })
172
+ export const subpathAtoms = atomFamily<
173
+ SvgRegisterState<SvgSubpath | null>,
174
+ string
175
+ >({ key: `subpath`, default: emptySvgRegister() })
176
+ export const nodeAtoms = atomFamily<SvgRegisterState<PointXY | null>, string>({
177
+ key: `node`,
178
+ default: emptySvgRegister(),
179
+ })
180
+ export const edgeAtoms = atomFamily<SvgRegisterState<SvgEdge | null>, string>({
181
+ key: `edge`,
182
+ default: emptySvgRegister(),
183
+ })
184
+
185
+ /** Local-only state is deliberately absent from the durable model inventory. */
186
+ export const svgElementAtom = atom<SVGSVGElement | null>({
187
+ key: `svgElement`,
188
+ default: null,
189
+ })
190
+ export const pointerCaptureAtom = atom<{
191
+ readonly element: Element
192
+ readonly pointerId: number
193
+ } | null>({ key: `pointerCapture`, default: null })
194
+ export const activeDragAtom = atom<SvgActiveDrag | null>({
195
+ key: `activeDrag`,
196
+ default: null,
197
+ })
198
+ export const viewportAtom = atom<SvgViewport>({
199
+ key: `viewport`,
200
+ default: { pan: { x: 0, y: 0 }, zoom: 1 },
201
+ })
202
+ export const workspaceAtom = atom<SvgWorkspace>({
203
+ key: `workspace`,
204
+ default: { activePathId: null, selectedSubpathIds: [] },
205
+ })
206
+
207
+ /** Ephemeral presence is addressable by logical actor/session, never by a DOM node. */
208
+ export const dragPresenceAtoms = atomFamily<SvgDragPresence | null, string>({
209
+ key: `dragPresence`,
210
+ default: null,
211
+ })
212
+
213
+ /** Identity, focus, pointer, and selection are lossy collaboration presence. */
214
+ export const collaborationPresenceAtoms = atomFamily<
215
+ SvgCollaborationPresence | null,
216
+ string
217
+ >({ key: `collaborationPresence`, default: null })
218
+
219
+ /** Distinguish concurrent sessions belonging to the same logical actor. */
220
+ export function svgDragPresenceKey(
221
+ identity: Pick<SvgGesture, `actor` | `session`>,
222
+ ): string {
223
+ return `${identity.actor}\u0000${identity.session}`
224
+ }
225
+
226
+ export const svgCollaborationPresenceKey = svgDragPresenceKey
227
+
228
+ export const projectedNodeSelectors = selectorFamily<PointXY | null, string>({
229
+ key: `projectedNode`,
230
+ get:
231
+ (subpathId) =>
232
+ ({ get }) => {
233
+ const durable = readSvgRegister(get(nodeAtoms, subpathId)) ?? null
234
+ const drag = get(activeDragAtom)
235
+ if (drag?.target.kind !== `node` || drag.target.subpathId !== subpathId) {
236
+ return durable
237
+ }
238
+ const presence = get(dragPresenceAtoms, svgDragPresenceKey(drag.gesture))
239
+ return presence?.gestureId === drag.gesture.id ? presence.point : durable
240
+ },
241
+ })
242
+
243
+ export const projectedEdgeSelectors = selectorFamily<SvgEdge | null, string>({
244
+ key: `projectedEdge`,
245
+ get:
246
+ (subpathId) =>
247
+ ({ get }) => {
248
+ const durable = readSvgRegister(get(edgeAtoms, subpathId)) ?? null
249
+ const drag = get(activeDragAtom)
250
+ if (
251
+ durable?.kind !== `cubic` ||
252
+ drag?.target.kind !== `edge-control` ||
253
+ drag.target.subpathId !== subpathId
254
+ ) {
255
+ return durable
256
+ }
257
+ const presence = get(dragPresenceAtoms, svgDragPresenceKey(drag.gesture))
258
+ const point =
259
+ presence?.gestureId === drag.gesture.id ? presence.point : undefined
260
+ if (point === undefined) return durable
261
+ return drag.target.control === `c`
262
+ ? { ...durable, c: point }
263
+ : { ...durable, s: point }
264
+ },
265
+ })
266
+
267
+ export const pathDrawSelectors = selectorFamily<string, string>({
268
+ key: `pathDraw`,
269
+ get:
270
+ (pathId) =>
271
+ ({ get }) =>
272
+ materializeSvgOrder(get(subpathOrderAtoms, pathId))
273
+ .map(({ value: subpathId }, index) => {
274
+ const node = get(projectedNodeSelectors, subpathId)
275
+ const edge = get(projectedEdgeSelectors, subpathId)
276
+ if (edge?.kind === `close`) return `Z`
277
+ if (node === null || edge === null) return ``
278
+ if (index === 0 || edge.kind === `move`) {
279
+ return `M ${node.x} ${node.y}`
280
+ }
281
+ if (edge.kind === `line`) return `L ${node.x} ${node.y}`
282
+ if (edge.kind === `cubic` && edge.c !== undefined) {
283
+ return `C ${edge.c.x} ${edge.c.y} ${edge.s.x} ${edge.s.y} ${node.x} ${node.y}`
284
+ }
285
+ if (edge.kind === `cubic`) {
286
+ return `S ${edge.s.x} ${edge.s.y} ${node.x} ${node.y}`
287
+ }
288
+ return ``
289
+ })
290
+ .filter(Boolean)
291
+ .join(` `),
292
+ })
293
+
294
+ export type SvgStructureViolation = {
295
+ readonly code:
296
+ | `duplicate-path`
297
+ | `duplicate-subpath`
298
+ | `edge-node-mismatch`
299
+ | `missing-edge`
300
+ | `missing-node`
301
+ | `missing-path`
302
+ | `missing-subpath`
303
+ | `wrong-path`
304
+ readonly pathId: string
305
+ readonly subpathId?: string
306
+ }
307
+
308
+ function duplicateValues(values: readonly string[]): ReadonlySet<string> {
309
+ const seen = new Set<string>()
310
+ const duplicates = new Set<string>()
311
+ for (const value of values) {
312
+ if (seen.has(value)) duplicates.add(value)
313
+ seen.add(value)
314
+ }
315
+ return duplicates
316
+ }
317
+
318
+ /** Inspect only durable state, so an in-flight drag cannot hide corruption. */
319
+ export const structureViolationsSelector = selector<
320
+ readonly SvgStructureViolation[]
321
+ >({
322
+ key: `structureViolations`,
323
+ get: ({ get }) => {
324
+ const violations: SvgStructureViolation[] = []
325
+ const pathIds = materializeSvgOrder(get(pathOrderAtom)).map(
326
+ ({ value }) => value,
327
+ )
328
+ for (const pathId of duplicateValues(pathIds)) {
329
+ violations.push({ code: `duplicate-path`, pathId })
330
+ }
331
+ for (const pathId of pathIds) {
332
+ const path = readSvgRegister(get(pathAtoms, pathId))
333
+ if (path?.id !== pathId) {
334
+ violations.push({ code: `missing-path`, pathId })
335
+ continue
336
+ }
337
+ const subpathIds = materializeSvgOrder(get(subpathOrderAtoms, pathId)).map(
338
+ ({ value }) => value,
339
+ )
340
+ for (const subpathId of duplicateValues(subpathIds)) {
341
+ violations.push({ code: `duplicate-subpath`, pathId, subpathId })
342
+ }
343
+ for (const subpathId of subpathIds) {
344
+ const subpath = readSvgRegister(get(subpathAtoms, subpathId))
345
+ if (subpath?.id !== subpathId) {
346
+ violations.push({ code: `missing-subpath`, pathId, subpathId })
347
+ continue
348
+ }
349
+ if (subpath.pathId !== pathId) {
350
+ violations.push({ code: `wrong-path`, pathId, subpathId })
351
+ }
352
+ const nodeState = get(nodeAtoms, subpathId)
353
+ const edgeState = get(edgeAtoms, subpathId)
354
+ const node = readSvgRegister(nodeState)
355
+ const edge = readSvgRegister(edgeState)
356
+ if (Object.keys(nodeState.operations).length === 0) {
357
+ violations.push({ code: `missing-node`, pathId, subpathId })
358
+ }
359
+ if (edge === undefined || edge === null) {
360
+ violations.push({ code: `missing-edge`, pathId, subpathId })
361
+ } else if ((edge.kind === `close`) !== (node === null)) {
362
+ violations.push({ code: `edge-node-mismatch`, pathId, subpathId })
363
+ }
364
+ }
365
+ }
366
+ return violations
367
+ },
368
+ })
369
+
370
+ export function createSvgGestureClock(options: {
371
+ readonly actor: string
372
+ readonly initialLogicalTime?: number
373
+ readonly session: string
374
+ }): {
375
+ readonly begin: () => SvgGesture
376
+ readonly observe: (logicalTime: number) => void
377
+ } {
378
+ if (options.actor.length === 0 || options.session.length === 0) {
379
+ throw new Error(`SVG gesture actor and session identities must be nonempty`)
380
+ }
381
+ const initialLogicalTime = options.initialLogicalTime ?? 0
382
+ if (!Number.isSafeInteger(initialLogicalTime) || initialLogicalTime < 0) {
383
+ throw new Error(`Initial SVG logical time must be a nonnegative integer`)
384
+ }
385
+ let logicalTime = initialLogicalTime
386
+ return {
387
+ begin: () => {
388
+ logicalTime++
389
+ return {
390
+ actor: options.actor,
391
+ id: `${options.actor}/${options.session}/${logicalTime.toString().padStart(12, `0`)}`,
392
+ logicalTime,
393
+ session: options.session,
394
+ }
395
+ },
396
+ observe: (observed) => {
397
+ if (!Number.isSafeInteger(observed) || observed < 0) {
398
+ throw new Error(
399
+ `Observed SVG logical time must be a nonnegative integer`,
400
+ )
401
+ }
402
+ logicalTime = Math.max(logicalTime, observed)
403
+ },
404
+ }
405
+ }
406
+
407
+ export function svgOperationId(gesture: SvgGesture, ordinal: number): string {
408
+ if (!Number.isSafeInteger(gesture.logicalTime) || gesture.logicalTime < 0) {
409
+ throw new Error(`SVG gesture logical time must be a nonnegative integer`)
410
+ }
411
+ if (!Number.isSafeInteger(ordinal) || ordinal < 0) {
412
+ throw new Error(`SVG operation ordinal must be a nonnegative integer`)
413
+ }
414
+ return `${gesture.logicalTime.toString().padStart(16, `0`)}:${gesture.actor}:${gesture.id}:${ordinal.toString().padStart(8, `0`)}`
415
+ }
416
+
417
+ function assertFixture(fixture: SvgDrawingFixture): void {
418
+ const pathIds = fixture.paths.map(({ id }) => id)
419
+ if (duplicateValues(pathIds).size > 0) {
420
+ throw new Error(`An SVG drawing cannot contain duplicate path IDs`)
421
+ }
422
+ const allSubpathIds = new Set<string>()
423
+ for (const path of fixture.paths) {
424
+ if (path.id.length === 0) throw new Error(`An SVG path ID cannot be empty`)
425
+ for (const subpath of path.subpaths) {
426
+ if (subpath.id.length === 0 || allSubpathIds.has(subpath.id)) {
427
+ throw new Error(`SVG subpath IDs must be nonempty and globally unique`)
428
+ }
429
+ allSubpathIds.add(subpath.id)
430
+ if ((subpath.edge.kind === `close`) !== (subpath.node === null)) {
431
+ throw new Error(`Only a close edge may have a null SVG node`)
432
+ }
433
+ }
434
+ }
435
+ }
436
+
437
+ type GestureInput = { readonly gesture: SvgGesture }
438
+
439
+ export const replaceDrawingTransaction = transaction<
440
+ (input: GestureInput & { readonly drawing: SvgDrawingFixture }) => void
441
+ >({
442
+ key: `replaceDrawing`,
443
+ do: ({ get, set }, { drawing, gesture }) => {
444
+ assertFixture(drawing)
445
+ let ordinal = 0
446
+ const nextId = () => svgOperationId(gesture, ordinal++)
447
+ let nextPathOrder = get(pathOrderAtom)
448
+ for (const { value: pathId } of materializeSvgOrder(nextPathOrder)) {
449
+ let nextSubpathOrder = get(subpathOrderAtoms, pathId)
450
+ for (const { entryId, value: subpathId } of materializeSvgOrder(
451
+ nextSubpathOrder,
452
+ )) {
453
+ nextSubpathOrder = removeSvgOrderEntry(
454
+ nextSubpathOrder,
455
+ entryId,
456
+ nextId(),
457
+ )
458
+ set(
459
+ subpathAtoms,
460
+ subpathId,
461
+ reduceSvgRegister(get(subpathAtoms, subpathId), {
462
+ id: nextId(),
463
+ value: null,
464
+ }),
465
+ )
466
+ set(
467
+ nodeAtoms,
468
+ subpathId,
469
+ reduceSvgRegister(get(nodeAtoms, subpathId), {
470
+ id: nextId(),
471
+ value: null,
472
+ }),
473
+ )
474
+ set(
475
+ edgeAtoms,
476
+ subpathId,
477
+ reduceSvgRegister(get(edgeAtoms, subpathId), {
478
+ id: nextId(),
479
+ value: null,
480
+ }),
481
+ )
482
+ }
483
+ set(subpathOrderAtoms, pathId, nextSubpathOrder)
484
+ set(
485
+ pathAtoms,
486
+ pathId,
487
+ reduceSvgRegister(get(pathAtoms, pathId), {
488
+ id: nextId(),
489
+ value: null,
490
+ }),
491
+ )
492
+ nextPathOrder = removeSvgOrderEntry(nextPathOrder, pathId, nextId())
493
+ }
494
+
495
+ for (const [pathIndex, path] of drawing.paths.entries()) {
496
+ set(
497
+ pathAtoms,
498
+ path.id,
499
+ reduceSvgRegister(get(pathAtoms, path.id), {
500
+ id: nextId(),
501
+ value: { id: path.id },
502
+ }),
503
+ )
504
+ nextPathOrder = placeSvgOrderEntry(nextPathOrder, {
505
+ entryId: path.id,
506
+ id: nextId(),
507
+ index: pathIndex,
508
+ value: path.id,
509
+ })
510
+ let nextSubpathOrder = get(subpathOrderAtoms, path.id)
511
+ for (const [subpathIndex, subpath] of path.subpaths.entries()) {
512
+ set(
513
+ subpathAtoms,
514
+ subpath.id,
515
+ reduceSvgRegister(get(subpathAtoms, subpath.id), {
516
+ id: nextId(),
517
+ value: { id: subpath.id, pathId: path.id },
518
+ }),
519
+ )
520
+ set(
521
+ nodeAtoms,
522
+ subpath.id,
523
+ reduceSvgRegister(get(nodeAtoms, subpath.id), {
524
+ id: nextId(),
525
+ value: subpath.node,
526
+ }),
527
+ )
528
+ set(
529
+ edgeAtoms,
530
+ subpath.id,
531
+ reduceSvgRegister(get(edgeAtoms, subpath.id), {
532
+ id: nextId(),
533
+ value: subpath.edge,
534
+ }),
535
+ )
536
+ nextSubpathOrder = placeSvgOrderEntry(nextSubpathOrder, {
537
+ entryId: subpath.id,
538
+ id: nextId(),
539
+ index: subpathIndex,
540
+ value: subpath.id,
541
+ })
542
+ }
543
+ set(subpathOrderAtoms, path.id, nextSubpathOrder)
544
+ }
545
+ set(pathOrderAtom, nextPathOrder)
546
+ },
547
+ })
548
+
549
+ export const insertPathTransaction = transaction<
550
+ (
551
+ input: GestureInput & {
552
+ readonly index: number
553
+ readonly pathId: string
554
+ },
555
+ ) => void
556
+ >({
557
+ key: `insertPath`,
558
+ do: ({ get, set }, { gesture, index, pathId }) => {
559
+ if (readSvgRegister(get(pathAtoms, pathId)) != null) {
560
+ throw new Error(`SVG path ${pathId} already exists`)
561
+ }
562
+ set(
563
+ pathAtoms,
564
+ pathId,
565
+ reduceSvgRegister(get(pathAtoms, pathId), {
566
+ id: svgOperationId(gesture, 0),
567
+ value: { id: pathId },
568
+ }),
569
+ )
570
+ set(
571
+ pathOrderAtom,
572
+ placeSvgOrderEntry(get(pathOrderAtom), {
573
+ entryId: pathId,
574
+ id: svgOperationId(gesture, 1),
575
+ index,
576
+ value: pathId,
577
+ }),
578
+ )
579
+ },
580
+ })
581
+
582
+ export const deletePathTransaction = transaction<
583
+ (input: GestureInput & { readonly pathId: string }) => void
584
+ >({
585
+ key: `deletePath`,
586
+ do: ({ get, set }, { gesture, pathId }) => {
587
+ if (readSvgRegister(get(pathAtoms, pathId)) == null) {
588
+ throw new Error(`SVG path ${pathId} does not exist`)
589
+ }
590
+ let ordinal = 0
591
+ const nextId = () => svgOperationId(gesture, ordinal++)
592
+ let order = get(subpathOrderAtoms, pathId)
593
+ for (const { entryId, value: subpathId } of materializeSvgOrder(order)) {
594
+ order = removeSvgOrderEntry(order, entryId, nextId())
595
+ set(
596
+ subpathAtoms,
597
+ subpathId,
598
+ reduceSvgRegister(get(subpathAtoms, subpathId), {
599
+ id: nextId(),
600
+ value: null,
601
+ }),
602
+ )
603
+ set(
604
+ nodeAtoms,
605
+ subpathId,
606
+ reduceSvgRegister(get(nodeAtoms, subpathId), {
607
+ id: nextId(),
608
+ value: null,
609
+ }),
610
+ )
611
+ set(
612
+ edgeAtoms,
613
+ subpathId,
614
+ reduceSvgRegister(get(edgeAtoms, subpathId), {
615
+ id: nextId(),
616
+ value: null,
617
+ }),
618
+ )
619
+ }
620
+ set(subpathOrderAtoms, pathId, order)
621
+ set(
622
+ pathAtoms,
623
+ pathId,
624
+ reduceSvgRegister(get(pathAtoms, pathId), {
625
+ id: nextId(),
626
+ value: null,
627
+ }),
628
+ )
629
+ set(pathOrderAtom, removeSvgOrderEntry(get(pathOrderAtom), pathId, nextId()))
630
+ },
631
+ })
632
+
633
+ type SubpathInput = GestureInput & {
634
+ readonly edge: SvgEdge
635
+ readonly index: number
636
+ readonly node: PointXY | null
637
+ readonly pathId: string
638
+ readonly subpathId: string
639
+ }
640
+
641
+ export const insertSubpathTransaction = transaction<
642
+ (input: SubpathInput) => void
643
+ >({
644
+ key: `insertSubpath`,
645
+ do: ({ get, set }, input) => {
646
+ if (readSvgRegister(get(pathAtoms, input.pathId)) == null) {
647
+ throw new Error(`SVG path ${input.pathId} does not exist`)
648
+ }
649
+ if (readSvgRegister(get(subpathAtoms, input.subpathId)) != null) {
650
+ throw new Error(`SVG subpath ${input.subpathId} already exists`)
651
+ }
652
+ if ((input.edge.kind === `close`) !== (input.node === null)) {
653
+ throw new Error(`Only a close edge may have a null SVG node`)
654
+ }
655
+ set(
656
+ subpathAtoms,
657
+ input.subpathId,
658
+ reduceSvgRegister(get(subpathAtoms, input.subpathId), {
659
+ id: svgOperationId(input.gesture, 0),
660
+ value: { id: input.subpathId, pathId: input.pathId },
661
+ }),
662
+ )
663
+ set(
664
+ nodeAtoms,
665
+ input.subpathId,
666
+ reduceSvgRegister(get(nodeAtoms, input.subpathId), {
667
+ id: svgOperationId(input.gesture, 1),
668
+ value: input.node,
669
+ }),
670
+ )
671
+ set(
672
+ edgeAtoms,
673
+ input.subpathId,
674
+ reduceSvgRegister(get(edgeAtoms, input.subpathId), {
675
+ id: svgOperationId(input.gesture, 2),
676
+ value: input.edge,
677
+ }),
678
+ )
679
+ set(
680
+ subpathOrderAtoms,
681
+ input.pathId,
682
+ placeSvgOrderEntry(get(subpathOrderAtoms, input.pathId), {
683
+ entryId: input.subpathId,
684
+ id: svgOperationId(input.gesture, 3),
685
+ index: input.index,
686
+ value: input.subpathId,
687
+ }),
688
+ )
689
+ },
690
+ })
691
+
692
+ export const deleteSubpathTransaction = transaction<
693
+ (
694
+ input: GestureInput & {
695
+ readonly pathId: string
696
+ readonly subpathId: string
697
+ },
698
+ ) => void
699
+ >({
700
+ key: `deleteSubpath`,
701
+ do: ({ get, set }, input) => {
702
+ const subpath = readSvgRegister(get(subpathAtoms, input.subpathId))
703
+ if (subpath?.pathId !== input.pathId) {
704
+ throw new Error(`SVG subpath ${input.subpathId} is not in ${input.pathId}`)
705
+ }
706
+ set(
707
+ subpathOrderAtoms,
708
+ input.pathId,
709
+ removeSvgOrderEntry(
710
+ get(subpathOrderAtoms, input.pathId),
711
+ input.subpathId,
712
+ svgOperationId(input.gesture, 0),
713
+ ),
714
+ )
715
+ set(
716
+ subpathAtoms,
717
+ input.subpathId,
718
+ reduceSvgRegister(get(subpathAtoms, input.subpathId), {
719
+ id: svgOperationId(input.gesture, 1),
720
+ value: null,
721
+ }),
722
+ )
723
+ set(
724
+ nodeAtoms,
725
+ input.subpathId,
726
+ reduceSvgRegister(get(nodeAtoms, input.subpathId), {
727
+ id: svgOperationId(input.gesture, 2),
728
+ value: null,
729
+ }),
730
+ )
731
+ set(
732
+ edgeAtoms,
733
+ input.subpathId,
734
+ reduceSvgRegister(get(edgeAtoms, input.subpathId), {
735
+ id: svgOperationId(input.gesture, 3),
736
+ value: null,
737
+ }),
738
+ )
739
+ },
740
+ })
741
+
742
+ /**
743
+ * Split geometry is supplied explicitly: model code does not guess Bezier math.
744
+ * The new edge and the continuation edge settle in the same transaction.
745
+ */
746
+ export const splitSubpathTransaction = transaction<
747
+ (
748
+ input: GestureInput & {
749
+ readonly continuationEdge: SvgEdge
750
+ readonly inserted: Omit<SubpathInput, `gesture` | `index` | `pathId`>
751
+ readonly pathId: string
752
+ readonly targetSubpathId: string
753
+ },
754
+ ) => void
755
+ >({
756
+ key: `splitSubpath`,
757
+ do: ({ get, set }, input) => {
758
+ const order = materializeSvgOrder(get(subpathOrderAtoms, input.pathId))
759
+ const targetIndex = order.findIndex(
760
+ ({ value }) => value === input.targetSubpathId,
761
+ )
762
+ if (targetIndex === -1) throw new Error(`Cannot split a missing SVG subpath`)
763
+ if (readSvgRegister(get(subpathAtoms, input.inserted.subpathId)) != null) {
764
+ throw new Error(`The inserted SVG subpath already exists`)
765
+ }
766
+ if (
767
+ (input.inserted.edge.kind === `close`) !==
768
+ (input.inserted.node === null)
769
+ ) {
770
+ throw new Error(`Only a close edge may have a null SVG node`)
771
+ }
772
+ const targetNodeState = get(nodeAtoms, input.targetSubpathId)
773
+ if (Object.keys(targetNodeState.operations).length === 0) {
774
+ throw new Error(`Cannot split a missing SVG node`)
775
+ }
776
+ const targetNode = readSvgRegister(targetNodeState)
777
+ if ((input.continuationEdge.kind === `close`) !== (targetNode === null)) {
778
+ throw new Error(`Only a close edge may have a null SVG node`)
779
+ }
780
+ set(
781
+ subpathAtoms,
782
+ input.inserted.subpathId,
783
+ reduceSvgRegister(get(subpathAtoms, input.inserted.subpathId), {
784
+ id: svgOperationId(input.gesture, 0),
785
+ value: { id: input.inserted.subpathId, pathId: input.pathId },
786
+ }),
787
+ )
788
+ set(
789
+ nodeAtoms,
790
+ input.inserted.subpathId,
791
+ reduceSvgRegister(get(nodeAtoms, input.inserted.subpathId), {
792
+ id: svgOperationId(input.gesture, 1),
793
+ value: input.inserted.node,
794
+ }),
795
+ )
796
+ set(
797
+ edgeAtoms,
798
+ input.inserted.subpathId,
799
+ reduceSvgRegister(get(edgeAtoms, input.inserted.subpathId), {
800
+ id: svgOperationId(input.gesture, 2),
801
+ value: input.inserted.edge,
802
+ }),
803
+ )
804
+ set(
805
+ edgeAtoms,
806
+ input.targetSubpathId,
807
+ reduceSvgRegister(get(edgeAtoms, input.targetSubpathId), {
808
+ id: svgOperationId(input.gesture, 3),
809
+ value: input.continuationEdge,
810
+ }),
811
+ )
812
+ set(
813
+ subpathOrderAtoms,
814
+ input.pathId,
815
+ placeSvgOrderEntry(get(subpathOrderAtoms, input.pathId), {
816
+ entryId: input.inserted.subpathId,
817
+ id: svgOperationId(input.gesture, 4),
818
+ index: targetIndex,
819
+ value: input.inserted.subpathId,
820
+ }),
821
+ )
822
+ },
823
+ })
824
+
825
+ export const reorderPathTransaction = transaction<
826
+ (
827
+ input: GestureInput & { readonly index: number; readonly pathId: string },
828
+ ) => void
829
+ >({
830
+ key: `reorderPath`,
831
+ do: ({ get, set }, input) => {
832
+ if (readSvgRegister(get(pathAtoms, input.pathId)) == null) {
833
+ throw new Error(`Cannot reorder a missing SVG path`)
834
+ }
835
+ set(
836
+ pathOrderAtom,
837
+ placeSvgOrderEntry(get(pathOrderAtom), {
838
+ entryId: input.pathId,
839
+ id: svgOperationId(input.gesture, 0),
840
+ index: input.index,
841
+ value: input.pathId,
842
+ }),
843
+ )
844
+ },
845
+ })
846
+
847
+ export const reorderSubpathTransaction = transaction<
848
+ (
849
+ input: GestureInput & {
850
+ readonly index: number
851
+ readonly pathId: string
852
+ readonly subpathId: string
853
+ },
854
+ ) => void
855
+ >({
856
+ key: `reorderSubpath`,
857
+ do: ({ get, set }, input) => {
858
+ const subpath = readSvgRegister(get(subpathAtoms, input.subpathId))
859
+ if (subpath?.pathId !== input.pathId) {
860
+ throw new Error(`Cannot reorder a missing SVG subpath`)
861
+ }
862
+ set(
863
+ subpathOrderAtoms,
864
+ input.pathId,
865
+ placeSvgOrderEntry(get(subpathOrderAtoms, input.pathId), {
866
+ entryId: input.subpathId,
867
+ id: svgOperationId(input.gesture, 0),
868
+ index: input.index,
869
+ value: input.subpathId,
870
+ }),
871
+ )
872
+ },
873
+ })
874
+
875
+ export const commitGeometryTransaction = transaction<
876
+ (
877
+ input: GestureInput & {
878
+ readonly point: PointXY
879
+ readonly target: SvgDragTarget
880
+ },
881
+ ) => void
882
+ >({
883
+ key: `commitGeometry`,
884
+ do: ({ get, set }, input) => {
885
+ if (input.target.kind === `node`) {
886
+ if (readSvgRegister(get(nodeAtoms, input.target.subpathId)) == null) {
887
+ throw new Error(`Cannot move a missing or closing SVG node`)
888
+ }
889
+ set(
890
+ nodeAtoms,
891
+ input.target.subpathId,
892
+ reduceSvgRegister(get(nodeAtoms, input.target.subpathId), {
893
+ id: svgOperationId(input.gesture, 0),
894
+ value: input.point,
895
+ }),
896
+ )
897
+ return
898
+ }
899
+ const edge = readSvgRegister(get(edgeAtoms, input.target.subpathId))
900
+ if (edge?.kind !== `cubic`) {
901
+ throw new Error(`Cannot move a control on a non-cubic SVG edge`)
902
+ }
903
+ set(
904
+ edgeAtoms,
905
+ input.target.subpathId,
906
+ reduceSvgRegister(get(edgeAtoms, input.target.subpathId), {
907
+ id: svgOperationId(input.gesture, 0),
908
+ value:
909
+ input.target.control === `c`
910
+ ? { ...edge, c: input.point }
911
+ : { ...edge, s: input.point },
912
+ }),
913
+ )
914
+ },
915
+ })
916
+
917
+ function runGestureTransaction<Input extends GestureInput>(
918
+ token: TransactionToken<(input: Input) => void>,
919
+ input: Input,
920
+ ): void {
921
+ runTransaction(token, input.gesture.id)(input)
922
+ }
923
+
924
+ export function replaceSvgDrawing(
925
+ drawing: SvgDrawingFixture,
926
+ gesture: SvgGesture,
927
+ ): void {
928
+ runGestureTransaction(replaceDrawingTransaction, { drawing, gesture })
929
+ }
930
+
931
+ export function beginSvgDrag(options: {
932
+ readonly element: Element
933
+ readonly gesture: SvgGesture
934
+ readonly point: PointXY
935
+ readonly pointerId: number
936
+ readonly target: SvgDragTarget
937
+ }): void {
938
+ const presenceKey = svgDragPresenceKey(options.gesture)
939
+ setState(activeDragAtom, {
940
+ gesture: options.gesture,
941
+ pointerId: options.pointerId,
942
+ target: options.target,
943
+ })
944
+ setState(pointerCaptureAtom, {
945
+ element: options.element,
946
+ pointerId: options.pointerId,
947
+ })
948
+ setState(dragPresenceAtoms, presenceKey, {
949
+ actor: options.gesture.actor,
950
+ gestureId: options.gesture.id,
951
+ point: options.point,
952
+ sequence: 0,
953
+ session: options.gesture.session,
954
+ target: options.target,
955
+ })
956
+ }
957
+
958
+ export function previewSvgDrag(point: PointXY): void {
959
+ const active = getState(activeDragAtom)
960
+ if (active === null) return
961
+ setState(
962
+ dragPresenceAtoms,
963
+ svgDragPresenceKey(active.gesture),
964
+ (previous) => ({
965
+ actor: active.gesture.actor,
966
+ gestureId: active.gesture.id,
967
+ point,
968
+ sequence: (previous?.sequence ?? -1) + 1,
969
+ session: active.gesture.session,
970
+ target: active.target,
971
+ }),
972
+ )
973
+ }
974
+
975
+ /** End one pointer gesture with at most one durable geometry transaction. */
976
+ export function finishSvgDrag(options: { readonly commit: boolean }): void {
977
+ const active = getState(activeDragAtom)
978
+ if (active === null) return
979
+ const presenceKey = svgDragPresenceKey(active.gesture)
980
+ const presence = getState(dragPresenceAtoms, presenceKey)
981
+ try {
982
+ if (
983
+ options.commit &&
984
+ presence !== null &&
985
+ presence.gestureId === active.gesture.id
986
+ ) {
987
+ runGestureTransaction(commitGeometryTransaction, {
988
+ gesture: active.gesture,
989
+ point: presence.point,
990
+ target: active.target,
991
+ })
992
+ }
993
+ } finally {
994
+ setState(dragPresenceAtoms, presenceKey, null)
995
+ setState(activeDragAtom, null)
996
+ setState(pointerCaptureAtom, null)
997
+ }
998
+ }
999
+
1000
+ /**
1001
+ * Explicit integration seam: schemas/reducers are public application modules,
1002
+ * while ownership, batching, transport, and history remain MOS-11 concerns.
1003
+ */
1004
+ export const svgHistoryPolicy = <State>(
1005
+ compact: (
1006
+ state: State,
1007
+ context: MosaicDomainHistoryCompactionContext,
1008
+ ) => State,
1009
+ ): {
1010
+ classify: (operation: {
1011
+ readonly mode?: `redo` | `undo`
1012
+ readonly targetOperationIds?: readonly string[]
1013
+ readonly type?: string
1014
+ }) => MosaicDomainHistoryOperationClassification
1015
+ compact: typeof compact
1016
+ compensate: (context: {
1017
+ readonly actor: string
1018
+ readonly mode: `redo` | `undo`
1019
+ readonly requestId: string
1020
+ readonly targets: readonly { readonly id: string }[]
1021
+ }) => SvgHistoryOperation
1022
+ } => ({
1023
+ classify(operation: {
1024
+ readonly mode?: `redo` | `undo`
1025
+ readonly targetOperationIds?: readonly string[]
1026
+ readonly type?: string
1027
+ }) {
1028
+ if (operation.type !== `history`) return { kind: `change` as const }
1029
+ if (
1030
+ (operation.mode !== `redo` && operation.mode !== `undo`) ||
1031
+ !Array.isArray(operation.targetOperationIds) ||
1032
+ operation.targetOperationIds.some(
1033
+ (id) => typeof id !== `string` || id.length === 0,
1034
+ )
1035
+ ) {
1036
+ throw new Error(`Invalid SVG history compensation operation.`)
1037
+ }
1038
+ return {
1039
+ kind: `compensation` as const,
1040
+ mode: operation.mode,
1041
+ targetOperationIds: operation.targetOperationIds,
1042
+ }
1043
+ },
1044
+ compact,
1045
+ compensate(context: {
1046
+ readonly actor: string
1047
+ readonly mode: `redo` | `undo`
1048
+ readonly requestId: string
1049
+ readonly targets: readonly { readonly id: string }[]
1050
+ }): SvgHistoryOperation {
1051
+ return {
1052
+ actor: context.actor,
1053
+ id: `svg-history:${context.requestId}`,
1054
+ mode: context.mode,
1055
+ targetOperationIds: context.targets.map(({ id }) => id),
1056
+ type: `history`,
1057
+ }
1058
+ },
1059
+ })
1060
+
1061
+ export const SVG_MEMBER_MODEL_SEAMS = {
1062
+ edge: {
1063
+ history: svgHistoryPolicy(compactSvgRegisterHistory<SvgEdge | null>),
1064
+ identity: { key: `svg/edge-register`, version: 2 },
1065
+ kind: `value`,
1066
+ operationSchema: svgEdgeOperationSchema,
1067
+ reduce: reduceSvgRegister<SvgEdge | null>,
1068
+ stateSchema: svgEdgeStateSchema,
1069
+ },
1070
+ node: {
1071
+ history: svgHistoryPolicy(compactSvgRegisterHistory<PointXY | null>),
1072
+ identity: { key: `svg/node-register`, version: 2 },
1073
+ kind: `value`,
1074
+ operationSchema: svgNodeOperationSchema,
1075
+ reduce: reduceSvgRegister<PointXY | null>,
1076
+ stateSchema: svgNodeStateSchema,
1077
+ },
1078
+ order: {
1079
+ history: svgHistoryPolicy(compactSvgOrderHistory),
1080
+ identity: { key: `svg/order`, version: 2 },
1081
+ kind: `value`,
1082
+ operationSchema: svgOrderModelOperationSchema,
1083
+ reduce: reduceSvgOrder,
1084
+ stateSchema: svgOrderStateSchema,
1085
+ },
1086
+ path: {
1087
+ history: svgHistoryPolicy(compactSvgRegisterHistory<SvgPath | null>),
1088
+ identity: { key: `svg/path-register`, version: 2 },
1089
+ kind: `value`,
1090
+ operationSchema: svgPathOperationSchema,
1091
+ reduce: reduceSvgRegister<SvgPath | null>,
1092
+ stateSchema: svgPathStateSchema,
1093
+ },
1094
+ subpath: {
1095
+ history: svgHistoryPolicy(compactSvgRegisterHistory<SvgSubpath | null>),
1096
+ identity: { key: `svg/subpath-register`, version: 2 },
1097
+ kind: `value`,
1098
+ operationSchema: svgSubpathOperationSchema,
1099
+ reduce: reduceSvgRegister<SvgSubpath | null>,
1100
+ stateSchema: svgSubpathStateSchema,
1101
+ },
1102
+ } as const