@atom.io/template-preact-svg-editor 0.0.93 → 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.
- package/CHANGELOG.md +20 -0
- package/COLLABORATION.md +132 -0
- package/README.md +43 -10
- package/eslint.config.ts +1 -1
- package/package.json +30 -16
- package/src/BezierPlayground.tsx +179 -320
- package/src/preact-logo.ts +155 -0
- package/src/svg-contour.test.ts +89 -0
- package/src/svg-contour.ts +579 -0
- package/src/svg-convergence.test.ts +264 -0
- package/src/svg-convergence.ts +566 -0
- package/src/svg-domain-realtime.test.ts +369 -0
- package/src/svg-domain.test.ts +618 -0
- package/src/svg-domain.ts +939 -0
- package/src/svg-editor-state.test.ts +401 -0
- package/src/svg-editor-state.ts +1102 -0
- package/vite.config.ts +1 -0
|
@@ -0,0 +1,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 }
|