@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,579 @@
1
+ import {
2
+ mosaicDomain,
3
+ type MosaicDomainHistoryRequestResult,
4
+ type MosaicDomainMemberHistoryPolicy,
5
+ type MosaicDomainValueModel,
6
+ } from "@atom.io/realtime"
7
+ import type {
8
+ MosaicDomainBatchClient,
9
+ MosaicDomainBatchClientOperation,
10
+ MosaicDomainPresenceClient,
11
+ } from "@atom.io/realtime/client"
12
+ import type { Silo } from "atom.io"
13
+ import { atom, atomFamily, getState, selector, setState } from "atom.io"
14
+ import type { Json } from "atom.io/foundations/json"
15
+ import { z } from "zod"
16
+
17
+ import {
18
+ compactSvgRegisterHistory,
19
+ readSvgRegister,
20
+ reduceSvgRegister,
21
+ type SvgRegisterModelOperation,
22
+ svgRegisterModelOperationSchema,
23
+ type SvgRegisterState,
24
+ svgRegisterStateSchema,
25
+ } from "./svg-convergence.ts"
26
+ import {
27
+ createSvgGestureClock,
28
+ type PointXY,
29
+ type SvgGesture,
30
+ svgHistoryPolicy,
31
+ svgOperationId,
32
+ } from "./svg-editor-state.ts"
33
+
34
+ const keySchema = z.string().min(1)
35
+ const pointSchema = z
36
+ .object({ id: keySchema, x: z.number().finite(), y: z.number().finite() })
37
+ .strict()
38
+
39
+ export type SvgContourNode = {
40
+ readonly id: string
41
+ readonly x: number
42
+ readonly y: number
43
+ }
44
+
45
+ /**
46
+ * A closed line contour. Edges are implicit between adjacent nodes, including
47
+ * the last and first nodes, so branching and dangling-edge states have no
48
+ * representation in this model.
49
+ */
50
+ export type SvgContour = {
51
+ readonly nodes: readonly SvgContourNode[]
52
+ }
53
+
54
+ export const svgContourSchema: z.ZodType<SvgContour> = z
55
+ .object({ nodes: z.array(pointSchema).min(3).max(64) })
56
+ .strict()
57
+ .refine(
58
+ ({ nodes }) => new Set(nodes.map(({ id }) => id)).size === nodes.length,
59
+ { message: `A contour cannot contain duplicate node IDs` },
60
+ )
61
+
62
+ export const DEFAULT_SVG_CONTOUR: SvgContour = {
63
+ nodes: [
64
+ { id: `north-west`, x: 72, y: 64 },
65
+ { id: `north-east`, x: 184, y: 72 },
66
+ { id: `south-east`, x: 184, y: 176 },
67
+ { id: `south-west`, x: 64, y: 184 },
68
+ ],
69
+ }
70
+
71
+ const DEFAULT_CONTOUR_OPERATION_ID = `0000000000000000:system:svg-contour-default:00000000`
72
+
73
+ export type SvgContourState = SvgRegisterState<SvgContour>
74
+ export type SvgContourOperation = SvgRegisterModelOperation<SvgContour>
75
+
76
+ export const svgContourOperationSchema =
77
+ svgRegisterModelOperationSchema(svgContourSchema)
78
+ export const svgContourStateSchema = svgRegisterStateSchema(svgContourSchema)
79
+
80
+ export const DEFAULT_SVG_CONTOUR_STATE: SvgContourState = {
81
+ operations: {
82
+ [DEFAULT_CONTOUR_OPERATION_ID]: {
83
+ id: DEFAULT_CONTOUR_OPERATION_ID,
84
+ value: DEFAULT_SVG_CONTOUR,
85
+ },
86
+ },
87
+ }
88
+
89
+ /** Parse before reduction so invalid contours cannot enter even in local use. */
90
+ export function reduceSvgContour(
91
+ state: SvgContourState,
92
+ operation: SvgContourOperation,
93
+ ): SvgContourState {
94
+ return reduceSvgRegister(state, svgContourOperationSchema.parse(operation))
95
+ }
96
+
97
+ export function readSvgContour(state: SvgContourState): SvgContour {
98
+ return (
99
+ readSvgRegister(state) ??
100
+ // Checkpoint corruption must fail closed rather than expose no contour.
101
+ svgContourSchema.parse(DEFAULT_SVG_CONTOUR)
102
+ )
103
+ }
104
+
105
+ export function svgContourPath(contour: SvgContour): string {
106
+ const [first, ...rest] = svgContourSchema.parse(contour).nodes
107
+ return [
108
+ `M ${first.x.toString()} ${first.y.toString()}`,
109
+ ...rest.map(({ x, y }) => `L ${x.toString()} ${y.toString()}`),
110
+ `Z`,
111
+ ].join(` `)
112
+ }
113
+
114
+ export type SvgContourPresence = {
115
+ readonly actor: string
116
+ readonly color: string
117
+ readonly name: string
118
+ readonly pointer: PointXY | null
119
+ readonly selectedNodeId: string | null
120
+ readonly session: string
121
+ }
122
+
123
+ export type SvgContourDragPresence = {
124
+ readonly actor: string
125
+ readonly gestureId: string
126
+ readonly nodeId: string
127
+ readonly point: PointXY
128
+ readonly sequence: number
129
+ readonly session: string
130
+ }
131
+
132
+ export type SvgContourActiveDrag = {
133
+ readonly gesture: SvgGesture
134
+ readonly nodeId: string
135
+ readonly pointerId: number
136
+ }
137
+
138
+ export type SvgContourWorkspace = { readonly selectedNodeId: string | null }
139
+ export type SvgContourViewport = { readonly pan: PointXY; readonly zoom: number }
140
+
141
+ const contourPresenceSchema: z.ZodType<SvgContourPresence> = z
142
+ .object({
143
+ actor: keySchema,
144
+ color: keySchema.max(64),
145
+ name: keySchema.max(128),
146
+ pointer: z
147
+ .object({ x: z.number().finite(), y: z.number().finite() })
148
+ .strict()
149
+ .nullable(),
150
+ selectedNodeId: keySchema.nullable(),
151
+ session: keySchema,
152
+ })
153
+ .strict()
154
+
155
+ const contourDragPresenceSchema: z.ZodType<SvgContourDragPresence> = z
156
+ .object({
157
+ actor: keySchema,
158
+ gestureId: keySchema,
159
+ nodeId: keySchema,
160
+ point: z.object({ x: z.number().finite(), y: z.number().finite() }).strict(),
161
+ sequence: z.number().int().nonnegative(),
162
+ session: keySchema,
163
+ })
164
+ .strict()
165
+
166
+ export const contourAtom = atom<SvgContourState>({
167
+ default: DEFAULT_SVG_CONTOUR_STATE,
168
+ key: `contour`,
169
+ })
170
+ export const contourActiveDragAtom = atom<SvgContourActiveDrag | null>({
171
+ default: null,
172
+ key: `contourActiveDrag`,
173
+ })
174
+ export const contourPointerCaptureAtom = atom<{
175
+ readonly element: Element
176
+ readonly pointerId: number
177
+ } | null>({ default: null, key: `contourPointerCapture` })
178
+ export const contourWorkspaceAtom = atom<SvgContourWorkspace>({
179
+ default: { selectedNodeId: null },
180
+ key: `contourWorkspace`,
181
+ })
182
+ export const contourViewportAtom = atom<SvgContourViewport>({
183
+ default: { pan: { x: 0, y: 0 }, zoom: 1 },
184
+ key: `contourViewport`,
185
+ })
186
+ export const contourPresenceAtoms = atomFamily<
187
+ SvgContourPresence | null,
188
+ string
189
+ >({ default: null, key: `contourPresence` })
190
+ export const contourDragPresenceAtoms = atomFamily<
191
+ SvgContourDragPresence | null,
192
+ string
193
+ >({ default: null, key: `contourDragPresence` })
194
+
195
+ export function svgContourPresenceKey(
196
+ identity: Pick<SvgContourPresence, `actor` | `session`>,
197
+ ): string {
198
+ return `${identity.actor}\u0000${identity.session}`
199
+ }
200
+
201
+ export const projectedContourSelector = selector<SvgContour>({
202
+ get: ({ get }) => {
203
+ const durable = readSvgContour(get(contourAtom))
204
+ const drag = get(contourActiveDragAtom)
205
+ if (drag === null) return durable
206
+ const presence = get(
207
+ contourDragPresenceAtoms,
208
+ svgContourPresenceKey({
209
+ actor: drag.gesture.actor,
210
+ session: drag.gesture.session,
211
+ }),
212
+ )
213
+ if (presence?.gestureId !== drag.gesture.id) return durable
214
+ return {
215
+ nodes: durable.nodes.map((node) =>
216
+ node.id === drag.nodeId ? { ...node, ...presence.point } : node,
217
+ ),
218
+ }
219
+ },
220
+ key: `projectedContour`,
221
+ })
222
+
223
+ export const contourPathSelector = selector<string>({
224
+ get: ({ get }) => svgContourPath(get(projectedContourSelector)),
225
+ key: `contourPath`,
226
+ })
227
+
228
+ type SvgContourReceiptState = {
229
+ readonly operations: Readonly<
230
+ Record<string, { readonly actor?: string | undefined }>
231
+ >
232
+ }
233
+
234
+ type AuthoredSvgContourOperation = {
235
+ readonly actor?: string | undefined
236
+ readonly targetOperationIds?: readonly string[] | undefined
237
+ type?: string | undefined
238
+ }
239
+
240
+ const contourModel: MosaicDomainValueModel<
241
+ Json.Serializable & SvgContourState,
242
+ Json.Serializable & SvgContourOperation
243
+ > = {
244
+ history: svgHistoryPolicy<SvgContourState>(
245
+ compactSvgRegisterHistory,
246
+ ) as MosaicDomainMemberHistoryPolicy<
247
+ Json.Serializable & SvgContourState,
248
+ Json.Serializable & SvgContourOperation
249
+ >,
250
+ identity: { key: `svg/closed-contour-register`, version: 1 },
251
+ kind: `value`,
252
+ operationSchema: svgContourOperationSchema as z.ZodType<
253
+ Json.Serializable & SvgContourOperation
254
+ >,
255
+ reduce(value, operation, context) {
256
+ const current = value as SvgContourReceiptState
257
+ const authored = operation as AuthoredSvgContourOperation
258
+ if (authored.actor !== context.actor) {
259
+ throw new Error(
260
+ `An SVG contour operation must name its authenticated actor.`,
261
+ )
262
+ }
263
+ if (authored.targetOperationIds !== undefined) {
264
+ for (const target of authored.targetOperationIds) {
265
+ if (current.operations[target]?.actor !== context.actor) {
266
+ throw new Error(
267
+ `An SVG contour compensation can target only the authenticated actor's operations.`,
268
+ )
269
+ }
270
+ }
271
+ }
272
+ return reduceSvgContour(value, operation) as Json.Serializable &
273
+ SvgContourState
274
+ },
275
+ }
276
+
277
+ export const svgContourDomain = mosaicDomain({
278
+ configSchema: z.object({}).strict(),
279
+ key: `svg-closed-contour`,
280
+ members: {
281
+ activeDrag: { role: `local`, token: contourActiveDragAtom },
282
+ collaborator: {
283
+ keySchema,
284
+ role: `ephemeral`,
285
+ schema: contourPresenceSchema,
286
+ token: contourPresenceAtoms,
287
+ },
288
+ contour: {
289
+ model: contourModel,
290
+ role: `durable`,
291
+ schema: svgContourStateSchema,
292
+ token: contourAtom,
293
+ },
294
+ dragPresence: {
295
+ keySchema,
296
+ role: `ephemeral`,
297
+ schema: contourDragPresenceSchema,
298
+ token: contourDragPresenceAtoms,
299
+ },
300
+ path: { role: `derived`, token: contourPathSelector },
301
+ pointerCapture: { role: `local`, token: contourPointerCaptureAtom },
302
+ projectedContour: { role: `derived`, token: projectedContourSelector },
303
+ viewport: { role: `local`, token: contourViewportAtom },
304
+ workspace: { role: `local`, token: contourWorkspaceAtom },
305
+ },
306
+ version: 1,
307
+ })
308
+
309
+ export const SVG_CONTOUR_DOMAIN_TOKENS = [
310
+ contourActiveDragAtom,
311
+ contourAtom,
312
+ contourDragPresenceAtoms,
313
+ contourPathSelector,
314
+ contourPointerCaptureAtom,
315
+ contourPresenceAtoms,
316
+ contourViewportAtom,
317
+ contourWorkspaceAtom,
318
+ projectedContourSelector,
319
+ ] as const
320
+
321
+ export async function activateSvgContourDomain(options: {
322
+ readonly instance: string
323
+ readonly silo: Pick<Silo, `install` | `store`>
324
+ }): Promise<SvgContourDomain> {
325
+ options.silo.install([...SVG_CONTOUR_DOMAIN_TOKENS])
326
+ return svgContourDomain.activate({
327
+ config: {},
328
+ instance: options.instance,
329
+ store: options.silo.store,
330
+ })
331
+ }
332
+
333
+ export type SvgContourDomain = Awaited<
334
+ ReturnType<typeof svgContourDomain.activate>
335
+ >
336
+
337
+ export type SvgContourStateAccess = {
338
+ readonly getState: (...parameters: any[]) => any
339
+ readonly setState: (...parameters: any[]) => void
340
+ }
341
+
342
+ const implicitState: SvgContourStateAccess = {
343
+ getState: getState as SvgContourStateAccess[`getState`],
344
+ setState: setState as SvgContourStateAccess[`setState`],
345
+ }
346
+
347
+ export type SvgContourEditor = {
348
+ addNode(input: {
349
+ readonly gesture: SvgGesture
350
+ readonly index: number
351
+ readonly node: SvgContourNode
352
+ }): Promise<void>
353
+ beginDrag(input: {
354
+ readonly element: Element
355
+ readonly gesture: SvgGesture
356
+ readonly nodeId: string
357
+ readonly point: PointXY
358
+ readonly pointerId: number
359
+ }): Promise<void>
360
+ deleteNode(input: {
361
+ readonly gesture: SvgGesture
362
+ readonly nodeId: string
363
+ }): Promise<void>
364
+ finishDrag(input: { readonly commit: boolean }): Promise<void>
365
+ moveNode(input: {
366
+ readonly gesture: SvgGesture
367
+ readonly nodeId: string
368
+ readonly point: PointXY
369
+ }): Promise<void>
370
+ previewDrag(point: PointXY): Promise<void>
371
+ redo(gesture: SvgGesture): Promise<boolean>
372
+ replaceContour(input: {
373
+ readonly contour: SvgContour
374
+ readonly gesture: SvgGesture
375
+ }): Promise<void>
376
+ undo(gesture: SvgGesture): Promise<boolean>
377
+ }
378
+
379
+ export function createSvgContourEditor(options: {
380
+ readonly batch: MosaicDomainBatchClient
381
+ readonly domain: SvgContourDomain
382
+ readonly history?: {
383
+ redo(): Promise<MosaicDomainHistoryRequestResult>
384
+ undo(): Promise<MosaicDomainHistoryRequestResult>
385
+ }
386
+ readonly presence?: MosaicDomainPresenceClient
387
+ readonly state?: SvgContourStateAccess
388
+ }): SvgContourEditor {
389
+ const state = options.state ?? implicitState
390
+ const sharePresence = async (
391
+ work: Promise<void> | undefined,
392
+ ): Promise<void> => {
393
+ try {
394
+ await work
395
+ } catch {
396
+ // Presence is advisory; its controller retains the actionable status.
397
+ }
398
+ }
399
+ const submit = async (
400
+ gesture: SvgGesture,
401
+ contour: SvgContour,
402
+ ): Promise<void> => {
403
+ const operation = svgContourOperationSchema.parse({
404
+ actor: gesture.actor,
405
+ id: svgOperationId(gesture, 0),
406
+ value: contour,
407
+ })
408
+ const submission = await options.batch.submit(
409
+ [
410
+ {
411
+ address: options.domain.address(`contour`),
412
+ id: operation.id,
413
+ operation,
414
+ },
415
+ ] as readonly MosaicDomainBatchClientOperation[],
416
+ gesture.id,
417
+ )
418
+ if (submission.state.status === `rejected`) {
419
+ throw new Error(submission.state.rejection.reason)
420
+ }
421
+ if (submission.state.status === `interrupted`) {
422
+ throw new Error(
423
+ `The SVG contour batch outcome is unknown; resynchronize before repeating this edit.`,
424
+ )
425
+ }
426
+ }
427
+ const current = (): SvgContour => readSvgContour(state.getState(contourAtom))
428
+ const withNode = (
429
+ nodeId: string,
430
+ change: (node: SvgContourNode) => SvgContourNode,
431
+ ): SvgContour => {
432
+ const contour = current()
433
+ if (!contour.nodes.some(({ id }) => id === nodeId)) {
434
+ throw new Error(`Cannot change a missing contour node.`)
435
+ }
436
+ return svgContourSchema.parse({
437
+ nodes: contour.nodes.map((node) =>
438
+ node.id === nodeId ? change(node) : node,
439
+ ),
440
+ })
441
+ }
442
+
443
+ const editor: SvgContourEditor = {
444
+ async addNode(input) {
445
+ const contour = current()
446
+ if (contour.nodes.some(({ id }) => id === input.node.id)) {
447
+ throw new Error(`A contour node ID must be unique.`)
448
+ }
449
+ const index = Math.max(0, Math.min(input.index, contour.nodes.length))
450
+ await submit(input.gesture, {
451
+ nodes: [
452
+ ...contour.nodes.slice(0, index),
453
+ input.node,
454
+ ...contour.nodes.slice(index),
455
+ ],
456
+ })
457
+ },
458
+ async beginDrag(input) {
459
+ if (!current().nodes.some(({ id }) => id === input.nodeId)) {
460
+ throw new Error(`Cannot drag a missing contour node.`)
461
+ }
462
+ state.setState(contourActiveDragAtom, {
463
+ gesture: input.gesture,
464
+ nodeId: input.nodeId,
465
+ pointerId: input.pointerId,
466
+ })
467
+ state.setState(contourPointerCaptureAtom, {
468
+ element: input.element,
469
+ pointerId: input.pointerId,
470
+ })
471
+ const presence: SvgContourDragPresence = {
472
+ actor: input.gesture.actor,
473
+ gestureId: input.gesture.id,
474
+ nodeId: input.nodeId,
475
+ point: input.point,
476
+ sequence: 0,
477
+ session: input.gesture.session,
478
+ }
479
+ const key = svgContourPresenceKey(presence)
480
+ state.setState(contourDragPresenceAtoms, key, presence)
481
+ await sharePresence(
482
+ options.presence?.publish(
483
+ options.domain.address(`dragPresence`, key),
484
+ presence,
485
+ ),
486
+ )
487
+ },
488
+ async deleteNode(input) {
489
+ const contour = current()
490
+ if (!contour.nodes.some(({ id }) => id === input.nodeId)) {
491
+ throw new Error(`Cannot delete a missing contour node.`)
492
+ }
493
+ await submit(input.gesture, {
494
+ nodes: contour.nodes.filter(({ id }) => id !== input.nodeId),
495
+ })
496
+ },
497
+ async finishDrag({ commit }) {
498
+ const active = state.getState(contourActiveDragAtom)
499
+ if (active === null) return
500
+ const key = svgContourPresenceKey({
501
+ actor: active.gesture.actor,
502
+ session: active.gesture.session,
503
+ })
504
+ const presence = state.getState(contourDragPresenceAtoms, key)
505
+ state.setState(contourDragPresenceAtoms, key, null)
506
+ state.setState(contourActiveDragAtom, null)
507
+ state.setState(contourPointerCaptureAtom, null)
508
+ const work: Promise<unknown>[] = []
509
+ if (options.presence !== undefined) {
510
+ work.push(
511
+ sharePresence(
512
+ options.presence.clear(options.domain.address(`dragPresence`, key)),
513
+ ),
514
+ )
515
+ }
516
+ if (commit && presence?.gestureId === active.gesture.id) {
517
+ work.push(
518
+ editor.moveNode({
519
+ gesture: active.gesture,
520
+ nodeId: active.nodeId,
521
+ point: presence.point,
522
+ }),
523
+ )
524
+ }
525
+ await Promise.all(work)
526
+ },
527
+ async moveNode(input) {
528
+ await submit(
529
+ input.gesture,
530
+ withNode(input.nodeId, (node) => ({ ...node, ...input.point })),
531
+ )
532
+ },
533
+ async previewDrag(point) {
534
+ const active = state.getState(contourActiveDragAtom)
535
+ if (active === null) return
536
+ const key = svgContourPresenceKey({
537
+ actor: active.gesture.actor,
538
+ session: active.gesture.session,
539
+ })
540
+ const previous = state.getState(contourDragPresenceAtoms, key)
541
+ const presence: SvgContourDragPresence = {
542
+ actor: active.gesture.actor,
543
+ gestureId: active.gesture.id,
544
+ nodeId: active.nodeId,
545
+ point,
546
+ sequence: (previous?.sequence ?? -1) + 1,
547
+ session: active.gesture.session,
548
+ }
549
+ state.setState(contourDragPresenceAtoms, key, presence)
550
+ await sharePresence(
551
+ options.presence?.publish(
552
+ options.domain.address(`dragPresence`, key),
553
+ presence,
554
+ ),
555
+ )
556
+ },
557
+ async redo(gesture) {
558
+ gesture
559
+ if (options.history === undefined) return false
560
+ const result = await options.history.redo()
561
+ if (result.status === `rejected`) throw new Error(result.reason)
562
+ return result.status === `accepted`
563
+ },
564
+ async replaceContour(input) {
565
+ await submit(input.gesture, svgContourSchema.parse(input.contour))
566
+ },
567
+ async undo(gesture) {
568
+ gesture
569
+ if (options.history === undefined) return false
570
+ const result = await options.history.undo()
571
+ if (result.status === `rejected`) throw new Error(result.reason)
572
+ return result.status === `accepted`
573
+ },
574
+ }
575
+ return editor
576
+ }
577
+
578
+ export { createSvgGestureClock }
579
+ export type { PointXY, SvgGesture }