@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.
- package/CHANGELOG.md +23 -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,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
|