From 0c7736dbc363cb899d93d29919f5668e8d30a339 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 2 Sep 2026 04:48:12 +0200 Subject: [PATCH] Release SVG Tools 0.2.0 --- .gitea/workflows/verify.yml | 39 ++++++++++ CHANGELOG.md | 15 +++- README.md | 8 ++- SOURCE.md | 32 ++++----- THIRD_PARTY_NOTICES.md | 8 +-- docs/REFERENCE_IMPLEMENTATIONS.md | 4 +- docs/SECURITY.md | 4 ++ package-lock.json | 40 +++++------ package.json | 10 +-- playwright.config.ts | 22 +++++- public/toolbox-app.json | 22 ++++-- src/components/Inspector.tsx | 38 ++++++++-- src/components/Workbench.tsx | 114 ++++++++++++++++++++++++++++++ src/styles.css | 9 +++ src/toolbox/manifest.source.json | 23 +++++- src/version.ts | 4 +- tests/browser/responsive.spec.ts | 18 +++++ tests/browser/svg-tools.spec.ts | 26 ++++++- 18 files changed, 366 insertions(+), 70 deletions(-) create mode 100644 .gitea/workflows/verify.yml create mode 100644 tests/browser/responsive.spec.ts diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml new file mode 100644 index 0000000..b84a7cb --- /dev/null +++ b/.gitea/workflows/verify.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 73928ba..c888bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index fcb86f6..54e8f0c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/SOURCE.md b/SOURCE.md index f8393c0..4d1c660 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -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 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 801f79d..c0fdf8e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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 | diff --git a/docs/REFERENCE_IMPLEMENTATIONS.md b/docs/REFERENCE_IMPLEMENTATIONS.md index bf73855..8e1973d 100644 --- a/docs/REFERENCE_IMPLEMENTATIONS.md +++ b/docs/REFERENCE_IMPLEMENTATIONS.md @@ -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. | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 8830dfc..d2de4b6 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 5f96238..fcc267c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 3fbae97..b823d64 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index 60dbc27..86274d6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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"] }, + }, ], }); diff --git a/public/toolbox-app.json b/public/toolbox-app.json index 8a6c8a8..8eb579b 100644 --- a/public/toolbox-app.json +++ b/public/toolbox-app.json @@ -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" diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index a26bcdd..47d8c08 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -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({ <{node.name}> {node.id ? `#${node.id}` : "No ID"} +
+ + +

Common styling

{tabs.map((tab) => ( ))}
-
+
{props.activeTab === "document" ? ( ) : null} {props.activeTab === "element" ? ( - + ) : null} {props.activeTab === "path" ? ( { + 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} diff --git a/src/styles.css b/src/styles.css index 404f2c0..5621c8b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; diff --git a/src/toolbox/manifest.source.json b/src/toolbox/manifest.source.json index 8a6c8a8..6dae441 100644 --- a/src/toolbox/manifest.source.json +++ b/src/toolbox/manifest.source.json @@ -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": { diff --git a/src/version.ts b/src/version.ts index db5dd19..9231749 100644 --- a/src/version.ts +++ b/src/version.ts @@ -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"; diff --git a/tests/browser/responsive.spec.ts b/tests/browser/responsive.spec.ts new file mode 100644 index 0000000..de01caf --- /dev/null +++ b/tests/browser/responsive.spec.ts @@ -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); +}); diff --git a/tests/browser/svg-tools.spec.ts b/tests/browser/svg-tools.spec.ts index 3a75a37..f7546ab 100644 --- a/tests/browser/svg-tools.spec.ts +++ b/tests/browser/svg-tools.spec.ts @@ -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, }) => {