Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c7736dbc3 | ||
|
|
902ff61e8d |
@@ -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
|
||||
@@ -3,6 +3,25 @@
|
||||
All notable changes follow Keep a Changelog. Versions follow Semantic
|
||||
Versioning.
|
||||
|
||||
## [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
|
||||
|
||||
- Added the requested exact reference-implementation review and adoption
|
||||
record.
|
||||
|
||||
## [0.1.0] - 2026-08-02
|
||||
|
||||
### Added
|
||||
@@ -19,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -159,7 +161,8 @@ should keep `Cross-Origin-Resource-Policy: same-origin`. The Toolbox Portal
|
||||
|
||||
## Source and licence
|
||||
|
||||
Corresponding source and inspected revisions are recorded in [SOURCE.md](SOURCE.md).
|
||||
Corresponding source and inspected revisions are recorded in [SOURCE.md](SOURCE.md)
|
||||
and [docs/REFERENCE_IMPLEMENTATIONS.md](docs/REFERENCE_IMPLEMENTATIONS.md).
|
||||
Third-party packages and adoption decisions are recorded in
|
||||
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) and [LICENSES](LICENSES/README.md).
|
||||
Project-authored fixtures contain no third-party artwork or text.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Reference implementations
|
||||
|
||||
This record complements [`SOURCE.md`](../SOURCE.md). Revisions were inspected
|
||||
on 2026-08-01/02 while implementing SVG Tools 0.1.0. Reference code, fixtures,
|
||||
assets, branding and explanatory text were not copied or adapted; the shipped
|
||||
path, transform, security and test code is project-authored.
|
||||
|
||||
| Reference | Exact revision/version | Licence | Purpose and decision |
|
||||
| ---------------- | ------------------------------------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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 | 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. |
|
||||
|
||||
Boxy SVG and SVGViewer were consulted only as public product/interaction
|
||||
references. Their proprietary source and assets were not used.
|
||||
|
||||
No boolean geometry engine was adopted: PathBool.js was considered too early
|
||||
for a mandatory production dependency, while Paper.js and PathKit would add a
|
||||
large secondary geometry/runtime model before the required fixture and fuzz
|
||||
spikes. No third-party raster engine was adopted; 0.1.0 uses the browser SVG
|
||||
decoder and Canvas 2D behind explicit limits. These decisions deliberately
|
||||
leave boolean operations and deterministic cross-browser rasterization on the
|
||||
roadmap rather than overstating support.
|
||||
@@ -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
|
||||
|
||||
Generated
+20
-20
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"><{node.name}></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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user