Release SVG Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 04:48:12 +02:00
parent 902ff61e8d
commit 0c7736dbc3
18 changed files with 366 additions and 70 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+14 -1
View File
@@ -3,7 +3,19 @@
All notable changes follow Keep a Changelog. Versions follow Semantic
Versioning.
## [Unreleased]
## [0.2.0] - 2026-09-02
### Added
- Added source-fidelity duplicate and delete operations for safe, ID-free
subtrees, with reference guards and undo/redo integration.
- Declared SVG/project input, SVG/SVGZ/raster/project export and optional worker
capability profiles for Toolbox handoff.
### Security
- Updated DOMPurify to 3.4.14 so the sanitized SVG projection includes the
upstream namespace-confusion fix tracked as GHSA-55q2-fjhq-7xh7.
### Documentation
@@ -26,4 +38,5 @@ Versioning.
- Deterministic standalone release packaging and Toolbox Portal assembly smoke
test.
[0.2.0]: https://git.add-ideas.de/lotobo/svg-tools/releases/tag/v0.2.0
[0.1.0]: https://git.add-ideas.de/lotobo/svg-tools/releases/tag/v0.1.0
+5 -3
View File
@@ -6,7 +6,7 @@ editing projection, a canvas overlay and inspectors from that source. Files are
processed in the browser; the app has no backend, upload endpoint, telemetry or
runtime CDN dependency.
Version 0.1.0 delivers the structured viewer/editor milestone (MVP 1) and
Version 0.2.0 delivers the structured viewer/editor milestone (MVP 1) and
tested vertical slices of the path, transform, reference, optimization,
security, accessibility, animation and export milestones. The later milestone
lists below remain a roadmap, not a claim of complete vector-editor coverage.
@@ -26,7 +26,7 @@ lists below remain a roadmap, not a claim of complete vector-editor coverage.
- No editor namespace or persistent editor attribute is added to normal SVG
exports.
## Implemented in 0.1.0
## Implemented in 0.2.0
- open, drop and paste SVG; magic-byte SVGZ import; new-document templates;
- XML editor, ranged diagnostics, structure tree, filtering and synchronized
@@ -34,6 +34,8 @@ lists below remain a roadmap, not a claim of complete vector-editor coverage.
- safe canvas, zoom, pan, grid, selected bounds and last-valid state;
- document dimensions, `viewBox`, `preserveAspectRatio`, arbitrary attributes,
fill, stroke, opacity, rotate, flip, translate, scale and skew;
- undoable subtree duplicate/delete for ID-free nodes, with duplicate-ID and
external-reference guards rather than silently breaking the document;
- exact undo/redo and explicit source-diff previews;
- parsing of `M L H V C S Q T A Z`, absolute/relative and repeated groups;
- anchors, cubic and quadratic controls, derived smooth controls, arc controls,
@@ -69,7 +71,7 @@ derived control intentionally expands shorthand to explicit normalized geometry.
Visual path edits currently serialize the selected `d` attribute in normalized
absolute form; surrounding source is untouched.
Not yet implemented: multi-node modes, delete/convert/join/break/combine,
Not yet implemented: multi-node modes, path join/break/combine,
subpath-start changes, a pen tool, snapping, simplification, boolean operations,
round corners and stroke-to-path.
+16 -16
View File
@@ -1,8 +1,8 @@
# Corresponding source and provenance
The corresponding source for SVG Tools 0.1.0 is:
The corresponding source for SVG Tools 0.2.0 is:
https://git.add-ideas.de/lotobo/svg-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/svg-tools/src/tag/v0.2.0
Build from that tag with Node.js 22 and the exact `package-lock.json`:
@@ -17,20 +17,20 @@ dependency inventory and notices.
## Revisions inspected
| Project | Revision/version inspected | Decision |
| ------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| SVG Tools | empty public `lotobo/svg-tools` repository before this initial commit | implemented here |
| Toolbox SDK/contract/AppShell/testkit | `ef2dab4b46c61812c9a877d8a19fe497b4a4630a`, packages 0.2.3 | adopted |
| Toolbox Portal | `55b2b12434465511586eaa2d0a43e81770868738`, tag v0.9.3 | release/UX contract reference; Portal 0.10.0 integrates the artifact |
| SVG-Edit | `244a26c88e1ab1c32911c5b3637e214d7a7d8b25` | interaction/implementation reference only; no source copied |
| SVG Path Editor | `937d75a83b6be2bdda11d02b9b3594841315223a` | path interaction reference only; no source copied |
| SVGPathCommander | `9aa91dd2119ee6a65b1807d55d33e86b1ccb27fc` | inspected, not adopted; project-authored path core is smaller and source-ranged |
| SVGO | npm 4.0.2, published `gitHead` `b2309cf541aee11634eb653157b0ff86ab326e98` | adopted in a worker; GitHub clone was not required for shipped bytes |
| DOMPurify | `9365501773d6665aaf334d8afa55081b9930a684`, npm 3.4.12 | adopted as one layer of the projection policy |
| css-tree | npm 3.2.1 | adopted for parsed CSS policy/value checks |
| Boolean/geometry engine | none | not adopted; boolean operations are not claimed |
| Raster engine | browser SVG image decoder and Canvas 2D | no third-party raster engine shipped |
| Compression fallback | fflate 0.8.3 | adopted for bounded SVGZ read/write |
| Project | Revision/version inspected | Decision |
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| SVG Tools | empty public `lotobo/svg-tools` repository before this initial commit | implemented here |
| Toolbox SDK/contract/AppShell/testkit | `bc91659a424aaaadc88fff3a98a2cc2412237386`, packages 0.3.0 | adopted |
| Toolbox Portal | `55b2b12434465511586eaa2d0a43e81770868738`, tag v0.9.3 | release/UX contract reference; Portal 0.10.0 integrates the artifact |
| SVG-Edit | `244a26c88e1ab1c32911c5b3637e214d7a7d8b25` | interaction/implementation reference only; no source copied |
| SVG Path Editor | `937d75a83b6be2bdda11d02b9b3594841315223a` | path interaction reference only; no source copied |
| SVGPathCommander | `9aa91dd2119ee6a65b1807d55d33e86b1ccb27fc` | inspected, not adopted; project-authored path core is smaller and source-ranged |
| SVGO | npm 4.0.2, published `gitHead` `b2309cf541aee11634eb653157b0ff86ab326e98` | adopted in a worker; GitHub clone was not required for shipped bytes |
| DOMPurify | npm 3.4.14, lockfile integrity `sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==` | adopted as one layer of the projection policy |
| css-tree | npm 3.2.1 | adopted for parsed CSS policy/value checks |
| Boolean/geometry engine | none | not adopted; boolean operations are not claimed |
| Raster engine | browser SVG image decoder and Canvas 2D | no third-party raster engine shipped |
| Compression fallback | fflate 0.8.3 | adopted for bounded SVGZ read/write |
Boxy SVG and SVGViewer were consulted only as public product references. Their
source, assets, branding, layout and text were not copied. All test SVG strings
+4 -4
View File
@@ -14,8 +14,8 @@ the packages.
| Project | Version | Licence | Role | Source |
| ----------------------- | ------- | --------------------- | -------------------------------------------------------- | ------------------------------------------------- |
| Toolbox contract | 0.2.3 | Apache-2.0 | manifest/context types | https://git.add-ideas.de/lotobo/toolbox-sdk |
| Toolbox shell React | 0.2.3 | Apache-2.0 | shared app shell/theme/help/source actions | https://git.add-ideas.de/lotobo/toolbox-sdk |
| Toolbox contract | 0.3.0 | Apache-2.0 | manifest/context types | https://git.add-ideas.de/lotobo/toolbox-sdk |
| Toolbox shell React | 0.3.0 | Apache-2.0 | shared app shell/theme/help/source actions | https://git.add-ideas.de/lotobo/toolbox-sdk |
| CodeMirror commands | 6.10.4 | MIT | editor commands/keymap | https://code.haverbeke.berlin/codemirror/commands |
| CodeMirror XML language | 6.1.0 | MIT | XML language support | https://github.com/codemirror/lang-xml |
| CodeMirror language | 6.12.4 | MIT | highlighting/folding | https://code.haverbeke.berlin/codemirror/language |
@@ -24,7 +24,7 @@ the packages.
| CodeMirror view | 6.43.6 | MIT | source editor UI | https://code.haverbeke.berlin/codemirror/view |
| Lezer XML | 1.0.6 | MIT | ranged XML syntax diagnostics | https://github.com/lezer-parser/xml |
| css-tree | 3.2.1 | MIT | parsed CSS security and animation-value checks | https://github.com/csstree/csstree |
| DOMPurify | 3.4.12 | MPL-2.0 OR Apache-2.0 | first sanitization layer | https://github.com/cure53/DOMPurify |
| DOMPurify | 3.4.14 | MPL-2.0 OR Apache-2.0 | first sanitization layer | https://github.com/cure53/DOMPurify |
| fflate | 0.8.3 | MIT | bounded SVGZ decompression and deterministic compression | https://github.com/101arrowz/fflate |
| React | 19.2.6 | MIT | application UI | https://github.com/facebook/react |
| React DOM | 19.2.6 | MIT | browser rendering | https://github.com/facebook/react |
@@ -67,7 +67,7 @@ as runtime modules in the release ZIP.
| Package | Version | Licence |
| ----------------------------- | ------- | ---------- |
| `@add-ideas/toolbox-testkit` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-testkit` | 0.3.0 | Apache-2.0 |
| `@eslint/js` | 10.0.1 | MIT |
| `@playwright/test` | 1.61.1 | Apache-2.0 |
| `@testing-library/jest-dom` | 6.9.1 | MIT |
+2 -2
View File
@@ -7,12 +7,12 @@ path, transform, security and test code is project-authored.
| Reference | Exact revision/version | Licence | Purpose and decision |
| ---------------- | ------------------------------------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Toolbox SDK | `ef2dab4b46c61812c9a877d8a19fe497b4a4630a` / packages 0.2.3 | Apache-2.0 | Adopted through its published contract, AppShell and testkit APIs. |
| Toolbox SDK | `bc91659a424aaaadc88fff3a98a2cc2412237386` / packages 0.3.0 | Apache-2.0 | Adopted through its published contract, AppShell and testkit APIs. |
| Toolbox Portal | `55b2b12434465511586eaa2d0a43e81770868738` (v0.9.3 before integration) | AGPL-3.0-only | Studied for release assembly, nested deployment, catalogue and shell conventions. SVG Tools is consumed only as an immutable release artifact. |
| SVG-Edit | `244a26c88e1ab1c32911c5b3637e214d7a7d8b25` | MIT | Interaction and implementation reference only. Its canvas/document model was not adopted because canonical SVG source must remain authoritative. |
| SVG Path Editor | `937d75a83b6be2bdda11d02b9b3594841315223a` | MIT | Path interaction reference only. Its source was not copied; SVG Tools uses its own source-ranged path model. |
| SVGPathCommander | `9aa91dd2119ee6a65b1807d55d33e86b1ccb27fc` | MIT | Evaluated for parsing and geometry, then not shipped. The initial project-authored core is smaller and retains source-command fragments. |
| DOMPurify | `9365501773d6665aaf334d8afa55081b9930a684` / npm 3.4.12 | MPL-2.0 OR Apache-2.0 | Adopted as the first projection-sanitization layer, followed by application-owned SVG, URL and parsed-CSS policy checks. |
| DOMPurify | npm 3.4.14 / lockfile-pinned tarball integrity | MPL-2.0 OR Apache-2.0 | Adopted as the first projection-sanitization layer, followed by application-owned SVG, URL and parsed-CSS policy checks. |
| SVGO | npm 4.0.2, published `gitHead` `b2309cf541aee11634eb653157b0ff86ab326e98` | MIT | Adopted behind a cancelable application worker. Direct GitHub cloning was unavailable in the build environment, so the exact locked npm source identity is recorded. |
| css-tree | npm 3.2.1 | MIT | Adopted for stylesheet, declaration and animation-value parsing. |
| fflate | npm 0.8.3 | MIT | Adopted for deterministic SVGZ output and bounded streaming decompression. |
+4
View File
@@ -73,6 +73,10 @@ and DOMPurify remain dependency/security boundaries. Bounded data URLs may still
decode expensive images within the raster-pixel limits. CSP header regressions
can break the fixed controller even when the content remains inert.
DOMPurify is pinned in the lockfile and its SVG policy is covered by the
project-authored adversarial suite. Dependency updates must run both
`npm run test:security` and the browser tests before release.
Report vulnerabilities privately through the repository owner/contact before
opening a public issue when disclosure could expose users. Include the SVG,
browser, deployment headers and observed network/execution behavior without
+20 -20
View File
@@ -1,16 +1,16 @@
{
"name": "svg-tools",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "svg-tools",
"version": "0.1.0",
"version": "0.2.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"@codemirror/commands": "6.10.4",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "6.12.4",
@@ -19,14 +19,14 @@
"@codemirror/view": "6.43.6",
"@lezer/xml": "1.0.6",
"css-tree": "3.2.1",
"dompurify": "3.4.12",
"dompurify": "3.4.14",
"fflate": "0.8.3",
"react": "19.2.6",
"react-dom": "19.2.6",
"svgo": "4.0.2"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.61.1",
"@testing-library/jest-dom": "6.9.1",
@@ -54,18 +54,18 @@
}
},
"node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0"
},
"node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz",
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"peerDependencies": {
"react": ">=18 <20",
@@ -73,13 +73,13 @@
}
},
"node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz",
"integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"bin": {
"toolbox-check": "dist/cli.js"
@@ -2330,9 +2330,9 @@
}
},
"node_modules/dompurify": {
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"version": "3.4.14",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz",
"integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "svg-tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Inspect, edit, optimize and transform SVG documents locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
@@ -41,8 +41,8 @@
"release:artifact": "npm run check && npm run test:security && npm run test:browser && npm run package:release -- --force && npm run portal:smoke"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"@codemirror/commands": "6.10.4",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "6.12.4",
@@ -51,14 +51,14 @@
"@codemirror/view": "6.43.6",
"@lezer/xml": "1.0.6",
"css-tree": "3.2.1",
"dompurify": "3.4.12",
"dompurify": "3.4.14",
"fflate": "0.8.3",
"react": "19.2.6",
"react-dom": "19.2.6",
"svgo": "4.0.2"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.61.1",
"@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -18,7 +18,25 @@ export default defineConfig({
timeout: 180_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{
name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
],
});
+16 -6
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.svg-tools",
"name": "SVG Tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Inspect, edit, optimize and transform SVG documents locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
@@ -24,16 +24,26 @@
},
"requirements": {
"secureContext": false,
"workers": true,
"workers": false,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"privacy": {
"processing": "local",
"fileUploads": false,
"telemetry": false
"io": {
"accepts": [
{ "mediaType": "image/svg+xml", "extensions": [".svg", ".svgz"] },
{ "mediaType": "application/json", "extensions": [".svgtools.json"] }
],
"produces": [
{ "mediaType": "image/svg+xml", "extensions": [".svg", ".svgz"] },
{ "mediaType": "image/png", "extensions": [".png"] },
{ "mediaType": "image/jpeg", "extensions": [".jpg"] },
{ "mediaType": "image/webp", "extensions": [".webp"] },
{ "mediaType": "application/json", "extensions": [".svgtools.json"] }
]
},
"capabilities": { "required": [], "optional": ["workers", "canvas"] },
"privacy": { "processing": "local", "fileUploads": true, "telemetry": false },
"source": {
"repository": "https://git.add-ideas.de/lotobo/svg-tools",
"license": "GPL-3.0-or-later"
+33 -5
View File
@@ -50,6 +50,8 @@ export interface InspectorProps {
value: string | null,
mergeKey?: string,
) => void;
onDuplicateNode: () => void;
onDeleteNode: () => void;
onCommitPath: (data: string, label: string) => void;
onTransformPreview: (matrix: Matrix | null) => void;
onApplyTransform: (matrix: Matrix, mode: "attribute" | "bake") => void;
@@ -186,9 +188,13 @@ function DocumentPanel({
function ElementPanel({
node,
onSetAttribute,
onDuplicateNode,
onDeleteNode,
}: {
node: SemanticSvgNode | undefined;
onSetAttribute: InspectorProps["onSetAttribute"];
onDuplicateNode: () => void;
onDeleteNode: () => void;
}) {
const [newName, setNewName] = useState("");
const [newValue, setNewValue] = useState("");
@@ -207,6 +213,24 @@ function ElementPanel({
<span className="element-glyph">&lt;{node.name}&gt;</span>
<span>{node.id ? `#${node.id}` : "No ID"}</span>
</div>
<div className="button-row">
<button
type="button"
className="secondary-button"
disabled={!node.parentKey}
onClick={onDuplicateNode}
>
Duplicate element
</button>
<button
type="button"
className="secondary-button danger-button"
disabled={!node.parentKey}
onClick={onDeleteNode}
>
Delete element
</button>
</div>
<h3>Common styling</h3>
<div className="field-grid">
<Field
@@ -898,14 +922,13 @@ export function Inspector(props: InspectorProps) {
</div>
<div
className="inspector-tabs"
role="tablist"
role="group"
aria-label="Inspector sections"
>
{tabs.map((tab) => (
<button
type="button"
role="tab"
aria-selected={props.activeTab === tab.id}
aria-pressed={props.activeTab === tab.id}
className={props.activeTab === tab.id ? "is-active" : ""}
key={tab.id}
onClick={() => props.onTabChange(tab.id)}
@@ -914,7 +937,7 @@ export function Inspector(props: InspectorProps) {
</button>
))}
</div>
<div className="inspector-body" role="tabpanel">
<div className="inspector-body">
<fieldset disabled={props.disabled} className="inspector-fieldset">
{props.activeTab === "document" ? (
<DocumentPanel
@@ -923,7 +946,12 @@ export function Inspector(props: InspectorProps) {
/>
) : null}
{props.activeTab === "element" ? (
<ElementPanel node={node} onSetAttribute={props.onSetAttribute} />
<ElementPanel
node={node}
onSetAttribute={props.onSetAttribute}
onDuplicateNode={props.onDuplicateNode}
onDeleteNode={props.onDeleteNode}
/>
) : null}
{props.activeTab === "path" ? (
<PathPanel
+114
View File
@@ -16,6 +16,7 @@ import { CommandHistory, createTransaction } from "../commands/history";
import { geometryDiagnostics } from "../diagnostics/geometry";
import type {
SemanticSvgDocument,
SemanticSvgNode,
SourcePatch,
SvgDiagnostic,
} from "../document/document.types";
@@ -49,6 +50,7 @@ import {
} from "../structure/reference-index";
import { APPLICATION_VERSION } from "../version";
import { STARTER_SVG } from "../app/sample";
import { defaultSvgLimits, utf8ByteLength } from "../app/limits";
import { CanvasPane, type CanvasView } from "./CanvasPane";
import { Inspector, type InspectorTab } from "./Inspector";
import { SourceEditor } from "./SourceEditor";
@@ -608,6 +610,115 @@ export function Workbench() {
}
};
const selectedSubtree = (): SemanticSvgNode[] => {
if (!selectedNode) return [];
const result: SemanticSvgNode[] = [];
const visit = (node: SemanticSvgNode) => {
result.push(node);
for (const childKey of node.childKeys) {
const child = snapshot.semantic.nodes.get(childKey);
if (child) visit(child);
}
};
visit(selectedNode);
return result;
};
const duplicateSelectedNode = () => {
if (visualDisabled || !selectedNode?.parentKey) return;
try {
const subtree = selectedSubtree();
const ids = subtree.flatMap((node) => (node.id ? [node.id] : []));
if (ids.length)
throw new Error(
`Duplicate is refused because the subtree owns ${ids.length} ID(s). Rename or remove them first so references cannot become ambiguous.`,
);
const fragment = source.slice(
selectedNode.sourceRange.full.from,
selectedNode.sourceRange.full.to,
);
const lineStart = Math.max(
source.lastIndexOf("\n", selectedNode.sourceRange.full.from - 1) + 1,
source.lastIndexOf("\r", selectedNode.sourceRange.full.from - 1) + 1,
);
const indentation =
/^\s*/u.exec(
source.slice(lineStart, selectedNode.sourceRange.full.from),
)?.[0] ?? "";
const insert = `${snapshot.semantic.preferences.newline}${indentation}${fragment}`;
if (
utf8ByteLength(source) + utf8ByteLength(insert) >
defaultSvgLimits.sourceHardBytes
)
throw new RangeError(
"Duplicate would exceed the 20 MiB SVG source limit.",
);
commitPatches(
[
{
from: selectedNode.sourceRange.full.to,
to: selectedNode.sourceRange.full.to,
insert,
label: `Duplicate <${selectedNode.name}>`,
},
],
`Duplicate <${selectedNode.name}>`,
undefined,
snapshot.revision,
);
setStatus(
`Duplicated <${selectedNode.name}> without introducing editor metadata.`,
);
} catch (error) {
setStatus(error instanceof Error ? error.message : "Duplicate failed");
}
};
const deleteSelectedNode = () => {
if (visualDisabled || !selectedNode?.parentKey) return;
try {
const subtree = selectedSubtree();
const subtreeKeys = new Set(subtree.map((node) => node.key));
const references = buildReferenceIndex(snapshot.semantic);
for (const node of subtree) {
if (!node.id) continue;
const externalIncoming = (
references.incomingById.get(node.id) ?? []
).filter((edge) => !subtreeKeys.has(edge.sourceKey));
if (externalIncoming.length)
throw new Error(
`Delete is refused because #${node.id} has ${externalIncoming.length} reference(s) from outside the selected subtree.`,
);
}
const parentKey = selectedNode.parentKey;
commitPatches(
[
{
from: selectedNode.sourceRange.full.from,
to: selectedNode.sourceRange.full.to,
insert: "",
label: `Delete <${selectedNode.name}>`,
},
],
`Delete <${selectedNode.name}>`,
undefined,
snapshot.revision,
);
setAnimations((current) =>
current.filter(
(animation) => !subtreeKeys.has(animation.targetNodeKey),
),
);
setSelectedKey(parentKey);
selectedRef.current = parentKey;
setStatus(
`Deleted <${selectedNode.name}> and its ${subtree.length - 1} descendant(s).`,
);
} catch (error) {
setStatus(error instanceof Error ? error.message : "Delete failed");
}
};
const applyTransform = (matrix: Matrix, mode: "attribute" | "bake") => {
if (visualDisabled || !selectedNode) return;
try {
@@ -848,6 +959,7 @@ export function Workbench() {
ref={fileInputRef}
className="sr-only"
type="file"
aria-label="Open SVG or SVG Tools project"
accept=".svg,.svgz,.svgtools.json,image/svg+xml,application/gzip,application/json"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
@@ -1104,6 +1216,8 @@ export function Workbench() {
onTabChange={setActiveTab}
onPathEditingChange={setPathEditing}
onSetAttribute={setAttribute}
onDuplicateNode={duplicateSelectedNode}
onDeleteNode={deleteSelectedNode}
onCommitPath={commitPath}
onTransformPreview={setTransformPreview}
onApplyTransform={applyTransform}
+9
View File
@@ -183,6 +183,15 @@ a:focus-visible,
color: var(--toolbox-accent);
}
.danger-button {
border-color: color-mix(
in srgb,
var(--toolbox-danger) 55%,
var(--toolbox-border)
);
color: var(--toolbox-danger);
}
.icon-button {
display: inline-grid;
width: 2.25rem;
+20 -3
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.svg-tools",
"name": "SVG Tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Inspect, edit, optimize and transform SVG documents locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
@@ -24,14 +24,31 @@
},
"requirements": {
"secureContext": false,
"workers": true,
"workers": false,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{ "mediaType": "image/svg+xml", "extensions": [".svg", ".svgz"] },
{ "mediaType": "application/json", "extensions": [".svgtools.json"] }
],
"produces": [
{ "mediaType": "image/svg+xml", "extensions": [".svg", ".svgz"] },
{ "mediaType": "image/png", "extensions": [".png"] },
{ "mediaType": "image/jpeg", "extensions": [".jpg"] },
{ "mediaType": "image/webp", "extensions": [".webp"] },
{ "mediaType": "application/json", "extensions": [".svgtools.json"] }
]
},
"capabilities": {
"required": [],
"optional": ["workers", "canvas"]
},
"privacy": {
"processing": "local",
"fileUploads": false,
"fileUploads": true,
"telemetry": false
},
"source": {
+2 -2
View File
@@ -1,5 +1,5 @@
export const APPLICATION_VERSION = "0.1.0";
export const DOMPURIFY_VERSION = "3.4.12";
export const APPLICATION_VERSION = "0.2.0";
export const DOMPURIFY_VERSION = "3.4.14";
export const SVGO_VERSION = "4.0.2";
export const SVG_PATH_COMMANDER_VERSION = "2.2.1";
export const CSS_TREE_VERSION = "3.2.1";
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/svg/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+25 -1
View File
@@ -67,7 +67,7 @@ test("synchronizes tree selection, source patches, undo and redo", async ({
const pathRow = page.getByRole("treeitem").filter({ hasText: "path" }).last();
await pathRow.click();
await expect(pathRow).toHaveAttribute("aria-selected", "true");
await page.getByRole("tab", { name: "Element" }).click();
await page.getByRole("button", { name: "Element" }).click();
const fill = page.getByLabel("Fill", { exact: true });
await fill.fill("#123456");
@@ -80,6 +80,30 @@ test("synchronizes tree selection, source patches, undo and redo", async ({
await expect(page.locator(".cm-content")).toContainText('fill="#123456"');
});
test("duplicates and deletes ID-free nodes while protecting referenced IDs", async ({
page,
}) => {
await page.goto("/deep/nested/svg/");
await page.getByPlaceholder("Filter elements…").fill("stop");
await page.getByRole("treeitem").filter({ hasText: "stop" }).first().click();
await page.getByRole("button", { name: "Element" }).click();
await page.getByRole("button", { name: "Duplicate element" }).click();
await expect(page.locator(".cm-content")).toContainText(
/stop-color="#725cff"[\s\S]*stop-color="#725cff"/u,
);
await page.getByRole("button", { name: "Delete element" }).click();
await expect(page.locator(".cm-content")).not.toContainText(
/stop-color="#725cff"[\s\S]*stop-color="#725cff"/u,
);
await page.getByPlaceholder("Filter elements…").fill("sun");
await page.getByRole("treeitem").filter({ hasText: "sun" }).click();
await page.getByRole("button", { name: "Duplicate element" }).click();
await expect(page.locator(".workbench-footer")).toContainText(
/Duplicate is refused/u,
);
});
test("connects to a valid same-origin Toolbox catalogue from the nested build", async ({
page,
}) => {