@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,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
|
+
})
|