@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.
@@ -1,92 +1,75 @@
1
+ import { useAtomicRef, useO } from "@atom.io/react"
1
2
  import type { Loadable } from "atom.io"
2
- import {
3
- atom,
4
- atomFamily,
5
- getState,
6
- resetState,
7
- runTransaction,
8
- selectorFamily,
9
- setState,
10
- transaction,
11
- } from "atom.io"
12
- import { useAtomicRef, useO } from "atom.io/react"
3
+ import { atom, getState, setState } from "atom.io"
13
4
  import type { PointerEventHandler, TargetedPointerEvent, VNode } from "preact"
14
5
  import { useCallback, useEffect, useRef } from "preact/hooks"
15
6
 
16
- type PointXY = { x: number; y: number }
17
- type EdgeXY = { c?: PointXY; s: PointXY }
7
+ import { parsePreactLogo } from "./preact-logo.ts"
8
+ import { materializeSvgOrder } from "./svg-convergence.ts"
9
+ import {
10
+ activeDragAtom,
11
+ beginSvgDrag,
12
+ createSvgGestureClock,
13
+ finishSvgDrag,
14
+ pathDrawSelectors,
15
+ pathOrderAtom,
16
+ pointerCaptureAtom,
17
+ type PointXY,
18
+ previewSvgDrag,
19
+ projectedEdgeSelectors,
20
+ projectedNodeSelectors,
21
+ replaceSvgDrawing,
22
+ subpathOrderAtoms,
23
+ type SvgDragTarget,
24
+ svgElementAtom,
25
+ } from "./svg-editor-state.ts"
18
26
 
19
- const pathKeysAtom = atom<string[]>({
20
- key: `pathKeys`,
21
- default: [],
22
- })
23
- const subpathKeysAtoms = atomFamily<string[], string>({
24
- key: `subpathKeys`,
25
- default: [],
26
- })
27
- const nodeAtoms = atomFamily<PointXY | null, string>({
28
- key: `node`,
29
- default: null,
30
- })
31
- const edgeAtoms = atomFamily<EdgeXY | boolean, string>({
32
- key: `edge`,
33
- default: true,
34
- })
35
- const pathDrawSelectors = selectorFamily<string, string>({
36
- key: `pathDraw`,
37
- get:
38
- (pathKey) =>
39
- ({ get }) => {
40
- const subpathKeys = get(subpathKeysAtoms, pathKey)
41
- return subpathKeys
42
- .map((subpathKey, idx) => {
43
- const node = get(nodeAtoms, subpathKey)
44
- const edge = get(edgeAtoms, subpathKey)
27
+ const WIDTH = 256
28
+ const HEIGHT = 296
45
29
 
46
- if (node === null) {
47
- return `Z`
48
- }
49
- if (idx === 0) {
50
- return `M ${node.x} ${node.y}`
51
- }
52
- if (edge === false) {
53
- return `M ${node.x} ${node.y}`
54
- }
55
- if (edge === true) {
56
- return `L ${node.x} ${node.y}`
57
- }
58
- if (`c` in edge) {
59
- return `C ${edge.c.x} ${edge.c.y} ${edge.s.x} ${edge.s.y} ${node.x} ${node.y}`
60
- }
61
- return `S ${edge.s.x} ${edge.s.y} ${node.x} ${node.y}`
62
- })
63
- .join(` `)
64
- },
65
- })
30
+ function clamp(number: number, minimum: number, maximum: number): number {
31
+ return Math.max(minimum, Math.min(maximum, number))
32
+ }
66
33
 
67
- function clamp(n: number, min: number, max: number) {
68
- return Math.max(min, Math.min(max, n))
34
+ function logicalPoint(evt: TargetedPointerEvent<SVGSVGElement>): PointXY | null {
35
+ const svg = getState(svgElementAtom)
36
+ if (svg === null) return null
37
+ const point = svg.createSVGPoint()
38
+ point.x = evt.clientX
39
+ point.y = evt.clientY
40
+ const inverse = svg.getScreenCTM()?.inverse()
41
+ if (inverse === undefined) return null
42
+ const transformed = point.matrixTransform(inverse)
43
+ return {
44
+ x: clamp(transformed.x, -185, WIDTH + 185),
45
+ y: clamp(transformed.y, -10, HEIGHT + 10),
46
+ }
69
47
  }
70
48
 
71
- function Bezier({
49
+ function ControlHandle({
72
50
  at,
73
- subpathKey,
74
- prevSubpathKey,
75
- node: maybeNode,
51
+ control,
52
+ origin,
53
+ startDrag,
54
+ subpathId,
76
55
  }: {
77
- at: PointXY
78
- subpathKey: string
79
- prevSubpathKey: string
80
- node?: PointXY
81
- }) {
82
- const previousNode = useO(nodeAtoms, prevSubpathKey)
83
- const node = maybeNode ?? previousNode
84
- return node === null ? null : (
56
+ readonly at: PointXY
57
+ readonly control: `c` | `s`
58
+ readonly origin: PointXY | null
59
+ readonly startDrag: (
60
+ event: TargetedPointerEvent<SVGCircleElement>,
61
+ target: SvgDragTarget,
62
+ point: PointXY,
63
+ ) => void
64
+ readonly subpathId: string
65
+ }): VNode | null {
66
+ if (origin === null) return null
67
+ return (
85
68
  <>
86
69
  <line
87
70
  class="bezier"
88
- x1={node.x}
89
- y1={node.y}
71
+ x1={origin.x}
72
+ y1={origin.y}
90
73
  x2={at.x}
91
74
  y2={at.y}
92
75
  stroke="#777"
@@ -107,12 +90,8 @@ function Bezier({
107
90
  cx={at.x}
108
91
  cy={at.y}
109
92
  r={6}
110
- onPointerDown={(evt) => {
111
- evt.currentTarget.setPointerCapture(evt.pointerId)
112
- setState(currentlyDraggingAtom, {
113
- key: subpathKey,
114
- by: !maybeNode ? `c` : `s`,
115
- })
93
+ onPointerDown={(event) => {
94
+ startDrag(event, { control, kind: `edge-control`, subpathId }, at)
116
95
  }}
117
96
  />
118
97
  </>
@@ -120,35 +99,49 @@ function Bezier({
120
99
  }
121
100
 
122
101
  function Node({
123
- subpathKey,
124
- nextSubpathKey,
102
+ previousSubpathId,
103
+ startDrag,
104
+ subpathId,
125
105
  }: {
126
- subpathKey: string
127
- nextSubpathKey: string
128
- }) {
129
- const node = useO(nodeAtoms, subpathKey)
130
- const edge = useO(edgeAtoms, subpathKey)
131
- return node === null ? null : (
106
+ readonly previousSubpathId: string | null
107
+ readonly startDrag: (
108
+ event: TargetedPointerEvent<SVGCircleElement>,
109
+ target: SvgDragTarget,
110
+ point: PointXY,
111
+ ) => void
112
+ readonly subpathId: string
113
+ }): VNode | null {
114
+ const node = useO(projectedNodeSelectors, subpathId)
115
+ const edge = useO(projectedEdgeSelectors, subpathId)
116
+ const previousNode = useO(
117
+ projectedNodeSelectors,
118
+ previousSubpathId ?? subpathId,
119
+ )
120
+ if (node === null || edge === null || edge.kind === `close`) return null
121
+ return (
132
122
  <>
133
- {typeof edge === `boolean` ? (
134
- <rect class="node" x={node.x - 3} y={node.y - 3} width={6} height={6} />
135
- ) : (
123
+ {edge.kind === `cubic` ? (
136
124
  <>
137
- <Bezier
125
+ <ControlHandle
138
126
  at={edge.s}
139
- node={node}
140
- subpathKey={subpathKey}
141
- prevSubpathKey={nextSubpathKey}
127
+ control="s"
128
+ origin={node}
129
+ startDrag={startDrag}
130
+ subpathId={subpathId}
142
131
  />
143
- {edge.c ? (
144
- <Bezier
132
+ {edge.c === undefined ? null : (
133
+ <ControlHandle
145
134
  at={edge.c}
146
- subpathKey={subpathKey}
147
- prevSubpathKey={nextSubpathKey}
135
+ control="c"
136
+ origin={previousNode}
137
+ startDrag={startDrag}
138
+ subpathId={subpathId}
148
139
  />
149
- ) : null}
140
+ )}
150
141
  <circle class="node" cx={node.x} cy={node.y} r={3} />
151
142
  </>
143
+ ) : (
144
+ <rect class="node" x={node.x - 3} y={node.y - 3} width={6} height={6} />
152
145
  )}
153
146
  <circle
154
147
  class="node-draggable"
@@ -156,254 +149,114 @@ function Node({
156
149
  cx={node.x}
157
150
  cy={node.y}
158
151
  r={10}
159
- onPointerDown={(evt) => {
160
- evt.currentTarget.setPointerCapture(evt.pointerId)
161
- setState(currentlyDraggingAtom, { key: subpathKey })
152
+ onPointerDown={(event) => {
153
+ startDrag(event, { kind: `node`, subpathId }, node)
162
154
  }}
163
155
  />
164
156
  </>
165
157
  )
166
158
  }
167
159
 
168
- function RenderedPath({ pathKey }: { pathKey: string }) {
169
- const draw = useO(pathDrawSelectors, pathKey)
170
- return <path d={`${draw} Z`} class="path" style={{ pointerEvents: `none` }} />
171
- }
172
-
173
- function Path({ pathKey }: { pathKey: string }) {
174
- const subpathKeys = useO(subpathKeysAtoms, pathKey)
175
- return (
176
- <>
177
- <RenderedPath pathKey={pathKey} />
178
- {subpathKeys.toReversed().map((spk, idx, arr) => (
179
- <Node
180
- subpathKey={spk}
181
- nextSubpathKey={arr[idx + 1] ?? arr[0]}
182
- key={spk}
183
- />
184
- ))}
185
- </>
186
- )
187
- }
188
-
189
- function PathsDemo() {
190
- const pathKeys = useO(pathKeysAtom)
160
+ function Path({
161
+ pathId,
162
+ startDrag,
163
+ }: {
164
+ readonly pathId: string
165
+ readonly startDrag: (
166
+ event: TargetedPointerEvent<SVGCircleElement>,
167
+ target: SvgDragTarget,
168
+ point: PointXY,
169
+ ) => void
170
+ }): VNode {
171
+ const draw = useO(pathDrawSelectors, pathId)
172
+ const subpathOrder = useO(subpathOrderAtoms, pathId)
173
+ const subpathIds = materializeSvgOrder(subpathOrder).map(({ value }) => value)
191
174
  return (
192
175
  <>
193
- {pathKeys.map((pathKey) => {
194
- return <Path pathKey={pathKey} key={pathKey} />
176
+ <path d={draw} class="path" style={{ pointerEvents: `none` }} />
177
+ {subpathIds.toReversed().map((subpathId, reverseIndex) => {
178
+ const index = subpathIds.length - reverseIndex - 1
179
+ return (
180
+ <Node
181
+ key={subpathId}
182
+ previousSubpathId={subpathIds[index - 1] ?? null}
183
+ startDrag={startDrag}
184
+ subpathId={subpathId}
185
+ />
186
+ )
195
187
  })}
196
188
  </>
197
189
  )
198
190
  }
199
191
 
200
- const svgRefAtom = atom<SVGSVGElement | null>({
201
- key: `svgRef`,
202
- default: null,
203
- })
204
- const currentlyDraggingAtom = atom<{ key: string; by?: `c` | `s` } | null>({
205
- key: `currentlyDragging`,
206
- default: null,
207
- })
208
-
209
- function onPointerMove(evt: TargetedPointerEvent<SVGSVGElement>): void {
210
- evt.preventDefault()
211
- const { key: currentlyDragging, by: draggingBy } =
212
- getState(currentlyDraggingAtom) ?? {}
213
- const svg = getState(svgRefAtom)
214
- if (!svg || !currentlyDragging) {
215
- return
216
- }
217
- const pt = svg.createSVGPoint()
218
- pt.x = evt.clientX
219
- pt.y = evt.clientY
220
- const ctm = svg.getScreenCTM()?.inverse()
221
- const { x, y } = pt.matrixTransform(ctm)
222
-
223
- switch (draggingBy) {
224
- case undefined:
225
- setState(nodeAtoms, currentlyDragging, {
226
- x: clamp(x, -185, WIDTH + 185),
227
- y: clamp(y, -10, HEIGHT + 10),
228
- })
229
- break
230
- case `s`:
231
- setState(edgeAtoms, currentlyDragging, (prev) => ({
232
- ...(prev as EdgeXY),
233
- s: { x: clamp(x, -185, WIDTH + 185), y: clamp(y, -10, HEIGHT + 10) },
234
- }))
235
- break
236
- case `c`:
237
- setState(edgeAtoms, currentlyDragging, (prev) => ({
238
- ...(prev as EdgeXY),
239
- c: { x: clamp(x, -185, WIDTH + 185), y: clamp(y, -10, HEIGHT + 10) },
240
- }))
241
- break
242
- }
243
- }
244
-
245
- const CODES = [`m`, `M`, `l`, `L`, `c`, `C`, `v`, `V`, `z`, `Z`] as const
246
-
247
192
  const preactLogoAtom = atom<Loadable<string>>({
248
193
  key: `preactLogo`,
249
- default: () => fetch(`preact.svg`).then((res) => res.text()),
194
+ default: () => fetch(`preact.svg`).then((response) => response.text()),
250
195
  })
251
196
 
252
- const resetTransaction = transaction<() => Promise<void>>({
253
- key: `reset`,
254
- do: async () => {
255
- const logo = await getState(preactLogoAtom)
256
- for (const pathKey of getState(pathKeysAtom)) {
257
- resetState(subpathKeysAtoms, pathKey)
258
- }
259
- resetState(pathKeysAtom)
260
-
261
- const shapes = logo
262
- .split(`\n`)
263
- .filter((line) => line.startsWith(`\t<path`))
264
- .map((line) => {
265
- const raw = line.split(`d="`)[1].slice(0, -9)
266
-
267
- type Letter = (typeof CODES)[number]
268
- let letter: Letter | undefined
269
- let number = ``
270
- let numbers: number[] = []
271
-
272
- const instructions: { letter: Letter; numbers: number[] }[] = []
273
- for (const c of raw) {
274
- if (CODES.includes(c as Letter)) {
275
- if (number) {
276
- numbers.push(Number.parseFloat(number))
277
- number = ``
278
- }
279
- if (letter) {
280
- instructions.push({ letter, numbers })
281
- }
282
- letter = c as Letter
283
- numbers = []
284
- continue
285
- }
286
- if (c === ` `) {
287
- numbers.push(Number.parseFloat(number))
288
- number = ``
289
- continue
290
- }
291
- if (c === `-` && number) {
292
- numbers.push(Number.parseFloat(number))
293
- number = `-`
294
- continue
295
- }
296
-
297
- number += c
298
- }
299
-
300
- let prev: PointXY = { x: 0, y: 0 }
301
- const edgeNodes = instructions.map<{
302
- node: PointXY | null
303
- edge: boolean | { c?: PointXY; s: PointXY }
304
- }>(({ letter: l, numbers: ns }) => {
305
- let node: PointXY | null
306
- let edge: boolean | { c?: PointXY; s: PointXY }
307
- switch (l) {
308
- case `m`:
309
- node = { x: prev.x + ns[0], y: prev.y + ns[1] }
310
- edge = false
311
- break
312
- case `M`:
313
- node = { x: ns[0], y: ns[1] }
314
- edge = false
315
- break
316
- case `l`:
317
- node = { x: prev.x + ns[0], y: prev.y + ns[1] }
318
- edge = true
319
- break
320
- case `L`:
321
- node = { x: ns[0], y: ns[1] }
322
- edge = true
323
- break
324
- case `c`:
325
- node = { x: prev.x + ns[4], y: prev.y + ns[5] }
326
- edge = {
327
- c: { x: prev.x + ns[0], y: prev.y + ns[1] },
328
- s: { x: prev.x + ns[2], y: prev.y + ns[3] },
329
- }
330
- break
331
- case `C`:
332
- node = { x: ns[4], y: ns[5] }
333
- edge = {
334
- c: { x: ns[0], y: ns[1] },
335
- s: { x: ns[2], y: ns[3] },
336
- }
337
- break
338
- case `v`:
339
- node = { x: prev.x, y: prev.y + ns[0] }
340
- edge = true
341
- break
342
- case `V`:
343
- node = { x: prev.x, y: ns[0] }
344
- edge = true
345
- break
346
- case `z`:
347
- case `Z`:
348
- node = null
349
- edge = true
350
- }
351
- if (node) {
352
- prev = node
353
- }
354
-
355
- return { node, edge }
356
- })
197
+ export default function BezierPlayground(): VNode {
198
+ const svgRef = useAtomicRef(svgElementAtom, useRef)
199
+ const pathOrder = useO(pathOrderAtom)
200
+ const gestureClock = useRef(
201
+ createSvgGestureClock({
202
+ actor: `local-designer`,
203
+ session: globalThis.crypto.randomUUID(),
204
+ }),
205
+ )
357
206
 
358
- return edgeNodes
207
+ const startDrag = useCallback(
208
+ (
209
+ event: TargetedPointerEvent<SVGCircleElement>,
210
+ target: SvgDragTarget,
211
+ point: PointXY,
212
+ ) => {
213
+ event.currentTarget.setPointerCapture(event.pointerId)
214
+ beginSvgDrag({
215
+ element: event.currentTarget,
216
+ gesture: gestureClock.current.begin(),
217
+ point,
218
+ pointerId: event.pointerId,
219
+ target,
359
220
  })
221
+ },
222
+ [],
223
+ )
360
224
 
361
- let i = 0
362
- let j = 0
363
- for (const shape of shapes) {
364
- const jj = j
365
- for (const { node, edge } of shape) {
366
- setState(edgeAtoms, `subpath${j}`, edge)
367
- setState(nodeAtoms, `subpath${j}`, node)
368
- j++
369
- }
370
- const numberOfNodes = j - jj
371
- setState(
372
- subpathKeysAtoms,
373
- `path${i}`,
374
- Array.from(
375
- { length: numberOfNodes },
376
- (_, nodeNum) => `subpath${jj + nodeNum}`,
377
- ),
378
- )
379
- setState(pathKeysAtom, (prev) => [...prev, `path${i}`])
380
- i++
225
+ const onPointerMove: PointerEventHandler<SVGSVGElement> = useCallback(
226
+ (event) => {
227
+ if (getState(activeDragAtom) === null) return
228
+ event.preventDefault()
229
+ const point = logicalPoint(event)
230
+ if (point !== null) previewSvgDrag(point)
231
+ },
232
+ [],
233
+ )
234
+
235
+ const finishPointer = useCallback((commit: boolean) => {
236
+ const capture = getState(pointerCaptureAtom)
237
+ if (capture?.element.hasPointerCapture(capture.pointerId) === true) {
238
+ capture.element.releasePointerCapture(capture.pointerId)
381
239
  }
382
- },
383
- })
384
- const reset = runTransaction(resetTransaction)
240
+ finishSvgDrag({ commit })
241
+ }, [])
385
242
 
386
- const WIDTH = 256
387
- const HEIGHT = 296
388
- export default function BezierPlayground(): VNode {
389
- const svgRef = useAtomicRef(svgRefAtom, useRef)
390
- const onPointerUp: PointerEventHandler<SVGSVGElement> = useCallback((evt) => {
391
- evt.currentTarget.releasePointerCapture(evt.pointerId)
392
- setState(currentlyDraggingAtom, null)
243
+ const reset = useCallback(async () => {
244
+ const svg = await getState(preactLogoAtom)
245
+ replaceSvgDrawing(parsePreactLogo(svg), gestureClock.current.begin())
393
246
  }, [])
394
247
 
395
- useEffect(() => void reset(), [])
248
+ useEffect(() => void reset(), [reset])
396
249
 
397
250
  return (
398
251
  <div
399
252
  style={{
253
+ alignItems: `center`,
400
254
  display: `flex`,
401
255
  flexFlow: `column`,
402
- position: `relative`,
403
- overflow: `hidden`,
404
256
  maxWidth: `1280px`,
257
+ overflow: `hidden`,
258
+ position: `relative`,
405
259
  width: `100vw`,
406
- alignItems: `center`,
407
260
  }}
408
261
  >
409
262
  <svg
@@ -412,8 +265,12 @@ export default function BezierPlayground(): VNode {
412
265
  width={1000}
413
266
  height={500}
414
267
  onPointerMove={onPointerMove}
415
- onPointerUp={onPointerUp}
416
- onPointerCancel={onPointerUp}
268
+ onPointerUp={() => {
269
+ finishPointer(true)
270
+ }}
271
+ onPointerCancel={() => {
272
+ finishPointer(false)
273
+ }}
417
274
  >
418
275
  <title>Bezier Playground</title>
419
276
  <defs>
@@ -429,7 +286,9 @@ export default function BezierPlayground(): VNode {
429
286
  height={HEIGHT + 20}
430
287
  fill="url(#grid)"
431
288
  />
432
- <PathsDemo />
289
+ {materializeSvgOrder(pathOrder).map(({ value: pathId }) => (
290
+ <Path pathId={pathId} startDrag={startDrag} key={pathId} />
291
+ ))}
433
292
  </svg>
434
293
  <button type="button" class="flat" onClick={reset}>
435
294
  Reset