@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,939 @@
1
+ import {
2
+ mosaicDomain,
3
+ type MosaicDomainHistoryRequestResult,
4
+ type MosaicDomainMemberAddress,
5
+ type MosaicDomainMemberHistoryPolicy,
6
+ type MosaicDomainValueModel,
7
+ } from "@atom.io/realtime"
8
+ import type {
9
+ MosaicDomainBatchClient,
10
+ MosaicDomainPresenceClient,
11
+ } from "@atom.io/realtime/client"
12
+ import type { Silo } from "atom.io"
13
+ import { getState, setState } from "atom.io"
14
+ import type { Json } from "atom.io/foundations/json"
15
+ import { z } from "zod"
16
+
17
+ import {
18
+ materializeSvgOrder,
19
+ placeSvgOrderEntry,
20
+ readSvgRegister,
21
+ removeSvgOrderEntry,
22
+ type SvgHistoryOperation,
23
+ type SvgOrderOperation,
24
+ type SvgOrderState,
25
+ type SvgRegisterOperation,
26
+ } from "./svg-convergence.ts"
27
+ import {
28
+ activeDragAtom,
29
+ collaborationPresenceAtoms,
30
+ dragPresenceAtoms,
31
+ edgeAtoms,
32
+ nodeAtoms,
33
+ pathAtoms,
34
+ pathDrawSelectors,
35
+ pathOrderAtom,
36
+ pointerCaptureAtom,
37
+ type PointXY,
38
+ projectedEdgeSelectors,
39
+ projectedNodeSelectors,
40
+ structureViolationsSelector,
41
+ subpathAtoms,
42
+ subpathOrderAtoms,
43
+ SVG_MEMBER_MODEL_SEAMS,
44
+ type SvgCollaborationPresence,
45
+ type SvgDragPresence,
46
+ svgDragPresenceKey,
47
+ type SvgDragTarget,
48
+ type SvgDrawingFixture,
49
+ type SvgEdge,
50
+ svgElementAtom,
51
+ type SvgGesture,
52
+ svgOperationId,
53
+ type SvgSubpath,
54
+ viewportAtom,
55
+ workspaceAtom,
56
+ } from "./svg-editor-state.ts"
57
+
58
+ const keySchema = z.string().min(1)
59
+ const pointSchema = z
60
+ .object({ x: z.number().finite(), y: z.number().finite() })
61
+ .strict()
62
+ const dragTargetSchema = z.discriminatedUnion(`kind`, [
63
+ z.object({ kind: z.literal(`node`), subpathId: keySchema }).strict(),
64
+ z
65
+ .object({
66
+ control: z.enum([`c`, `s`]),
67
+ kind: z.literal(`edge-control`),
68
+ subpathId: keySchema,
69
+ })
70
+ .strict(),
71
+ ])
72
+ export const svgDragPresenceSchema: z.ZodType<SvgDragPresence> = z
73
+ .object({
74
+ actor: keySchema,
75
+ gestureId: keySchema,
76
+ point: pointSchema,
77
+ sequence: z.number().int().nonnegative(),
78
+ session: keySchema,
79
+ target: dragTargetSchema,
80
+ })
81
+ .strict()
82
+
83
+ export const svgCollaborationPresenceSchema: z.ZodType<SvgCollaborationPresence> =
84
+ z
85
+ .object({
86
+ activePathId: keySchema.nullable(),
87
+ actor: keySchema,
88
+ color: keySchema.max(64),
89
+ name: keySchema.max(128),
90
+ pointer: pointSchema.nullable(),
91
+ selectedSubpathId: keySchema.nullable(),
92
+ session: keySchema,
93
+ })
94
+ .strict()
95
+
96
+ type SvgReceiptState = {
97
+ readonly operations: Readonly<
98
+ Record<string, { readonly actor?: string | undefined }>
99
+ >
100
+ }
101
+
102
+ type AuthoredSvgOperation = {
103
+ readonly actor?: string | undefined
104
+ readonly targetOperationIds?: readonly string[] | undefined
105
+ readonly type?: string | undefined
106
+ }
107
+
108
+ const valueModel = <
109
+ Value extends SvgReceiptState,
110
+ Operation extends AuthoredSvgOperation,
111
+ >(seam: {
112
+ readonly identity: { readonly key: string; readonly version: number }
113
+ readonly history: object
114
+ readonly kind: `value`
115
+ readonly operationSchema: z.ZodType<Operation>
116
+ readonly reduce: (value: Value, operation: Operation) => Value
117
+ }): MosaicDomainValueModel<
118
+ Json.Serializable & Value,
119
+ Json.Serializable & Operation
120
+ > => ({
121
+ history: seam.history as MosaicDomainMemberHistoryPolicy<
122
+ Json.Serializable & Value,
123
+ Json.Serializable & Operation
124
+ >,
125
+ identity: seam.identity,
126
+ kind: `value`,
127
+ operationSchema: seam.operationSchema as z.ZodType<
128
+ Json.Serializable & Operation
129
+ >,
130
+ reduce: (value, operation, context) => {
131
+ if (operation.actor !== context.actor) {
132
+ throw new Error(
133
+ `An SVG member operation must name its authenticated actor.`,
134
+ )
135
+ }
136
+ if (operation.targetOperationIds !== undefined) {
137
+ for (const target of operation.targetOperationIds) {
138
+ if (value.operations[target]?.actor !== context.actor) {
139
+ throw new Error(
140
+ `An SVG compensation can target only the authenticated actor's operations.`,
141
+ )
142
+ }
143
+ }
144
+ }
145
+ return seam.reduce(value, operation) as Json.Serializable & Value
146
+ },
147
+ })
148
+
149
+ /** Public Domain inventory; render and workspace state remain ordinary Atom.io tokens. */
150
+ export const svgDesignDomain = mosaicDomain({
151
+ configSchema: z.object({}).strict(),
152
+ key: `svg-design`,
153
+ members: {
154
+ activeDrag: { role: `local`, token: activeDragAtom },
155
+ collaborator: {
156
+ keySchema,
157
+ role: `ephemeral`,
158
+ schema: svgCollaborationPresenceSchema,
159
+ token: collaborationPresenceAtoms,
160
+ },
161
+ dragPresence: {
162
+ keySchema,
163
+ role: `ephemeral`,
164
+ schema: svgDragPresenceSchema,
165
+ token: dragPresenceAtoms,
166
+ },
167
+ edges: {
168
+ keySchema,
169
+ model: valueModel(SVG_MEMBER_MODEL_SEAMS.edge),
170
+ role: `durable`,
171
+ schema: SVG_MEMBER_MODEL_SEAMS.edge.stateSchema,
172
+ token: edgeAtoms,
173
+ },
174
+ nodes: {
175
+ keySchema,
176
+ model: valueModel(SVG_MEMBER_MODEL_SEAMS.node),
177
+ role: `durable`,
178
+ schema: SVG_MEMBER_MODEL_SEAMS.node.stateSchema,
179
+ token: nodeAtoms,
180
+ },
181
+ pathDrawing: { keySchema, role: `derived`, token: pathDrawSelectors },
182
+ pathOrder: {
183
+ model: valueModel(SVG_MEMBER_MODEL_SEAMS.order),
184
+ role: `durable`,
185
+ schema: SVG_MEMBER_MODEL_SEAMS.order.stateSchema,
186
+ token: pathOrderAtom,
187
+ },
188
+ paths: {
189
+ keySchema,
190
+ model: valueModel(SVG_MEMBER_MODEL_SEAMS.path),
191
+ role: `durable`,
192
+ schema: SVG_MEMBER_MODEL_SEAMS.path.stateSchema,
193
+ token: pathAtoms,
194
+ },
195
+ pointerCapture: { role: `local`, token: pointerCaptureAtom },
196
+ projectedEdges: {
197
+ keySchema,
198
+ role: `derived`,
199
+ token: projectedEdgeSelectors,
200
+ },
201
+ projectedNodes: {
202
+ keySchema,
203
+ role: `derived`,
204
+ token: projectedNodeSelectors,
205
+ },
206
+ structureViolations: { role: `derived`, token: structureViolationsSelector },
207
+ subpathOrder: {
208
+ keySchema,
209
+ model: valueModel(SVG_MEMBER_MODEL_SEAMS.order),
210
+ role: `durable`,
211
+ schema: SVG_MEMBER_MODEL_SEAMS.order.stateSchema,
212
+ token: subpathOrderAtoms,
213
+ },
214
+ subpaths: {
215
+ keySchema,
216
+ model: valueModel(SVG_MEMBER_MODEL_SEAMS.subpath),
217
+ role: `durable`,
218
+ schema: SVG_MEMBER_MODEL_SEAMS.subpath.stateSchema,
219
+ token: subpathAtoms,
220
+ },
221
+ svgElement: { role: `local`, token: svgElementAtom },
222
+ viewport: { role: `local`, token: viewportAtom },
223
+ workspace: { role: `local`, token: workspaceAtom },
224
+ },
225
+ version: 2,
226
+ })
227
+
228
+ /** Install the ordinary template tokens before activating in an isolated Silo. */
229
+ export const SVG_DOMAIN_TOKENS = [
230
+ activeDragAtom,
231
+ collaborationPresenceAtoms,
232
+ dragPresenceAtoms,
233
+ edgeAtoms,
234
+ nodeAtoms,
235
+ pathDrawSelectors,
236
+ pathOrderAtom,
237
+ pathAtoms,
238
+ pointerCaptureAtom,
239
+ projectedEdgeSelectors,
240
+ projectedNodeSelectors,
241
+ structureViolationsSelector,
242
+ subpathOrderAtoms,
243
+ subpathAtoms,
244
+ svgElementAtom,
245
+ viewportAtom,
246
+ workspaceAtom,
247
+ ] as const
248
+
249
+ export async function activateSvgDesignDomain(options: {
250
+ readonly instance: string
251
+ readonly silo: Pick<Silo, `install` | `store`>
252
+ }): Promise<SvgDesignDomain> {
253
+ options.silo.install([...SVG_DOMAIN_TOKENS])
254
+ return svgDesignDomain.activate({
255
+ config: {},
256
+ instance: options.instance,
257
+ store: options.silo.store,
258
+ })
259
+ }
260
+
261
+ export type SvgDesignDomain = Awaited<
262
+ ReturnType<typeof svgDesignDomain.activate>
263
+ >
264
+
265
+ export type SvgDomainState = {
266
+ readonly getState: (...parameters: any[]) => any
267
+ readonly setState: (...parameters: any[]) => void
268
+ }
269
+
270
+ const implicitState: SvgDomainState = {
271
+ getState: getState as SvgDomainState[`getState`],
272
+ setState: setState as SvgDomainState[`setState`],
273
+ }
274
+
275
+ type Planned = {
276
+ readonly address: MosaicDomainMemberAddress
277
+ readonly id: string
278
+ readonly operation:
279
+ | SvgHistoryOperation
280
+ | SvgOrderOperation
281
+ | SvgRegisterOperation<any>
282
+ }
283
+
284
+ export type SvgDomainEditor = {
285
+ beginDrag(options: {
286
+ readonly element: Element
287
+ readonly gesture: SvgGesture
288
+ readonly point: PointXY
289
+ readonly pointerId: number
290
+ readonly target: SvgDragTarget
291
+ }): Promise<void>
292
+ commitGeometry(input: {
293
+ readonly gesture: SvgGesture
294
+ readonly point: PointXY
295
+ readonly target: SvgDragTarget
296
+ }): Promise<void>
297
+ deletePath(input: {
298
+ readonly gesture: SvgGesture
299
+ readonly pathId: string
300
+ }): Promise<void>
301
+ deleteSubpath(input: {
302
+ readonly gesture: SvgGesture
303
+ readonly pathId: string
304
+ readonly subpathId: string
305
+ }): Promise<void>
306
+ finishDrag(options: { readonly commit: boolean }): Promise<void>
307
+ insertPath(input: {
308
+ readonly gesture: SvgGesture
309
+ readonly index: number
310
+ readonly pathId: string
311
+ }): Promise<void>
312
+ insertSubpath(input: {
313
+ readonly edge: SvgEdge
314
+ readonly gesture: SvgGesture
315
+ readonly index: number
316
+ readonly node: PointXY | null
317
+ readonly pathId: string
318
+ readonly subpathId: string
319
+ }): Promise<void>
320
+ previewDrag(point: PointXY): Promise<void>
321
+ reorderPath(input: {
322
+ readonly gesture: SvgGesture
323
+ readonly index: number
324
+ readonly pathId: string
325
+ }): Promise<void>
326
+ reorderSubpath(input: {
327
+ readonly gesture: SvgGesture
328
+ readonly index: number
329
+ readonly pathId: string
330
+ readonly subpathId: string
331
+ }): Promise<void>
332
+ replaceDrawing(input: {
333
+ readonly drawing: SvgDrawingFixture
334
+ readonly gesture: SvgGesture
335
+ }): Promise<void>
336
+ redo(gesture: SvgGesture): Promise<boolean>
337
+ splitSubpath(input: {
338
+ readonly continuationEdge: SvgEdge
339
+ readonly gesture: SvgGesture
340
+ readonly inserted: {
341
+ readonly edge: SvgEdge
342
+ readonly node: PointXY | null
343
+ readonly subpathId: string
344
+ }
345
+ readonly pathId: string
346
+ readonly targetSubpathId: string
347
+ }): Promise<void>
348
+ undo(gesture: SvgGesture): Promise<boolean>
349
+ }
350
+
351
+ const operationAt = <Operation extends { readonly id: string }>(
352
+ state: { readonly operations: Readonly<Record<string, Operation>> },
353
+ id: string,
354
+ ): Operation => {
355
+ const operation = state.operations[id]
356
+ if (operation === undefined)
357
+ throw new Error(`SVG operation ${id} was not produced.`)
358
+ return operation
359
+ }
360
+
361
+ function assertClosePair(edge: SvgEdge, node: PointXY | null): void {
362
+ if ((edge.kind === `close`) !== (node === null)) {
363
+ throw new Error(`Only a close edge may have a null SVG node`)
364
+ }
365
+ }
366
+
367
+ /** Build and submit explicit public Domain batches; no private transaction bridge. */
368
+ export function createSvgDomainEditor(options: {
369
+ readonly batch: MosaicDomainBatchClient
370
+ readonly domain: SvgDesignDomain
371
+ readonly history?: {
372
+ redo(): Promise<MosaicDomainHistoryRequestResult>
373
+ undo(): Promise<MosaicDomainHistoryRequestResult>
374
+ }
375
+ readonly presence?: MosaicDomainPresenceClient
376
+ readonly state?: SvgDomainState
377
+ }): SvgDomainEditor {
378
+ const state = options.state ?? implicitState
379
+ const sharePresence = async (
380
+ work: Promise<void> | undefined,
381
+ ): Promise<void> => {
382
+ try {
383
+ await work
384
+ } catch {
385
+ // Presence is advisory; its controller retains the actionable status.
386
+ }
387
+ }
388
+ const op = (
389
+ address: Planned[`address`],
390
+ operation: Planned[`operation`],
391
+ ): Planned => ({ address, id: operation.id, operation })
392
+ const submit = async (
393
+ gesture: SvgGesture,
394
+ operations: readonly Planned[],
395
+ ): Promise<void> => {
396
+ const authored = operations.map((planned) => ({
397
+ ...planned,
398
+ operation: { ...planned.operation, actor: gesture.actor },
399
+ }))
400
+ await options.batch.submit(authored, gesture.id)
401
+ if (options.batch.state.status === `rejected`) {
402
+ throw new Error(
403
+ options.batch.state.problem?.reason ??
404
+ `The SVG Domain batch was rejected.`,
405
+ )
406
+ }
407
+ }
408
+ const orderPlacement = (
409
+ current: SvgOrderState,
410
+ gesture: SvgGesture,
411
+ ordinal: number,
412
+ entryId: string,
413
+ index: number,
414
+ value = entryId,
415
+ ): SvgOrderOperation => {
416
+ const id = svgOperationId(gesture, ordinal)
417
+ return operationAt(
418
+ placeSvgOrderEntry(current, { entryId, id, index, value }),
419
+ id,
420
+ )
421
+ }
422
+ const orderRemoval = (
423
+ current: SvgOrderState,
424
+ gesture: SvgGesture,
425
+ ordinal: number,
426
+ entryId: string,
427
+ ): SvgOrderOperation => {
428
+ const id = svgOperationId(gesture, ordinal)
429
+ return operationAt(removeSvgOrderEntry(current, entryId, id), id)
430
+ }
431
+
432
+ const editor: SvgDomainEditor = {
433
+ async beginDrag(input) {
434
+ state.setState(activeDragAtom, {
435
+ gesture: input.gesture,
436
+ pointerId: input.pointerId,
437
+ target: input.target,
438
+ })
439
+ state.setState(pointerCaptureAtom, {
440
+ element: input.element,
441
+ pointerId: input.pointerId,
442
+ })
443
+ const presence: SvgDragPresence = {
444
+ actor: input.gesture.actor,
445
+ gestureId: input.gesture.id,
446
+ point: input.point,
447
+ sequence: 0,
448
+ session: input.gesture.session,
449
+ target: input.target,
450
+ }
451
+ state.setState(
452
+ dragPresenceAtoms,
453
+ svgDragPresenceKey(input.gesture),
454
+ presence,
455
+ )
456
+ await sharePresence(
457
+ options.presence?.publish(
458
+ options.domain.address(
459
+ `dragPresence`,
460
+ svgDragPresenceKey(input.gesture),
461
+ ),
462
+ presence,
463
+ ),
464
+ )
465
+ },
466
+ async commitGeometry(input) {
467
+ if (input.target.kind === `node`) {
468
+ if (
469
+ readSvgRegister(state.getState(nodeAtoms, input.target.subpathId)) ==
470
+ null
471
+ ) {
472
+ throw new Error(`Cannot move a missing or closing SVG node`)
473
+ }
474
+ const operation = {
475
+ id: svgOperationId(input.gesture, 0),
476
+ value: input.point,
477
+ }
478
+ await submit(input.gesture, [
479
+ op(options.domain.address(`nodes`, input.target.subpathId), operation),
480
+ ])
481
+ return
482
+ }
483
+ const edge = readSvgRegister<SvgEdge | null>(
484
+ state.getState(edgeAtoms, input.target.subpathId),
485
+ )
486
+ if (edge?.kind !== `cubic`) {
487
+ throw new Error(`Cannot move a control on a non-cubic SVG edge`)
488
+ }
489
+ const operation = {
490
+ id: svgOperationId(input.gesture, 0),
491
+ value:
492
+ input.target.control === `c`
493
+ ? { ...edge, c: input.point }
494
+ : { ...edge, s: input.point },
495
+ }
496
+ await submit(input.gesture, [
497
+ op(options.domain.address(`edges`, input.target.subpathId), operation),
498
+ ])
499
+ },
500
+ async deletePath(input) {
501
+ if (readSvgRegister(state.getState(pathAtoms, input.pathId)) == null) {
502
+ throw new Error(`SVG path ${input.pathId} does not exist`)
503
+ }
504
+ let ordinal = 0
505
+ const operations: Planned[] = []
506
+ let subpathOrder = state.getState(subpathOrderAtoms, input.pathId)
507
+ for (const { entryId, value: subpathId } of materializeSvgOrder(
508
+ subpathOrder,
509
+ )) {
510
+ const removal = orderRemoval(
511
+ subpathOrder,
512
+ input.gesture,
513
+ ordinal++,
514
+ entryId,
515
+ )
516
+ subpathOrder = {
517
+ operations: { ...subpathOrder.operations, [removal.id]: removal },
518
+ }
519
+ operations.push(
520
+ op(options.domain.address(`subpathOrder`, input.pathId), removal),
521
+ op(options.domain.address(`subpaths`, subpathId), {
522
+ id: svgOperationId(input.gesture, ordinal++),
523
+ value: null,
524
+ }),
525
+ op(options.domain.address(`nodes`, subpathId), {
526
+ id: svgOperationId(input.gesture, ordinal++),
527
+ value: null,
528
+ }),
529
+ op(options.domain.address(`edges`, subpathId), {
530
+ id: svgOperationId(input.gesture, ordinal++),
531
+ value: null,
532
+ }),
533
+ )
534
+ }
535
+ operations.push(
536
+ op(options.domain.address(`paths`, input.pathId), {
537
+ id: svgOperationId(input.gesture, ordinal++),
538
+ value: null,
539
+ }),
540
+ op(
541
+ options.domain.address(`pathOrder`),
542
+ orderRemoval(
543
+ state.getState(pathOrderAtom),
544
+ input.gesture,
545
+ ordinal,
546
+ input.pathId,
547
+ ),
548
+ ),
549
+ )
550
+ await submit(input.gesture, operations)
551
+ },
552
+ async deleteSubpath(input) {
553
+ const subpath = readSvgRegister<SvgSubpath | null>(
554
+ state.getState(subpathAtoms, input.subpathId),
555
+ )
556
+ if (subpath?.pathId !== input.pathId) {
557
+ throw new Error(
558
+ `SVG subpath ${input.subpathId} is not in ${input.pathId}`,
559
+ )
560
+ }
561
+ await submit(input.gesture, [
562
+ op(
563
+ options.domain.address(`subpathOrder`, input.pathId),
564
+ orderRemoval(
565
+ state.getState(subpathOrderAtoms, input.pathId),
566
+ input.gesture,
567
+ 0,
568
+ input.subpathId,
569
+ ),
570
+ ),
571
+ op(options.domain.address(`subpaths`, input.subpathId), {
572
+ id: svgOperationId(input.gesture, 1),
573
+ value: null,
574
+ }),
575
+ op(options.domain.address(`nodes`, input.subpathId), {
576
+ id: svgOperationId(input.gesture, 2),
577
+ value: null,
578
+ }),
579
+ op(options.domain.address(`edges`, input.subpathId), {
580
+ id: svgOperationId(input.gesture, 3),
581
+ value: null,
582
+ }),
583
+ ])
584
+ },
585
+ async finishDrag({ commit }) {
586
+ const active = state.getState(activeDragAtom)
587
+ if (active === null) return
588
+ const key = svgDragPresenceKey(active.gesture)
589
+ const presence = state.getState(dragPresenceAtoms, key)
590
+ state.setState(dragPresenceAtoms, key, null)
591
+ state.setState(activeDragAtom, null)
592
+ state.setState(pointerCaptureAtom, null)
593
+ const work: Promise<unknown>[] = []
594
+ if (options.presence !== undefined) {
595
+ work.push(
596
+ sharePresence(
597
+ options.presence.clear(options.domain.address(`dragPresence`, key)),
598
+ ),
599
+ )
600
+ }
601
+ if (commit && presence?.gestureId === active.gesture.id) {
602
+ work.push(
603
+ editor.commitGeometry({
604
+ gesture: active.gesture,
605
+ point: presence.point,
606
+ target: active.target,
607
+ }),
608
+ )
609
+ }
610
+ await Promise.all(work)
611
+ },
612
+ async insertPath(input) {
613
+ if (readSvgRegister(state.getState(pathAtoms, input.pathId)) != null) {
614
+ throw new Error(`SVG path ${input.pathId} already exists`)
615
+ }
616
+ await submit(input.gesture, [
617
+ op(options.domain.address(`paths`, input.pathId), {
618
+ id: svgOperationId(input.gesture, 0),
619
+ value: { id: input.pathId },
620
+ }),
621
+ op(
622
+ options.domain.address(`pathOrder`),
623
+ orderPlacement(
624
+ state.getState(pathOrderAtom),
625
+ input.gesture,
626
+ 1,
627
+ input.pathId,
628
+ input.index,
629
+ ),
630
+ ),
631
+ ])
632
+ },
633
+ async insertSubpath(input) {
634
+ if (readSvgRegister(state.getState(pathAtoms, input.pathId)) == null) {
635
+ throw new Error(`SVG path ${input.pathId} does not exist`)
636
+ }
637
+ if (
638
+ readSvgRegister(state.getState(subpathAtoms, input.subpathId)) != null
639
+ ) {
640
+ throw new Error(`SVG subpath ${input.subpathId} already exists`)
641
+ }
642
+ assertClosePair(input.edge, input.node)
643
+ await submit(input.gesture, [
644
+ op(options.domain.address(`subpaths`, input.subpathId), {
645
+ id: svgOperationId(input.gesture, 0),
646
+ value: { id: input.subpathId, pathId: input.pathId },
647
+ }),
648
+ op(options.domain.address(`nodes`, input.subpathId), {
649
+ id: svgOperationId(input.gesture, 1),
650
+ value: input.node,
651
+ }),
652
+ op(options.domain.address(`edges`, input.subpathId), {
653
+ id: svgOperationId(input.gesture, 2),
654
+ value: input.edge,
655
+ }),
656
+ op(
657
+ options.domain.address(`subpathOrder`, input.pathId),
658
+ orderPlacement(
659
+ state.getState(subpathOrderAtoms, input.pathId),
660
+ input.gesture,
661
+ 3,
662
+ input.subpathId,
663
+ input.index,
664
+ ),
665
+ ),
666
+ ])
667
+ },
668
+ async previewDrag(point) {
669
+ const active = state.getState(activeDragAtom)
670
+ if (active === null) return
671
+ const key = svgDragPresenceKey(active.gesture)
672
+ const previous = state.getState(dragPresenceAtoms, key)
673
+ const presence: SvgDragPresence = {
674
+ actor: active.gesture.actor,
675
+ gestureId: active.gesture.id,
676
+ point,
677
+ sequence: (previous?.sequence ?? -1) + 1,
678
+ session: active.gesture.session,
679
+ target: active.target,
680
+ }
681
+ state.setState(dragPresenceAtoms, key, presence)
682
+ await sharePresence(
683
+ options.presence?.publish(
684
+ options.domain.address(`dragPresence`, key),
685
+ presence,
686
+ ),
687
+ )
688
+ },
689
+ async reorderPath(input) {
690
+ if (readSvgRegister(state.getState(pathAtoms, input.pathId)) == null) {
691
+ throw new Error(`Cannot reorder a missing SVG path`)
692
+ }
693
+ await submit(input.gesture, [
694
+ op(
695
+ options.domain.address(`pathOrder`),
696
+ orderPlacement(
697
+ state.getState(pathOrderAtom),
698
+ input.gesture,
699
+ 0,
700
+ input.pathId,
701
+ input.index,
702
+ ),
703
+ ),
704
+ ])
705
+ },
706
+ async redo(_gesture) {
707
+ if (options.history === undefined) return false
708
+ const result = await options.history.redo()
709
+ if (result.status === `rejected`) throw new Error(result.reason)
710
+ return result.status === `accepted`
711
+ },
712
+ async reorderSubpath(input) {
713
+ const subpath = readSvgRegister<SvgSubpath | null>(
714
+ state.getState(subpathAtoms, input.subpathId),
715
+ )
716
+ if (subpath?.pathId !== input.pathId) {
717
+ throw new Error(`Cannot reorder a missing SVG subpath`)
718
+ }
719
+ const node = readSvgRegister<PointXY | null>(
720
+ state.getState(nodeAtoms, input.subpathId),
721
+ )
722
+ const edge = readSvgRegister<SvgEdge | null>(
723
+ state.getState(edgeAtoms, input.subpathId),
724
+ )
725
+ if (edge == null || node === undefined) {
726
+ throw new Error(`Cannot reorder an incomplete SVG subpath`)
727
+ }
728
+ await submit(input.gesture, [
729
+ op(options.domain.address(`subpaths`, input.subpathId), {
730
+ id: svgOperationId(input.gesture, 0),
731
+ value: subpath,
732
+ }),
733
+ op(options.domain.address(`nodes`, input.subpathId), {
734
+ id: svgOperationId(input.gesture, 1),
735
+ value: node,
736
+ }),
737
+ op(options.domain.address(`edges`, input.subpathId), {
738
+ id: svgOperationId(input.gesture, 2),
739
+ value: edge,
740
+ }),
741
+ op(
742
+ options.domain.address(`subpathOrder`, input.pathId),
743
+ orderPlacement(
744
+ state.getState(subpathOrderAtoms, input.pathId),
745
+ input.gesture,
746
+ 3,
747
+ input.subpathId,
748
+ input.index,
749
+ ),
750
+ ),
751
+ ])
752
+ },
753
+ async replaceDrawing(input) {
754
+ const pathIds = input.drawing.paths.map(({ id }) => id)
755
+ if (new Set(pathIds).size !== pathIds.length) {
756
+ throw new Error(`An SVG drawing cannot contain duplicate path IDs`)
757
+ }
758
+ const subpathIds = new Set<string>()
759
+ for (const path of input.drawing.paths) {
760
+ for (const subpath of path.subpaths) {
761
+ if (subpathIds.has(subpath.id)) {
762
+ throw new Error(`SVG subpath IDs must be globally unique`)
763
+ }
764
+ subpathIds.add(subpath.id)
765
+ assertClosePair(subpath.edge, subpath.node)
766
+ }
767
+ }
768
+ let ordinal = 0
769
+ const operations: Planned[] = []
770
+ const clearedSubpathOrders = new Map<string, SvgOrderState>()
771
+ let pathOrder = state.getState(pathOrderAtom)
772
+ for (const { entryId, value: pathId } of materializeSvgOrder(pathOrder)) {
773
+ let order = state.getState(subpathOrderAtoms, pathId)
774
+ for (const {
775
+ entryId: subpathEntry,
776
+ value: subpathId,
777
+ } of materializeSvgOrder(order)) {
778
+ const removal = orderRemoval(
779
+ order,
780
+ input.gesture,
781
+ ordinal++,
782
+ subpathEntry,
783
+ )
784
+ order = { operations: { ...order.operations, [removal.id]: removal } }
785
+ operations.push(
786
+ op(options.domain.address(`subpathOrder`, pathId), removal),
787
+ op(options.domain.address(`subpaths`, subpathId), {
788
+ id: svgOperationId(input.gesture, ordinal++),
789
+ value: null,
790
+ }),
791
+ op(options.domain.address(`nodes`, subpathId), {
792
+ id: svgOperationId(input.gesture, ordinal++),
793
+ value: null,
794
+ }),
795
+ op(options.domain.address(`edges`, subpathId), {
796
+ id: svgOperationId(input.gesture, ordinal++),
797
+ value: null,
798
+ }),
799
+ )
800
+ }
801
+ clearedSubpathOrders.set(pathId, order)
802
+ operations.push(
803
+ op(options.domain.address(`paths`, pathId), {
804
+ id: svgOperationId(input.gesture, ordinal++),
805
+ value: null,
806
+ }),
807
+ )
808
+ const removal = orderRemoval(
809
+ pathOrder,
810
+ input.gesture,
811
+ ordinal++,
812
+ entryId,
813
+ )
814
+ pathOrder = {
815
+ operations: { ...pathOrder.operations, [removal.id]: removal },
816
+ }
817
+ operations.push(op(options.domain.address(`pathOrder`), removal))
818
+ }
819
+ for (const [pathIndex, path] of input.drawing.paths.entries()) {
820
+ operations.push(
821
+ op(options.domain.address(`paths`, path.id), {
822
+ id: svgOperationId(input.gesture, ordinal++),
823
+ value: { id: path.id },
824
+ }),
825
+ )
826
+ const pathPlacement = orderPlacement(
827
+ pathOrder,
828
+ input.gesture,
829
+ ordinal++,
830
+ path.id,
831
+ pathIndex,
832
+ )
833
+ pathOrder = {
834
+ operations: {
835
+ ...pathOrder.operations,
836
+ [pathPlacement.id]: pathPlacement,
837
+ },
838
+ }
839
+ operations.push(op(options.domain.address(`pathOrder`), pathPlacement))
840
+ let order =
841
+ clearedSubpathOrders.get(path.id) ??
842
+ state.getState(subpathOrderAtoms, path.id)
843
+ for (const [index, subpath] of path.subpaths.entries()) {
844
+ operations.push(
845
+ op(options.domain.address(`subpaths`, subpath.id), {
846
+ id: svgOperationId(input.gesture, ordinal++),
847
+ value: { id: subpath.id, pathId: path.id },
848
+ }),
849
+ op(options.domain.address(`nodes`, subpath.id), {
850
+ id: svgOperationId(input.gesture, ordinal++),
851
+ value: subpath.node,
852
+ }),
853
+ op(options.domain.address(`edges`, subpath.id), {
854
+ id: svgOperationId(input.gesture, ordinal++),
855
+ value: subpath.edge,
856
+ }),
857
+ )
858
+ const subpathPlacement = orderPlacement(
859
+ order,
860
+ input.gesture,
861
+ ordinal++,
862
+ subpath.id,
863
+ index,
864
+ )
865
+ order = {
866
+ operations: {
867
+ ...order.operations,
868
+ [subpathPlacement.id]: subpathPlacement,
869
+ },
870
+ }
871
+ operations.push(
872
+ op(
873
+ options.domain.address(`subpathOrder`, path.id),
874
+ subpathPlacement,
875
+ ),
876
+ )
877
+ }
878
+ }
879
+ await submit(input.gesture, operations)
880
+ },
881
+ async splitSubpath(input) {
882
+ const order = materializeSvgOrder(
883
+ state.getState(subpathOrderAtoms, input.pathId),
884
+ )
885
+ const targetIndex = order.findIndex(
886
+ ({ value }) => value === input.targetSubpathId,
887
+ )
888
+ if (targetIndex === -1)
889
+ throw new Error(`Cannot split a missing SVG subpath`)
890
+ if (
891
+ readSvgRegister(
892
+ state.getState(subpathAtoms, input.inserted.subpathId),
893
+ ) != null
894
+ ) {
895
+ throw new Error(`The inserted SVG subpath already exists`)
896
+ }
897
+ assertClosePair(input.inserted.edge, input.inserted.node)
898
+ const targetNode = readSvgRegister<PointXY | null>(
899
+ state.getState(nodeAtoms, input.targetSubpathId),
900
+ )
901
+ assertClosePair(input.continuationEdge, targetNode ?? null)
902
+ await submit(input.gesture, [
903
+ op(options.domain.address(`subpaths`, input.inserted.subpathId), {
904
+ id: svgOperationId(input.gesture, 0),
905
+ value: { id: input.inserted.subpathId, pathId: input.pathId },
906
+ }),
907
+ op(options.domain.address(`nodes`, input.inserted.subpathId), {
908
+ id: svgOperationId(input.gesture, 1),
909
+ value: input.inserted.node,
910
+ }),
911
+ op(options.domain.address(`edges`, input.inserted.subpathId), {
912
+ id: svgOperationId(input.gesture, 2),
913
+ value: input.inserted.edge,
914
+ }),
915
+ op(options.domain.address(`edges`, input.targetSubpathId), {
916
+ id: svgOperationId(input.gesture, 3),
917
+ value: input.continuationEdge,
918
+ }),
919
+ op(
920
+ options.domain.address(`subpathOrder`, input.pathId),
921
+ orderPlacement(
922
+ state.getState(subpathOrderAtoms, input.pathId),
923
+ input.gesture,
924
+ 4,
925
+ input.inserted.subpathId,
926
+ targetIndex,
927
+ ),
928
+ ),
929
+ ])
930
+ },
931
+ async undo(_gesture) {
932
+ if (options.history === undefined) return false
933
+ const result = await options.history.undo()
934
+ if (result.status === `rejected`) throw new Error(result.reason)
935
+ return result.status === `accepted`
936
+ },
937
+ }
938
+ return editor
939
+ }