@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.
@@ -0,0 +1,155 @@
1
+ import type { PointXY, SvgDrawingFixture, SvgEdge } from "./svg-editor-state.ts"
2
+
3
+ const COMMAND_ARITY = {
4
+ C: 6,
5
+ L: 2,
6
+ M: 2,
7
+ V: 1,
8
+ c: 6,
9
+ l: 2,
10
+ m: 2,
11
+ v: 1,
12
+ Z: 0,
13
+ z: 0,
14
+ } as const
15
+
16
+ type SvgCommand = keyof typeof COMMAND_ARITY
17
+
18
+ const tokenPattern = /[A-Za-z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/giu
19
+ const separatorPattern = /^[\s,]*$/u
20
+
21
+ function isCommand(token: string): token is SvgCommand {
22
+ return token in COMMAND_ARITY
23
+ }
24
+
25
+ function instructions(pathData: string): readonly {
26
+ readonly command: SvgCommand
27
+ readonly numbers: readonly number[]
28
+ }[] {
29
+ const tokens: string[] = []
30
+ let previousEnd = 0
31
+ for (const match of pathData.matchAll(tokenPattern)) {
32
+ if (!separatorPattern.test(pathData.slice(previousEnd, match.index))) {
33
+ throw new Error(`SVG path data contains an invalid token`)
34
+ }
35
+ tokens.push(match[0])
36
+ previousEnd = match.index + match[0].length
37
+ }
38
+ if (!separatorPattern.test(pathData.slice(previousEnd))) {
39
+ throw new Error(`SVG path data contains an invalid token`)
40
+ }
41
+ const result: { command: SvgCommand; numbers: number[] }[] = []
42
+ let command: SvgCommand | null = null
43
+ let awaitingArguments = false
44
+ let index = 0
45
+ while (index < tokens.length) {
46
+ const token = tokens[index]
47
+ if (isCommand(token)) {
48
+ if (command !== null && awaitingArguments) {
49
+ throw new Error(`SVG path command ${command} is incomplete`)
50
+ }
51
+ command = token
52
+ awaitingArguments = true
53
+ index++
54
+ if (COMMAND_ARITY[command] === 0) {
55
+ result.push({ command, numbers: [] })
56
+ command = null
57
+ awaitingArguments = false
58
+ }
59
+ continue
60
+ }
61
+ if (/^[A-Za-z]$/u.test(token)) {
62
+ throw new Error(`Unsupported SVG path command ${token}`)
63
+ }
64
+ if (command === null) throw new Error(`SVG path data is missing a command`)
65
+ const arity = COMMAND_ARITY[command]
66
+ const numbers = tokens.slice(index, index + arity).map(Number)
67
+ if (
68
+ numbers.length !== arity ||
69
+ numbers.some((number) => !Number.isFinite(number))
70
+ ) {
71
+ throw new Error(`SVG path command ${command} is incomplete`)
72
+ }
73
+ result.push({ command, numbers })
74
+ awaitingArguments = false
75
+ index += arity
76
+ if (command === `m`) command = `l`
77
+ if (command === `M`) command = `L`
78
+ }
79
+ if (command !== null && awaitingArguments) {
80
+ throw new Error(`SVG path command ${command} is incomplete`)
81
+ }
82
+ return result
83
+ }
84
+
85
+ /** Parse the deliberately small command subset used by the bundled Preact SVG. */
86
+ export function parsePreactLogo(svg: string): SvgDrawingFixture {
87
+ const pathData = [...svg.matchAll(/<path\b[^>]*\bd="([^"]+)"[^>]*>/gu)].map(
88
+ ([, data]) => data,
89
+ )
90
+ let subpathOrdinal = 0
91
+ return {
92
+ paths: pathData.map((data, pathOrdinal) => {
93
+ let previous: PointXY = { x: 0, y: 0 }
94
+ let subpathStart: PointXY = previous
95
+ return {
96
+ id: `path${pathOrdinal}`,
97
+ subpaths: instructions(data).map(({ command, numbers }) => {
98
+ let edge: SvgEdge
99
+ let node: PointXY | null
100
+ switch (command) {
101
+ case `m`:
102
+ node = { x: previous.x + numbers[0], y: previous.y + numbers[1] }
103
+ edge = { kind: `move` }
104
+ subpathStart = node
105
+ break
106
+ case `M`:
107
+ node = { x: numbers[0], y: numbers[1] }
108
+ edge = { kind: `move` }
109
+ subpathStart = node
110
+ break
111
+ case `l`:
112
+ node = { x: previous.x + numbers[0], y: previous.y + numbers[1] }
113
+ edge = { kind: `line` }
114
+ break
115
+ case `L`:
116
+ node = { x: numbers[0], y: numbers[1] }
117
+ edge = { kind: `line` }
118
+ break
119
+ case `c`:
120
+ node = { x: previous.x + numbers[4], y: previous.y + numbers[5] }
121
+ edge = {
122
+ c: { x: previous.x + numbers[0], y: previous.y + numbers[1] },
123
+ kind: `cubic`,
124
+ s: { x: previous.x + numbers[2], y: previous.y + numbers[3] },
125
+ }
126
+ break
127
+ case `C`:
128
+ node = { x: numbers[4], y: numbers[5] }
129
+ edge = {
130
+ c: { x: numbers[0], y: numbers[1] },
131
+ kind: `cubic`,
132
+ s: { x: numbers[2], y: numbers[3] },
133
+ }
134
+ break
135
+ case `v`:
136
+ node = { x: previous.x, y: previous.y + numbers[0] }
137
+ edge = { kind: `line` }
138
+ break
139
+ case `V`:
140
+ node = { x: previous.x, y: numbers[0] }
141
+ edge = { kind: `line` }
142
+ break
143
+ case `z`:
144
+ case `Z`:
145
+ node = null
146
+ edge = { kind: `close` }
147
+ previous = subpathStart
148
+ }
149
+ if (node !== null) previous = node
150
+ return { edge, id: `subpath${subpathOrdinal++}`, node }
151
+ }),
152
+ }
153
+ }),
154
+ }
155
+ }
@@ -0,0 +1,89 @@
1
+ import { describe, expect, test } from "vitest"
2
+
3
+ import {
4
+ DEFAULT_SVG_CONTOUR_STATE,
5
+ readSvgContour,
6
+ reduceSvgContour,
7
+ type SvgContour,
8
+ svgContourPath,
9
+ svgContourSchema,
10
+ } from "./svg-contour.ts"
11
+
12
+ const triangle = (offset = 0): SvgContour => ({
13
+ nodes: [
14
+ { id: `a`, x: offset, y: 0 },
15
+ { id: `b`, x: 20 + offset, y: 0 },
16
+ { id: `c`, x: 10 + offset, y: 20 },
17
+ ],
18
+ })
19
+
20
+ function expectClosedContour(contour: SvgContour): void {
21
+ expect(svgContourSchema.parse(contour)).toEqual(contour)
22
+ const degrees = new Map(contour.nodes.map(({ id }) => [id, 0]))
23
+ for (const [index, node] of contour.nodes.entries()) {
24
+ const next = contour.nodes[(index + 1) % contour.nodes.length]
25
+ degrees.set(node.id, degrees.get(node.id)! + 1)
26
+ degrees.set(next.id, degrees.get(next.id)! + 1)
27
+ }
28
+ expect([...degrees.values()]).toEqual(contour.nodes.map(() => 2))
29
+ }
30
+
31
+ describe(`closed contour model`, () => {
32
+ test(`branching, dangling, and curved edge states have no representation`, () => {
33
+ expect(() =>
34
+ svgContourSchema.parse({ nodes: triangle().nodes.slice(0, 2) }),
35
+ ).toThrow()
36
+ expect(() =>
37
+ svgContourSchema.parse({
38
+ nodes: [triangle().nodes[0], triangle().nodes[0], triangle().nodes[2]],
39
+ }),
40
+ ).toThrow(`duplicate node IDs`)
41
+ expect(svgContourPath(triangle())).toBe(`M 0 0 L 20 0 L 10 20 Z`)
42
+ expect(svgContourPath(triangle())).not.toMatch(/[CSQ]/u)
43
+ })
44
+
45
+ test(`overlapping participation and selective undo can expose only valid snapshots`, () => {
46
+ const aliceId = `0000000000000001:alice:insert:00000000`
47
+ const bobId = `0000000000000002:bob:participate:00000000`
48
+ let state = reduceSvgContour(DEFAULT_SVG_CONTOUR_STATE, {
49
+ actor: `alice`,
50
+ id: aliceId,
51
+ value: triangle(),
52
+ })
53
+ state = reduceSvgContour(state, {
54
+ actor: `bob`,
55
+ id: bobId,
56
+ value: triangle(5),
57
+ })
58
+ state = reduceSvgContour(state, {
59
+ actor: `alice`,
60
+ id: `svg-history:alice-undo`,
61
+ mode: `undo`,
62
+ targetOperationIds: [aliceId],
63
+ type: `history`,
64
+ })
65
+
66
+ const afterUndo = readSvgContour(state)
67
+ expect(afterUndo).toEqual(triangle(5))
68
+ expectClosedContour(afterUndo)
69
+
70
+ state = reduceSvgContour(state, {
71
+ actor: `bob`,
72
+ id: `svg-history:bob-undo`,
73
+ mode: `undo`,
74
+ targetOperationIds: [bobId],
75
+ type: `history`,
76
+ })
77
+ expectClosedContour(readSvgContour(state))
78
+ })
79
+
80
+ test(`invalid replacement operations fail before reduction`, () => {
81
+ expect(() =>
82
+ reduceSvgContour(DEFAULT_SVG_CONTOUR_STATE, {
83
+ actor: `alice`,
84
+ id: `0000000000000001:alice:bad:00000000`,
85
+ value: { nodes: triangle().nodes.slice(0, 2) },
86
+ }),
87
+ ).toThrow()
88
+ })
89
+ })