Jan2026 fixes (#1077)
* fix release script * fix svgcanvas edge cases * Update path-actions.js * add modern js * update deps * Update CHANGES.md
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# SVG-Edit CHANGES
|
# SVG-Edit CHANGES
|
||||||
|
|
||||||
|
## 7.4.1
|
||||||
|
- Fix: parent transform iteration and undo/redo for grouped elements
|
||||||
|
- Fix: gradient inheritance, clipPath translation, blur filters, layer operations
|
||||||
|
- Tests: add Playwright regression tests for 11 GitHub issues
|
||||||
|
- Build: ensure coverage instrumentation for e2e tests
|
||||||
|
|
||||||
## 7.4.0
|
## 7.4.0
|
||||||
- Scripts: adapt `build` and `publish` for root-managed builds/publishes across workspaces.
|
- Scripts: adapt `build` and `publish` for root-managed builds/publishes across workspaces.
|
||||||
- Docs: Update release/publish instructions to reflect workspace versioning and the new `scripts/version-bump.mjs` helper.
|
- Docs: Update release/publish instructions to reflect workspace versioning and the new `scripts/version-bump.mjs` helper.
|
||||||
|
|||||||
@@ -1,14 +1,60 @@
|
|||||||
{"total": {"lines":{"total":1903,"covered":1152,"skipped":0,"pct":60.53},"statements":{"total":2952,"covered":1665,"skipped":0,"pct":56.4},"functions":{"total":219,"covered":127,"skipped":0,"pct":57.99},"branches":{"total":853,"covered":434,"skipped":0,"pct":50.87},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}}
|
{"total": {"lines":{"total":6678,"covered":4401,"skipped":0,"pct":65.9},"statements":{"total":9862,"covered":6108,"skipped":0,"pct":61.93},"functions":{"total":1141,"covered":621,"skipped":0,"pct":54.42},"branches":{"total":3800,"covered":1905,"skipped":0,"pct":50.13},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}}
|
||||||
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/common/util.js": {"lines":{"total":138,"covered":99,"skipped":0,"pct":71.73},"functions":{"total":8,"covered":8,"skipped":0,"pct":100},"statements":{"total":194,"covered":138,"skipped":0,"pct":71.13},"branches":{"total":98,"covered":60,"skipped":0,"pct":61.22}}
|
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/common/browser.js": {"lines":{"total":37,"covered":15,"skipped":0,"pct":40.54},"functions":{"total":10,"covered":4,"skipped":0,"pct":40},"statements":{"total":42,"covered":17,"skipped":0,"pct":40.47},"branches":{"total":5,"covered":2,"skipped":0,"pct":40}}
|
||||||
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/clear.js": {"lines":{"total":22,"covered":22,"skipped":0,"pct":100},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":38,"covered":38,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}}
|
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/common/util.js": {"lines":{"total":81,"covered":63,"skipped":0,"pct":77.77},"functions":{"total":31,"covered":18,"skipped":0,"pct":58.06},"statements":{"total":206,"covered":154,"skipped":0,"pct":74.75},"branches":{"total":104,"covered":72,"skipped":0,"pct":69.23}}
|
||||||
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/coords.js": {"lines":{"total":251,"covered":102,"skipped":0,"pct":40.63},"functions":{"total":12,"covered":8,"skipped":0,"pct":66.66},"statements":{"total":378,"covered":159,"skipped":0,"pct":42.06},"branches":{"total":87,"covered":24,"skipped":0,"pct":27.58}}
|
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/clear.js": {"lines":{"total":29,"covered":28,"skipped":0,"pct":96.55},"functions":{"total":5,"covered":5,"skipped":0,"pct":100},"statements":{"total":75,"covered":70,"skipped":0,"pct":93.33},"branches":{"total":12,"covered":8,"skipped":0,"pct":66.66}}
|
||||||
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/dataStorage.js": {"lines":{"total":16,"covered":16,"skipped":0,"pct":100},"functions":{"total":5,"covered":5,"skipped":0,"pct":100},"statements":{"total":20,"covered":20,"skipped":0,"pct":100},"branches":{"total":6,"covered":6,"skipped":0,"pct":100}}
|
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/coords.js": {"lines":{"total":362,"covered":281,"skipped":0,"pct":77.62},"functions":{"total":21,"covered":21,"skipped":0,"pct":100},"statements":{"total":850,"covered":594,"skipped":0,"pct":69.88},"branches":{"total":374,"covered":201,"skipped":0,"pct":53.74}}
|
||||||
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/paint.js": {"lines":{"total":51,"covered":44,"skipped":0,"pct":86.27},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":75,"covered":65,"skipped":0,"pct":86.66},"branches":{"total":24,"covered":21,"skipped":0,"pct":87.5}}
|
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/dataStorage.js": {"lines":{"total":37,"covered":37,"skipped":0,"pct":100},"functions":{"total":5,"covered":5,"skipped":0,"pct":100},"statements":{"total":49,"covered":49,"skipped":0,"pct":100},"branches":{"total":17,"covered":16,"skipped":0,"pct":94.11}}
|
||||||
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/path.js": {"lines":{"total":312,"covered":130,"skipped":0,"pct":41.66},"functions":{"total":21,"covered":11,"skipped":0,"pct":52.38},"statements":{"total":511,"covered":193,"skipped":0,"pct":37.76},"branches":{"total":111,"covered":33,"skipped":0,"pct":29.72}}
|
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/draw.js": {"lines":{"total":396,"covered":208,"skipped":0,"pct":52.52},"functions":{"total":56,"covered":32,"skipped":0,"pct":57.14},"statements":{"total":399,"covered":210,"skipped":0,"pct":52.63},"branches":{"total":203,"covered":97,"skipped":0,"pct":47.78}}
|
||||||
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/recalculate.js": {"lines":{"total":241,"covered":102,"skipped":0,"pct":42.32},"functions":{"total":5,"covered":4,"skipped":0,"pct":80},"statements":{"total":338,"covered":133,"skipped":0,"pct":39.34},"branches":{"total":140,"covered":62,"skipped":0,"pct":44.28}}
|
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/history.js": {"lines":{"total":174,"covered":84,"skipped":0,"pct":48.27},"functions":{"total":48,"covered":36,"skipped":0,"pct":75},"statements":{"total":183,"covered":84,"skipped":0,"pct":45.9},"branches":{"total":120,"covered":38,"skipped":0,"pct":31.66}}
|
||||||
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/touch.js": {"lines":{"total":22,"covered":22,"skipped":0,"pct":100},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":40,"covered":36,"skipped":0,"pct":90},"branches":{"total":6,"covered":5,"skipped":0,"pct":83.33}}
|
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/layer.js": {"lines":{"total":81,"covered":74,"skipped":0,"pct":91.35},"functions":{"total":16,"covered":16,"skipped":0,"pct":100},"statements":{"total":116,"covered":108,"skipped":0,"pct":93.1},"branches":{"total":36,"covered":31,"skipped":0,"pct":86.11}}
|
||||||
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/utilities.js": {"lines":{"total":669,"covered":451,"skipped":0,"pct":67.41},"functions":{"total":78,"covered":58,"skipped":0,"pct":74.35},"statements":{"total":991,"covered":649,"skipped":0,"pct":65.48},"branches":{"total":312,"covered":167,"skipped":0,"pct":53.52}}
|
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/math.js": {"lines":{"total":93,"covered":67,"skipped":0,"pct":72.04},"functions":{"total":14,"covered":12,"skipped":0,"pct":85.71},"statements":{"total":97,"covered":70,"skipped":0,"pct":72.16},"branches":{"total":40,"covered":30,"skipped":0,"pct":75}}
|
||||||
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/MainMenu.js": {"lines":{"total":138,"covered":123,"skipped":0,"pct":89.13},"functions":{"total":15,"covered":13,"skipped":0,"pct":86.66},"statements":{"total":186,"covered":166,"skipped":0,"pct":89.24},"branches":{"total":44,"covered":33,"skipped":0,"pct":75}}
|
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/namespaces.js": {"lines":{"total":6,"covered":6,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":6,"covered":6,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
|
||||||
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/contextmenu.js": {"lines":{"total":25,"covered":24,"skipped":0,"pct":96},"functions":{"total":8,"covered":8,"skipped":0,"pct":100},"statements":{"total":38,"covered":36,"skipped":0,"pct":94.73},"branches":{"total":13,"covered":12,"skipped":0,"pct":92.3}}
|
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/paint.js": {"lines":{"total":94,"covered":93,"skipped":0,"pct":98.93},"functions":{"total":6,"covered":6,"skipped":0,"pct":100},"statements":{"total":160,"covered":153,"skipped":0,"pct":95.62},"branches":{"total":77,"covered":67,"skipped":0,"pct":87.01}}
|
||||||
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/locale.js": {"lines":{"total":18,"covered":17,"skipped":0,"pct":94.44},"functions":{"total":60,"covered":5,"skipped":0,"pct":8.33},"statements":{"total":143,"covered":32,"skipped":0,"pct":22.37},"branches":{"total":10,"covered":9,"skipped":0,"pct":90}}
|
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/path.js": {"lines":{"total":343,"covered":269,"skipped":0,"pct":78.42},"functions":{"total":40,"covered":17,"skipped":0,"pct":42.5},"statements":{"total":902,"covered":579,"skipped":0,"pct":64.19},"branches":{"total":290,"covered":147,"skipped":0,"pct":50.68}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/recalculate.js": {"lines":{"total":553,"covered":219,"skipped":0,"pct":39.6},"functions":{"total":10,"covered":9,"skipped":0,"pct":90},"statements":{"total":1283,"covered":435,"skipped":0,"pct":33.9},"branches":{"total":620,"covered":239,"skipped":0,"pct":38.54}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/sanitize.js": {"lines":{"total":123,"covered":110,"skipped":0,"pct":89.43},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":183,"covered":157,"skipped":0,"pct":85.79},"branches":{"total":79,"covered":61,"skipped":0,"pct":77.21}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/touch.js": {"lines":{"total":22,"covered":22,"skipped":0,"pct":100},"functions":{"total":5,"covered":5,"skipped":0,"pct":100},"statements":{"total":62,"covered":57,"skipped":0,"pct":91.93},"branches":{"total":12,"covered":8,"skipped":0,"pct":66.66}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/units.js": {"lines":{"total":72,"covered":51,"skipped":0,"pct":70.83},"functions":{"total":10,"covered":8,"skipped":0,"pct":80},"statements":{"total":74,"covered":52,"skipped":0,"pct":70.27},"branches":{"total":28,"covered":15,"skipped":0,"pct":53.57}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/utilities.js": {"lines":{"total":652,"covered":502,"skipped":0,"pct":76.99},"functions":{"total":131,"covered":99,"skipped":0,"pct":75.57},"statements":{"total":1552,"covered":1004,"skipped":0,"pct":64.69},"branches":{"total":628,"covered":304,"skipped":0,"pct":48.4}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/ConfigObj.js": {"lines":{"total":105,"covered":50,"skipped":0,"pct":47.61},"functions":{"total":15,"covered":11,"skipped":0,"pct":73.33},"statements":{"total":107,"covered":50,"skipped":0,"pct":46.72},"branches":{"total":79,"covered":30,"skipped":0,"pct":37.97}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/Editor.js": {"lines":{"total":414,"covered":207,"skipped":0,"pct":50},"functions":{"total":103,"covered":35,"skipped":0,"pct":33.98},"statements":{"total":420,"covered":208,"skipped":0,"pct":49.52},"branches":{"total":155,"covered":61,"skipped":0,"pct":39.35}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/EditorStartup.js": {"lines":{"total":389,"covered":228,"skipped":0,"pct":58.61},"functions":{"total":58,"covered":28,"skipped":0,"pct":48.27},"statements":{"total":401,"covered":234,"skipped":0,"pct":58.35},"branches":{"total":114,"covered":35,"skipped":0,"pct":30.7}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/MainMenu.js": {"lines":{"total":101,"covered":78,"skipped":0,"pct":77.22},"functions":{"total":15,"covered":13,"skipped":0,"pct":86.66},"statements":{"total":102,"covered":79,"skipped":0,"pct":77.45},"branches":{"total":42,"covered":26,"skipped":0,"pct":61.9}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/Rulers.js": {"lines":{"total":119,"covered":94,"skipped":0,"pct":78.99},"functions":{"total":6,"covered":5,"skipped":0,"pct":83.33},"statements":{"total":124,"covered":97,"skipped":0,"pct":78.22},"branches":{"total":33,"covered":25,"skipped":0,"pct":75.75}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/contextmenu.js": {"lines":{"total":22,"covered":9,"skipped":0,"pct":40.9},"functions":{"total":8,"covered":2,"skipped":0,"pct":25},"statements":{"total":25,"covered":12,"skipped":0,"pct":48},"branches":{"total":15,"covered":5,"skipped":0,"pct":33.33}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/locale.js": {"lines":{"total":15,"covered":10,"skipped":0,"pct":66.66},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":15,"covered":10,"skipped":0,"pct":66.66},"branches":{"total":8,"covered":2,"skipped":0,"pct":25}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/PaintBox.js": {"lines":{"total":64,"covered":51,"skipped":0,"pct":79.68},"functions":{"total":4,"covered":4,"skipped":0,"pct":100},"statements":{"total":67,"covered":53,"skipped":0,"pct":79.1},"branches":{"total":28,"covered":16,"skipped":0,"pct":57.14}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/seButton.js": {"lines":{"total":57,"covered":45,"skipped":0,"pct":78.94},"functions":{"total":15,"covered":8,"skipped":0,"pct":53.33},"statements":{"total":60,"covered":47,"skipped":0,"pct":78.33},"branches":{"total":29,"covered":25,"skipped":0,"pct":86.2}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/seColorPicker.js": {"lines":{"total":56,"covered":34,"skipped":0,"pct":60.71},"functions":{"total":19,"covered":8,"skipped":0,"pct":42.1},"statements":{"total":57,"covered":34,"skipped":0,"pct":59.64},"branches":{"total":10,"covered":6,"skipped":0,"pct":60}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/seExplorerButton.js": {"lines":{"total":99,"covered":56,"skipped":0,"pct":56.56},"functions":{"total":18,"covered":9,"skipped":0,"pct":50},"statements":{"total":102,"covered":56,"skipped":0,"pct":54.9},"branches":{"total":29,"covered":9,"skipped":0,"pct":31.03}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/seFlyingButton.js": {"lines":{"total":77,"covered":46,"skipped":0,"pct":59.74},"functions":{"total":15,"covered":10,"skipped":0,"pct":66.66},"statements":{"total":78,"covered":47,"skipped":0,"pct":60.25},"branches":{"total":29,"covered":9,"skipped":0,"pct":31.03}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/seInput.js": {"lines":{"total":46,"covered":24,"skipped":0,"pct":52.17},"functions":{"total":16,"covered":5,"skipped":0,"pct":31.25},"statements":{"total":47,"covered":24,"skipped":0,"pct":51.06},"branches":{"total":7,"covered":3,"skipped":0,"pct":42.85}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/seList.js": {"lines":{"total":87,"covered":50,"skipped":0,"pct":57.47},"functions":{"total":21,"covered":7,"skipped":0,"pct":33.33},"statements":{"total":89,"covered":52,"skipped":0,"pct":58.42},"branches":{"total":18,"covered":9,"skipped":0,"pct":50}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/seListItem.js": {"lines":{"total":39,"covered":25,"skipped":0,"pct":64.1},"functions":{"total":12,"covered":3,"skipped":0,"pct":25},"statements":{"total":40,"covered":26,"skipped":0,"pct":65},"branches":{"total":9,"covered":7,"skipped":0,"pct":77.77}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/seMenu.js": {"lines":{"total":27,"covered":21,"skipped":0,"pct":77.77},"functions":{"total":7,"covered":3,"skipped":0,"pct":42.85},"statements":{"total":28,"covered":21,"skipped":0,"pct":75},"branches":{"total":4,"covered":2,"skipped":0,"pct":50}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/seMenuItem.js": {"lines":{"total":37,"covered":28,"skipped":0,"pct":75.67},"functions":{"total":9,"covered":5,"skipped":0,"pct":55.55},"statements":{"total":40,"covered":29,"skipped":0,"pct":72.5},"branches":{"total":16,"covered":11,"skipped":0,"pct":68.75}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/sePalette.js": {"lines":{"total":54,"covered":39,"skipped":0,"pct":72.22},"functions":{"total":11,"covered":8,"skipped":0,"pct":72.72},"statements":{"total":54,"covered":39,"skipped":0,"pct":72.22},"branches":{"total":13,"covered":3,"skipped":0,"pct":23.07}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/sePlainBorderButton.js": {"lines":{"total":3,"covered":3,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":3,"covered":3,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/sePlainMenuButton.js": {"lines":{"total":2,"covered":2,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":2,"covered":2,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/seSelect.js": {"lines":{"total":56,"covered":42,"skipped":0,"pct":75},"functions":{"total":17,"covered":8,"skipped":0,"pct":47.05},"statements":{"total":59,"covered":44,"skipped":0,"pct":74.57},"branches":{"total":15,"covered":14,"skipped":0,"pct":93.33}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/seSpinInput.js": {"lines":{"total":65,"covered":48,"skipped":0,"pct":73.84},"functions":{"total":18,"covered":8,"skipped":0,"pct":44.44},"statements":{"total":66,"covered":48,"skipped":0,"pct":72.72},"branches":{"total":14,"covered":10,"skipped":0,"pct":71.42}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/seText.js": {"lines":{"total":28,"covered":18,"skipped":0,"pct":64.28},"functions":{"total":10,"covered":4,"skipped":0,"pct":40},"statements":{"total":29,"covered":18,"skipped":0,"pct":62.06},"branches":{"total":7,"covered":4,"skipped":0,"pct":57.14}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/seZoom.js": {"lines":{"total":102,"covered":65,"skipped":0,"pct":63.72},"functions":{"total":28,"covered":9,"skipped":0,"pct":32.14},"statements":{"total":107,"covered":66,"skipped":0,"pct":61.68},"branches":{"total":27,"covered":6,"skipped":0,"pct":22.22}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/SePlainAlertDialog.js": {"lines":{"total":12,"covered":7,"skipped":0,"pct":58.33},"functions":{"total":3,"covered":1,"skipped":0,"pct":33.33},"statements":{"total":12,"covered":7,"skipped":0,"pct":58.33},"branches":{"total":2,"covered":0,"skipped":0,"pct":0}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/cmenuDialog.js": {"lines":{"total":120,"covered":112,"skipped":0,"pct":93.33},"functions":{"total":28,"covered":15,"skipped":0,"pct":53.57},"statements":{"total":131,"covered":116,"skipped":0,"pct":88.54},"branches":{"total":19,"covered":17,"skipped":0,"pct":89.47}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/cmenuLayersDialog.js": {"lines":{"total":61,"covered":51,"skipped":0,"pct":83.6},"functions":{"total":16,"covered":7,"skipped":0,"pct":43.75},"statements":{"total":66,"covered":51,"skipped":0,"pct":77.27},"branches":{"total":13,"covered":12,"skipped":0,"pct":92.3}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/editorPreferencesDialog.js": {"lines":{"total":157,"covered":120,"skipped":0,"pct":76.43},"functions":{"total":30,"covered":8,"skipped":0,"pct":26.66},"statements":{"total":159,"covered":121,"skipped":0,"pct":76.1},"branches":{"total":42,"covered":31,"skipped":0,"pct":73.8}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/exportDialog.js": {"lines":{"total":52,"covered":36,"skipped":0,"pct":69.23},"functions":{"total":14,"covered":5,"skipped":0,"pct":35.71},"statements":{"total":55,"covered":36,"skipped":0,"pct":65.45},"branches":{"total":11,"covered":5,"skipped":0,"pct":45.45}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/imagePropertiesDialog.js": {"lines":{"total":161,"covered":91,"skipped":0,"pct":56.52},"functions":{"total":20,"covered":5,"skipped":0,"pct":25},"statements":{"total":162,"covered":92,"skipped":0,"pct":56.79},"branches":{"total":46,"covered":24,"skipped":0,"pct":52.17}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/seAlertDialog.js": {"lines":{"total":6,"covered":6,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":6,"covered":6,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/seConfirmDialog.js": {"lines":{"total":8,"covered":7,"skipped":0,"pct":87.5},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":8,"covered":7,"skipped":0,"pct":87.5},"branches":{"total":4,"covered":1,"skipped":0,"pct":25}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/sePromptDialog.js": {"lines":{"total":24,"covered":19,"skipped":0,"pct":79.16},"functions":{"total":7,"covered":6,"skipped":0,"pct":85.71},"statements":{"total":24,"covered":19,"skipped":0,"pct":79.16},"branches":{"total":8,"covered":5,"skipped":0,"pct":62.5}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/seSelectDialog.js": {"lines":{"total":8,"covered":7,"skipped":0,"pct":87.5},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":8,"covered":7,"skipped":0,"pct":87.5},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/svgSourceDialog.js": {"lines":{"total":74,"covered":56,"skipped":0,"pct":75.67},"functions":{"total":17,"covered":6,"skipped":0,"pct":35.29},"statements":{"total":75,"covered":56,"skipped":0,"pct":74.66},"branches":{"total":16,"covered":12,"skipped":0,"pct":75}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/se-elix/define/NumberSpinBox.js": {"lines":{"total":1,"covered":1,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":1,"covered":1,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/se-elix/src/base/NumberSpinBox.js": {"lines":{"total":54,"covered":31,"skipped":0,"pct":57.4},"functions":{"total":13,"covered":6,"skipped":0,"pct":46.15},"statements":{"total":54,"covered":31,"skipped":0,"pct":57.4},"branches":{"total":46,"covered":33,"skipped":0,"pct":71.73}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/panels/BottomPanel.js": {"lines":{"total":84,"covered":46,"skipped":0,"pct":54.76},"functions":{"total":23,"covered":9,"skipped":0,"pct":39.13},"statements":{"total":84,"covered":46,"skipped":0,"pct":54.76},"branches":{"total":28,"covered":6,"skipped":0,"pct":21.42}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/panels/LayersPanel.js": {"lines":{"total":165,"covered":125,"skipped":0,"pct":75.75},"functions":{"total":27,"covered":19,"skipped":0,"pct":70.37},"statements":{"total":171,"covered":127,"skipped":0,"pct":74.26},"branches":{"total":35,"covered":18,"skipped":0,"pct":51.42}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/panels/LeftPanel.js": {"lines":{"total":57,"covered":28,"skipped":0,"pct":49.12},"functions":{"total":20,"covered":6,"skipped":0,"pct":30},"statements":{"total":58,"covered":28,"skipped":0,"pct":48.27},"branches":{"total":16,"covered":2,"skipped":0,"pct":12.5}}
|
||||||
|
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/panels/TopPanel.js": {"lines":{"total":415,"covered":256,"skipped":0,"pct":61.68},"functions":{"total":78,"covered":30,"skipped":0,"pct":38.46},"statements":{"total":427,"covered":259,"skipped":0,"pct":60.65},"branches":{"total":168,"covered":85,"skipped":0,"pct":50.59}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
- Confirm `CHANGES.md` has been updated.
|
- Confirm `CHANGES.md` has been updated.
|
||||||
- Run the full release checks (`npm run test-build` → tests, docs, and build); it exits on failure.
|
- Run the full release checks (`npm run test-build` → tests, docs, and build); it exits on failure.
|
||||||
- Ask before creating a release commit and tag (defaults to `v<version>`); declining aborts the publish.
|
- Ask before creating a release commit and tag (defaults to `v<version>`); declining aborts the publish.
|
||||||
- Publish all workspaces and the root package together.
|
- Publish all workspaces first, then the root package.
|
||||||
|
|
||||||
You will need to be a member of the npm group to do this step.
|
You will need to be a member of the npm group to do this step.
|
||||||
|
|
||||||
|
|||||||
544
package-lock.json
generated
544
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "svgedit",
|
"name": "svgedit",
|
||||||
"version": "7.4.0",
|
"version": "7.4.1",
|
||||||
"description": "Powerful SVG-Editor for your browser ",
|
"description": "Powerful SVG-Editor for your browser ",
|
||||||
"main": "dist/editor/Editor.js",
|
"main": "dist/editor/Editor.js",
|
||||||
"module": "dist/editor/Editor.js",
|
"module": "dist/editor/Editor.js",
|
||||||
@@ -82,21 +82,21 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@svgedit/svgcanvas": "workspace:*",
|
"@svgedit/svgcanvas": "7.4.1",
|
||||||
"browser-fs-access": "0.38.0",
|
"browser-fs-access": "0.38.0",
|
||||||
"elix": "15.0.1",
|
"elix": "15.0.1",
|
||||||
"i18next": "25.7.1",
|
"i18next": "25.7.4",
|
||||||
"jspdf": "3.0.4",
|
"jspdf": "4.0.0",
|
||||||
"pathseg": "1.2.1",
|
"pathseg": "1.2.1",
|
||||||
"svg2pdf.js": "2.6.0"
|
"svg2pdf.js": "2.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
"@rollup/plugin-dynamic-import-vars": "2.1.5",
|
"@rollup/plugin-dynamic-import-vars": "2.1.5",
|
||||||
"@vitest/coverage-v8": "^4.0.15",
|
"@vitest/coverage-v8": "^4.0.16",
|
||||||
"jamilih": "0.63.1",
|
"jamilih": "0.63.1",
|
||||||
"jsdoc": "4.0.5",
|
"jsdoc": "4.0.5",
|
||||||
"jsdom": "^27.2.0",
|
"jsdom": "^27.4.0",
|
||||||
"npm-run-all": "4.1.5",
|
"npm-run-all": "4.1.5",
|
||||||
"nyc": "17.1.0",
|
"nyc": "17.1.0",
|
||||||
"open-cli": "8.0.0",
|
"open-cli": "8.0.0",
|
||||||
@@ -104,12 +104,12 @@
|
|||||||
"remark-lint-ordered-list-marker-value": "4.0.1",
|
"remark-lint-ordered-list-marker-value": "4.0.1",
|
||||||
"rimraf": "6.1.2",
|
"rimraf": "6.1.2",
|
||||||
"standard": "17.1.2",
|
"standard": "17.1.2",
|
||||||
"vite": "^7.2.6",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-istanbul": "^7.2.1",
|
"vite-plugin-istanbul": "^7.2.1",
|
||||||
"vite-plugin-string": "^1.2.3",
|
"vite-plugin-string": "^1.2.3",
|
||||||
"vitest": "^4.0.15"
|
"vitest": "^4.0.16"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.53.3"
|
"@rollup/rollup-linux-x64-gnu": "4.55.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@svgedit/react-test",
|
"name": "@svgedit/react-test",
|
||||||
"version": "7.4.0",
|
"version": "7.4.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -8,16 +8,74 @@
|
|||||||
|
|
||||||
const NSSVG = 'http://www.w3.org/2000/svg'
|
const NSSVG = 'http://www.w3.org/2000/svg'
|
||||||
|
|
||||||
const { userAgent } = navigator
|
/**
|
||||||
|
* Browser capabilities and detection object.
|
||||||
|
* Uses modern feature detection and lazy evaluation patterns.
|
||||||
|
*/
|
||||||
|
class BrowserDetector {
|
||||||
|
#userAgent = navigator.userAgent
|
||||||
|
#cachedResults = new Map()
|
||||||
|
|
||||||
// Note: Browser sniffing should only be used if no other detection method is possible
|
/**
|
||||||
const isWebkit_ = userAgent.includes('AppleWebKit')
|
* Detects if the browser is WebKit-based
|
||||||
const isGecko_ = userAgent.includes('Gecko/')
|
* @returns {boolean}
|
||||||
const isChrome_ = userAgent.includes('Chrome/')
|
*/
|
||||||
const isMac_ = userAgent.includes('Macintosh')
|
get isWebkit () {
|
||||||
|
if (!this.#cachedResults.has('isWebkit')) {
|
||||||
|
this.#cachedResults.set('isWebkit', this.#userAgent.includes('AppleWebKit'))
|
||||||
|
}
|
||||||
|
return this.#cachedResults.get('isWebkit')
|
||||||
|
}
|
||||||
|
|
||||||
// text character positioning (for IE9 and now Chrome)
|
/**
|
||||||
const supportsGoodTextCharPos_ = (function () {
|
* Detects if the browser is Gecko-based
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
get isGecko () {
|
||||||
|
if (!this.#cachedResults.has('isGecko')) {
|
||||||
|
this.#cachedResults.set('isGecko', this.#userAgent.includes('Gecko/'))
|
||||||
|
}
|
||||||
|
return this.#cachedResults.get('isGecko')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects if the browser is Chrome
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
get isChrome () {
|
||||||
|
if (!this.#cachedResults.has('isChrome')) {
|
||||||
|
this.#cachedResults.set('isChrome', this.#userAgent.includes('Chrome/'))
|
||||||
|
}
|
||||||
|
return this.#cachedResults.get('isChrome')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects if the platform is macOS
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
get isMac () {
|
||||||
|
if (!this.#cachedResults.has('isMac')) {
|
||||||
|
this.#cachedResults.set('isMac', this.#userAgent.includes('Macintosh'))
|
||||||
|
}
|
||||||
|
return this.#cachedResults.get('isMac')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests if the browser supports accurate text character positioning
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
get supportsGoodTextCharPos () {
|
||||||
|
if (!this.#cachedResults.has('supportsGoodTextCharPos')) {
|
||||||
|
this.#cachedResults.set('supportsGoodTextCharPos', this.#testTextCharPos())
|
||||||
|
}
|
||||||
|
return this.#cachedResults.get('supportsGoodTextCharPos')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private method to test text character positioning support
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
#testTextCharPos () {
|
||||||
const svgroot = document.createElementNS(NSSVG, 'svg')
|
const svgroot = document.createElementNS(NSSVG, 'svg')
|
||||||
const svgContent = document.createElementNS(NSSVG, 'svg')
|
const svgContent = document.createElementNS(NSSVG, 'svg')
|
||||||
document.documentElement.append(svgroot)
|
document.documentElement.append(svgroot)
|
||||||
@@ -26,42 +84,51 @@ const supportsGoodTextCharPos_ = (function () {
|
|||||||
const text = document.createElementNS(NSSVG, 'text')
|
const text = document.createElementNS(NSSVG, 'text')
|
||||||
text.textContent = 'a'
|
text.textContent = 'a'
|
||||||
svgContent.append(text)
|
svgContent.append(text)
|
||||||
try { // Chrome now fails here
|
|
||||||
|
try {
|
||||||
const pos = text.getStartPositionOfChar(0).x
|
const pos = text.getStartPositionOfChar(0).x
|
||||||
return (pos === 0)
|
return pos === 0
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return false
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
svgroot.remove()
|
svgroot.remove()
|
||||||
}
|
}
|
||||||
}())
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Public API
|
// Create singleton instance
|
||||||
|
const browser = new BrowserDetector()
|
||||||
|
|
||||||
|
// Export as functions for backward compatibility
|
||||||
/**
|
/**
|
||||||
* @function module:browser.isWebkit
|
* @function module:browser.isWebkit
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
export const isWebkit = () => isWebkit_
|
export const isWebkit = () => browser.isWebkit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @function module:browser.isGecko
|
* @function module:browser.isGecko
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
export const isGecko = () => isGecko_
|
export const isGecko = () => browser.isGecko
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @function module:browser.isChrome
|
* @function module:browser.isChrome
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
export const isChrome = () => isChrome_
|
export const isChrome = () => browser.isChrome
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @function module:browser.isMac
|
* @function module:browser.isMac
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
export const isMac = () => isMac_
|
export const isMac = () => browser.isMac
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @function module:browser.supportsGoodTextCharPos
|
* @function module:browser.supportsGoodTextCharPos
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
export const supportsGoodTextCharPos = () => supportsGoodTextCharPos_
|
export const supportsGoodTextCharPos = () => browser.supportsGoodTextCharPos
|
||||||
|
|
||||||
|
// Export browser instance for direct access
|
||||||
|
export default browser
|
||||||
|
|||||||
151
packages/svgcanvas/common/logger.js
Normal file
151
packages/svgcanvas/common/logger.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Centralized logging utility for SVGCanvas.
|
||||||
|
* Provides configurable log levels and the ability to disable logging in production.
|
||||||
|
* @module logger
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log levels in order of severity
|
||||||
|
* @enum {number}
|
||||||
|
*/
|
||||||
|
export const LogLevel = {
|
||||||
|
NONE: 0,
|
||||||
|
ERROR: 1,
|
||||||
|
WARN: 2,
|
||||||
|
INFO: 3,
|
||||||
|
DEBUG: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logger configuration
|
||||||
|
* @type {Object}
|
||||||
|
*/
|
||||||
|
const config = {
|
||||||
|
currentLevel: LogLevel.WARN,
|
||||||
|
enabled: true,
|
||||||
|
prefix: '[SVGCanvas]'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the logging level
|
||||||
|
* @param {LogLevel} level - The log level to set
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export const setLogLevel = (level) => {
|
||||||
|
if (Object.values(LogLevel).includes(level)) {
|
||||||
|
config.currentLevel = level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable logging
|
||||||
|
* @param {boolean} enabled - Whether logging should be enabled
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export const setLoggingEnabled = (enabled) => {
|
||||||
|
config.enabled = Boolean(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the log prefix
|
||||||
|
* @param {string} prefix - The prefix to use for log messages
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export const setLogPrefix = (prefix) => {
|
||||||
|
config.prefix = String(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a log message with prefix and context
|
||||||
|
* @param {string} message - The log message
|
||||||
|
* @param {string} [context=''] - Optional context information
|
||||||
|
* @returns {string} Formatted message
|
||||||
|
*/
|
||||||
|
const formatMessage = (message, context = '') => {
|
||||||
|
const contextStr = context ? ` [${context}]` : ''
|
||||||
|
return `${config.prefix}${contextStr} ${message}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an error message
|
||||||
|
* @param {string} message - The error message
|
||||||
|
* @param {Error|any} [error] - Optional error object or additional data
|
||||||
|
* @param {string} [context=''] - Optional context (e.g., module name)
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export const error = (message, error, context = '') => {
|
||||||
|
if (!config.enabled || config.currentLevel < LogLevel.ERROR) return
|
||||||
|
|
||||||
|
console.error(formatMessage(message, context))
|
||||||
|
if (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a warning message
|
||||||
|
* @param {string} message - The warning message
|
||||||
|
* @param {any} [data] - Optional additional data
|
||||||
|
* @param {string} [context=''] - Optional context (e.g., module name)
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export const warn = (message, data, context = '') => {
|
||||||
|
if (!config.enabled || config.currentLevel < LogLevel.WARN) return
|
||||||
|
|
||||||
|
console.warn(formatMessage(message, context))
|
||||||
|
if (data !== undefined) {
|
||||||
|
console.warn(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an info message
|
||||||
|
* @param {string} message - The info message
|
||||||
|
* @param {any} [data] - Optional additional data
|
||||||
|
* @param {string} [context=''] - Optional context (e.g., module name)
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export const info = (message, data, context = '') => {
|
||||||
|
if (!config.enabled || config.currentLevel < LogLevel.INFO) return
|
||||||
|
|
||||||
|
console.info(formatMessage(message, context))
|
||||||
|
if (data !== undefined) {
|
||||||
|
console.info(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a debug message
|
||||||
|
* @param {string} message - The debug message
|
||||||
|
* @param {any} [data] - Optional additional data
|
||||||
|
* @param {string} [context=''] - Optional context (e.g., module name)
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export const debug = (message, data, context = '') => {
|
||||||
|
if (!config.enabled || config.currentLevel < LogLevel.DEBUG) return
|
||||||
|
|
||||||
|
console.debug(formatMessage(message, context))
|
||||||
|
if (data !== undefined) {
|
||||||
|
console.debug(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current logger configuration
|
||||||
|
* @returns {Object} Current configuration
|
||||||
|
*/
|
||||||
|
export const getConfig = () => ({ ...config })
|
||||||
|
|
||||||
|
// Default export as namespace
|
||||||
|
export default {
|
||||||
|
LogLevel,
|
||||||
|
setLogLevel,
|
||||||
|
setLoggingEnabled,
|
||||||
|
setLogPrefix,
|
||||||
|
error,
|
||||||
|
warn,
|
||||||
|
info,
|
||||||
|
debug,
|
||||||
|
getConfig
|
||||||
|
}
|
||||||
@@ -2,197 +2,138 @@
|
|||||||
* @param {any} obj
|
* @param {any} obj
|
||||||
* @returns {any}
|
* @returns {any}
|
||||||
*/
|
*/
|
||||||
export function findPos (obj) {
|
export const findPos = (obj) => {
|
||||||
let curleft = 0
|
let left = 0
|
||||||
let curtop = 0
|
let top = 0
|
||||||
if (obj.offsetParent) {
|
|
||||||
|
if (obj?.offsetParent) {
|
||||||
|
let current = obj
|
||||||
do {
|
do {
|
||||||
curleft += obj.offsetLeft
|
left += current.offsetLeft
|
||||||
curtop += obj.offsetTop
|
top += current.offsetTop
|
||||||
// eslint-disable-next-line no-cond-assign
|
current = current.offsetParent
|
||||||
} while (obj = obj.offsetParent)
|
} while (current)
|
||||||
return { left: curleft, top: curtop }
|
|
||||||
}
|
}
|
||||||
return { left: curleft, top: curtop }
|
|
||||||
|
return { left, top }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isObject (item) {
|
export const isObject = (item) =>
|
||||||
return (item && typeof item === 'object' && !Array.isArray(item))
|
item && typeof item === 'object' && !Array.isArray(item)
|
||||||
}
|
|
||||||
|
export const mergeDeep = (target, source) => {
|
||||||
|
const output = { ...target }
|
||||||
|
|
||||||
export function mergeDeep (target, source) {
|
|
||||||
const output = Object.assign({}, target)
|
|
||||||
if (isObject(target) && isObject(source)) {
|
if (isObject(target) && isObject(source)) {
|
||||||
Object.keys(source).forEach((key) => {
|
for (const key of Object.keys(source)) {
|
||||||
if (isObject(source[key])) {
|
if (isObject(source[key])) {
|
||||||
if (!(key in target)) { Object.assign(output, { [key]: source[key] }) } else { output[key] = mergeDeep(target[key], source[key]) }
|
output[key] = key in target
|
||||||
|
? mergeDeep(target[key], source[key])
|
||||||
|
: source[key]
|
||||||
} else {
|
} else {
|
||||||
Object.assign(output, { [key]: source[key] })
|
output[key] = source[key]
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the closest matching element up the DOM tree.
|
* Get the closest matching element up the DOM tree.
|
||||||
|
* Uses native Element.closest() when possible for better performance.
|
||||||
* @param {Element} elem Starting element
|
* @param {Element} elem Starting element
|
||||||
* @param {String} selector Selector to match against (class, ID, data attribute, or tag)
|
* @param {String} selector Selector to match against (class, ID, data attribute, or tag)
|
||||||
* @return {Boolean|Element} Returns null if not match found
|
* @return {Element|null} Returns null if no match found
|
||||||
*/
|
*/
|
||||||
export function getClosest (elem, selector) {
|
export const getClosest = (elem, selector) => {
|
||||||
|
// Use native closest for standard CSS selectors
|
||||||
|
if (elem?.closest) {
|
||||||
|
try {
|
||||||
|
return elem.closest(selector)
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback for invalid selectors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback implementation for edge cases
|
||||||
|
const selectorMatcher = {
|
||||||
|
'.': (el, sel) => el.classList?.contains(sel.slice(1)),
|
||||||
|
'#': (el, sel) => el.id === sel.slice(1),
|
||||||
|
'[': (el, sel) => {
|
||||||
|
const [attr, val] = sel.slice(1, -1).split('=').map(s => s.replace(/["']/g, ''))
|
||||||
|
return val ? el.getAttribute(attr) === val : el.hasAttribute(attr)
|
||||||
|
},
|
||||||
|
tag: (el, sel) => el.tagName?.toLowerCase() === sel
|
||||||
|
}
|
||||||
|
|
||||||
const firstChar = selector.charAt(0)
|
const firstChar = selector.charAt(0)
|
||||||
const supports = 'classList' in document.documentElement
|
const matcher = selectorMatcher[firstChar] || selectorMatcher.tag
|
||||||
let attribute; let value
|
|
||||||
// If selector is a data attribute, split attribute from value
|
for (let current = elem; current && current !== document && current.nodeType === 1; current = current.parentNode) {
|
||||||
if (firstChar === '[') {
|
if (matcher(current, selector)) return current
|
||||||
selector = selector.substr(1, selector.length - 2)
|
|
||||||
attribute = selector.split('=')
|
|
||||||
if (attribute.length > 1) {
|
|
||||||
value = true
|
|
||||||
attribute[1] = attribute[1].replace(/"/g, '').replace(/'/g, '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Get closest match
|
|
||||||
for (; elem && elem !== document && elem.nodeType === 1; elem = elem.parentNode) {
|
|
||||||
// If selector is a class
|
|
||||||
if (firstChar === '.') {
|
|
||||||
if (supports) {
|
|
||||||
if (elem.classList.contains(selector.substr(1))) {
|
|
||||||
return elem
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (new RegExp('(^|\\s)' + selector.substr(1) + '(\\s|$)').test(elem.className)) {
|
|
||||||
return elem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If selector is an ID
|
|
||||||
if (firstChar === '#') {
|
|
||||||
if (elem.id === selector.substr(1)) {
|
|
||||||
return elem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If selector is a data attribute
|
|
||||||
if (firstChar === '[') {
|
|
||||||
if (elem.hasAttribute(attribute[0])) {
|
|
||||||
if (value) {
|
|
||||||
if (elem.getAttribute(attribute[0]) === attribute[1]) {
|
|
||||||
return elem
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return elem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If selector is a tag
|
|
||||||
if (elem.tagName.toLowerCase() === selector) {
|
|
||||||
return elem
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all DOM element up the tree that contain a class, ID, or data attribute
|
* Get all DOM elements up the tree that match a selector
|
||||||
* @param {Node} elem The base element
|
* @param {Node} elem The base element
|
||||||
* @param {String} selector The class, id, data attribute, or tag to look for
|
* @param {String} selector The class, id, data attribute, or tag to look for
|
||||||
* @return {Array} Null if no match
|
* @return {Array|null} Array of matching elements or null if no match
|
||||||
*/
|
*/
|
||||||
export function getParents (elem, selector) {
|
export const getParents = (elem, selector) => {
|
||||||
const parents = []
|
const parents = []
|
||||||
|
const matchers = {
|
||||||
|
'.': (el, sel) => el.classList?.contains(sel.slice(1)),
|
||||||
|
'#': (el, sel) => el.id === sel.slice(1),
|
||||||
|
'[': (el, sel) => el.hasAttribute(sel.slice(1, -1)),
|
||||||
|
tag: (el, sel) => el.tagName?.toLowerCase() === sel
|
||||||
|
}
|
||||||
|
|
||||||
const firstChar = selector?.charAt(0)
|
const firstChar = selector?.charAt(0)
|
||||||
// Get matches
|
const matcher = selector ? (matchers[firstChar] || matchers.tag) : null
|
||||||
for (; elem && elem !== document; elem = elem.parentNode) {
|
|
||||||
if (selector) {
|
for (let current = elem; current && current !== document; current = current.parentNode) {
|
||||||
// If selector is a class
|
if (!selector || matcher(current, selector)) {
|
||||||
if (firstChar === '.') {
|
parents.push(current)
|
||||||
if (elem.classList.contains(selector.substr(1))) {
|
|
||||||
parents.push(elem)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If selector is an ID
|
|
||||||
if (firstChar === '#') {
|
return parents.length > 0 ? parents : null
|
||||||
if (elem.id === selector.substr(1)) {
|
|
||||||
parents.push(elem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If selector is a data attribute
|
|
||||||
if (firstChar === '[') {
|
|
||||||
if (elem.hasAttribute(selector.substr(1, selector.length - 1))) {
|
|
||||||
parents.push(elem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If selector is a tag
|
|
||||||
if (elem.tagName.toLowerCase() === selector) {
|
|
||||||
parents.push(elem)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
parents.push(elem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Return parents if any exist
|
|
||||||
return parents.length ? parents : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getParentsUntil (elem, parent, selector) {
|
export const getParentsUntil = (elem, parent, selector) => {
|
||||||
const parents = []
|
const parents = []
|
||||||
const parentType = parent?.charAt(0)
|
|
||||||
const selectorType = selector?.charAt(0)
|
const matchers = {
|
||||||
// Get matches
|
'.': (el, sel) => el.classList?.contains(sel.slice(1)),
|
||||||
for (; elem && elem !== document; elem = elem.parentNode) {
|
'#': (el, sel) => el.id === sel.slice(1),
|
||||||
// Check if parent has been reached
|
'[': (el, sel) => el.hasAttribute(sel.slice(1, -1)),
|
||||||
if (parent) {
|
tag: (el, sel) => el.tagName?.toLowerCase() === sel
|
||||||
// If parent is a class
|
}
|
||||||
if (parentType === '.') {
|
|
||||||
if (elem.classList.contains(parent.substr(1))) {
|
const getMatcherFn = (selectorStr) => {
|
||||||
|
if (!selectorStr) return null
|
||||||
|
const firstChar = selectorStr.charAt(0)
|
||||||
|
return matchers[firstChar] || matchers.tag
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentMatcher = getMatcherFn(parent)
|
||||||
|
const selectorMatcher = getMatcherFn(selector)
|
||||||
|
|
||||||
|
for (let current = elem; current && current !== document; current = current.parentNode) {
|
||||||
|
// Check if we've reached the parent boundary
|
||||||
|
if (parent && parentMatcher?.(current, parent)) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// If parent is an ID
|
// Add to results if matches selector (or no selector specified)
|
||||||
if (parentType === '#') {
|
if (!selector || selectorMatcher?.(current, selector)) {
|
||||||
if (elem.id === parent.substr(1)) {
|
parents.push(current)
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If parent is a data attribute
|
|
||||||
if (parentType === '[') {
|
return parents.length > 0 ? parents : null
|
||||||
if (elem.hasAttribute(parent.substr(1, parent.length - 1))) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If parent is a tag
|
|
||||||
if (elem.tagName.toLowerCase() === parent) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (selector) {
|
|
||||||
// If selector is a class
|
|
||||||
if (selectorType === '.') {
|
|
||||||
if (elem.classList.contains(selector.substr(1))) {
|
|
||||||
parents.push(elem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If selector is an ID
|
|
||||||
if (selectorType === '#') {
|
|
||||||
if (elem.id === selector.substr(1)) {
|
|
||||||
parents.push(elem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If selector is a data attribute
|
|
||||||
if (selectorType === '[') {
|
|
||||||
if (elem.hasAttribute(selector.substr(1, selector.length - 1))) {
|
|
||||||
parents.push(elem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If selector is a tag
|
|
||||||
if (elem.tagName.toLowerCase() === selector) {
|
|
||||||
parents.push(elem)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
parents.push(elem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Return parents if any exist
|
|
||||||
return parents.length ? parents : null
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,44 +16,92 @@ export const init = (canvas) => {
|
|||||||
svgCanvas = canvas
|
svgCanvas = canvas
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Element} filterElem
|
||||||
|
* @returns {?Element}
|
||||||
|
*/
|
||||||
|
const getFeGaussianBlurElem = (filterElem) => {
|
||||||
|
if (!filterElem || filterElem.nodeType !== 1) return null
|
||||||
|
return filterElem.querySelector('feGaussianBlur') || filterElem.firstElementChild
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the `stdDeviation` blur value on the selected element without being undoable.
|
* Sets the `stdDeviation` blur value on the selected element without being undoable.
|
||||||
* @function module:svgcanvas.SvgCanvas#setBlurNoUndo
|
* @function module:svgcanvas.SvgCanvas#setBlurNoUndo
|
||||||
* @param {Float} val - The new `stdDeviation` value
|
* @param {Float} val - The new `stdDeviation` value
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export const setBlurNoUndo = function (val) {
|
export const setBlurNoUndo = (val) => {
|
||||||
const selectedElements = svgCanvas.getSelectedElements()
|
const selectedElements = svgCanvas.getSelectedElements()
|
||||||
if (!svgCanvas.getFilter()) {
|
const elem = selectedElements[0]
|
||||||
svgCanvas.setBlur(val)
|
if (!elem) return
|
||||||
return
|
|
||||||
|
let filter = svgCanvas.getFilter()
|
||||||
|
if (!filter) {
|
||||||
|
filter = svgCanvas.getElement(`${elem.id}_blur`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (val === 0) {
|
if (val === 0) {
|
||||||
// Don't change the StdDev, as that will hide the element.
|
// Don't change the StdDev, as that will hide the element.
|
||||||
// Instead, just remove the value for "filter"
|
// Instead, just remove the value for "filter"
|
||||||
svgCanvas.changeSelectedAttributeNoUndo('filter', '')
|
svgCanvas.changeSelectedAttributeNoUndo('filter', '')
|
||||||
svgCanvas.setFilterHidden(true)
|
svgCanvas.setFilterHidden(true)
|
||||||
} else {
|
} else {
|
||||||
const elem = selectedElements[0]
|
if (!filter) {
|
||||||
if (svgCanvas.getFilterHidden()) {
|
// Create the filter if missing, but don't add history.
|
||||||
svgCanvas.changeSelectedAttributeNoUndo('filter', 'url(#' + elem.id + '_blur)')
|
const blurElem = svgCanvas.addSVGElementsFromJson({
|
||||||
|
element: 'feGaussianBlur',
|
||||||
|
attr: {
|
||||||
|
in: 'SourceGraphic',
|
||||||
|
stdDeviation: val
|
||||||
}
|
}
|
||||||
const filter = svgCanvas.getFilter()
|
})
|
||||||
svgCanvas.changeSelectedAttributeNoUndo('stdDeviation', val, [filter.firstChild])
|
filter = svgCanvas.addSVGElementsFromJson({
|
||||||
|
element: 'filter',
|
||||||
|
attr: {
|
||||||
|
id: `${elem.id}_blur`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
filter.append(blurElem)
|
||||||
|
svgCanvas.findDefs().append(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (svgCanvas.getFilterHidden() || !elem.getAttribute('filter')) {
|
||||||
|
svgCanvas.changeSelectedAttributeNoUndo('filter', `url(#${filter.id})`)
|
||||||
|
svgCanvas.setFilterHidden(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const blurElem = getFeGaussianBlurElem(filter)
|
||||||
|
if (!blurElem) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svgCanvas.changeSelectedAttributeNoUndo('stdDeviation', val, [blurElem])
|
||||||
svgCanvas.setBlurOffsets(filter, val)
|
svgCanvas.setBlurOffsets(filter, val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* Finishes the blur change command and adds it to history if not empty.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
function finishChange () {
|
const finishChange = () => {
|
||||||
const bCmd = svgCanvas.undoMgr.finishUndoableChange()
|
const curCommand = svgCanvas.getCurCommand()
|
||||||
svgCanvas.getCurCommand().addSubCommand(bCmd)
|
if (!curCommand) {
|
||||||
svgCanvas.addCommandToHistory(svgCanvas.getCurCommand())
|
|
||||||
svgCanvas.setCurCommand(null)
|
svgCanvas.setCurCommand(null)
|
||||||
svgCanvas.setFilter(null)
|
svgCanvas.setFilter(null)
|
||||||
|
svgCanvas.setFilterHidden(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const bCmd = svgCanvas.undoMgr.finishUndoableChange()
|
||||||
|
if (!bCmd.isEmpty()) {
|
||||||
|
curCommand.addSubCommand(bCmd)
|
||||||
|
}
|
||||||
|
if (!curCommand.isEmpty()) {
|
||||||
|
svgCanvas.addCommandToHistory(curCommand)
|
||||||
|
}
|
||||||
|
svgCanvas.setCurCommand(null)
|
||||||
|
svgCanvas.setFilter(null)
|
||||||
|
svgCanvas.setFilterHidden(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,7 +112,13 @@ function finishChange () {
|
|||||||
* @param {Float} stdDev - The standard deviation value on which to base the offset size
|
* @param {Float} stdDev - The standard deviation value on which to base the offset size
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export const setBlurOffsets = function (filterElem, stdDev) {
|
export const setBlurOffsets = (filterElem, stdDev) => {
|
||||||
|
if (!filterElem || filterElem.nodeType !== 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stdDev = Number(stdDev) || 0
|
||||||
|
|
||||||
if (stdDev > 3) {
|
if (stdDev > 3) {
|
||||||
// TODO: Create algorithm here where size is based on expected blur
|
// TODO: Create algorithm here where size is based on expected blur
|
||||||
svgCanvas.assignAttributes(filterElem, {
|
svgCanvas.assignAttributes(filterElem, {
|
||||||
@@ -88,7 +142,7 @@ export const setBlurOffsets = function (filterElem, stdDev) {
|
|||||||
* @param {boolean} complete - Whether or not the action should be completed (to add to the undo manager)
|
* @param {boolean} complete - Whether or not the action should be completed (to add to the undo manager)
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export const setBlur = function (val, complete) {
|
export const setBlur = (val, complete) => {
|
||||||
const {
|
const {
|
||||||
InsertElementCommand, ChangeElementCommand, BatchCommand
|
InsertElementCommand, ChangeElementCommand, BatchCommand
|
||||||
} = svgCanvas.history
|
} = svgCanvas.history
|
||||||
@@ -101,20 +155,33 @@ export const setBlur = function (val, complete) {
|
|||||||
|
|
||||||
// Looks for associated blur, creates one if not found
|
// Looks for associated blur, creates one if not found
|
||||||
const elem = selectedElements[0]
|
const elem = selectedElements[0]
|
||||||
const elemId = elem.id
|
if (!elem) {
|
||||||
svgCanvas.setFilter(svgCanvas.getElement(elemId + '_blur'))
|
return
|
||||||
|
|
||||||
val -= 0
|
|
||||||
|
|
||||||
const batchCmd = new BatchCommand()
|
|
||||||
|
|
||||||
// Blur found!
|
|
||||||
if (svgCanvas.getFilter()) {
|
|
||||||
if (val === 0) {
|
|
||||||
svgCanvas.setFilter(null)
|
|
||||||
}
|
}
|
||||||
} else {
|
const elemId = elem.id
|
||||||
// Not found, so create
|
let filter = svgCanvas.getElement(`${elemId}_blur`)
|
||||||
|
svgCanvas.setFilter(filter)
|
||||||
|
|
||||||
|
val = Number(val) || 0
|
||||||
|
|
||||||
|
const batchCmd = new BatchCommand('Change blur')
|
||||||
|
|
||||||
|
if (val === 0) {
|
||||||
|
const oldFilter = elem.getAttribute('filter')
|
||||||
|
if (!oldFilter) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const changes = { filter: oldFilter }
|
||||||
|
elem.removeAttribute('filter')
|
||||||
|
batchCmd.addSubCommand(new ChangeElementCommand(elem, changes))
|
||||||
|
svgCanvas.addCommandToHistory(batchCmd)
|
||||||
|
svgCanvas.setFilter(null)
|
||||||
|
svgCanvas.setFilterHidden(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure blur filter exists.
|
||||||
|
if (!filter) {
|
||||||
const newblur = svgCanvas.addSVGElementsFromJson({
|
const newblur = svgCanvas.addSVGElementsFromJson({
|
||||||
element: 'feGaussianBlur',
|
element: 'feGaussianBlur',
|
||||||
attr: {
|
attr: {
|
||||||
@@ -123,32 +190,29 @@ export const setBlur = function (val, complete) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
svgCanvas.setFilter(svgCanvas.addSVGElementsFromJson({
|
filter = svgCanvas.addSVGElementsFromJson({
|
||||||
element: 'filter',
|
element: 'filter',
|
||||||
attr: {
|
attr: {
|
||||||
id: elemId + '_blur'
|
id: `${elemId}_blur`
|
||||||
}
|
}
|
||||||
}))
|
})
|
||||||
svgCanvas.getFilter().append(newblur)
|
filter.append(newblur)
|
||||||
svgCanvas.findDefs().append(svgCanvas.getFilter())
|
const defs = svgCanvas.findDefs()
|
||||||
|
if (defs && defs.ownerDocument === filter.ownerDocument) {
|
||||||
batchCmd.addSubCommand(new InsertElementCommand(svgCanvas.getFilter()))
|
defs.append(filter)
|
||||||
|
}
|
||||||
|
svgCanvas.setFilter(filter)
|
||||||
|
batchCmd.addSubCommand(new InsertElementCommand(filter))
|
||||||
}
|
}
|
||||||
|
|
||||||
const changes = { filter: elem.getAttribute('filter') }
|
const changes = { filter: elem.getAttribute('filter') }
|
||||||
|
svgCanvas.changeSelectedAttributeNoUndo('filter', `url(#${filter.id})`)
|
||||||
if (val === 0) {
|
|
||||||
elem.removeAttribute('filter')
|
|
||||||
batchCmd.addSubCommand(new ChangeElementCommand(elem, changes))
|
batchCmd.addSubCommand(new ChangeElementCommand(elem, changes))
|
||||||
return
|
svgCanvas.setBlurOffsets(filter, val)
|
||||||
}
|
|
||||||
|
|
||||||
svgCanvas.changeSelectedAttribute('filter', 'url(#' + elemId + '_blur)')
|
|
||||||
batchCmd.addSubCommand(new ChangeElementCommand(elem, changes))
|
|
||||||
svgCanvas.setBlurOffsets(svgCanvas.getFilter(), val)
|
|
||||||
const filter = svgCanvas.getFilter()
|
|
||||||
svgCanvas.setCurCommand(batchCmd)
|
svgCanvas.setCurCommand(batchCmd)
|
||||||
svgCanvas.undoMgr.beginUndoableChange('stdDeviation', [filter ? filter.firstChild : null])
|
|
||||||
|
const blurElem = getFeGaussianBlurElem(filter)
|
||||||
|
svgCanvas.undoMgr.beginUndoableChange('stdDeviation', [blurElem])
|
||||||
if (complete) {
|
if (complete) {
|
||||||
svgCanvas.setBlurNoUndo(val)
|
svgCanvas.setBlurNoUndo(val)
|
||||||
finishChange()
|
finishChange()
|
||||||
|
|||||||
@@ -24,7 +24,15 @@ export const clearSvgContentElementInit = () => {
|
|||||||
// empty
|
// empty
|
||||||
while (el.firstChild) { el.removeChild(el.firstChild) }
|
while (el.firstChild) { el.removeChild(el.firstChild) }
|
||||||
|
|
||||||
// TODO: Clear out all other attributes first?
|
// Reset any stale attributes from the previous document.
|
||||||
|
for (const attr of Array.from(el.attributes)) {
|
||||||
|
if (attr.namespaceURI) {
|
||||||
|
el.removeAttributeNS(attr.namespaceURI, attr.localName)
|
||||||
|
} else {
|
||||||
|
el.removeAttribute(attr.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pel = svgCanvas.getSvgRoot()
|
const pel = svgCanvas.getSvgRoot()
|
||||||
el.setAttribute('id', 'svgcontent')
|
el.setAttribute('id', 'svgcontent')
|
||||||
el.setAttribute('width', dimensions[0])
|
el.setAttribute('width', dimensions[0])
|
||||||
@@ -35,9 +43,11 @@ export const clearSvgContentElementInit = () => {
|
|||||||
el.setAttribute('xmlns', NS.SVG)
|
el.setAttribute('xmlns', NS.SVG)
|
||||||
el.setAttribute('xmlns:se', NS.SE)
|
el.setAttribute('xmlns:se', NS.SE)
|
||||||
el.setAttribute('xmlns:xlink', NS.XLINK)
|
el.setAttribute('xmlns:xlink', NS.XLINK)
|
||||||
|
if (el.parentNode !== pel) {
|
||||||
pel.appendChild(el)
|
pel.appendChild(el)
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: make this string optional and set by the client
|
// TODO: make this string optional and set by the client
|
||||||
const comment = svgCanvas.getDOMDocument().createComment(' Created with SVG-edit - https://github.com/SVG-Edit/svgedit')
|
const comment = svgCanvas.getDOMDocument().createComment(' Created with SVG-edit - https://github.com/SVG-Edit/svgedit')
|
||||||
svgCanvas.getSvgContent().append(comment)
|
el.append(comment)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { warn } from '../common/logger.js'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
snapToGrid,
|
snapToGrid,
|
||||||
assignAttributes,
|
assignAttributes,
|
||||||
@@ -22,6 +24,30 @@ import { convertToNum } from './units.js'
|
|||||||
|
|
||||||
let svgCanvas = null
|
let svgCanvas = null
|
||||||
|
|
||||||
|
const flipBoxCoordinate = (value) => {
|
||||||
|
if (value === null || value === undefined) return null
|
||||||
|
const str = String(value).trim()
|
||||||
|
if (!str) return null
|
||||||
|
|
||||||
|
if (str.endsWith('%')) {
|
||||||
|
const num = Number.parseFloat(str.slice(0, -1))
|
||||||
|
return Number.isNaN(num) ? str : `${100 - num}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = Number.parseFloat(str)
|
||||||
|
return Number.isNaN(num) ? str : String(1 - num)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flipAttributeInBoxUnits = (elem, attr) => {
|
||||||
|
const value = elem.getAttribute(attr)
|
||||||
|
if (value === null || value === undefined) return
|
||||||
|
|
||||||
|
const flipped = flipBoxCoordinate(value)
|
||||||
|
if (flipped !== null && flipped !== undefined) {
|
||||||
|
elem.setAttribute(attr, flipped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the coords module with the SVG canvas.
|
* Initialize the coords module with the SVG canvas.
|
||||||
* @function module:coords.init
|
* @function module:coords.init
|
||||||
@@ -32,28 +58,9 @@ export const init = canvas => {
|
|||||||
svgCanvas = canvas
|
svgCanvas = canvas
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is how we map path segment types to their corresponding commands
|
// Map path segment types to their corresponding commands
|
||||||
const pathMap = [
|
const pathMap = [
|
||||||
0,
|
0, 'z', 'M', 'm', 'L', 'l', 'C', 'c', 'Q', 'q', 'A', 'a', 'H', 'h', 'V', 'v', 'S', 's', 'T', 't'
|
||||||
'z',
|
|
||||||
'M',
|
|
||||||
'm',
|
|
||||||
'L',
|
|
||||||
'l',
|
|
||||||
'C',
|
|
||||||
'c',
|
|
||||||
'Q',
|
|
||||||
'q',
|
|
||||||
'A',
|
|
||||||
'a',
|
|
||||||
'H',
|
|
||||||
'h',
|
|
||||||
'V',
|
|
||||||
'v',
|
|
||||||
'S',
|
|
||||||
's',
|
|
||||||
'T',
|
|
||||||
't'
|
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,19 +73,21 @@ const pathMap = [
|
|||||||
*/
|
*/
|
||||||
export const remapElement = (selected, changes, m) => {
|
export const remapElement = (selected, changes, m) => {
|
||||||
const remap = (x, y) => transformPoint(x, y, m)
|
const remap = (x, y) => transformPoint(x, y, m)
|
||||||
const scalew = w => m.a * w
|
const scalew = (w) => m.a * w
|
||||||
const scaleh = h => m.d * h
|
const scaleh = (h) => m.d * h
|
||||||
const doSnapping =
|
const doSnapping =
|
||||||
svgCanvas.getGridSnapping() &&
|
svgCanvas.getGridSnapping?.() &&
|
||||||
selected.parentNode.parentNode.localName === 'svg'
|
selected?.parentNode?.parentNode?.localName === 'svg'
|
||||||
|
|
||||||
const finishUp = () => {
|
const finishUp = () => {
|
||||||
if (doSnapping) {
|
if (doSnapping) {
|
||||||
Object.entries(changes).forEach(([attr, value]) => {
|
for (const [attr, value] of Object.entries(changes)) {
|
||||||
changes[attr] = snapToGrid(value)
|
changes[attr] = snapToGrid(value)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
assignAttributes(selected, changes, 1000, true)
|
assignAttributes(selected, changes, 1000, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const box = getBBox(selected)
|
const box = getBBox(selected)
|
||||||
|
|
||||||
// Handle gradients and patterns
|
// Handle gradients and patterns
|
||||||
@@ -86,25 +95,47 @@ export const remapElement = (selected, changes, m) => {
|
|||||||
const attrVal = selected.getAttribute(type)
|
const attrVal = selected.getAttribute(type)
|
||||||
if (attrVal?.startsWith('url(') && (m.a < 0 || m.d < 0)) {
|
if (attrVal?.startsWith('url(') && (m.a < 0 || m.d < 0)) {
|
||||||
const grad = getRefElem(attrVal)
|
const grad = getRefElem(attrVal)
|
||||||
|
if (!grad) return
|
||||||
|
|
||||||
|
const tagName = (grad.tagName || '').toLowerCase()
|
||||||
|
if (!['lineargradient', 'radialgradient'].includes(tagName)) return
|
||||||
|
|
||||||
|
// userSpaceOnUse gradients do not need object-bounding-box correction.
|
||||||
|
if (grad.getAttribute('gradientUnits') === 'userSpaceOnUse') return
|
||||||
|
|
||||||
const newgrad = grad.cloneNode(true)
|
const newgrad = grad.cloneNode(true)
|
||||||
if (m.a < 0) {
|
if (m.a < 0) {
|
||||||
// Flip x
|
// Flip x
|
||||||
const x1 = newgrad.getAttribute('x1')
|
if (tagName === 'lineargradient') {
|
||||||
const x2 = newgrad.getAttribute('x2')
|
flipAttributeInBoxUnits(newgrad, 'x1')
|
||||||
newgrad.setAttribute('x1', -(x1 - 1))
|
flipAttributeInBoxUnits(newgrad, 'x2')
|
||||||
newgrad.setAttribute('x2', -(x2 - 1))
|
} else {
|
||||||
|
flipAttributeInBoxUnits(newgrad, 'cx')
|
||||||
|
flipAttributeInBoxUnits(newgrad, 'fx')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m.d < 0) {
|
if (m.d < 0) {
|
||||||
// Flip y
|
// Flip y
|
||||||
const y1 = newgrad.getAttribute('y1')
|
if (tagName === 'lineargradient') {
|
||||||
const y2 = newgrad.getAttribute('y2')
|
flipAttributeInBoxUnits(newgrad, 'y1')
|
||||||
newgrad.setAttribute('y1', -(y1 - 1))
|
flipAttributeInBoxUnits(newgrad, 'y2')
|
||||||
newgrad.setAttribute('y2', -(y2 - 1))
|
} else {
|
||||||
|
flipAttributeInBoxUnits(newgrad, 'cy')
|
||||||
|
flipAttributeInBoxUnits(newgrad, 'fy')
|
||||||
}
|
}
|
||||||
newgrad.id = svgCanvas.getCurrentDrawing().getNextId()
|
}
|
||||||
|
|
||||||
|
const drawing = svgCanvas.getCurrentDrawing?.() || svgCanvas.getDrawing?.()
|
||||||
|
const generatedId = drawing?.getNextId?.() ??
|
||||||
|
(grad.id ? `${grad.id}-mirrored` : `mirrored-grad-${Date.now()}`)
|
||||||
|
if (!generatedId) {
|
||||||
|
warn('Unable to mirror gradient: no drawing context available', null, 'coords')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newgrad.id = generatedId
|
||||||
findDefs().append(newgrad)
|
findDefs().append(newgrad)
|
||||||
selected.setAttribute(type, 'url(#' + newgrad.id + ')')
|
selected.setAttribute(type, `url(#${newgrad.id})`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -265,10 +296,63 @@ export const remapElement = (selected, changes, m) => {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'path': {
|
case 'path': {
|
||||||
|
const supportsPathData =
|
||||||
|
typeof selected.getPathData === 'function' &&
|
||||||
|
typeof selected.setPathData === 'function'
|
||||||
|
|
||||||
// Handle path segments
|
// Handle path segments
|
||||||
const segList = selected.pathSegList
|
const segList = supportsPathData ? null : selected.pathSegList
|
||||||
const len = segList.numberOfItems
|
const len = supportsPathData ? selected.getPathData().length : segList.numberOfItems
|
||||||
|
const det = m.a * m.d - m.b * m.c
|
||||||
|
const shouldToggleArcSweep = det < 0
|
||||||
changes.d = []
|
changes.d = []
|
||||||
|
if (supportsPathData) {
|
||||||
|
const pathDataSegments = selected.getPathData()
|
||||||
|
for (let i = 0; i < len; ++i) {
|
||||||
|
const seg = pathDataSegments[i]
|
||||||
|
const t = seg.type
|
||||||
|
const type = pathMap.indexOf(t)
|
||||||
|
if (type === -1) continue
|
||||||
|
const values = seg.values || []
|
||||||
|
const entry = { type }
|
||||||
|
switch (t.toUpperCase()) {
|
||||||
|
case 'M':
|
||||||
|
case 'L':
|
||||||
|
case 'T':
|
||||||
|
[entry.x, entry.y] = values
|
||||||
|
break
|
||||||
|
case 'H':
|
||||||
|
[entry.x] = values
|
||||||
|
break
|
||||||
|
case 'V':
|
||||||
|
[entry.y] = values
|
||||||
|
break
|
||||||
|
case 'C':
|
||||||
|
[entry.x1, entry.y1, entry.x2, entry.y2, entry.x, entry.y] = values
|
||||||
|
break
|
||||||
|
case 'S':
|
||||||
|
[entry.x2, entry.y2, entry.x, entry.y] = values
|
||||||
|
break
|
||||||
|
case 'Q':
|
||||||
|
[entry.x1, entry.y1, entry.x, entry.y] = values
|
||||||
|
break
|
||||||
|
case 'A':
|
||||||
|
[
|
||||||
|
entry.r1,
|
||||||
|
entry.r2,
|
||||||
|
entry.angle,
|
||||||
|
entry.largeArcFlag,
|
||||||
|
entry.sweepFlag,
|
||||||
|
entry.x,
|
||||||
|
entry.y
|
||||||
|
] = values
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
changes.d[i] = entry
|
||||||
|
}
|
||||||
|
} else {
|
||||||
for (let i = 0; i < len; ++i) {
|
for (let i = 0; i < len; ++i) {
|
||||||
const seg = segList.getItem(i)
|
const seg = segList.getItem(i)
|
||||||
changes.d[i] = {
|
changes.d[i] = {
|
||||||
@@ -286,6 +370,7 @@ export const remapElement = (selected, changes, m) => {
|
|||||||
sweepFlag: seg.sweepFlag
|
sweepFlag: seg.sweepFlag
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const firstseg = changes.d[0]
|
const firstseg = changes.d[0]
|
||||||
let currentpt
|
let currentpt
|
||||||
@@ -302,41 +387,65 @@ export const remapElement = (selected, changes, m) => {
|
|||||||
const thisx = seg.x !== undefined ? seg.x : currentpt.x // For V commands
|
const thisx = seg.x !== undefined ? seg.x : currentpt.x // For V commands
|
||||||
const thisy = seg.y !== undefined ? seg.y : currentpt.y // For H commands
|
const thisy = seg.y !== undefined ? seg.y : currentpt.y // For H commands
|
||||||
const pt = remap(thisx, thisy)
|
const pt = remap(thisx, thisy)
|
||||||
const pt1 = remap(seg.x1, seg.y1)
|
|
||||||
const pt2 = remap(seg.x2, seg.y2)
|
|
||||||
seg.x = pt.x
|
seg.x = pt.x
|
||||||
seg.y = pt.y
|
seg.y = pt.y
|
||||||
|
if (seg.x1 !== undefined && seg.y1 !== undefined) {
|
||||||
|
const pt1 = remap(seg.x1, seg.y1)
|
||||||
seg.x1 = pt1.x
|
seg.x1 = pt1.x
|
||||||
seg.y1 = pt1.y
|
seg.y1 = pt1.y
|
||||||
|
}
|
||||||
|
if (seg.x2 !== undefined && seg.y2 !== undefined) {
|
||||||
|
const pt2 = remap(seg.x2, seg.y2)
|
||||||
seg.x2 = pt2.x
|
seg.x2 = pt2.x
|
||||||
seg.y2 = pt2.y
|
seg.y2 = pt2.y
|
||||||
seg.r1 = scalew(seg.r1)
|
}
|
||||||
seg.r2 = scaleh(seg.r2)
|
if (type === 10) {
|
||||||
|
seg.r1 = Math.abs(scalew(seg.r1))
|
||||||
|
seg.r2 = Math.abs(scaleh(seg.r2))
|
||||||
|
if (shouldToggleArcSweep) {
|
||||||
|
seg.sweepFlag = Number(seg.sweepFlag) ? 0 : 1
|
||||||
|
if (typeof seg.angle === 'number') {
|
||||||
|
seg.angle = -seg.angle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// For relative segments, scale x, y, x1, y1, x2, y2
|
// For relative segments, scale x, y, x1, y1, x2, y2
|
||||||
seg.x = scalew(seg.x)
|
if (seg.x !== undefined) seg.x = scalew(seg.x)
|
||||||
seg.y = scaleh(seg.y)
|
if (seg.y !== undefined) seg.y = scaleh(seg.y)
|
||||||
seg.x1 = scalew(seg.x1)
|
if (seg.x1 !== undefined) seg.x1 = scalew(seg.x1)
|
||||||
seg.y1 = scaleh(seg.y1)
|
if (seg.y1 !== undefined) seg.y1 = scaleh(seg.y1)
|
||||||
seg.x2 = scalew(seg.x2)
|
if (seg.x2 !== undefined) seg.x2 = scalew(seg.x2)
|
||||||
seg.y2 = scaleh(seg.y2)
|
if (seg.y2 !== undefined) seg.y2 = scaleh(seg.y2)
|
||||||
seg.r1 = scalew(seg.r1)
|
if (type === 11) {
|
||||||
seg.r2 = scaleh(seg.r2)
|
seg.r1 = Math.abs(scalew(seg.r1))
|
||||||
|
seg.r2 = Math.abs(scaleh(seg.r2))
|
||||||
|
if (shouldToggleArcSweep) {
|
||||||
|
seg.sweepFlag = Number(seg.sweepFlag) ? 0 : 1
|
||||||
|
if (typeof seg.angle === 'number') {
|
||||||
|
seg.angle = -seg.angle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let dstr = ''
|
let dstr = ''
|
||||||
|
const newPathData = []
|
||||||
changes.d.forEach(seg => {
|
changes.d.forEach(seg => {
|
||||||
const { type } = seg
|
const { type } = seg
|
||||||
dstr += pathMap[type]
|
const letter = pathMap[type]
|
||||||
|
dstr += letter
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 13: // relative horizontal line (h)
|
case 13: // relative horizontal line (h)
|
||||||
case 12: // absolute horizontal line (H)
|
case 12: // absolute horizontal line (H)
|
||||||
dstr += seg.x + ' '
|
dstr += `${seg.x} `
|
||||||
|
newPathData.push({ type: letter, values: [seg.x] })
|
||||||
break
|
break
|
||||||
case 15: // relative vertical line (v)
|
case 15: // relative vertical line (v)
|
||||||
case 14: // absolute vertical line (V)
|
case 14: // absolute vertical line (V)
|
||||||
dstr += seg.y + ' '
|
dstr += `${seg.y} `
|
||||||
|
newPathData.push({ type: letter, values: [seg.y] })
|
||||||
break
|
break
|
||||||
case 3: // relative move (m)
|
case 3: // relative move (m)
|
||||||
case 5: // relative line (l)
|
case 5: // relative line (l)
|
||||||
@@ -344,27 +453,21 @@ export const remapElement = (selected, changes, m) => {
|
|||||||
case 2: // absolute move (M)
|
case 2: // absolute move (M)
|
||||||
case 4: // absolute line (L)
|
case 4: // absolute line (L)
|
||||||
case 18: // absolute smooth quad (T)
|
case 18: // absolute smooth quad (T)
|
||||||
dstr += seg.x + ',' + seg.y + ' '
|
dstr += `${seg.x},${seg.y} `
|
||||||
|
newPathData.push({ type: letter, values: [seg.x, seg.y] })
|
||||||
break
|
break
|
||||||
case 7: // relative cubic (c)
|
case 7: // relative cubic (c)
|
||||||
case 6: // absolute cubic (C)
|
case 6: // absolute cubic (C)
|
||||||
dstr +=
|
dstr += `${seg.x1},${seg.y1} ${seg.x2},${seg.y2} ${seg.x},${seg.y} `
|
||||||
seg.x1 +
|
newPathData.push({
|
||||||
',' +
|
type: letter,
|
||||||
seg.y1 +
|
values: [seg.x1, seg.y1, seg.x2, seg.y2, seg.x, seg.y]
|
||||||
' ' +
|
})
|
||||||
seg.x2 +
|
|
||||||
',' +
|
|
||||||
seg.y2 +
|
|
||||||
' ' +
|
|
||||||
seg.x +
|
|
||||||
',' +
|
|
||||||
seg.y +
|
|
||||||
' '
|
|
||||||
break
|
break
|
||||||
case 9: // relative quad (q)
|
case 9: // relative quad (q)
|
||||||
case 8: // absolute quad (Q)
|
case 8: // absolute quad (Q)
|
||||||
dstr += seg.x1 + ',' + seg.y1 + ' ' + seg.x + ',' + seg.y + ' '
|
dstr += `${seg.x1},${seg.y1} ${seg.x},${seg.y} `
|
||||||
|
newPathData.push({ type: letter, values: [seg.x1, seg.y1, seg.x, seg.y] })
|
||||||
break
|
break
|
||||||
case 11: // relative elliptical arc (a)
|
case 11: // relative elliptical arc (a)
|
||||||
case 10: // absolute elliptical arc (A)
|
case 10: // absolute elliptical arc (A)
|
||||||
@@ -383,17 +486,38 @@ export const remapElement = (selected, changes, m) => {
|
|||||||
',' +
|
',' +
|
||||||
seg.y +
|
seg.y +
|
||||||
' '
|
' '
|
||||||
|
newPathData.push({
|
||||||
|
type: letter,
|
||||||
|
values: [
|
||||||
|
seg.r1,
|
||||||
|
seg.r2,
|
||||||
|
seg.angle,
|
||||||
|
Number(seg.largeArcFlag),
|
||||||
|
Number(seg.sweepFlag),
|
||||||
|
seg.x,
|
||||||
|
seg.y
|
||||||
|
]
|
||||||
|
})
|
||||||
break
|
break
|
||||||
case 17: // relative smooth cubic (s)
|
case 17: // relative smooth cubic (s)
|
||||||
case 16: // absolute smooth cubic (S)
|
case 16: // absolute smooth cubic (S)
|
||||||
dstr += seg.x2 + ',' + seg.y2 + ' ' + seg.x + ',' + seg.y + ' '
|
dstr += `${seg.x2},${seg.y2} ${seg.x},${seg.y} `
|
||||||
|
newPathData.push({ type: letter, values: [seg.x2, seg.y2, seg.x, seg.y] })
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
selected.setAttribute('d', dstr.trim())
|
const d = dstr.trim()
|
||||||
|
selected.setAttribute('d', d)
|
||||||
|
if (supportsPathData) {
|
||||||
|
try {
|
||||||
|
selected.setPathData(newPathData)
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to 'd' attribute if setPathData is unavailable or throws.
|
||||||
|
}
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { preventClickDefault } from './utilities.js'
|
import { preventClickDefault } from './utilities.js'
|
||||||
|
import dataStorage from './dataStorage.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a clone of an element, updating its ID and its children's IDs when needed.
|
* Create a clone of an element, updating its ID and its children's IDs when needed.
|
||||||
@@ -7,37 +8,50 @@ import { preventClickDefault } from './utilities.js'
|
|||||||
* @param {module:utilities.GetNextID} getNextId - The getter of the next unique ID.
|
* @param {module:utilities.GetNextID} getNextId - The getter of the next unique ID.
|
||||||
* @returns {Element} The cloned element
|
* @returns {Element} The cloned element
|
||||||
*/
|
*/
|
||||||
export const copyElem = function (el, getNextId) {
|
export const copyElem = (el, getNextId) => {
|
||||||
|
const ownerDocument = el?.ownerDocument || document
|
||||||
// manually create a copy of the element
|
// manually create a copy of the element
|
||||||
const newEl = document.createElementNS(el.namespaceURI, el.nodeName)
|
const newEl = ownerDocument.createElementNS(el.namespaceURI, el.nodeName)
|
||||||
Object.values(el.attributes).forEach((attr) => {
|
Array.from(el.attributes).forEach((attr) => {
|
||||||
newEl.setAttributeNS(attr.namespaceURI, attr.nodeName, attr.value)
|
if (attr.namespaceURI) {
|
||||||
|
newEl.setAttributeNS(attr.namespaceURI, attr.name, attr.value)
|
||||||
|
} else {
|
||||||
|
newEl.setAttribute(attr.name, attr.value)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
// set the copied element's new id
|
// set the copied element's new id
|
||||||
newEl.removeAttribute('id')
|
newEl.removeAttribute('id')
|
||||||
newEl.id = getNextId()
|
newEl.id = getNextId()
|
||||||
|
|
||||||
// now create copies of all children
|
// now create copies of all children
|
||||||
el.childNodes.forEach(function (child) {
|
el.childNodes.forEach((child) => {
|
||||||
switch (child.nodeType) {
|
switch (child.nodeType) {
|
||||||
case 1: // element node
|
case 1: // element node
|
||||||
newEl.append(copyElem(child, getNextId))
|
newEl.append(copyElem(child, getNextId))
|
||||||
break
|
break
|
||||||
case 3: // text node
|
case 3: // text node
|
||||||
newEl.textContent = child.nodeValue
|
case 4: // cdata section node
|
||||||
|
newEl.append(ownerDocument.createTextNode(child.nodeValue ?? ''))
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (el.dataset.gsvg) {
|
if (dataStorage.has(el, 'gsvg')) {
|
||||||
newEl.dataset.gsvg = newEl.firstChild
|
const firstChild = newEl.firstElementChild || newEl.firstChild
|
||||||
} else if (el.dataset.symbol) {
|
if (firstChild) {
|
||||||
const ref = el.dataset.symbol
|
dataStorage.put(newEl, 'gsvg', firstChild)
|
||||||
newEl.dataset.ref = ref
|
}
|
||||||
newEl.dataset.symbol = ref
|
}
|
||||||
} else if (newEl.tagName === 'image') {
|
if (dataStorage.has(el, 'symbol')) {
|
||||||
|
dataStorage.put(newEl, 'symbol', dataStorage.get(el, 'symbol'))
|
||||||
|
}
|
||||||
|
if (dataStorage.has(el, 'ref')) {
|
||||||
|
dataStorage.put(newEl, 'ref', dataStorage.get(el, 'ref'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newEl.tagName === 'image') {
|
||||||
preventClickDefault(newEl)
|
preventClickDefault(newEl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,91 @@
|
|||||||
/** A storage solution aimed at replacing jQuerys data function.
|
/**
|
||||||
* Implementation Note: Elements are stored in a (WeakMap)[https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap].
|
* A storage solution aimed at replacing jQuery's data function.
|
||||||
* This makes sure the data is garbage collected when the node is removed.
|
* Implementation Note: Elements are stored in a [WeakMap](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap).
|
||||||
*/
|
* This makes sure the data is garbage collected when the node is removed.
|
||||||
const dataStorage = {
|
*
|
||||||
_storage: new WeakMap(),
|
* @module dataStorage
|
||||||
put: function (element, key, obj) {
|
* @license MIT
|
||||||
if (!this._storage.has(element)) {
|
*/
|
||||||
this._storage.set(element, new Map())
|
class DataStorage {
|
||||||
|
#storage = new WeakMap()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the provided element is a valid WeakMap key.
|
||||||
|
* @param {any} element - The element to validate
|
||||||
|
* @returns {boolean} True if the element can be used as a WeakMap key
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
#isValidKey = (element) => {
|
||||||
|
return element !== null && (typeof element === 'object' || typeof element === 'function')
|
||||||
}
|
}
|
||||||
this._storage.get(element).set(key, obj)
|
|
||||||
},
|
/**
|
||||||
get: function (element, key) {
|
* Stores data associated with an element.
|
||||||
return this._storage.get(element)?.get(key)
|
* @param {Object|Function} element - The element to store data for
|
||||||
},
|
* @param {string} key - The key to store the data under
|
||||||
has: function (element, key) {
|
* @param {any} obj - The data to store
|
||||||
return this._storage.has(element) && this._storage.get(element).has(key)
|
* @returns {void}
|
||||||
},
|
*/
|
||||||
remove: function (element, key) {
|
put (element, key, obj) {
|
||||||
const ret = this._storage.get(element).delete(key)
|
if (!this.#isValidKey(element)) {
|
||||||
if (this._storage.get(element).size === 0) {
|
return
|
||||||
this._storage.delete(element)
|
}
|
||||||
|
let elementMap = this.#storage.get(element)
|
||||||
|
if (!elementMap) {
|
||||||
|
elementMap = new Map()
|
||||||
|
this.#storage.set(element, elementMap)
|
||||||
|
}
|
||||||
|
elementMap.set(key, obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves data associated with an element.
|
||||||
|
* @param {Object|Function} element - The element to retrieve data for
|
||||||
|
* @param {string} key - The key the data was stored under
|
||||||
|
* @returns {any|undefined} The stored data, or undefined if not found
|
||||||
|
*/
|
||||||
|
get (element, key) {
|
||||||
|
if (!this.#isValidKey(element)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return this.#storage.get(element)?.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an element has data stored under a specific key.
|
||||||
|
* @param {Object|Function} element - The element to check
|
||||||
|
* @param {string} key - The key to check for
|
||||||
|
* @returns {boolean} True if the element has data stored under the key
|
||||||
|
*/
|
||||||
|
has (element, key) {
|
||||||
|
if (!this.#isValidKey(element)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return this.#storage.get(element)?.has(key) === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes data associated with an element.
|
||||||
|
* @param {Object|Function} element - The element to remove data from
|
||||||
|
* @param {string} key - The key the data was stored under
|
||||||
|
* @returns {boolean} True if the data was removed, false otherwise
|
||||||
|
*/
|
||||||
|
remove (element, key) {
|
||||||
|
if (!this.#isValidKey(element)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const elementMap = this.#storage.get(element)
|
||||||
|
if (!elementMap) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const ret = elementMap.delete(key)
|
||||||
|
if (elementMap.size === 0) {
|
||||||
|
this.#storage.delete(element)
|
||||||
}
|
}
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export singleton instance for backward compatibility
|
||||||
|
const dataStorage = new DataStorage()
|
||||||
export default dataStorage
|
export default dataStorage
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { NS } from './namespaces.js'
|
|||||||
import { toXml, getElement } from './utilities.js'
|
import { toXml, getElement } from './utilities.js'
|
||||||
import { copyElem as utilCopyElem } from './copy-elem.js'
|
import { copyElem as utilCopyElem } from './copy-elem.js'
|
||||||
import { getParentsUntil } from '../common/util.js'
|
import { getParentsUntil } from '../common/util.js'
|
||||||
|
import { warn } from '../common/logger.js'
|
||||||
|
|
||||||
const visElems =
|
const visElems =
|
||||||
'a,circle,ellipse,foreignObject,g,image,line,path,polygon,polyline,rect,svg,text,tspan,use'.split(
|
'a,circle,ellipse,foreignObject,g,image,line,path,polygon,polyline,rect,svg,text,tspan,use'.split(
|
||||||
@@ -32,7 +33,7 @@ let disabledElems = []
|
|||||||
* @param {module:history.HistoryRecordingService} [hrService] - if exists, return it instead of creating a new service.
|
* @param {module:history.HistoryRecordingService} [hrService] - if exists, return it instead of creating a new service.
|
||||||
* @returns {module:history.HistoryRecordingService}
|
* @returns {module:history.HistoryRecordingService}
|
||||||
*/
|
*/
|
||||||
function historyRecordingService (hrService) {
|
const historyRecordingService = (hrService) => {
|
||||||
return hrService || new HistoryRecordingService(svgCanvas.undoMgr)
|
return hrService || new HistoryRecordingService(svgCanvas.undoMgr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,18 +42,18 @@ function historyRecordingService (hrService) {
|
|||||||
* @param {Element} group The group element to search in.
|
* @param {Element} group The group element to search in.
|
||||||
* @returns {string} The layer name or empty string.
|
* @returns {string} The layer name or empty string.
|
||||||
*/
|
*/
|
||||||
function findLayerNameInGroup (group) {
|
const findLayerNameInGroup = (group) => {
|
||||||
const sel = group.querySelector('title')
|
const sel = group.querySelector('title')
|
||||||
return sel ? sel.textContent : ''
|
return sel ? sel.textContent : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify the classList of the given element : if the classList contains 'layer', return true, then return false
|
* Checks if the given element's classList contains 'layer'.
|
||||||
*
|
*
|
||||||
* @param {Element} element - The given element
|
* @param {Element} element - The given element
|
||||||
* @returns {boolean} Return true if the classList contains 'layer' then return false
|
* @returns {boolean} True if the classList contains 'layer', false otherwise
|
||||||
*/
|
*/
|
||||||
function isLayerElement (element) {
|
const isLayerElement = (element) => {
|
||||||
return element.classList.contains('layer')
|
return element.classList.contains('layer')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ function isLayerElement (element) {
|
|||||||
* @param {string[]} existingLayerNames - Existing layer names.
|
* @param {string[]} existingLayerNames - Existing layer names.
|
||||||
* @returns {string} - The new name.
|
* @returns {string} - The new name.
|
||||||
*/
|
*/
|
||||||
function getNewLayerName (existingLayerNames) {
|
const getNewLayerName = (existingLayerNames) => {
|
||||||
let i = 1
|
let i = 1
|
||||||
while (existingLayerNames.includes(`Layer ${i}`)) {
|
while (existingLayerNames.includes(`Layer ${i}`)) {
|
||||||
i++
|
i++
|
||||||
@@ -163,10 +164,10 @@ export class Drawing {
|
|||||||
getElem_ (id) {
|
getElem_ (id) {
|
||||||
if (this.svgElem_.querySelector) {
|
if (this.svgElem_.querySelector) {
|
||||||
// querySelector lookup
|
// querySelector lookup
|
||||||
return this.svgElem_.querySelector('#' + id)
|
return this.svgElem_.querySelector(`#${id}`)
|
||||||
}
|
}
|
||||||
// jQuery lookup: twice as slow as xpath in FF
|
// jQuery lookup: twice as slow as xpath in FF
|
||||||
return this.svgElem_.querySelector('[id=' + id + ']')
|
return this.svgElem_.querySelector(`[id=${id}]`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -209,7 +210,7 @@ export class Drawing {
|
|||||||
*/
|
*/
|
||||||
getId () {
|
getId () {
|
||||||
return this.nonce_
|
return this.nonce_
|
||||||
? this.idPrefix + this.nonce_ + '_' + this.obj_num
|
? `${this.idPrefix}${this.nonce_}_${this.obj_num}`
|
||||||
: this.idPrefix + this.obj_num
|
: this.idPrefix + this.obj_num
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,12 +259,16 @@ export class Drawing {
|
|||||||
*/
|
*/
|
||||||
releaseId (id) {
|
releaseId (id) {
|
||||||
// confirm if this is a valid id for this Document, else return false
|
// confirm if this is a valid id for this Document, else return false
|
||||||
const front = this.idPrefix + (this.nonce_ ? this.nonce_ + '_' : '')
|
const front = `${this.idPrefix}${this.nonce_ ? `${this.nonce_}_` : ''}`
|
||||||
if (typeof id !== 'string' || !id.startsWith(front)) {
|
if (typeof id !== 'string' || !id.startsWith(front)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// extract the obj_num of this id
|
// extract the obj_num of this id
|
||||||
const num = Number.parseInt(id.substr(front.length))
|
const suffix = id.slice(front.length)
|
||||||
|
if (!/^[0-9]+$/.test(suffix)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const num = Number.parseInt(suffix)
|
||||||
|
|
||||||
// if we didn't get a positive number or we already released this number
|
// if we didn't get a positive number or we already released this number
|
||||||
// then return false.
|
// then return false.
|
||||||
@@ -612,6 +617,10 @@ export class Drawing {
|
|||||||
// Clone children
|
// Clone children
|
||||||
const children = [...currentGroup.childNodes]
|
const children = [...currentGroup.childNodes]
|
||||||
children.forEach(child => {
|
children.forEach(child => {
|
||||||
|
if (child.nodeType !== 1) {
|
||||||
|
group.append(child.cloneNode(true))
|
||||||
|
return
|
||||||
|
}
|
||||||
if (child.localName === 'title') {
|
if (child.localName === 'title') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -710,10 +719,7 @@ export class Drawing {
|
|||||||
* @returns {Element}
|
* @returns {Element}
|
||||||
*/
|
*/
|
||||||
copyElem (el) {
|
copyElem (el) {
|
||||||
const that = this
|
const getNextIdClosure = () => this.getNextId()
|
||||||
const getNextIdClosure = function () {
|
|
||||||
return that.getNextId()
|
|
||||||
}
|
|
||||||
return utilCopyElem(el, getNextIdClosure)
|
return utilCopyElem(el, getNextIdClosure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -726,7 +732,7 @@ export class Drawing {
|
|||||||
* @param {draw.Drawing} currentDrawing
|
* @param {draw.Drawing} currentDrawing
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export const randomizeIds = function (enableRandomization, currentDrawing) {
|
export const randomizeIds = (enableRandomization, currentDrawing) => {
|
||||||
randIds =
|
randIds =
|
||||||
enableRandomization === false
|
enableRandomization === false
|
||||||
? RandomizeModes.NEVER_RANDOMIZE
|
? RandomizeModes.NEVER_RANDOMIZE
|
||||||
@@ -868,6 +874,10 @@ export const cloneLayer = (name, hrService) => {
|
|||||||
const newLayer = svgCanvas
|
const newLayer = svgCanvas
|
||||||
.getCurrentDrawing()
|
.getCurrentDrawing()
|
||||||
.cloneLayer(name, historyRecordingService(hrService))
|
.cloneLayer(name, historyRecordingService(hrService))
|
||||||
|
if (!newLayer) {
|
||||||
|
warn('cloneLayer: no layer returned', null, 'draw')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
svgCanvas.clearSelection()
|
svgCanvas.clearSelection()
|
||||||
leaveContext()
|
leaveContext()
|
||||||
@@ -883,15 +893,19 @@ export const cloneLayer = (name, hrService) => {
|
|||||||
*/
|
*/
|
||||||
export const deleteCurrentLayer = () => {
|
export const deleteCurrentLayer = () => {
|
||||||
const { BatchCommand, RemoveElementCommand } = svgCanvas.history
|
const { BatchCommand, RemoveElementCommand } = svgCanvas.history
|
||||||
let currentLayer = svgCanvas.getCurrentDrawing().getCurrentLayer()
|
const currentLayer = svgCanvas.getCurrentDrawing().getCurrentLayer()
|
||||||
|
if (!currentLayer) {
|
||||||
|
warn('deleteCurrentLayer: no current layer', null, 'draw')
|
||||||
|
return false
|
||||||
|
}
|
||||||
const { nextSibling } = currentLayer
|
const { nextSibling } = currentLayer
|
||||||
const parent = currentLayer.parentNode
|
const parent = currentLayer.parentNode
|
||||||
currentLayer = svgCanvas.getCurrentDrawing().deleteCurrentLayer()
|
const removedLayer = svgCanvas.getCurrentDrawing().deleteCurrentLayer()
|
||||||
if (currentLayer) {
|
if (removedLayer && parent) {
|
||||||
const batchCmd = new BatchCommand('Delete Layer')
|
const batchCmd = new BatchCommand('Delete Layer')
|
||||||
// store in our Undo History
|
// store in our Undo History
|
||||||
batchCmd.addSubCommand(
|
batchCmd.addSubCommand(
|
||||||
new RemoveElementCommand(currentLayer, nextSibling, parent)
|
new RemoveElementCommand(removedLayer, nextSibling, parent)
|
||||||
)
|
)
|
||||||
svgCanvas.addCommandToHistory(batchCmd)
|
svgCanvas.addCommandToHistory(batchCmd)
|
||||||
svgCanvas.clearSelection()
|
svgCanvas.clearSelection()
|
||||||
@@ -978,20 +992,19 @@ export const setCurrentLayerPosition = newPos => {
|
|||||||
export const setLayerVisibility = (layerName, bVisible) => {
|
export const setLayerVisibility = (layerName, bVisible) => {
|
||||||
const { ChangeElementCommand } = svgCanvas.history
|
const { ChangeElementCommand } = svgCanvas.history
|
||||||
const drawing = svgCanvas.getCurrentDrawing()
|
const drawing = svgCanvas.getCurrentDrawing()
|
||||||
const prevVisibility = drawing.getLayerVisibility(layerName)
|
const layerGroup = drawing.getLayerByName(layerName)
|
||||||
const layer = drawing.setLayerVisibility(layerName, bVisible)
|
if (!layerGroup) {
|
||||||
if (layer) {
|
warn('setLayerVisibility: layer not found', layerName, 'draw')
|
||||||
const oldDisplay = prevVisibility ? 'inline' : 'none'
|
|
||||||
svgCanvas.addCommandToHistory(
|
|
||||||
new ChangeElementCommand(
|
|
||||||
layer,
|
|
||||||
{ display: oldDisplay },
|
|
||||||
'Layer Visibility'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
const oldDisplay = layerGroup.getAttribute('display')
|
||||||
|
const layer = drawing.setLayerVisibility(layerName, bVisible)
|
||||||
|
if (!layer) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
svgCanvas.addCommandToHistory(
|
||||||
|
new ChangeElementCommand(layer, { display: oldDisplay }, 'Layer Visibility')
|
||||||
|
)
|
||||||
|
|
||||||
if (layer === drawing.getCurrentLayer()) {
|
if (layer === drawing.getCurrentLayer()) {
|
||||||
svgCanvas.clearSelection()
|
svgCanvas.clearSelection()
|
||||||
@@ -1024,18 +1037,21 @@ export const moveSelectedToLayer = layerName => {
|
|||||||
let i = selElems.length
|
let i = selElems.length
|
||||||
while (i--) {
|
while (i--) {
|
||||||
const elem = selElems[i]
|
const elem = selElems[i]
|
||||||
if (!elem) {
|
const oldLayer = elem?.parentNode
|
||||||
|
if (!elem || !oldLayer || oldLayer === layer) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const oldNextSibling = elem.nextSibling
|
const oldNextSibling = elem.nextSibling
|
||||||
// TODO: this is pretty brittle!
|
|
||||||
const oldLayer = elem.parentNode
|
|
||||||
layer.append(elem)
|
layer.append(elem)
|
||||||
batchCmd.addSubCommand(
|
batchCmd.addSubCommand(
|
||||||
new MoveElementCommand(elem, oldNextSibling, oldLayer)
|
new MoveElementCommand(elem, oldNextSibling, oldLayer)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (batchCmd.isEmpty()) {
|
||||||
|
warn('moveSelectedToLayer: no elements moved', null, 'draw')
|
||||||
|
return false
|
||||||
|
}
|
||||||
svgCanvas.addCommandToHistory(batchCmd)
|
svgCanvas.addCommandToHistory(batchCmd)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -1081,12 +1097,13 @@ export const leaveContext = () => {
|
|||||||
for (let i = 0; i < len; i++) {
|
for (let i = 0; i < len; i++) {
|
||||||
const elem = disabledElems[i]
|
const elem = disabledElems[i]
|
||||||
const orig = dataStorage.get(elem, 'orig_opac')
|
const orig = dataStorage.get(elem, 'orig_opac')
|
||||||
if (orig !== 1) {
|
if (orig === null || orig === undefined) {
|
||||||
elem.setAttribute('opacity', orig)
|
|
||||||
} else {
|
|
||||||
elem.removeAttribute('opacity')
|
elem.removeAttribute('opacity')
|
||||||
|
} else {
|
||||||
|
elem.setAttribute('opacity', orig)
|
||||||
}
|
}
|
||||||
elem.setAttribute('style', 'pointer-events: inherit')
|
elem.setAttribute('style', 'pointer-events: inherit')
|
||||||
|
dataStorage.remove(elem, 'orig_opac')
|
||||||
}
|
}
|
||||||
disabledElems = []
|
disabledElems = []
|
||||||
svgCanvas.clearSelection(true)
|
svgCanvas.clearSelection(true)
|
||||||
@@ -1106,7 +1123,22 @@ export const setContext = elem => {
|
|||||||
const dataStorage = svgCanvas.getDataStorage()
|
const dataStorage = svgCanvas.getDataStorage()
|
||||||
leaveContext()
|
leaveContext()
|
||||||
if (typeof elem === 'string') {
|
if (typeof elem === 'string') {
|
||||||
elem = getElement(elem)
|
const id = elem
|
||||||
|
try {
|
||||||
|
elem = getElement(id)
|
||||||
|
} catch (e) {
|
||||||
|
elem = null
|
||||||
|
}
|
||||||
|
if (!elem && typeof document !== 'undefined') {
|
||||||
|
const candidate = document.getElementById(id)
|
||||||
|
const svgContent = svgCanvas.getSvgContent?.()
|
||||||
|
elem = candidate && (svgContent ? svgContent.contains(candidate) : true)
|
||||||
|
? candidate
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!elem) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit inside this group
|
// Edit inside this group
|
||||||
@@ -1114,8 +1146,14 @@ export const setContext = elem => {
|
|||||||
|
|
||||||
// Disable other elements
|
// Disable other elements
|
||||||
const parentsUntil = getParentsUntil(elem, '#svgcontent')
|
const parentsUntil = getParentsUntil(elem, '#svgcontent')
|
||||||
|
if (!parentsUntil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const siblings = []
|
const siblings = []
|
||||||
parentsUntil.forEach(function (parent) {
|
parentsUntil.forEach(function (parent) {
|
||||||
|
if (!parent?.parentNode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const elements = Array.prototype.filter.call(
|
const elements = Array.prototype.filter.call(
|
||||||
parent.parentNode.children,
|
parent.parentNode.children,
|
||||||
function (child) {
|
function (child) {
|
||||||
@@ -1128,9 +1166,11 @@ export const setContext = elem => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
siblings.forEach(function (curthis) {
|
siblings.forEach(function (curthis) {
|
||||||
const opac = curthis.getAttribute('opacity') || 1
|
|
||||||
// Store the original's opacity
|
// Store the original's opacity
|
||||||
dataStorage.put(curthis, 'orig_opac', opac)
|
const origOpacity = curthis.getAttribute('opacity')
|
||||||
|
dataStorage.put(curthis, 'orig_opac', origOpacity)
|
||||||
|
const parsedOpacity = Number.parseFloat(origOpacity)
|
||||||
|
const opac = Number.isFinite(parsedOpacity) ? parsedOpacity : 1
|
||||||
curthis.setAttribute('opacity', opac * 0.33)
|
curthis.setAttribute('opacity', opac * 0.33)
|
||||||
curthis.setAttribute('style', 'pointer-events: none')
|
curthis.setAttribute('style', 'pointer-events: none')
|
||||||
disabledElems.push(curthis)
|
disabledElems.push(curthis)
|
||||||
|
|||||||
@@ -122,25 +122,36 @@ const setGroupTitleMethod = (val) => {
|
|||||||
const selectedElements = svgCanvas.getSelectedElements()
|
const selectedElements = svgCanvas.getSelectedElements()
|
||||||
const dataStorage = svgCanvas.getDataStorage()
|
const dataStorage = svgCanvas.getDataStorage()
|
||||||
let elem = selectedElements[0]
|
let elem = selectedElements[0]
|
||||||
|
if (!elem) { return }
|
||||||
if (dataStorage.has(elem, 'gsvg')) {
|
if (dataStorage.has(elem, 'gsvg')) {
|
||||||
elem = dataStorage.get(elem, 'gsvg')
|
elem = dataStorage.get(elem, 'gsvg')
|
||||||
|
} else if (dataStorage.has(elem, 'symbol')) {
|
||||||
|
elem = dataStorage.get(elem, 'symbol')
|
||||||
}
|
}
|
||||||
|
if (!elem) { return }
|
||||||
const ts = elem.querySelectorAll('title')
|
|
||||||
|
|
||||||
const batchCmd = new BatchCommand('Set Label')
|
const batchCmd = new BatchCommand('Set Label')
|
||||||
|
|
||||||
let title
|
let title = null
|
||||||
|
for (const child of elem.childNodes) {
|
||||||
|
if (child.nodeName === 'title') {
|
||||||
|
title = child
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (val.length === 0) {
|
if (val.length === 0) {
|
||||||
|
if (!title) { return }
|
||||||
// Remove title element
|
// Remove title element
|
||||||
const tsNextSibling = ts.nextSibling
|
const { nextSibling } = title
|
||||||
batchCmd.addSubCommand(new RemoveElementCommand(ts[0], tsNextSibling, elem))
|
title.remove()
|
||||||
ts.remove()
|
batchCmd.addSubCommand(new RemoveElementCommand(title, nextSibling, elem))
|
||||||
} else if (ts.length) {
|
} else if (title) {
|
||||||
// Change title contents
|
// Change title contents
|
||||||
title = ts[0]
|
const oldText = title.textContent
|
||||||
batchCmd.addSubCommand(new ChangeElementCommand(title, { '#text': title.textContent }))
|
if (oldText === val) { return }
|
||||||
title.textContent = val
|
title.textContent = val
|
||||||
|
batchCmd.addSubCommand(new ChangeElementCommand(title, { '#text': oldText }))
|
||||||
} else {
|
} else {
|
||||||
// Add title element
|
// Add title element
|
||||||
title = svgCanvas.getDOMDocument().createElementNS(NS.SVG, 'title')
|
title = svgCanvas.getDOMDocument().createElementNS(NS.SVG, 'title')
|
||||||
@@ -149,7 +160,9 @@ const setGroupTitleMethod = (val) => {
|
|||||||
batchCmd.addSubCommand(new InsertElementCommand(title))
|
batchCmd.addSubCommand(new InsertElementCommand(title))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!batchCmd.isEmpty()) {
|
||||||
svgCanvas.addCommandToHistory(batchCmd)
|
svgCanvas.addCommandToHistory(batchCmd)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,33 +173,44 @@ const setGroupTitleMethod = (val) => {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const setDocumentTitleMethod = (newTitle) => {
|
const setDocumentTitleMethod = (newTitle) => {
|
||||||
const { ChangeElementCommand, BatchCommand } = svgCanvas.history
|
const {
|
||||||
const childs = svgCanvas.getSvgContent().childNodes
|
InsertElementCommand, RemoveElementCommand,
|
||||||
let docTitle = false; let oldTitle = ''
|
ChangeElementCommand, BatchCommand
|
||||||
|
} = svgCanvas.history
|
||||||
|
const svgContent = svgCanvas.getSvgContent()
|
||||||
|
|
||||||
const batchCmd = new BatchCommand('Change Image Title')
|
const batchCmd = new BatchCommand('Change Image Title')
|
||||||
|
|
||||||
for (const child of childs) {
|
/** @type {Element|null} */
|
||||||
|
let docTitle = null
|
||||||
|
for (const child of svgContent.childNodes) {
|
||||||
if (child.nodeName === 'title') {
|
if (child.nodeName === 'title') {
|
||||||
docTitle = child
|
docTitle = child
|
||||||
oldTitle = docTitle.textContent
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!docTitle) {
|
|
||||||
docTitle = svgCanvas.getDOMDocument().createElementNS(NS.SVG, 'title')
|
|
||||||
svgCanvas.getSvgContent().insertBefore(docTitle, svgCanvas.getSvgContent().firstChild)
|
|
||||||
// svgContent.firstChild.before(docTitle); // Ok to replace above with this?
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newTitle.length) {
|
if (!docTitle) {
|
||||||
|
if (!newTitle.length) { return }
|
||||||
|
docTitle = svgCanvas.getDOMDocument().createElementNS(NS.SVG, 'title')
|
||||||
docTitle.textContent = newTitle
|
docTitle.textContent = newTitle
|
||||||
|
svgContent.insertBefore(docTitle, svgContent.firstChild)
|
||||||
|
batchCmd.addSubCommand(new InsertElementCommand(docTitle))
|
||||||
|
} else if (newTitle.length) {
|
||||||
|
const oldTitle = docTitle.textContent
|
||||||
|
if (oldTitle === newTitle) { return }
|
||||||
|
docTitle.textContent = newTitle
|
||||||
|
batchCmd.addSubCommand(new ChangeElementCommand(docTitle, { '#text': oldTitle }))
|
||||||
} else {
|
} else {
|
||||||
// No title given, so element is not necessary
|
// No title given, so element is not necessary
|
||||||
|
const { nextSibling } = docTitle
|
||||||
docTitle.remove()
|
docTitle.remove()
|
||||||
|
batchCmd.addSubCommand(new RemoveElementCommand(docTitle, nextSibling, svgContent))
|
||||||
}
|
}
|
||||||
batchCmd.addSubCommand(new ChangeElementCommand(docTitle, { '#text': oldTitle }))
|
|
||||||
|
if (!batchCmd.isEmpty()) {
|
||||||
svgCanvas.addCommandToHistory(batchCmd)
|
svgCanvas.addCommandToHistory(batchCmd)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -201,7 +225,6 @@ const setDocumentTitleMethod = (newTitle) => {
|
|||||||
*/
|
*/
|
||||||
const setResolutionMethod = (x, y) => {
|
const setResolutionMethod = (x, y) => {
|
||||||
const { ChangeElementCommand, BatchCommand } = svgCanvas.history
|
const { ChangeElementCommand, BatchCommand } = svgCanvas.history
|
||||||
const zoom = svgCanvas.getZoom()
|
|
||||||
const res = svgCanvas.getResolution()
|
const res = svgCanvas.getResolution()
|
||||||
const { w, h } = res
|
const { w, h } = res
|
||||||
let batchCmd
|
let batchCmd
|
||||||
@@ -220,8 +243,10 @@ const setResolutionMethod = (x, y) => {
|
|||||||
dy.push(bbox.y * -1)
|
dy.push(bbox.y * -1)
|
||||||
})
|
})
|
||||||
|
|
||||||
const cmd = svgCanvas.moveSelectedElements(dx, dy, true)
|
const cmd = svgCanvas.moveSelectedElements(dx, dy, false)
|
||||||
|
if (cmd) {
|
||||||
batchCmd.addSubCommand(cmd)
|
batchCmd.addSubCommand(cmd)
|
||||||
|
}
|
||||||
svgCanvas.clearSelection()
|
svgCanvas.clearSelection()
|
||||||
|
|
||||||
x = Math.round(bbox.width)
|
x = Math.round(bbox.width)
|
||||||
@@ -230,26 +255,25 @@ const setResolutionMethod = (x, y) => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (x !== w || y !== h) {
|
const newW = convertToNum('width', x)
|
||||||
|
const newH = convertToNum('height', y)
|
||||||
|
if (newW !== w || newH !== h) {
|
||||||
if (!batchCmd) {
|
if (!batchCmd) {
|
||||||
batchCmd = new BatchCommand('Change Image Dimensions')
|
batchCmd = new BatchCommand('Change Image Dimensions')
|
||||||
}
|
}
|
||||||
|
const svgContent = svgCanvas.getSvgContent()
|
||||||
|
const oldViewBox = svgContent.getAttribute('viewBox')
|
||||||
|
|
||||||
x = convertToNum('width', x)
|
svgContent.setAttribute('width', newW)
|
||||||
y = convertToNum('height', y)
|
svgContent.setAttribute('height', newH)
|
||||||
|
|
||||||
svgCanvas.getSvgContent().setAttribute('width', x)
|
svgCanvas.contentW = newW
|
||||||
svgCanvas.getSvgContent().setAttribute('height', y)
|
svgCanvas.contentH = newH
|
||||||
|
svgContent.setAttribute('viewBox', [0, 0, newW, newH].join(' '))
|
||||||
svgCanvas.contentW = x
|
batchCmd.addSubCommand(new ChangeElementCommand(svgContent, { width: w, height: h, viewBox: oldViewBox }))
|
||||||
svgCanvas.contentH = y
|
|
||||||
batchCmd.addSubCommand(new ChangeElementCommand(svgCanvas.getSvgContent(), { width: w, height: h }))
|
|
||||||
|
|
||||||
svgCanvas.getSvgContent().setAttribute('viewBox', [0, 0, x / zoom, y / zoom].join(' '))
|
|
||||||
batchCmd.addSubCommand(new ChangeElementCommand(svgCanvas.getSvgContent(), { viewBox: ['0 0', w, h].join(' ') }))
|
|
||||||
|
|
||||||
svgCanvas.addCommandToHistory(batchCmd)
|
svgCanvas.addCommandToHistory(batchCmd)
|
||||||
svgCanvas.call('changed', [svgCanvas.getSvgContent()])
|
svgCanvas.call('changed', [svgContent])
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -286,20 +310,36 @@ const setBBoxZoomMethod = (val, editorW, editorH) => {
|
|||||||
let spacer = 0.85
|
let spacer = 0.85
|
||||||
let bb
|
let bb
|
||||||
const calcZoom = (bb) => {
|
const calcZoom = (bb) => {
|
||||||
if (!bb) { return false }
|
if (!bb) { return undefined }
|
||||||
|
if (!Number.isFinite(editorW) || !Number.isFinite(editorH) || editorW <= 0 || editorH <= 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(bb.width) || !Number.isFinite(bb.height) || bb.width <= 0 || bb.height <= 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
const wZoom = Math.round((editorW / bb.width) * 100 * spacer) / 100
|
const wZoom = Math.round((editorW / bb.width) * 100 * spacer) / 100
|
||||||
const hZoom = Math.round((editorH / bb.height) * 100 * spacer) / 100
|
const hZoom = Math.round((editorH / bb.height) * 100 * spacer) / 100
|
||||||
const zoom = Math.min(wZoom, hZoom)
|
const zoom = Math.min(wZoom, hZoom)
|
||||||
|
if (!Number.isFinite(zoom) || zoom <= 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
svgCanvas.setZoom(zoom)
|
svgCanvas.setZoom(zoom)
|
||||||
return { zoom, bbox: bb }
|
return { zoom, bbox: bb }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof val === 'object') {
|
if (val && typeof val === 'object') {
|
||||||
bb = val
|
bb = val
|
||||||
if (bb.width === 0 || bb.height === 0) {
|
if (bb.width === 0 || bb.height === 0) {
|
||||||
const newzoom = bb.zoom ? bb.zoom : zoom * bb.factor
|
let newzoom = zoom
|
||||||
|
if (Number.isFinite(bb.zoom) && bb.zoom > 0) {
|
||||||
|
newzoom = bb.zoom
|
||||||
|
} else if (Number.isFinite(bb.factor) && bb.factor > 0) {
|
||||||
|
newzoom = zoom * bb.factor
|
||||||
|
}
|
||||||
|
if (Number.isFinite(newzoom) && newzoom > 0) {
|
||||||
svgCanvas.setZoom(newzoom)
|
svgCanvas.setZoom(newzoom)
|
||||||
return { zoom, bbox: bb }
|
}
|
||||||
|
return { zoom: newzoom, bbox: bb }
|
||||||
}
|
}
|
||||||
return calcZoom(bb)
|
return calcZoom(bb)
|
||||||
}
|
}
|
||||||
@@ -307,12 +347,7 @@ const setBBoxZoomMethod = (val, editorW, editorH) => {
|
|||||||
switch (val) {
|
switch (val) {
|
||||||
case 'selection': {
|
case 'selection': {
|
||||||
if (!selectedElements[0]) { return undefined }
|
if (!selectedElements[0]) { return undefined }
|
||||||
const selectedElems = selectedElements.map((n, _) => {
|
const selectedElems = selectedElements.filter(Boolean)
|
||||||
if (n) {
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
bb = getStrokedBBoxDefaultVisible(selectedElems)
|
bb = getStrokedBBoxDefaultVisible(selectedElems)
|
||||||
break
|
break
|
||||||
} case 'canvas': {
|
} case 'canvas': {
|
||||||
@@ -340,13 +375,22 @@ const setBBoxZoomMethod = (val, editorW, editorH) => {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const setZoomMethod = (zoomLevel) => {
|
const setZoomMethod = (zoomLevel) => {
|
||||||
|
if (!Number.isFinite(zoomLevel) || zoomLevel <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const selectedElements = svgCanvas.getSelectedElements()
|
const selectedElements = svgCanvas.getSelectedElements()
|
||||||
const res = svgCanvas.getResolution()
|
const res = svgCanvas.getResolution()
|
||||||
svgCanvas.getSvgContent().setAttribute('viewBox', '0 0 ' + res.w / zoomLevel + ' ' + res.h / zoomLevel)
|
const w = res.w / zoomLevel
|
||||||
|
const h = res.h / zoomLevel
|
||||||
|
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svgCanvas.getSvgContent().setAttribute('viewBox', `0 0 ${w} ${h}`)
|
||||||
svgCanvas.setZoom(zoomLevel)
|
svgCanvas.setZoom(zoomLevel)
|
||||||
selectedElements.forEach((elem) => {
|
selectedElements.forEach((elem) => {
|
||||||
if (!elem) { return }
|
if (!elem) { return }
|
||||||
svgCanvas.selectorManager.requestSelector(elem).resize()
|
const selector = svgCanvas.selectorManager.requestSelector(elem)
|
||||||
|
selector && selector.resize()
|
||||||
})
|
})
|
||||||
svgCanvas.pathActions.zoomChange()
|
svgCanvas.pathActions.zoomChange()
|
||||||
svgCanvas.runExtensions('zoomChanged', zoomLevel)
|
svgCanvas.runExtensions('zoomChanged', zoomLevel)
|
||||||
@@ -364,7 +408,7 @@ const setZoomMethod = (zoomLevel) => {
|
|||||||
const setColorMethod = (type, val, preventUndo) => {
|
const setColorMethod = (type, val, preventUndo) => {
|
||||||
const selectedElements = svgCanvas.getSelectedElements()
|
const selectedElements = svgCanvas.getSelectedElements()
|
||||||
svgCanvas.setCurShape(type, val)
|
svgCanvas.setCurShape(type, val)
|
||||||
svgCanvas.setCurProperties(type + '_paint', { type: 'solidColor' })
|
svgCanvas.setCurProperties(`${type}_paint`, { type: 'solidColor' })
|
||||||
const elems = []
|
const elems = []
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@@ -408,10 +452,11 @@ const setColorMethod = (type, val, preventUndo) => {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const setGradientMethod = (type) => {
|
const setGradientMethod = (type) => {
|
||||||
if (!svgCanvas.getCurProperties(type + '_paint') ||
|
if (!svgCanvas.getCurProperties(`${type}_paint`) ||
|
||||||
svgCanvas.getCurProperties(type + '_paint').type === 'solidColor') { return }
|
svgCanvas.getCurProperties(`${type}_paint`).type === 'solidColor') { return }
|
||||||
const canvas = svgCanvas
|
const canvas = svgCanvas
|
||||||
let grad = canvas[type + 'Grad']
|
let grad = canvas[type + 'Grad']
|
||||||
|
if (!grad) { return }
|
||||||
// find out if there is a duplicate gradient already in the defs
|
// find out if there is a duplicate gradient already in the defs
|
||||||
const duplicateGrad = findDuplicateGradient(grad)
|
const duplicateGrad = findDuplicateGradient(grad)
|
||||||
const defs = findDefs()
|
const defs = findDefs()
|
||||||
@@ -425,7 +470,7 @@ const setGradientMethod = (type) => {
|
|||||||
} else { // use existing gradient
|
} else { // use existing gradient
|
||||||
grad = duplicateGrad
|
grad = duplicateGrad
|
||||||
}
|
}
|
||||||
svgCanvas.setColor(type, 'url(#' + grad.id + ')')
|
svgCanvas.setColor(type, `url(#${grad.id})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -435,12 +480,21 @@ const setGradientMethod = (type) => {
|
|||||||
* @returns {SVGGradientElement} The existing gradient if found, `null` if not
|
* @returns {SVGGradientElement} The existing gradient if found, `null` if not
|
||||||
*/
|
*/
|
||||||
const findDuplicateGradient = (grad) => {
|
const findDuplicateGradient = (grad) => {
|
||||||
|
if (!grad) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!['linearGradient', 'radialGradient'].includes(grad.tagName)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
const defs = findDefs()
|
const defs = findDefs()
|
||||||
const existingGrads = defs.querySelectorAll('linearGradient, radialGradient')
|
const existingGrads = defs.querySelectorAll('linearGradient, radialGradient')
|
||||||
let i = existingGrads.length
|
let i = existingGrads.length
|
||||||
const radAttrs = ['r', 'cx', 'cy', 'fx', 'fy']
|
const radAttrs = ['r', 'cx', 'cy', 'fx', 'fy']
|
||||||
while (i--) {
|
while (i--) {
|
||||||
const og = existingGrads[i]
|
const og = existingGrads[i]
|
||||||
|
if (og.tagName !== grad.tagName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (grad.tagName === 'linearGradient') {
|
if (grad.tagName === 'linearGradient') {
|
||||||
if (grad.getAttribute('x1') !== og.getAttribute('x1') ||
|
if (grad.getAttribute('x1') !== og.getAttribute('x1') ||
|
||||||
grad.getAttribute('y1') !== og.getAttribute('y1') ||
|
grad.getAttribute('y1') !== og.getAttribute('y1') ||
|
||||||
@@ -514,10 +568,10 @@ const setPaintMethod = (type, paint) => {
|
|||||||
svgCanvas.setPaintOpacity(type, p.alpha / 100, true)
|
svgCanvas.setPaintOpacity(type, p.alpha / 100, true)
|
||||||
|
|
||||||
// now set the current paint object
|
// now set the current paint object
|
||||||
svgCanvas.setCurProperties(type + '_paint', p)
|
svgCanvas.setCurProperties(`${type}_paint`, p)
|
||||||
switch (p.type) {
|
switch (p.type) {
|
||||||
case 'solidColor':
|
case 'solidColor':
|
||||||
svgCanvas.setColor(type, p.solidColor !== 'none' ? '#' + p.solidColor : 'none')
|
svgCanvas.setColor(type, p.solidColor !== 'none' ? `#${p.solidColor}` : 'none')
|
||||||
break
|
break
|
||||||
case 'linearGradient':
|
case 'linearGradient':
|
||||||
case 'radialGradient':
|
case 'radialGradient':
|
||||||
@@ -653,7 +707,7 @@ const addTextDecorationMethod = (value) => {
|
|||||||
// Add the new text decoration value if it did not exist
|
// Add the new text decoration value if it did not exist
|
||||||
if (!oldValue.includes(value)) {
|
if (!oldValue.includes(value)) {
|
||||||
batchCmd.addSubCommand(new ChangeElementCommand(elem, { 'text-decoration': oldValue }))
|
batchCmd.addSubCommand(new ChangeElementCommand(elem, { 'text-decoration': oldValue }))
|
||||||
svgCanvas.changeSelectedAttributeNoUndo('text-decoration', (oldValue + ' ' + value).trim(), [elem])
|
svgCanvas.changeSelectedAttributeNoUndo('text-decoration', `${oldValue} ${value}`.trim(), [elem])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (!batchCmd.isEmpty()) {
|
if (!batchCmd.isEmpty()) {
|
||||||
@@ -892,32 +946,48 @@ const setImageURLMethod = (val) => {
|
|||||||
const setsize = (!attrs.width || !attrs.height)
|
const setsize = (!attrs.width || !attrs.height)
|
||||||
|
|
||||||
const curHref = getHref(elem)
|
const curHref = getHref(elem)
|
||||||
|
const hrefChanged = curHref !== val
|
||||||
|
|
||||||
// Do nothing if no URL change or size change
|
// Do nothing if no URL change or size change
|
||||||
if (curHref === val && !setsize) {
|
if (!hrefChanged && !setsize) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const batchCmd = new BatchCommand('Change Image URL')
|
const batchCmd = new BatchCommand('Change Image URL')
|
||||||
|
|
||||||
|
if (hrefChanged) {
|
||||||
setHref(elem, val)
|
setHref(elem, val)
|
||||||
batchCmd.addSubCommand(new ChangeElementCommand(elem, {
|
batchCmd.addSubCommand(new ChangeElementCommand(elem, {
|
||||||
'#href': curHref
|
'#href': curHref
|
||||||
}))
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalized = false
|
||||||
|
const finalize = () => {
|
||||||
|
if (finalized) { return }
|
||||||
|
finalized = true
|
||||||
|
if (batchCmd.isEmpty()) { return }
|
||||||
|
svgCanvas.addCommandToHistory(batchCmd)
|
||||||
|
svgCanvas.call('changed', [elem])
|
||||||
|
}
|
||||||
|
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
img.onload = function () {
|
img.onload = () => {
|
||||||
const changes = {
|
const changes = {
|
||||||
width: elem.getAttribute('width'),
|
width: elem.getAttribute('width'),
|
||||||
height: elem.getAttribute('height')
|
height: elem.getAttribute('height')
|
||||||
}
|
}
|
||||||
elem.setAttribute('width', this.width)
|
elem.setAttribute('width', img.width)
|
||||||
elem.setAttribute('height', this.height)
|
elem.setAttribute('height', img.height)
|
||||||
|
|
||||||
svgCanvas.selectorManager.requestSelector(elem).resize()
|
const selector = svgCanvas.selectorManager.requestSelector(elem)
|
||||||
|
selector && selector.resize()
|
||||||
|
|
||||||
batchCmd.addSubCommand(new ChangeElementCommand(elem, changes))
|
batchCmd.addSubCommand(new ChangeElementCommand(elem, changes))
|
||||||
svgCanvas.addCommandToHistory(batchCmd)
|
finalize()
|
||||||
svgCanvas.call('changed', [elem])
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
finalize()
|
||||||
}
|
}
|
||||||
img.src = val
|
img.src = val
|
||||||
}
|
}
|
||||||
@@ -969,15 +1039,27 @@ const setRectRadiusMethod = (val) => {
|
|||||||
const { ChangeElementCommand } = svgCanvas.history
|
const { ChangeElementCommand } = svgCanvas.history
|
||||||
const selectedElements = svgCanvas.getSelectedElements()
|
const selectedElements = svgCanvas.getSelectedElements()
|
||||||
const selected = selectedElements[0]
|
const selected = selectedElements[0]
|
||||||
if (selected?.tagName === 'rect') {
|
if (selected?.tagName !== 'rect') { return }
|
||||||
const r = Number(selected.getAttribute('rx'))
|
|
||||||
if (r !== val) {
|
const radius = Number(val)
|
||||||
selected.setAttribute('rx', val)
|
if (!Number.isFinite(radius) || radius < 0) {
|
||||||
selected.setAttribute('ry', val)
|
return
|
||||||
svgCanvas.addCommandToHistory(new ChangeElementCommand(selected, { rx: r, ry: r }, 'Radius'))
|
}
|
||||||
|
|
||||||
|
const oldRx = selected.getAttribute('rx')
|
||||||
|
const oldRy = selected.getAttribute('ry')
|
||||||
|
const currentRx = Number(oldRx)
|
||||||
|
const currentRy = Number(oldRy)
|
||||||
|
const hasCurrentRx = oldRx !== null && Number.isFinite(currentRx)
|
||||||
|
const hasCurrentRy = oldRy !== null && Number.isFinite(currentRy)
|
||||||
|
const already = (radius === 0 && oldRx === null && oldRy === null) ||
|
||||||
|
(hasCurrentRx && hasCurrentRy && currentRx === radius && currentRy === radius)
|
||||||
|
if (already) { return }
|
||||||
|
|
||||||
|
selected.setAttribute('rx', radius)
|
||||||
|
selected.setAttribute('ry', radius)
|
||||||
|
svgCanvas.addCommandToHistory(new ChangeElementCommand(selected, { rx: oldRx, ry: oldRy }, 'Radius'))
|
||||||
svgCanvas.call('changed', [selected])
|
svgCanvas.call('changed', [selected])
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1021,7 +1103,9 @@ const setSegTypeMethod = (newType) => {
|
|||||||
*/
|
*/
|
||||||
const setBackgroundMethod = (color, url) => {
|
const setBackgroundMethod = (color, url) => {
|
||||||
const bg = getElement('canvasBackground')
|
const bg = getElement('canvasBackground')
|
||||||
|
if (!bg) { return }
|
||||||
const border = bg.querySelector('rect')
|
const border = bg.querySelector('rect')
|
||||||
|
if (!border) { return }
|
||||||
let bgImg = getElement('background_image')
|
let bgImg = getElement('background_image')
|
||||||
let bgPattern = getElement('background_pattern')
|
let bgPattern = getElement('background_pattern')
|
||||||
border.setAttribute('fill', color === 'chessboard' ? '#fff' : color)
|
border.setAttribute('fill', color === 'chessboard' ? '#fff' : color)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
convertAttrs
|
convertAttrs
|
||||||
} from './units.js'
|
} from './units.js'
|
||||||
import {
|
import {
|
||||||
transformPoint, hasMatrixTransform, getMatrix, snapToAngle, getTransformList
|
transformPoint, hasMatrixTransform, getMatrix, snapToAngle, getTransformList, transformListToTransform
|
||||||
} from './math.js'
|
} from './math.js'
|
||||||
import * as draw from './draw.js'
|
import * as draw from './draw.js'
|
||||||
import * as pathModule from './path.js'
|
import * as pathModule from './path.js'
|
||||||
@@ -20,7 +20,9 @@ import * as hstry from './history.js'
|
|||||||
import { findPos } from '../../svgcanvas/common/util.js'
|
import { findPos } from '../../svgcanvas/common/util.js'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
InsertElementCommand
|
InsertElementCommand,
|
||||||
|
BatchCommand,
|
||||||
|
ChangeElementCommand
|
||||||
} = hstry
|
} = hstry
|
||||||
|
|
||||||
let svgCanvas = null
|
let svgCanvas = null
|
||||||
@@ -84,6 +86,7 @@ const updateTransformList = (svgRoot, element, dx, dy) => {
|
|||||||
const xform = svgRoot.createSVGTransform()
|
const xform = svgRoot.createSVGTransform()
|
||||||
xform.setTranslate(dx, dy)
|
xform.setTranslate(dx, dy)
|
||||||
const tlist = getTransformList(element)
|
const tlist = getTransformList(element)
|
||||||
|
if (!tlist) { return }
|
||||||
if (tlist.numberOfItems) {
|
if (tlist.numberOfItems) {
|
||||||
const firstItem = tlist.getItem(0)
|
const firstItem = tlist.getItem(0)
|
||||||
if (firstItem.type === 2) { // SVG_TRANSFORM_TRANSLATE = 2
|
if (firstItem.type === 2) { // SVG_TRANSFORM_TRANSLATE = 2
|
||||||
@@ -145,6 +148,25 @@ const mouseMoveEvent = (evt) => {
|
|||||||
let tlist
|
let tlist
|
||||||
switch (svgCanvas.getCurrentMode()) {
|
switch (svgCanvas.getCurrentMode()) {
|
||||||
case 'select': {
|
case 'select': {
|
||||||
|
// Insert dummy transform on first mouse move (drag start), not on click.
|
||||||
|
// This avoids creating multiple transforms that trigger unwanted flattening.
|
||||||
|
if (!svgCanvas.hasDragStartTransform && selectedElements.length > 0) {
|
||||||
|
// Store original transforms BEFORE adding the drag transform (for undo)
|
||||||
|
svgCanvas.dragStartTransforms = new Map()
|
||||||
|
for (const selectedElement of selectedElements) {
|
||||||
|
if (!selectedElement) { continue }
|
||||||
|
// Capture the transform attribute before we modify it
|
||||||
|
svgCanvas.dragStartTransforms.set(selectedElement, selectedElement.getAttribute('transform') || '')
|
||||||
|
const slist = getTransformList(selectedElement)
|
||||||
|
if (!slist) { continue }
|
||||||
|
if (slist.numberOfItems) {
|
||||||
|
slist.insertItemBefore(svgRoot.createSVGTransform(), 0)
|
||||||
|
} else {
|
||||||
|
slist.appendItem(svgRoot.createSVGTransform())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svgCanvas.hasDragStartTransform = true
|
||||||
|
}
|
||||||
// we temporarily use a translate on the element(s) being dragged
|
// we temporarily use a translate on the element(s) being dragged
|
||||||
// this transform is removed upon mousing up and the element is
|
// this transform is removed upon mousing up and the element is
|
||||||
// relocated to the new location
|
// relocated to the new location
|
||||||
@@ -222,6 +244,7 @@ const mouseMoveEvent = (evt) => {
|
|||||||
// while the mouse is down, when mouse goes up, we use this to recalculate
|
// while the mouse is down, when mouse goes up, we use this to recalculate
|
||||||
// the shape's coordinates
|
// the shape's coordinates
|
||||||
tlist = getTransformList(selected)
|
tlist = getTransformList(selected)
|
||||||
|
if (!tlist) { break }
|
||||||
const hasMatrix = hasMatrixTransform(tlist)
|
const hasMatrix = hasMatrixTransform(tlist)
|
||||||
box = hasMatrix ? svgCanvas.getInitBbox() : getBBox(selected)
|
box = hasMatrix ? svgCanvas.getInitBbox() : getBBox(selected)
|
||||||
let left = box.x
|
let left = box.x
|
||||||
@@ -548,10 +571,21 @@ const mouseMoveEvent = (evt) => {
|
|||||||
*
|
*
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const mouseOutEvent = () => {
|
const mouseOutEvent = (evt) => {
|
||||||
const { $id } = svgCanvas
|
const { $id } = svgCanvas
|
||||||
if (svgCanvas.getCurrentMode() !== 'select' && svgCanvas.getStarted()) {
|
if (svgCanvas.getCurrentMode() !== 'select' && svgCanvas.getStarted()) {
|
||||||
const event = new Event('mouseup')
|
const event = new MouseEvent('mouseup', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
clientX: evt?.clientX ?? 0,
|
||||||
|
clientY: evt?.clientY ?? 0,
|
||||||
|
button: evt?.button ?? 0,
|
||||||
|
buttons: evt?.buttons ?? 0,
|
||||||
|
altKey: evt?.altKey ?? false,
|
||||||
|
ctrlKey: evt?.ctrlKey ?? false,
|
||||||
|
metaKey: evt?.metaKey ?? false,
|
||||||
|
shiftKey: evt?.shiftKey ?? false
|
||||||
|
})
|
||||||
$id('svgcanvas').dispatchEvent(event)
|
$id('svgcanvas').dispatchEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -637,10 +671,71 @@ const mouseUpEvent = (evt) => {
|
|||||||
}
|
}
|
||||||
svgCanvas.selectorManager.requestSelector(selected).showGrips(true)
|
svgCanvas.selectorManager.requestSelector(selected).showGrips(true)
|
||||||
}
|
}
|
||||||
// always recalculate dimensions to strip off stray identity transforms
|
|
||||||
svgCanvas.recalculateAllSelectedDimensions()
|
|
||||||
// if it was being dragged/resized
|
// if it was being dragged/resized
|
||||||
if (realX !== svgCanvas.getRStartX() || realY !== svgCanvas.getRStartY()) {
|
if (realX !== svgCanvas.getRStartX() || realY !== svgCanvas.getRStartY()) {
|
||||||
|
// Only recalculate dimensions after actual dragging/resizing to avoid
|
||||||
|
// unwanted transform flattening on simple clicks
|
||||||
|
|
||||||
|
// Create a single batch command for all moved elements
|
||||||
|
const batchCmd = new BatchCommand('position')
|
||||||
|
|
||||||
|
selectedElements.forEach((elem) => {
|
||||||
|
if (!elem) return
|
||||||
|
|
||||||
|
const tlist = getTransformList(elem)
|
||||||
|
if (!tlist || tlist.numberOfItems === 0) return
|
||||||
|
|
||||||
|
// Get the transform from BEFORE the drag started
|
||||||
|
const oldTransform = svgCanvas.dragStartTransforms?.get(elem) || ''
|
||||||
|
|
||||||
|
// Check if the first transform is a translate (the drag transform we added)
|
||||||
|
const firstTransform = tlist.getItem(0)
|
||||||
|
const hasDragTranslate = firstTransform.type === 2 // SVG_TRANSFORM_TRANSLATE
|
||||||
|
|
||||||
|
// For groups, we always consolidate the transforms (recalculateDimensions returns null for groups)
|
||||||
|
const isGroup = elem.tagName === 'g' || elem.tagName === 'a'
|
||||||
|
|
||||||
|
// If element has 2+ transforms, or is a group with a drag translate, consolidate
|
||||||
|
if ((tlist.numberOfItems > 1 && hasDragTranslate) || (isGroup && hasDragTranslate)) {
|
||||||
|
const consolidatedMatrix = transformListToTransform(tlist).matrix
|
||||||
|
|
||||||
|
// Clear the transform list
|
||||||
|
while (tlist.numberOfItems > 0) {
|
||||||
|
tlist.removeItem(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the consolidated matrix
|
||||||
|
const newTransform = svgCanvas.getSvgRoot().createSVGTransform()
|
||||||
|
newTransform.setMatrix(consolidatedMatrix)
|
||||||
|
tlist.appendItem(newTransform)
|
||||||
|
|
||||||
|
// Record the transform change for undo
|
||||||
|
batchCmd.addSubCommand(new ChangeElementCommand(elem, { transform: oldTransform }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-group elements with simple transforms, try recalculateDimensions
|
||||||
|
const cmd = svgCanvas.recalculateDimensions(elem)
|
||||||
|
if (cmd) {
|
||||||
|
batchCmd.addSubCommand(cmd)
|
||||||
|
} else {
|
||||||
|
// recalculateDimensions returned null
|
||||||
|
// Check if the transform actually changed and record it manually
|
||||||
|
const newTransform = elem.getAttribute('transform') || ''
|
||||||
|
if (newTransform !== oldTransform) {
|
||||||
|
batchCmd.addSubCommand(new ChangeElementCommand(elem, { transform: oldTransform }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!batchCmd.isEmpty()) {
|
||||||
|
svgCanvas.addCommandToHistory(batchCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the stored transforms AND reset the flag together
|
||||||
|
svgCanvas.dragStartTransforms = null
|
||||||
|
svgCanvas.hasDragStartTransform = false
|
||||||
|
|
||||||
const len = selectedElements.length
|
const len = selectedElements.length
|
||||||
for (let i = 0; i < len; ++i) {
|
for (let i = 0; i < len; ++i) {
|
||||||
if (!selectedElements[i]) { break }
|
if (!selectedElements[i]) { break }
|
||||||
@@ -799,6 +894,8 @@ const mouseUpEvent = (evt) => {
|
|||||||
svgCanvas.textActions.mouseUp(evt, mouseX, mouseY)
|
svgCanvas.textActions.mouseUp(evt, mouseX, mouseY)
|
||||||
break
|
break
|
||||||
case 'rotate': {
|
case 'rotate': {
|
||||||
|
svgCanvas.hasDragStartTransform = false
|
||||||
|
svgCanvas.dragStartTransforms = null
|
||||||
keep = true
|
keep = true
|
||||||
element = null
|
element = null
|
||||||
svgCanvas.setCurrentMode('select')
|
svgCanvas.setCurrentMode('select')
|
||||||
@@ -812,8 +909,13 @@ const mouseUpEvent = (evt) => {
|
|||||||
break
|
break
|
||||||
} default:
|
} default:
|
||||||
// This could occur in an extension
|
// This could occur in an extension
|
||||||
|
svgCanvas.hasDragStartTransform = false
|
||||||
|
svgCanvas.dragStartTransforms = null
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
// Reset drag flag after any mouseUp
|
||||||
|
svgCanvas.hasDragStartTransform = false
|
||||||
|
svgCanvas.dragStartTransforms = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The main (left) mouse button is released (anywhere).
|
* The main (left) mouse button is released (anywhere).
|
||||||
@@ -979,7 +1081,13 @@ const mouseDownEvent = (evt) => {
|
|||||||
svgCanvas.cloneSelectedElements(0, 0)
|
svgCanvas.cloneSelectedElements(0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
svgCanvas.setRootSctm($id('svgcontent').querySelector('g').getScreenCTM().inverse())
|
// Get screenCTM from the first child group of svgcontent
|
||||||
|
// Note: svgcontent itself has x/y offset attributes, so we use its first child
|
||||||
|
const svgContent = $id('svgcontent')
|
||||||
|
const rootGroup = svgContent?.querySelector('g')
|
||||||
|
const screenCTM = rootGroup?.getScreenCTM?.()
|
||||||
|
if (!screenCTM) { return }
|
||||||
|
svgCanvas.setRootSctm(screenCTM.inverse())
|
||||||
|
|
||||||
const pt = transformPoint(evt.clientX, evt.clientY, svgCanvas.getrootSctm())
|
const pt = transformPoint(evt.clientX, evt.clientY, svgCanvas.getrootSctm())
|
||||||
const mouseX = pt.x * zoom
|
const mouseX = pt.x * zoom
|
||||||
@@ -1039,12 +1147,22 @@ const mouseDownEvent = (evt) => {
|
|||||||
svgCanvas.setStartTransform(mouseTarget.getAttribute('transform'))
|
svgCanvas.setStartTransform(mouseTarget.getAttribute('transform'))
|
||||||
|
|
||||||
const tlist = getTransformList(mouseTarget)
|
const tlist = getTransformList(mouseTarget)
|
||||||
// consolidate transforms using standard SVG but keep the transformation used for the move/scale
|
|
||||||
if (tlist.numberOfItems > 1) {
|
// Consolidate transforms for non-group elements to simplify dragging
|
||||||
const firstTransform = tlist.getItem(0)
|
// For elements with multiple transforms (e.g., after ungrouping), consolidate them
|
||||||
|
// into a single matrix so the dummy translate can be properly applied during drag
|
||||||
|
if (tlist?.numberOfItems > 1 && mouseTarget.tagName !== 'g' && mouseTarget.tagName !== 'a') {
|
||||||
|
// Compute the consolidated matrix from all transforms
|
||||||
|
const consolidatedMatrix = transformListToTransform(tlist).matrix
|
||||||
|
|
||||||
|
// Clear the transform list and add a single matrix transform
|
||||||
|
while (tlist.numberOfItems > 0) {
|
||||||
tlist.removeItem(0)
|
tlist.removeItem(0)
|
||||||
tlist.consolidate()
|
}
|
||||||
tlist.insertItemBefore(firstTransform, 0)
|
|
||||||
|
const newTransform = svgCanvas.getSvgRoot().createSVGTransform()
|
||||||
|
newTransform.setMatrix(consolidatedMatrix)
|
||||||
|
tlist.appendItem(newTransform)
|
||||||
}
|
}
|
||||||
switch (svgCanvas.getCurrentMode()) {
|
switch (svgCanvas.getCurrentMode()) {
|
||||||
case 'select':
|
case 'select':
|
||||||
@@ -1067,19 +1185,9 @@ const mouseDownEvent = (evt) => {
|
|||||||
}
|
}
|
||||||
// else if it's a path, go into pathedit mode in mouseup
|
// else if it's a path, go into pathedit mode in mouseup
|
||||||
|
|
||||||
if (!rightClick) {
|
// Note: Dummy transform insertion moved to mouseMove to avoid triggering
|
||||||
// insert a dummy transform so if the element(s) are moved it will have
|
// recalculateDimensions on simple clicks. The dummy transform is only needed
|
||||||
// a transform to use for its translate
|
// when actually starting a drag operation.
|
||||||
for (const selectedElement of selectedElements) {
|
|
||||||
if (!selectedElement) { continue }
|
|
||||||
const slist = getTransformList(selectedElement)
|
|
||||||
if (slist.numberOfItems) {
|
|
||||||
slist.insertItemBefore(svgRoot.createSVGTransform(), 0)
|
|
||||||
} else {
|
|
||||||
slist.appendItem(svgRoot.createSVGTransform())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (!rightClick) {
|
} else if (!rightClick) {
|
||||||
svgCanvas.clearSelection()
|
svgCanvas.clearSelection()
|
||||||
svgCanvas.setCurrentMode('multiselect')
|
svgCanvas.setCurrentMode('multiselect')
|
||||||
@@ -1105,13 +1213,14 @@ const mouseDownEvent = (evt) => {
|
|||||||
}
|
}
|
||||||
assignAttributes(svgCanvas.getRubberBox(), {
|
assignAttributes(svgCanvas.getRubberBox(), {
|
||||||
x: realX * zoom,
|
x: realX * zoom,
|
||||||
y: realX * zoom,
|
y: realY * zoom,
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
display: 'inline'
|
display: 'inline'
|
||||||
}, 100)
|
}, 100)
|
||||||
break
|
break
|
||||||
case 'resize': {
|
case 'resize': {
|
||||||
|
if (!tlist) { break }
|
||||||
svgCanvas.setStarted(true)
|
svgCanvas.setStarted(true)
|
||||||
svgCanvas.setStartX(x)
|
svgCanvas.setStartX(x)
|
||||||
svgCanvas.setStartY(y)
|
svgCanvas.setStartY(y)
|
||||||
@@ -1339,7 +1448,13 @@ const DOMMouseScrollEvent = (e) => {
|
|||||||
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
svgCanvas.setRootSctm($id('svgcontent').querySelector('g').getScreenCTM().inverse())
|
// Get screenCTM from the first child group of svgcontent
|
||||||
|
// Note: svgcontent itself has x/y offset attributes, so we use its first child
|
||||||
|
const svgContent = $id('svgcontent')
|
||||||
|
const rootGroup = svgContent?.querySelector('g')
|
||||||
|
const screenCTM = rootGroup?.getScreenCTM?.()
|
||||||
|
if (!screenCTM) { return }
|
||||||
|
svgCanvas.setRootSctm(screenCTM.inverse())
|
||||||
|
|
||||||
const workarea = document.getElementById('workarea')
|
const workarea = document.getElementById('workarea')
|
||||||
const scrbar = 15
|
const scrbar = 15
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* @copyright 2010 Jeff Schiller
|
* @copyright 2010 Jeff Schiller
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { NS } from './namespaces.js'
|
||||||
import { getHref, setHref, getRotationAngle, getBBox } from './utilities.js'
|
import { getHref, setHref, getRotationAngle, getBBox } from './utilities.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,7 +141,7 @@ export class MoveElementCommand extends Command {
|
|||||||
constructor (elem, oldNextSibling, oldParent, text) {
|
constructor (elem, oldNextSibling, oldParent, text) {
|
||||||
super()
|
super()
|
||||||
this.elem = elem
|
this.elem = elem
|
||||||
this.text = text ? ('Move ' + elem.tagName + ' to ' + text) : ('Move ' + elem.tagName)
|
this.text = text ? `Move ${elem.tagName} to ${text}` : `Move ${elem.tagName}`
|
||||||
this.oldNextSibling = oldNextSibling
|
this.oldNextSibling = oldNextSibling
|
||||||
this.oldParent = oldParent
|
this.oldParent = oldParent
|
||||||
this.newNextSibling = elem.nextSibling
|
this.newNextSibling = elem.nextSibling
|
||||||
@@ -155,7 +156,11 @@ export class MoveElementCommand extends Command {
|
|||||||
*/
|
*/
|
||||||
apply (handler) {
|
apply (handler) {
|
||||||
super.apply(handler, () => {
|
super.apply(handler, () => {
|
||||||
this.elem = this.newParent.insertBefore(this.elem, this.newNextSibling)
|
const reference =
|
||||||
|
this.newNextSibling && this.newNextSibling.parentNode === this.newParent
|
||||||
|
? this.newNextSibling
|
||||||
|
: null
|
||||||
|
this.elem = this.newParent.insertBefore(this.elem, reference)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +172,11 @@ export class MoveElementCommand extends Command {
|
|||||||
*/
|
*/
|
||||||
unapply (handler) {
|
unapply (handler) {
|
||||||
super.unapply(handler, () => {
|
super.unapply(handler, () => {
|
||||||
this.elem = this.oldParent.insertBefore(this.elem, this.oldNextSibling)
|
const reference =
|
||||||
|
this.oldNextSibling && this.oldNextSibling.parentNode === this.oldParent
|
||||||
|
? this.oldNextSibling
|
||||||
|
: null
|
||||||
|
this.elem = this.oldParent.insertBefore(this.elem, reference)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,7 +193,7 @@ export class InsertElementCommand extends Command {
|
|||||||
constructor (elem, text) {
|
constructor (elem, text) {
|
||||||
super()
|
super()
|
||||||
this.elem = elem
|
this.elem = elem
|
||||||
this.text = text || ('Create ' + elem.tagName)
|
this.text = text || `Create ${elem.tagName}`
|
||||||
this.parent = elem.parentNode
|
this.parent = elem.parentNode
|
||||||
this.nextSibling = this.elem.nextSibling
|
this.nextSibling = this.elem.nextSibling
|
||||||
}
|
}
|
||||||
@@ -197,7 +206,11 @@ export class InsertElementCommand extends Command {
|
|||||||
*/
|
*/
|
||||||
apply (handler) {
|
apply (handler) {
|
||||||
super.apply(handler, () => {
|
super.apply(handler, () => {
|
||||||
this.elem = this.parent.insertBefore(this.elem, this.nextSibling)
|
const reference =
|
||||||
|
this.nextSibling && this.nextSibling.parentNode === this.parent
|
||||||
|
? this.nextSibling
|
||||||
|
: null
|
||||||
|
this.elem = this.parent.insertBefore(this.elem, reference)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +242,7 @@ export class RemoveElementCommand extends Command {
|
|||||||
constructor (elem, oldNextSibling, oldParent, text) {
|
constructor (elem, oldNextSibling, oldParent, text) {
|
||||||
super()
|
super()
|
||||||
this.elem = elem
|
this.elem = elem
|
||||||
this.text = text || ('Delete ' + elem.tagName)
|
this.text = text || `Delete ${elem.tagName}`
|
||||||
this.nextSibling = oldNextSibling
|
this.nextSibling = oldNextSibling
|
||||||
this.parent = oldParent
|
this.parent = oldParent
|
||||||
}
|
}
|
||||||
@@ -255,10 +268,11 @@ export class RemoveElementCommand extends Command {
|
|||||||
*/
|
*/
|
||||||
unapply (handler) {
|
unapply (handler) {
|
||||||
super.unapply(handler, () => {
|
super.unapply(handler, () => {
|
||||||
if (!this.nextSibling) {
|
const reference =
|
||||||
console.error('Reference element was lost')
|
this.nextSibling && this.nextSibling.parentNode === this.parent
|
||||||
}
|
? this.nextSibling
|
||||||
this.parent.insertBefore(this.elem, this.nextSibling) // Don't use `before` or `prepend` as `this.nextSibling` may be `null`
|
: null
|
||||||
|
this.parent.insertBefore(this.elem, reference) // Don't use `before` or `prepend` as `reference` may be `null`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,7 +298,7 @@ export class ChangeElementCommand extends Command {
|
|||||||
constructor (elem, attrs, text) {
|
constructor (elem, attrs, text) {
|
||||||
super()
|
super()
|
||||||
this.elem = elem
|
this.elem = elem
|
||||||
this.text = text ? ('Change ' + elem.tagName + ' ' + text) : ('Change ' + elem.tagName)
|
this.text = text ? `Change ${elem.tagName} ${text}` : `Change ${elem.tagName}`
|
||||||
this.newValues = {}
|
this.newValues = {}
|
||||||
this.oldValues = attrs
|
this.oldValues = attrs
|
||||||
for (const attr in attrs) {
|
for (const attr in attrs) {
|
||||||
@@ -308,19 +322,21 @@ export class ChangeElementCommand extends Command {
|
|||||||
super.apply(handler, () => {
|
super.apply(handler, () => {
|
||||||
let bChangedTransform = false
|
let bChangedTransform = false
|
||||||
Object.entries(this.newValues).forEach(([attr, value]) => {
|
Object.entries(this.newValues).forEach(([attr, value]) => {
|
||||||
if (value) {
|
const isNullishOrEmpty = value === null || value === undefined || value === ''
|
||||||
if (attr === '#text') {
|
if (attr === '#text') {
|
||||||
this.elem.textContent = value
|
this.elem.textContent = value === null || value === undefined ? '' : String(value)
|
||||||
} else if (attr === '#href') {
|
} else if (attr === '#href') {
|
||||||
setHref(this.elem, value)
|
if (isNullishOrEmpty) {
|
||||||
|
this.elem.removeAttribute('href')
|
||||||
|
this.elem.removeAttributeNS(NS.XLINK, 'href')
|
||||||
} else {
|
} else {
|
||||||
this.elem.setAttribute(attr, value)
|
setHref(this.elem, String(value))
|
||||||
}
|
}
|
||||||
} else if (attr === '#text') {
|
} else if (isNullishOrEmpty) {
|
||||||
this.elem.textContent = ''
|
|
||||||
} else {
|
|
||||||
this.elem.setAttribute(attr, '')
|
this.elem.setAttribute(attr, '')
|
||||||
this.elem.removeAttribute(attr)
|
this.elem.removeAttribute(attr)
|
||||||
|
} else {
|
||||||
|
this.elem.setAttribute(attr, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attr === 'transform') { bChangedTransform = true }
|
if (attr === 'transform') { bChangedTransform = true }
|
||||||
@@ -331,6 +347,7 @@ export class ChangeElementCommand extends Command {
|
|||||||
const angle = getRotationAngle(this.elem)
|
const angle = getRotationAngle(this.elem)
|
||||||
if (angle) {
|
if (angle) {
|
||||||
const bbox = getBBox(this.elem)
|
const bbox = getBBox(this.elem)
|
||||||
|
if (!bbox) return
|
||||||
const cx = bbox.x + bbox.width / 2
|
const cx = bbox.x + bbox.width / 2
|
||||||
const cy = bbox.y + bbox.height / 2
|
const cy = bbox.y + bbox.height / 2
|
||||||
const rotate = ['rotate(', angle, ' ', cx, ',', cy, ')'].join('')
|
const rotate = ['rotate(', angle, ' ', cx, ',', cy, ')'].join('')
|
||||||
@@ -352,19 +369,21 @@ export class ChangeElementCommand extends Command {
|
|||||||
super.unapply(handler, () => {
|
super.unapply(handler, () => {
|
||||||
let bChangedTransform = false
|
let bChangedTransform = false
|
||||||
Object.entries(this.oldValues).forEach(([attr, value]) => {
|
Object.entries(this.oldValues).forEach(([attr, value]) => {
|
||||||
if (value) {
|
const isNullishOrEmpty = value === null || value === undefined || value === ''
|
||||||
if (attr === '#text') {
|
if (attr === '#text') {
|
||||||
this.elem.textContent = value
|
this.elem.textContent = value === null || value === undefined ? '' : String(value)
|
||||||
} else if (attr === '#href') {
|
} else if (attr === '#href') {
|
||||||
setHref(this.elem, value)
|
if (isNullishOrEmpty) {
|
||||||
|
this.elem.removeAttribute('href')
|
||||||
|
this.elem.removeAttributeNS(NS.XLINK, 'href')
|
||||||
|
} else {
|
||||||
|
setHref(this.elem, String(value))
|
||||||
|
}
|
||||||
|
} else if (isNullishOrEmpty) {
|
||||||
|
this.elem.removeAttribute(attr)
|
||||||
} else {
|
} else {
|
||||||
this.elem.setAttribute(attr, value)
|
this.elem.setAttribute(attr, value)
|
||||||
}
|
}
|
||||||
} else if (attr === '#text') {
|
|
||||||
this.elem.textContent = ''
|
|
||||||
} else {
|
|
||||||
this.elem.removeAttribute(attr)
|
|
||||||
}
|
|
||||||
if (attr === 'transform') { bChangedTransform = true }
|
if (attr === 'transform') { bChangedTransform = true }
|
||||||
})
|
})
|
||||||
// relocate rotational transform, if necessary
|
// relocate rotational transform, if necessary
|
||||||
@@ -372,6 +391,7 @@ export class ChangeElementCommand extends Command {
|
|||||||
const angle = getRotationAngle(this.elem)
|
const angle = getRotationAngle(this.elem)
|
||||||
if (angle) {
|
if (angle) {
|
||||||
const bbox = getBBox(this.elem)
|
const bbox = getBBox(this.elem)
|
||||||
|
if (!bbox) return
|
||||||
const cx = bbox.x + bbox.width / 2
|
const cx = bbox.x + bbox.width / 2
|
||||||
const cy = bbox.y + bbox.height / 2
|
const cy = bbox.y + bbox.height / 2
|
||||||
const rotate = ['rotate(', angle, ' ', cx, ',', cy, ')'].join('')
|
const rotate = ['rotate(', angle, ' ', cx, ',', cy, ')'].join('')
|
||||||
@@ -602,7 +622,7 @@ export class UndoManager {
|
|||||||
const p = this.undoChangeStackPointer--
|
const p = this.undoChangeStackPointer--
|
||||||
const changeset = this.undoableChangeStack[p]
|
const changeset = this.undoableChangeStack[p]
|
||||||
const { attrName } = changeset
|
const { attrName } = changeset
|
||||||
const batchCmd = new BatchCommand('Change ' + attrName)
|
const batchCmd = new BatchCommand(`Change ${attrName}`)
|
||||||
let i = changeset.elements.length
|
let i = changeset.elements.length
|
||||||
while (i--) {
|
while (i--) {
|
||||||
const elem = changeset.elements[i]
|
const elem = changeset.elements[i]
|
||||||
|
|||||||
@@ -79,8 +79,10 @@ class HistoryRecordingService {
|
|||||||
this.batchCommandStack_.pop()
|
this.batchCommandStack_.pop()
|
||||||
const { length: len } = this.batchCommandStack_
|
const { length: len } = this.batchCommandStack_
|
||||||
this.currentBatchCommand_ = len ? this.batchCommandStack_[len - 1] : null
|
this.currentBatchCommand_ = len ? this.batchCommandStack_[len - 1] : null
|
||||||
|
if (!batchCommand.isEmpty()) {
|
||||||
this.addCommand_(batchCommand)
|
this.addCommand_(batchCommand)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,5 +159,5 @@ class HistoryRecordingService {
|
|||||||
* @memberof module:history.HistoryRecordingService
|
* @memberof module:history.HistoryRecordingService
|
||||||
* @property {module:history.HistoryRecordingService} NO_HISTORY - Singleton that can be passed to functions that record history, but the caller requires that no history be recorded.
|
* @property {module:history.HistoryRecordingService} NO_HISTORY - Singleton that can be passed to functions that record history, but the caller requires that no history be recorded.
|
||||||
*/
|
*/
|
||||||
HistoryRecordingService.NO_HISTORY = new HistoryRecordingService()
|
HistoryRecordingService.NO_HISTORY = new HistoryRecordingService(null)
|
||||||
export default HistoryRecordingService
|
export default HistoryRecordingService
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ let svgdoc_ = null
|
|||||||
*/
|
*/
|
||||||
export const init = (canvas) => {
|
export const init = (canvas) => {
|
||||||
svgCanvas = canvas
|
svgCanvas = canvas
|
||||||
svgdoc_ = canvas.getDOMDocument()
|
svgdoc_ = canvas.getDOMDocument?.() || (typeof document !== 'undefined' ? document : null)
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* @function module:json.getJsonFromSvgElements Iterate element and return json format
|
* @function module:json.getJsonFromSvgElements Iterate element and return json format
|
||||||
@@ -35,8 +35,12 @@ export const init = (canvas) => {
|
|||||||
* @returns {svgRootElement}
|
* @returns {svgRootElement}
|
||||||
*/
|
*/
|
||||||
export const getJsonFromSvgElements = (data) => {
|
export const getJsonFromSvgElements = (data) => {
|
||||||
|
if (!data) return null
|
||||||
|
|
||||||
// Text node
|
// Text node
|
||||||
if (data.nodeType === 3) return data.nodeValue
|
if (data.nodeType === 3 || data.nodeType === 4) return data.nodeValue
|
||||||
|
// Ignore non-element nodes (e.g., comments)
|
||||||
|
if (data.nodeType !== 1) return null
|
||||||
|
|
||||||
const retval = {
|
const retval = {
|
||||||
element: data.tagName,
|
element: data.tagName,
|
||||||
@@ -46,13 +50,25 @@ export const getJsonFromSvgElements = (data) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Iterate attributes
|
// Iterate attributes
|
||||||
for (let i = 0, attr; (attr = data.attributes[i]); i++) {
|
const attributes = data.attributes
|
||||||
|
if (attributes) {
|
||||||
|
for (let i = 0; i < attributes.length; i++) {
|
||||||
|
const attr = attributes[i]
|
||||||
|
if (!attr) continue
|
||||||
retval.attr[attr.name] = attr.value
|
retval.attr[attr.name] = attr.value
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Iterate children
|
// Iterate children
|
||||||
for (let i = 0, node; (node = data.childNodes[i]); i++) {
|
const childNodes = data.childNodes
|
||||||
retval.children[i] = getJsonFromSvgElements(node)
|
if (childNodes) {
|
||||||
|
for (let i = 0; i < childNodes.length; i++) {
|
||||||
|
const node = childNodes[i]
|
||||||
|
const child = getJsonFromSvgElements(node)
|
||||||
|
if (child !== null && child !== undefined) {
|
||||||
|
retval.children.push(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return retval
|
return retval
|
||||||
@@ -65,11 +81,29 @@ export const getJsonFromSvgElements = (data) => {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const addSVGElementsFromJson = (data) => {
|
export const addSVGElementsFromJson = (data) => {
|
||||||
|
if (!svgdoc_) { return null }
|
||||||
|
if (data === null || data === undefined) return svgdoc_.createTextNode('')
|
||||||
if (typeof data === 'string') return svgdoc_.createTextNode(data)
|
if (typeof data === 'string') return svgdoc_.createTextNode(data)
|
||||||
|
|
||||||
let shape = getElement(data.attr.id)
|
const attrs = data.attr || {}
|
||||||
|
const id = attrs.id
|
||||||
|
let shape = null
|
||||||
|
if (typeof id === 'string' && id) {
|
||||||
|
try {
|
||||||
|
shape = getElement(id)
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore (CSS selector may be invalid); fallback to getElementById below
|
||||||
|
}
|
||||||
|
if (!shape) {
|
||||||
|
const byId = svgdoc_.getElementById?.(id)
|
||||||
|
const svgRoot = svgCanvas?.getSvgRoot?.()
|
||||||
|
if (byId && (!svgRoot || svgRoot.contains(byId))) {
|
||||||
|
shape = byId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// if shape is a path but we need to create a rect/ellipse, then remove the path
|
// if shape is a path but we need to create a rect/ellipse, then remove the path
|
||||||
const currentLayer = svgCanvas.getDrawing().getCurrentLayer()
|
const currentLayer = svgCanvas?.getDrawing?.()?.getCurrentLayer?.()
|
||||||
if (shape && data.element !== shape.tagName) {
|
if (shape && data.element !== shape.tagName) {
|
||||||
shape.remove()
|
shape.remove()
|
||||||
shape = null
|
shape = null
|
||||||
@@ -81,8 +115,10 @@ export const addSVGElementsFromJson = (data) => {
|
|||||||
(svgCanvas.getCurrentGroup() || currentLayer).append(shape)
|
(svgCanvas.getCurrentGroup() || currentLayer).append(shape)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const curShape = svgCanvas.getCurShape()
|
const curShape = svgCanvas.getCurShape?.() || {}
|
||||||
if (data.curStyles) {
|
if (data.curStyles) {
|
||||||
|
const curOpacity = Number(curShape.opacity)
|
||||||
|
const opacity = Number.isFinite(curOpacity) ? (curOpacity / 2) : 0.5
|
||||||
assignAttributes(shape, {
|
assignAttributes(shape, {
|
||||||
fill: curShape.fill,
|
fill: curShape.fill,
|
||||||
stroke: curShape.stroke,
|
stroke: curShape.stroke,
|
||||||
@@ -92,17 +128,23 @@ export const addSVGElementsFromJson = (data) => {
|
|||||||
'stroke-linecap': curShape.stroke_linecap,
|
'stroke-linecap': curShape.stroke_linecap,
|
||||||
'stroke-opacity': curShape.stroke_opacity,
|
'stroke-opacity': curShape.stroke_opacity,
|
||||||
'fill-opacity': curShape.fill_opacity,
|
'fill-opacity': curShape.fill_opacity,
|
||||||
opacity: curShape.opacity / 2,
|
opacity,
|
||||||
style: 'pointer-events:inherit'
|
style: 'pointer-events:inherit'
|
||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
assignAttributes(shape, data.attr, 100)
|
assignAttributes(shape, attrs, 100)
|
||||||
cleanupElement(shape)
|
cleanupElement(shape)
|
||||||
|
|
||||||
// Children
|
// Children
|
||||||
if (data.children) {
|
if (data.children) {
|
||||||
|
while (shape.firstChild) {
|
||||||
|
shape.firstChild.remove()
|
||||||
|
}
|
||||||
data.children.forEach((child) => {
|
data.children.forEach((child) => {
|
||||||
shape.append(addSVGElementsFromJson(child))
|
const childNode = addSVGElementsFromJson(child)
|
||||||
|
if (childNode) {
|
||||||
|
shape.append(childNode)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,19 +40,16 @@ class Layer {
|
|||||||
const layerTitle = svgdoc.createElementNS(NS.SVG, 'title')
|
const layerTitle = svgdoc.createElementNS(NS.SVG, 'title')
|
||||||
layerTitle.textContent = name
|
layerTitle.textContent = name
|
||||||
this.group_.append(layerTitle)
|
this.group_.append(layerTitle)
|
||||||
if (group) {
|
|
||||||
group.insertAdjacentElement('afterend', this.group_)
|
group ? group.insertAdjacentElement('afterend', this.group_) : svgElem.append(this.group_)
|
||||||
} else {
|
|
||||||
svgElem.append(this.group_)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addLayerClass(this.group_)
|
addLayerClass(this.group_)
|
||||||
walkTree(this.group_, function (e) {
|
walkTree(this.group_, function (e) {
|
||||||
e.setAttribute('style', 'pointer-events:inherit')
|
e.style.pointerEvents = 'inherit'
|
||||||
})
|
})
|
||||||
|
|
||||||
this.group_.setAttribute('style', svgElem ? 'pointer-events:all' : 'pointer-events:none')
|
this.group_.style.pointerEvents = svgElem ? 'all' : 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,7 +73,7 @@ class Layer {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
activate () {
|
activate () {
|
||||||
this.group_.setAttribute('style', 'pointer-events:all')
|
this.group_.style.pointerEvents = 'all'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,7 +81,7 @@ class Layer {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
deactivate () {
|
deactivate () {
|
||||||
this.group_.setAttribute('style', 'pointer-events:none')
|
this.group_.style.pointerEvents = 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,7 +90,7 @@ class Layer {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
setVisible (visible) {
|
setVisible (visible) {
|
||||||
const expected = visible === undefined || visible ? 'inline' : 'none'
|
const expected = (visible === undefined || visible) ? 'inline' : 'none'
|
||||||
const oldDisplay = this.group_.getAttribute('display')
|
const oldDisplay = this.group_.getAttribute('display')
|
||||||
if (oldDisplay !== expected) {
|
if (oldDisplay !== expected) {
|
||||||
this.group_.setAttribute('display', expected)
|
this.group_.setAttribute('display', expected)
|
||||||
@@ -114,10 +111,7 @@ class Layer {
|
|||||||
*/
|
*/
|
||||||
getOpacity () {
|
getOpacity () {
|
||||||
const opacity = this.group_.getAttribute('opacity')
|
const opacity = this.group_.getAttribute('opacity')
|
||||||
if (!opacity) {
|
return opacity ? Number.parseFloat(opacity) : 1
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return Number.parseFloat(opacity)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -208,7 +202,7 @@ Layer.CLASS_NAME = 'layer'
|
|||||||
/**
|
/**
|
||||||
* @property {RegExp} CLASS_REGEX - Used to test presence of class Layer.CLASS_NAME
|
* @property {RegExp} CLASS_REGEX - Used to test presence of class Layer.CLASS_NAME
|
||||||
*/
|
*/
|
||||||
Layer.CLASS_REGEX = new RegExp('(\\s|^)' + Layer.CLASS_NAME + '(\\s|$)')
|
Layer.CLASS_REGEX = new RegExp(`(\\s|^)${Layer.CLASS_NAME}(\\s|$)`)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add class `Layer.CLASS_NAME` to the element (usually `class='layer'`).
|
* Add class `Layer.CLASS_NAME` to the element (usually `class='layer'`).
|
||||||
@@ -216,12 +210,12 @@ Layer.CLASS_REGEX = new RegExp('(\\s|^)' + Layer.CLASS_NAME + '(\\s|$)')
|
|||||||
* @param {SVGGElement} elem - The SVG element to update
|
* @param {SVGGElement} elem - The SVG element to update
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
function addLayerClass (elem) {
|
const addLayerClass = (elem) => {
|
||||||
const classes = elem.getAttribute('class')
|
const classes = elem.getAttribute('class')
|
||||||
if (!classes || !classes.length) {
|
if (!classes || !classes.length) {
|
||||||
elem.setAttribute('class', Layer.CLASS_NAME)
|
elem.setAttribute('class', Layer.CLASS_NAME)
|
||||||
} else if (!Layer.CLASS_REGEX.test(classes)) {
|
} else if (!Layer.CLASS_REGEX.test(classes)) {
|
||||||
elem.setAttribute('class', classes + ' ' + Layer.CLASS_NAME)
|
elem.setAttribute('class', `${classes} ${Layer.CLASS_NAME}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NS } from './namespaces.js'
|
import { NS } from './namespaces.js'
|
||||||
|
import { warn } from '../common/logger.js'
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const NEAR_ZERO = 1e-10
|
const NEAR_ZERO = 1e-10
|
||||||
@@ -27,6 +28,38 @@ const NEAR_ZERO = 1e-10
|
|||||||
// Create a throwaway SVG element for matrix operations
|
// Create a throwaway SVG element for matrix operations
|
||||||
const svg = document.createElementNS(NS.SVG, 'svg')
|
const svg = document.createElementNS(NS.SVG, 'svg')
|
||||||
|
|
||||||
|
const createTransformFromMatrix = (m) => {
|
||||||
|
const createFallback = (matrix) => {
|
||||||
|
const fallback = svg.createSVGMatrix()
|
||||||
|
Object.assign(fallback, {
|
||||||
|
a: matrix.a,
|
||||||
|
b: matrix.b,
|
||||||
|
c: matrix.c,
|
||||||
|
d: matrix.d,
|
||||||
|
e: matrix.e,
|
||||||
|
f: matrix.f
|
||||||
|
})
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return svg.createSVGTransformFromMatrix(m)
|
||||||
|
} catch (e) {
|
||||||
|
const t = svg.createSVGTransform()
|
||||||
|
try {
|
||||||
|
t.setMatrix(m)
|
||||||
|
return t
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
return svg.createSVGTransformFromMatrix(createFallback(m))
|
||||||
|
} catch (e2) {
|
||||||
|
t.setMatrix(createFallback(m))
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transforms a point by a given matrix without DOM calls.
|
* Transforms a point by a given matrix without DOM calls.
|
||||||
* @function transformPoint
|
* @function transformPoint
|
||||||
@@ -56,7 +89,7 @@ export const getTransformList = elem => {
|
|||||||
if (elem.patternTransform?.baseVal) {
|
if (elem.patternTransform?.baseVal) {
|
||||||
return elem.patternTransform.baseVal
|
return elem.patternTransform.baseVal
|
||||||
}
|
}
|
||||||
console.warn('No transform list found. Check browser compatibility.', elem)
|
warn('No transform list found. Check browser compatibility.', elem, 'math')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,7 +99,12 @@ export const getTransformList = elem => {
|
|||||||
* @returns {boolean} True if it's an identity matrix (1,0,0,1,0,0)
|
* @returns {boolean} True if it's an identity matrix (1,0,0,1,0,0)
|
||||||
*/
|
*/
|
||||||
export const isIdentity = m =>
|
export const isIdentity = m =>
|
||||||
m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1 && m.e === 0 && m.f === 0
|
Math.abs(m.a - 1) < NEAR_ZERO &&
|
||||||
|
Math.abs(m.b) < NEAR_ZERO &&
|
||||||
|
Math.abs(m.c) < NEAR_ZERO &&
|
||||||
|
Math.abs(m.d - 1) < NEAR_ZERO &&
|
||||||
|
Math.abs(m.e) < NEAR_ZERO &&
|
||||||
|
Math.abs(m.f) < NEAR_ZERO
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multiplies multiple matrices together (m1 * m2 * ...).
|
* Multiplies multiple matrices together (m1 * m2 * ...).
|
||||||
@@ -76,22 +114,54 @@ export const isIdentity = m =>
|
|||||||
* @returns {SVGMatrix} The resulting matrix
|
* @returns {SVGMatrix} The resulting matrix
|
||||||
*/
|
*/
|
||||||
export const matrixMultiply = (...args) => {
|
export const matrixMultiply = (...args) => {
|
||||||
// If no matrices are given, return an identity matrix
|
|
||||||
if (args.length === 0) {
|
if (args.length === 0) {
|
||||||
return svg.createSVGMatrix()
|
return svg.createSVGMatrix()
|
||||||
}
|
}
|
||||||
|
|
||||||
const m = args.reduceRight((prev, curr) => curr.multiply(prev))
|
const normalizeNearZero = (matrix) => {
|
||||||
|
const props = ['a', 'b', 'c', 'd', 'e', 'f']
|
||||||
|
for (const prop of props) {
|
||||||
|
if (Math.abs(matrix[prop]) < NEAR_ZERO) {
|
||||||
|
matrix[prop] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matrix
|
||||||
|
}
|
||||||
|
|
||||||
// Round near-zero values to zero
|
if (typeof DOMMatrix === 'function' && typeof DOMMatrix.fromMatrix === 'function') {
|
||||||
if (Math.abs(m.a) < NEAR_ZERO) m.a = 0
|
const result = args.reduce(
|
||||||
if (Math.abs(m.b) < NEAR_ZERO) m.b = 0
|
(acc, curr) => acc.multiply(DOMMatrix.fromMatrix(curr)),
|
||||||
if (Math.abs(m.c) < NEAR_ZERO) m.c = 0
|
new DOMMatrix()
|
||||||
if (Math.abs(m.d) < NEAR_ZERO) m.d = 0
|
)
|
||||||
if (Math.abs(m.e) < NEAR_ZERO) m.e = 0
|
|
||||||
if (Math.abs(m.f) < NEAR_ZERO) m.f = 0
|
|
||||||
|
|
||||||
return m
|
const out = svg.createSVGMatrix()
|
||||||
|
Object.assign(out, {
|
||||||
|
a: result.a,
|
||||||
|
b: result.b,
|
||||||
|
c: result.c,
|
||||||
|
d: result.d,
|
||||||
|
e: result.e,
|
||||||
|
f: result.f
|
||||||
|
})
|
||||||
|
|
||||||
|
return normalizeNearZero(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
let m = svg.createSVGMatrix()
|
||||||
|
for (const curr of args) {
|
||||||
|
const next = svg.createSVGMatrix()
|
||||||
|
Object.assign(next, {
|
||||||
|
a: m.a * curr.a + m.c * curr.b,
|
||||||
|
b: m.b * curr.a + m.d * curr.b,
|
||||||
|
c: m.a * curr.c + m.c * curr.d,
|
||||||
|
d: m.b * curr.c + m.d * curr.d,
|
||||||
|
e: m.a * curr.e + m.c * curr.f + m.e,
|
||||||
|
f: m.b * curr.e + m.d * curr.f + m.f
|
||||||
|
})
|
||||||
|
m = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeNearZero(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -172,25 +242,34 @@ export const transformBox = (l, t, w, h, m) => {
|
|||||||
*/
|
*/
|
||||||
export const transformListToTransform = (tlist, min = 0, max = null) => {
|
export const transformListToTransform = (tlist, min = 0, max = null) => {
|
||||||
if (!tlist) {
|
if (!tlist) {
|
||||||
return svg.createSVGTransformFromMatrix(svg.createSVGMatrix())
|
return createTransformFromMatrix(svg.createSVGMatrix())
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = Number.parseInt(min, 10)
|
const start = Number.parseInt(min, 10)
|
||||||
const end = Number.parseInt(max ?? tlist.numberOfItems - 1, 10)
|
const end = Number.parseInt(max ?? tlist.numberOfItems - 1, 10)
|
||||||
const low = Math.min(start, end)
|
const [low, high] = [Math.min(start, end), Math.max(start, end)]
|
||||||
const high = Math.max(start, end)
|
|
||||||
|
|
||||||
let combinedMatrix = svg.createSVGMatrix()
|
const matrices = []
|
||||||
for (let i = low; i <= high; i++) {
|
for (let i = low; i <= high; i++) {
|
||||||
// If out of range, use identity
|
const matrix = (i >= 0 && i < tlist.numberOfItems)
|
||||||
const currentMatrix =
|
|
||||||
i >= 0 && i < tlist.numberOfItems
|
|
||||||
? tlist.getItem(i).matrix
|
? tlist.getItem(i).matrix
|
||||||
: svg.createSVGMatrix()
|
: svg.createSVGMatrix()
|
||||||
combinedMatrix = matrixMultiply(combinedMatrix, currentMatrix)
|
matrices.push(matrix)
|
||||||
}
|
}
|
||||||
|
|
||||||
return svg.createSVGTransformFromMatrix(combinedMatrix)
|
const combinedMatrix = matrixMultiply(...matrices)
|
||||||
|
|
||||||
|
const out = svg.createSVGMatrix()
|
||||||
|
Object.assign(out, {
|
||||||
|
a: combinedMatrix.a,
|
||||||
|
b: combinedMatrix.b,
|
||||||
|
c: combinedMatrix.c,
|
||||||
|
d: combinedMatrix.d,
|
||||||
|
e: combinedMatrix.e,
|
||||||
|
f: combinedMatrix.f
|
||||||
|
})
|
||||||
|
|
||||||
|
return createTransformFromMatrix(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common namepaces constants in alpha order.
|
* Common namespaces constants in alpha order.
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
* @type {PlainObject}
|
* @type {PlainObject}
|
||||||
* @memberof module:namespaces
|
* @memberof module:namespaces
|
||||||
@@ -29,12 +29,12 @@ export const NS = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @function module:namespaces.getReverseNS
|
* @function module:namespaces.getReverseNS
|
||||||
* @returns {string} The NS with key values switched and lowercase
|
* @returns {PlainObject<string, string>} The namespace URI map with values swapped to their lowercase keys
|
||||||
*/
|
*/
|
||||||
export const getReverseNS = function () {
|
export const getReverseNS = () => {
|
||||||
const reverseNS = {}
|
const reverseNS = {}
|
||||||
Object.entries(NS).forEach(([name, URI]) => {
|
for (const [name, URI] of Object.entries(NS)) {
|
||||||
reverseNS[URI] = name.toLowerCase()
|
reverseNS[URI] = name.toLowerCase()
|
||||||
})
|
}
|
||||||
return reverseNS
|
return reverseNS
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,95 @@
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export default class Paint {
|
export default class Paint {
|
||||||
|
static #normalizeAlpha (alpha) {
|
||||||
|
const numeric = Number(alpha)
|
||||||
|
if (!Number.isFinite(numeric)) return 100
|
||||||
|
return Math.min(100, Math.max(0, numeric))
|
||||||
|
}
|
||||||
|
|
||||||
|
static #normalizeSolidColor (color) {
|
||||||
|
if (color === null || color === undefined) return null
|
||||||
|
const str = String(color).trim()
|
||||||
|
if (!str) return null
|
||||||
|
if (str === 'none') return 'none'
|
||||||
|
return str.startsWith('#') ? str.slice(1) : str
|
||||||
|
}
|
||||||
|
|
||||||
|
static #extractHrefId (hrefAttr) {
|
||||||
|
if (!hrefAttr) return null
|
||||||
|
const href = String(hrefAttr).trim()
|
||||||
|
if (!href) return null
|
||||||
|
if (href.startsWith('#')) return href.slice(1)
|
||||||
|
const urlMatch = href.match(/url\(\s*['"]?#([^'")\s]+)['"]?\s*\)/)
|
||||||
|
if (urlMatch?.[1]) return urlMatch[1]
|
||||||
|
const hashIndex = href.lastIndexOf('#')
|
||||||
|
if (hashIndex >= 0 && hashIndex < href.length - 1) {
|
||||||
|
return href.slice(hashIndex + 1)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
static #resolveGradient (gradient) {
|
||||||
|
if (!gradient?.cloneNode) return null
|
||||||
|
const doc = gradient.ownerDocument || document
|
||||||
|
const visited = new Set()
|
||||||
|
const clone = gradient.cloneNode(true)
|
||||||
|
|
||||||
|
let refId = Paint.#extractHrefId(
|
||||||
|
clone.getAttribute('href') || clone.getAttribute('xlink:href')
|
||||||
|
)
|
||||||
|
|
||||||
|
while (refId && !visited.has(refId)) {
|
||||||
|
visited.add(refId)
|
||||||
|
|
||||||
|
const referenced = doc.getElementById(refId)
|
||||||
|
if (!referenced?.getAttribute) break
|
||||||
|
|
||||||
|
const cloneTag = String(clone.tagName || '').toLowerCase()
|
||||||
|
const referencedTag = String(referenced.tagName || '').toLowerCase()
|
||||||
|
if (
|
||||||
|
!['lineargradient', 'radialgradient'].includes(referencedTag) ||
|
||||||
|
referencedTag !== cloneTag
|
||||||
|
) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy missing attributes from referenced gradient (matches SVG href inheritance).
|
||||||
|
for (const attr of referenced.attributes || []) {
|
||||||
|
const name = attr.name
|
||||||
|
if (name === 'id' || name === 'href' || name === 'xlink:href') continue
|
||||||
|
const current = clone.getAttribute(name)
|
||||||
|
if (current === null || current === '') {
|
||||||
|
clone.setAttribute(name, attr.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the referencing gradient has no stops, inherit stops from the referenced gradient.
|
||||||
|
if (clone.querySelectorAll('stop').length === 0) {
|
||||||
|
for (const stop of referenced.querySelectorAll?.('stop') || []) {
|
||||||
|
clone.append(stop.cloneNode(true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare to continue resolving deeper links if present.
|
||||||
|
refId = Paint.#extractHrefId(
|
||||||
|
referenced.getAttribute('href') || referenced.getAttribute('xlink:href')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clone is now self-contained; remove any href.
|
||||||
|
clone.removeAttribute('href')
|
||||||
|
clone.removeAttribute('xlink:href')
|
||||||
|
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {module:jGraduate.jGraduatePaintOptions} [opt]
|
* @param {module:jGraduate.jGraduatePaintOptions} [opt]
|
||||||
*/
|
*/
|
||||||
constructor (opt) {
|
constructor (opt) {
|
||||||
const options = opt || {}
|
const options = opt || {}
|
||||||
this.alpha = isNaN(options.alpha) ? 100 : options.alpha
|
this.alpha = Paint.#normalizeAlpha(options.alpha)
|
||||||
// copy paint object
|
// copy paint object
|
||||||
if (options.copy) {
|
if (options.copy) {
|
||||||
/**
|
/**
|
||||||
@@ -20,7 +103,7 @@ export default class Paint {
|
|||||||
* @name module:jGraduate~Paint#alpha
|
* @name module:jGraduate~Paint#alpha
|
||||||
* @type {Float}
|
* @type {Float}
|
||||||
*/
|
*/
|
||||||
this.alpha = options.copy.alpha
|
this.alpha = Paint.#normalizeAlpha(options.copy.alpha)
|
||||||
/**
|
/**
|
||||||
* Represents #RRGGBB hex of color.
|
* Represents #RRGGBB hex of color.
|
||||||
* @name module:jGraduate~Paint#solidColor
|
* @name module:jGraduate~Paint#solidColor
|
||||||
@@ -42,13 +125,17 @@ export default class Paint {
|
|||||||
case 'none':
|
case 'none':
|
||||||
break
|
break
|
||||||
case 'solidColor':
|
case 'solidColor':
|
||||||
this.solidColor = options.copy.solidColor
|
this.solidColor = Paint.#normalizeSolidColor(options.copy.solidColor)
|
||||||
break
|
break
|
||||||
case 'linearGradient':
|
case 'linearGradient':
|
||||||
this.linearGradient = options.copy.linearGradient.cloneNode(true)
|
this.linearGradient = options.copy.linearGradient?.cloneNode
|
||||||
|
? options.copy.linearGradient.cloneNode(true)
|
||||||
|
: null
|
||||||
break
|
break
|
||||||
case 'radialGradient':
|
case 'radialGradient':
|
||||||
this.radialGradient = options.copy.radialGradient.cloneNode(true)
|
this.radialGradient = options.copy.radialGradient?.cloneNode
|
||||||
|
? options.copy.radialGradient.cloneNode(true)
|
||||||
|
: null
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// create linear gradient paint
|
// create linear gradient paint
|
||||||
@@ -56,33 +143,17 @@ export default class Paint {
|
|||||||
this.type = 'linearGradient'
|
this.type = 'linearGradient'
|
||||||
this.solidColor = null
|
this.solidColor = null
|
||||||
this.radialGradient = null
|
this.radialGradient = null
|
||||||
const hrefAttr =
|
this.linearGradient = Paint.#resolveGradient(options.linearGradient)
|
||||||
options.linearGradient.getAttribute('href') ||
|
|
||||||
options.linearGradient.getAttribute('xlink:href')
|
|
||||||
if (hrefAttr) {
|
|
||||||
const xhref = document.getElementById(hrefAttr.replace(/^#/, ''))
|
|
||||||
this.linearGradient = xhref.cloneNode(true)
|
|
||||||
} else {
|
|
||||||
this.linearGradient = options.linearGradient.cloneNode(true)
|
|
||||||
}
|
|
||||||
// create linear gradient paint
|
// create linear gradient paint
|
||||||
} else if (options.radialGradient) {
|
} else if (options.radialGradient) {
|
||||||
this.type = 'radialGradient'
|
this.type = 'radialGradient'
|
||||||
this.solidColor = null
|
this.solidColor = null
|
||||||
this.linearGradient = null
|
this.linearGradient = null
|
||||||
const hrefAttr =
|
this.radialGradient = Paint.#resolveGradient(options.radialGradient)
|
||||||
options.radialGradient.getAttribute('href') ||
|
|
||||||
options.radialGradient.getAttribute('xlink:href')
|
|
||||||
if (hrefAttr) {
|
|
||||||
const xhref = document.getElementById(hrefAttr.replace(/^#/, ''))
|
|
||||||
this.radialGradient = xhref.cloneNode(true)
|
|
||||||
} else {
|
|
||||||
this.radialGradient = options.radialGradient.cloneNode(true)
|
|
||||||
}
|
|
||||||
// create solid color paint
|
// create solid color paint
|
||||||
} else if (options.solidColor) {
|
} else if (options.solidColor) {
|
||||||
this.type = 'solidColor'
|
this.type = 'solidColor'
|
||||||
this.solidColor = options.solidColor
|
this.solidColor = Paint.#normalizeSolidColor(options.solidColor)
|
||||||
// create empty paint
|
// create empty paint
|
||||||
} else {
|
} else {
|
||||||
this.type = 'none'
|
this.type = 'none'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
getStrokedBBoxDefaultVisible
|
getStrokedBBoxDefaultVisible,
|
||||||
|
getUrlFromAttr
|
||||||
} from './utilities.js'
|
} from './utilities.js'
|
||||||
import * as hstry from './history.js'
|
import * as hstry from './history.js'
|
||||||
|
|
||||||
@@ -27,11 +28,15 @@ export const init = (canvas) => {
|
|||||||
* @fires module:svgcanvas.SvgCanvas#event:ext_IDsUpdated
|
* @fires module:svgcanvas.SvgCanvas#event:ext_IDsUpdated
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export const pasteElementsMethod = function (type, x, y) {
|
export const pasteElementsMethod = (type, x, y) => {
|
||||||
let clipb = JSON.parse(sessionStorage.getItem(svgCanvas.getClipboardID()))
|
const rawClipboard = sessionStorage.getItem(svgCanvas.getClipboardID())
|
||||||
if (!clipb) return
|
let clipb
|
||||||
let len = clipb.length
|
try {
|
||||||
if (!len) return
|
clipb = JSON.parse(rawClipboard)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Array.isArray(clipb) || !clipb.length) return
|
||||||
|
|
||||||
const pasted = []
|
const pasted = []
|
||||||
const batchCmd = new BatchCommand('Paste elements')
|
const batchCmd = new BatchCommand('Paste elements')
|
||||||
@@ -50,7 +55,7 @@ export const pasteElementsMethod = function (type, x, y) {
|
|||||||
* @param {module:svgcanvas.SVGAsJSON} elem
|
* @param {module:svgcanvas.SVGAsJSON} elem
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
function checkIDs (elem) {
|
const checkIDs = (elem) => {
|
||||||
if (elem.attr?.id) {
|
if (elem.attr?.id) {
|
||||||
changedIDs[elem.attr.id] = svgCanvas.getNextId()
|
changedIDs[elem.attr.id] = svgCanvas.getNextId()
|
||||||
elem.attr.id = changedIDs[elem.attr.id]
|
elem.attr.id = changedIDs[elem.attr.id]
|
||||||
@@ -59,6 +64,35 @@ export const pasteElementsMethod = function (type, x, y) {
|
|||||||
}
|
}
|
||||||
clipb.forEach((elem) => checkIDs(elem))
|
clipb.forEach((elem) => checkIDs(elem))
|
||||||
|
|
||||||
|
// Update any internal references in the clipboard to match the new IDs.
|
||||||
|
/**
|
||||||
|
* @param {module:svgcanvas.SVGAsJSON} elem
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const remapReferences = (elem) => {
|
||||||
|
const attrs = elem?.attr
|
||||||
|
if (attrs) {
|
||||||
|
for (const [attrName, attrVal] of Object.entries(attrs)) {
|
||||||
|
if (typeof attrVal !== 'string' || !attrVal) continue
|
||||||
|
if ((attrName === 'href' || attrName === 'xlink:href') && attrVal.startsWith('#')) {
|
||||||
|
const refId = attrVal.slice(1)
|
||||||
|
if (refId in changedIDs) {
|
||||||
|
attrs[attrName] = `#${changedIDs[refId]}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const url = getUrlFromAttr(attrVal)
|
||||||
|
if (url) {
|
||||||
|
const refId = url.slice(1)
|
||||||
|
if (refId in changedIDs) {
|
||||||
|
attrs[attrName] = attrVal.replace(url, `#${changedIDs[refId]}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (elem.children) elem.children.forEach((child) => remapReferences(child))
|
||||||
|
}
|
||||||
|
clipb.forEach((elem) => remapReferences(elem))
|
||||||
|
|
||||||
// Give extensions like the connector extension a chance to reflect new IDs and remove invalid elements
|
// Give extensions like the connector extension a chance to reflect new IDs and remove invalid elements
|
||||||
/**
|
/**
|
||||||
* Triggered when `pasteElements` is called from a paste action (context menu or key).
|
* Triggered when `pasteElements` is called from a paste action (context menu or key).
|
||||||
@@ -77,12 +111,14 @@ export const pasteElementsMethod = function (type, x, y) {
|
|||||||
|
|
||||||
extChanges.remove.forEach(function (removeID) {
|
extChanges.remove.forEach(function (removeID) {
|
||||||
clipb = clipb.filter(function (clipBoardItem) {
|
clipb = clipb.filter(function (clipBoardItem) {
|
||||||
return clipBoardItem.attr.id !== removeID
|
return clipBoardItem?.attr?.id !== removeID
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Move elements to lastClickPoint
|
// Move elements to lastClickPoint
|
||||||
|
let len = clipb.length
|
||||||
|
if (!len) return
|
||||||
while (len--) {
|
while (len--) {
|
||||||
const elem = clipb[len]
|
const elem = clipb[len]
|
||||||
if (!elem) { continue }
|
if (!elem) { continue }
|
||||||
@@ -94,6 +130,7 @@ export const pasteElementsMethod = function (type, x, y) {
|
|||||||
svgCanvas.restoreRefElements(copy)
|
svgCanvas.restoreRefElements(copy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!pasted.length) return
|
||||||
svgCanvas.selectOnly(pasted)
|
svgCanvas.selectOnly(pasted)
|
||||||
|
|
||||||
if (type !== 'in_place') {
|
if (type !== 'in_place') {
|
||||||
@@ -108,6 +145,7 @@ export const pasteElementsMethod = function (type, x, y) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bbox = getStrokedBBoxDefaultVisible(pasted)
|
const bbox = getStrokedBBoxDefaultVisible(pasted)
|
||||||
|
if (bbox && Number.isFinite(ctrX) && Number.isFinite(ctrY)) {
|
||||||
const cx = ctrX - (bbox.x + bbox.width / 2)
|
const cx = ctrX - (bbox.x + bbox.width / 2)
|
||||||
const cy = ctrY - (bbox.y + bbox.height / 2)
|
const cy = ctrY - (bbox.y + bbox.height / 2)
|
||||||
const dx = []
|
const dx = []
|
||||||
@@ -121,6 +159,7 @@ export const pasteElementsMethod = function (type, x, y) {
|
|||||||
const cmd = svgCanvas.moveSelectedElements(dx, dy, false)
|
const cmd = svgCanvas.moveSelectedElements(dx, dy, false)
|
||||||
if (cmd) batchCmd.addSubCommand(cmd)
|
if (cmd) batchCmd.addSubCommand(cmd)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
svgCanvas.addCommandToHistory(batchCmd)
|
svgCanvas.addCommandToHistory(batchCmd)
|
||||||
svgCanvas.call('changed', pasted)
|
svgCanvas.call('changed', pasted)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const init = (canvas) => {
|
|||||||
* @param {boolean} toRel - true of convert to relative
|
* @param {boolean} toRel - true of convert to relative
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export const convertPath = function (pth, toRel) {
|
export const convertPath = (pth, toRel) => {
|
||||||
const { pathSegList } = pth
|
const { pathSegList } = pth
|
||||||
const len = pathSegList.numberOfItems
|
const len = pathSegList.numberOfItems
|
||||||
let curx = 0; let cury = 0
|
let curx = 0; let cury = 0
|
||||||
@@ -64,7 +64,7 @@ export const convertPath = function (pth, toRel) {
|
|||||||
case 'z': // z,Z closepath (Z/z)
|
case 'z': // z,Z closepath (Z/z)
|
||||||
case 'Z':
|
case 'Z':
|
||||||
d += 'z'
|
d += 'z'
|
||||||
if (lastM && !toRel) {
|
if (lastM) {
|
||||||
curx = lastM[0]
|
curx = lastM[0]
|
||||||
cury = lastM[1]
|
cury = lastM[1]
|
||||||
}
|
}
|
||||||
@@ -217,34 +217,27 @@ export const convertPath = function (pth, toRel) {
|
|||||||
* @param {Integer[]} [lastPoint] - x,y point
|
* @param {Integer[]} [lastPoint] - x,y point
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function pathDSegment (letter, points, morePoints, lastPoint) {
|
const pathDSegment = (letter, points, morePoints, lastPoint) => {
|
||||||
points.forEach(function (pnt, i) {
|
const parts = [
|
||||||
points[i] = shortFloat(pnt)
|
letter + points.map(pnt => shortFloat(pnt)).join(' '),
|
||||||
})
|
morePoints ? morePoints.join(' ') : null,
|
||||||
let segment = letter + points.join(' ')
|
lastPoint ? shortFloat(lastPoint) : null
|
||||||
if (morePoints) {
|
].filter(Boolean)
|
||||||
segment += ' ' + morePoints.join(' ')
|
return parts.join(' ')
|
||||||
}
|
|
||||||
if (lastPoint) {
|
|
||||||
segment += ' ' + shortFloat(lastPoint)
|
|
||||||
}
|
|
||||||
return segment
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group: Path edit functions.
|
* Group: Path edit functions.
|
||||||
* Functions relating to editing path elements.
|
* Functions relating to editing path elements.
|
||||||
* @namespace {PlainObject} pathActions
|
* @class PathActions
|
||||||
* @memberof module:path
|
* @memberof module:path
|
||||||
*/
|
*/
|
||||||
export const pathActionsMethod = (function () {
|
class PathActions {
|
||||||
let subpath = false
|
#subpath = false
|
||||||
let newPoint; let firstCtrl
|
#newPoint = null
|
||||||
|
#firstCtrl = null
|
||||||
let currentPath = null
|
#currentPath = null
|
||||||
let hasMoved = false
|
#hasMoved = false
|
||||||
// No `svgCanvas` yet but should be ok as is `null` by default
|
|
||||||
// svgCanvas.setDrawnPath(null);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function converts a polyline (created by the fh_path tool) into
|
* This function converts a polyline (created by the fh_path tool) into
|
||||||
@@ -253,9 +246,9 @@ export const pathActionsMethod = (function () {
|
|||||||
* @function smoothPolylineIntoPath
|
* @function smoothPolylineIntoPath
|
||||||
* @param {Element} element
|
* @param {Element} element
|
||||||
* @returns {Element}
|
* @returns {Element}
|
||||||
|
* @private
|
||||||
*/
|
*/
|
||||||
const smoothPolylineIntoPath = function (element) {
|
#smoothPolylineIntoPath = (element) => {
|
||||||
let i
|
|
||||||
const { points } = element
|
const { points } = element
|
||||||
const N = points.numberOfItems
|
const N = points.numberOfItems
|
||||||
if (N >= 4) {
|
if (N >= 4) {
|
||||||
@@ -272,9 +265,11 @@ export const pathActionsMethod = (function () {
|
|||||||
// - https://www.codeproject.com/KB/graphics/BezierSpline.aspx?msg=2956963
|
// - https://www.codeproject.com/KB/graphics/BezierSpline.aspx?msg=2956963
|
||||||
// - https://www.ian-ko.com/ET_GeoWizards/UserGuide/smooth.htm
|
// - https://www.ian-ko.com/ET_GeoWizards/UserGuide/smooth.htm
|
||||||
// - https://www.cs.mtu.edu/~shene/COURSES/cs3621/NOTES/spline/Bezier/bezier-der.html
|
// - https://www.cs.mtu.edu/~shene/COURSES/cs3621/NOTES/spline/Bezier/bezier-der.html
|
||||||
let curpos = points.getItem(0); let prevCtlPt = null
|
let curpos = points.getItem(0)
|
||||||
|
let prevCtlPt = null
|
||||||
let d = []
|
let d = []
|
||||||
d.push(['M', curpos.x, ',', curpos.y, ' C'].join(''))
|
d.push(`M${curpos.x},${curpos.y} C`)
|
||||||
|
let i
|
||||||
for (i = 1; i <= (N - 4); i += 3) {
|
for (i = 1; i <= (N - 4); i += 3) {
|
||||||
let ct1 = points.getItem(i)
|
let ct1 = points.getItem(i)
|
||||||
const ct2 = points.getItem(i + 1)
|
const ct2 = points.getItem(i + 1)
|
||||||
@@ -321,7 +316,6 @@ export const pathActionsMethod = (function () {
|
|||||||
return element
|
return element
|
||||||
}
|
}
|
||||||
|
|
||||||
return (/** @lends module:path.pathActions */ {
|
|
||||||
/**
|
/**
|
||||||
* @param {MouseEvent} evt
|
* @param {MouseEvent} evt
|
||||||
* @param {Element} mouseTarget
|
* @param {Element} mouseTarget
|
||||||
@@ -339,7 +333,7 @@ export const pathActionsMethod = (function () {
|
|||||||
let x = mouseX / zoom
|
let x = mouseX / zoom
|
||||||
let y = mouseY / zoom
|
let y = mouseY / zoom
|
||||||
let stretchy = getElement('path_stretch_line')
|
let stretchy = getElement('path_stretch_line')
|
||||||
newPoint = [x, y]
|
this.#newPoint = [x, y]
|
||||||
|
|
||||||
if (svgCanvas.getGridSnapping()) {
|
if (svgCanvas.getGridSnapping()) {
|
||||||
x = snapToGrid(x)
|
x = snapToGrid(x)
|
||||||
@@ -365,7 +359,7 @@ export const pathActionsMethod = (function () {
|
|||||||
// if pts array is empty, create path element with M at current point
|
// if pts array is empty, create path element with M at current point
|
||||||
const drawnPath = svgCanvas.getDrawnPath()
|
const drawnPath = svgCanvas.getDrawnPath()
|
||||||
if (!drawnPath) {
|
if (!drawnPath) {
|
||||||
const dAttr = 'M' + x + ',' + y + ' ' // Was this meant to work with the other `dAttr`? (was defined globally so adding `var` to at least avoid a global)
|
const dAttr = `M${x},${y} `
|
||||||
/* drawnPath = */ svgCanvas.setDrawnPath(svgCanvas.addSVGElementsFromJson({
|
/* drawnPath = */ svgCanvas.setDrawnPath(svgCanvas.addSVGElementsFromJson({
|
||||||
element: 'path',
|
element: 'path',
|
||||||
curStyles: true,
|
curStyles: true,
|
||||||
@@ -376,8 +370,8 @@ export const pathActionsMethod = (function () {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
// set stretchy line to first point
|
// set stretchy line to first point
|
||||||
stretchy.setAttribute('d', ['M', mouseX, mouseY, mouseX, mouseY].join(' '))
|
stretchy.setAttribute('d', `M${mouseX} ${mouseY} ${mouseX} ${mouseY}`)
|
||||||
index = subpath ? path.segs.length : 0
|
index = this.#subpath ? path.segs.length : 0
|
||||||
svgCanvas.addPointGrip(index, mouseX, mouseY)
|
svgCanvas.addPointGrip(index, mouseX, mouseY)
|
||||||
} else {
|
} else {
|
||||||
// determine if we clicked on an existing point
|
// determine if we clicked on an existing point
|
||||||
@@ -439,7 +433,7 @@ export const pathActionsMethod = (function () {
|
|||||||
/* drawnPath = */ svgCanvas.setDrawnPath(null)
|
/* drawnPath = */ svgCanvas.setDrawnPath(null)
|
||||||
svgCanvas.setStarted(false)
|
svgCanvas.setStarted(false)
|
||||||
|
|
||||||
if (subpath) {
|
if (this.#subpath) {
|
||||||
if (path.matrix) {
|
if (path.matrix) {
|
||||||
svgCanvas.remapElement(newpath, {}, path.matrix.inverse())
|
svgCanvas.remapElement(newpath, {}, path.matrix.inverse())
|
||||||
}
|
}
|
||||||
@@ -495,7 +489,7 @@ export const pathActionsMethod = (function () {
|
|||||||
// set stretchy line to latest point
|
// set stretchy line to latest point
|
||||||
stretchy.setAttribute('d', ['M', x, y, x, y].join(' '))
|
stretchy.setAttribute('d', ['M', x, y, x, y].join(' '))
|
||||||
index = num
|
index = num
|
||||||
if (subpath) { index += path.segs.length }
|
if (this.#subpath) { index += path.segs.length }
|
||||||
svgCanvas.addPointGrip(index, x, y)
|
svgCanvas.addPointGrip(index, x, y)
|
||||||
}
|
}
|
||||||
// keep = true;
|
// keep = true;
|
||||||
@@ -511,9 +505,9 @@ export const pathActionsMethod = (function () {
|
|||||||
|
|
||||||
({ id } = evt.target)
|
({ id } = evt.target)
|
||||||
let curPt
|
let curPt
|
||||||
if (id.substr(0, 14) === 'pathpointgrip_') {
|
if (id.startsWith('pathpointgrip_')) {
|
||||||
// Select this point
|
// Select this point
|
||||||
curPt = path.cur_pt = Number.parseInt(id.substr(14))
|
curPt = path.cur_pt = Number.parseInt(id.slice(14))
|
||||||
path.dragging = [startX, startY]
|
path.dragging = [startX, startY]
|
||||||
const seg = path.segs[curPt]
|
const seg = path.segs[curPt]
|
||||||
|
|
||||||
@@ -556,7 +550,8 @@ export const pathActionsMethod = (function () {
|
|||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Float} mouseX
|
* @param {Float} mouseX
|
||||||
* @param {Float} mouseY
|
* @param {Float} mouseY
|
||||||
@@ -564,14 +559,14 @@ export const pathActionsMethod = (function () {
|
|||||||
*/
|
*/
|
||||||
mouseMove (mouseX, mouseY) {
|
mouseMove (mouseX, mouseY) {
|
||||||
const zoom = svgCanvas.getZoom()
|
const zoom = svgCanvas.getZoom()
|
||||||
hasMoved = true
|
this.#hasMoved = true
|
||||||
const drawnPath = svgCanvas.getDrawnPath()
|
const drawnPath = svgCanvas.getDrawnPath()
|
||||||
if (svgCanvas.getCurrentMode() === 'path') {
|
if (svgCanvas.getCurrentMode() === 'path') {
|
||||||
if (!drawnPath) { return }
|
if (!drawnPath) { return }
|
||||||
const seglist = drawnPath.pathSegList
|
const seglist = drawnPath.pathSegList
|
||||||
const index = seglist.numberOfItems - 1
|
const index = seglist.numberOfItems - 1
|
||||||
|
|
||||||
if (newPoint) {
|
if (this.#newPoint) {
|
||||||
// First point
|
// First point
|
||||||
// if (!index) { return; }
|
// if (!index) { return; }
|
||||||
|
|
||||||
@@ -584,8 +579,8 @@ export const pathActionsMethod = (function () {
|
|||||||
pointGrip1.setAttribute('cy', mouseY)
|
pointGrip1.setAttribute('cy', mouseY)
|
||||||
pointGrip1.setAttribute('display', 'inline')
|
pointGrip1.setAttribute('display', 'inline')
|
||||||
|
|
||||||
const ptX = newPoint[0]
|
const ptX = this.#newPoint[0]
|
||||||
const ptY = newPoint[1]
|
const ptY = this.#newPoint[1]
|
||||||
|
|
||||||
// set curve
|
// set curve
|
||||||
// const seg = seglist.getItem(index);
|
// const seg = seglist.getItem(index);
|
||||||
@@ -608,7 +603,7 @@ export const pathActionsMethod = (function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
firstCtrl = [mouseX, mouseY]
|
this.#firstCtrl = [mouseX, mouseY]
|
||||||
} else {
|
} else {
|
||||||
const last = seglist.getItem(index - 1)
|
const last = seglist.getItem(index - 1)
|
||||||
let lastX = last.x
|
let lastX = last.x
|
||||||
@@ -617,9 +612,9 @@ export const pathActionsMethod = (function () {
|
|||||||
if (last.pathSegType === 6) {
|
if (last.pathSegType === 6) {
|
||||||
lastX += (lastX - last.x2)
|
lastX += (lastX - last.x2)
|
||||||
lastY += (lastY - last.y2)
|
lastY += (lastY - last.y2)
|
||||||
} else if (firstCtrl) {
|
} else if (this.#firstCtrl) {
|
||||||
lastX = firstCtrl[0] / zoom
|
lastX = this.#firstCtrl[0] / zoom
|
||||||
lastY = firstCtrl[1] / zoom
|
lastY = this.#firstCtrl[1] / zoom
|
||||||
}
|
}
|
||||||
svgCanvas.replacePathSeg(6, index, [ptX, ptY, lastX, lastY, altX, altY], drawnPath)
|
svgCanvas.replacePathSeg(6, index, [ptX, ptY, lastX, lastY, altX, altY], drawnPath)
|
||||||
}
|
}
|
||||||
@@ -636,8 +631,8 @@ export const pathActionsMethod = (function () {
|
|||||||
[mouseX, mouseY, prevX * zoom, prevY * zoom, mouseX, mouseY],
|
[mouseX, mouseY, prevX * zoom, prevY * zoom, mouseX, mouseY],
|
||||||
stretchy
|
stretchy
|
||||||
)
|
)
|
||||||
} else if (firstCtrl) {
|
} else if (this.#firstCtrl) {
|
||||||
svgCanvas.replacePathSeg(6, 1, [mouseX, mouseY, firstCtrl[0], firstCtrl[1], mouseX, mouseY], stretchy)
|
svgCanvas.replacePathSeg(6, 1, [mouseX, mouseY, this.#firstCtrl[0], this.#firstCtrl[1], mouseX, mouseY], stretchy)
|
||||||
} else {
|
} else {
|
||||||
svgCanvas.replacePathSeg(4, 1, [mouseX, mouseY], stretchy)
|
svgCanvas.replacePathSeg(4, 1, [mouseX, mouseY], stretchy)
|
||||||
}
|
}
|
||||||
@@ -668,7 +663,7 @@ export const pathActionsMethod = (function () {
|
|||||||
path.selected_pts = []
|
path.selected_pts = []
|
||||||
path.eachSeg(function (_i) {
|
path.eachSeg(function (_i) {
|
||||||
const seg = this
|
const seg = this
|
||||||
if (!seg.next && !seg.prev) { return }
|
if (!seg.next && !seg.prev) return
|
||||||
|
|
||||||
// const {item} = seg;
|
// const {item} = seg;
|
||||||
const rubberBox = svgCanvas.getRubberBox()
|
const rubberBox = svgCanvas.getRubberBox()
|
||||||
@@ -689,7 +684,8 @@ export const pathActionsMethod = (function () {
|
|||||||
if (sel) { path.selected_pts.push(seg.index) }
|
if (sel) { path.selected_pts.push(seg.index) }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef module:path.keepElement
|
* @typedef module:path.keepElement
|
||||||
* @type {PlainObject}
|
* @type {PlainObject}
|
||||||
@@ -707,11 +703,11 @@ export const pathActionsMethod = (function () {
|
|||||||
const drawnPath = svgCanvas.getDrawnPath()
|
const drawnPath = svgCanvas.getDrawnPath()
|
||||||
// Create mode
|
// Create mode
|
||||||
if (svgCanvas.getCurrentMode() === 'path') {
|
if (svgCanvas.getCurrentMode() === 'path') {
|
||||||
newPoint = null
|
this.#newPoint = null
|
||||||
if (!drawnPath) {
|
if (!drawnPath) {
|
||||||
element = getElement(svgCanvas.getId())
|
element = getElement(svgCanvas.getId())
|
||||||
svgCanvas.setStarted(false)
|
svgCanvas.setStarted(false)
|
||||||
firstCtrl = null
|
this.#firstCtrl = null
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -729,11 +725,11 @@ export const pathActionsMethod = (function () {
|
|||||||
path.dragctrl = false
|
path.dragctrl = false
|
||||||
path.update()
|
path.update()
|
||||||
|
|
||||||
if (hasMoved) {
|
if (this.#hasMoved) {
|
||||||
path.endChanges('Move path point(s)')
|
path.endChanges('Move path point(s)')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!evt.shiftKey && !hasMoved) {
|
if (!evt.shiftKey && !this.#hasMoved) {
|
||||||
path.selectPt(lastPt)
|
path.selectPt(lastPt)
|
||||||
}
|
}
|
||||||
} else if (rubberBox?.getAttribute('display') !== 'none') {
|
} else if (rubberBox?.getAttribute('display') !== 'none') {
|
||||||
@@ -748,9 +744,10 @@ export const pathActionsMethod = (function () {
|
|||||||
} else {
|
} else {
|
||||||
pathActionsMethod.toSelectMode(evt.target)
|
pathActionsMethod.toSelectMode(evt.target)
|
||||||
}
|
}
|
||||||
hasMoved = false
|
this.#hasMoved = false
|
||||||
return undefined
|
return undefined
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Element} element
|
* @param {Element} element
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
@@ -762,8 +759,9 @@ export const pathActionsMethod = (function () {
|
|||||||
path.setPathContext()
|
path.setPathContext()
|
||||||
path.show(true).update()
|
path.show(true).update()
|
||||||
path.oldbbox = getBBox(path.elem)
|
path.oldbbox = getBBox(path.elem)
|
||||||
subpath = false
|
this.#subpath = false
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Element} elem
|
* @param {Element} elem
|
||||||
* @fires module:svgcanvas.SvgCanvas#event:selected
|
* @fires module:svgcanvas.SvgCanvas#event:selected
|
||||||
@@ -774,7 +772,7 @@ export const pathActionsMethod = (function () {
|
|||||||
svgCanvas.setCurrentMode('select')
|
svgCanvas.setCurrentMode('select')
|
||||||
path.setPathContext()
|
path.setPathContext()
|
||||||
path.show(false)
|
path.show(false)
|
||||||
currentPath = false
|
this.#currentPath = false
|
||||||
svgCanvas.clearSelection()
|
svgCanvas.clearSelection()
|
||||||
|
|
||||||
if (path.matrix) {
|
if (path.matrix) {
|
||||||
@@ -786,7 +784,8 @@ export const pathActionsMethod = (function () {
|
|||||||
svgCanvas.call('selected', [elem])
|
svgCanvas.call('selected', [elem])
|
||||||
svgCanvas.addToSelection([elem], true)
|
svgCanvas.addToSelection([elem], true)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {boolean} on
|
* @param {boolean} on
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
@@ -796,25 +795,27 @@ export const pathActionsMethod = (function () {
|
|||||||
// Internally we go into "path" mode, but in the UI it will
|
// Internally we go into "path" mode, but in the UI it will
|
||||||
// still appear as if in "pathedit" mode.
|
// still appear as if in "pathedit" mode.
|
||||||
svgCanvas.setCurrentMode('path')
|
svgCanvas.setCurrentMode('path')
|
||||||
subpath = true
|
this.#subpath = true
|
||||||
} else {
|
} else {
|
||||||
pathActionsMethod.clear(true)
|
pathActionsMethod.clear(true)
|
||||||
pathActionsMethod.toEditMode(path.elem)
|
pathActionsMethod.toEditMode(path.elem)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Element} target
|
* @param {Element} target
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
select (target) {
|
select (target) {
|
||||||
if (currentPath === target) {
|
if (this.#currentPath === target) {
|
||||||
pathActionsMethod.toEditMode(target)
|
pathActionsMethod.toEditMode(target)
|
||||||
svgCanvas.setCurrentMode('pathedit')
|
svgCanvas.setCurrentMode('pathedit')
|
||||||
// going into pathedit mode
|
// going into pathedit mode
|
||||||
} else {
|
} else {
|
||||||
currentPath = target
|
this.#currentPath = target
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @fires module:svgcanvas.SvgCanvas#event:changed
|
* @fires module:svgcanvas.SvgCanvas#event:changed
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
@@ -822,6 +823,7 @@ export const pathActionsMethod = (function () {
|
|||||||
reorient () {
|
reorient () {
|
||||||
const elem = svgCanvas.getSelectedElements()[0]
|
const elem = svgCanvas.getSelectedElements()[0]
|
||||||
if (!elem) { return }
|
if (!elem) { return }
|
||||||
|
if (elem.nodeName !== 'path') { return }
|
||||||
const angl = getRotationAngle(elem)
|
const angl = getRotationAngle(elem)
|
||||||
if (angl === 0) { return }
|
if (angl === 0) { return }
|
||||||
|
|
||||||
@@ -843,7 +845,7 @@ export const pathActionsMethod = (function () {
|
|||||||
|
|
||||||
svgCanvas.addToSelection([elem], true)
|
svgCanvas.addToSelection([elem], true)
|
||||||
svgCanvas.call('changed', svgCanvas.getSelectedElements())
|
svgCanvas.call('changed', svgCanvas.getSelectedElements())
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {boolean} remove Not in use
|
* @param {boolean} remove Not in use
|
||||||
@@ -851,7 +853,7 @@ export const pathActionsMethod = (function () {
|
|||||||
*/
|
*/
|
||||||
clear () {
|
clear () {
|
||||||
const drawnPath = svgCanvas.getDrawnPath()
|
const drawnPath = svgCanvas.getDrawnPath()
|
||||||
currentPath = null
|
this.#currentPath = null
|
||||||
if (drawnPath) {
|
if (drawnPath) {
|
||||||
const elem = getElement(svgCanvas.getId())
|
const elem = getElement(svgCanvas.getId())
|
||||||
const psl = getElement('path_stretch_line')
|
const psl = getElement('path_stretch_line')
|
||||||
@@ -859,17 +861,18 @@ export const pathActionsMethod = (function () {
|
|||||||
elem.parentNode.removeChild(elem)
|
elem.parentNode.removeChild(elem)
|
||||||
const pathpointgripContainer = getElement('pathpointgrip_container')
|
const pathpointgripContainer = getElement('pathpointgrip_container')
|
||||||
const elements = pathpointgripContainer.querySelectorAll('*')
|
const elements = pathpointgripContainer.querySelectorAll('*')
|
||||||
Array.prototype.forEach.call(elements, function (el) {
|
for (const el of elements) {
|
||||||
el.setAttribute('display', 'none')
|
el.setAttribute('display', 'none')
|
||||||
})
|
}
|
||||||
firstCtrl = null
|
this.#firstCtrl = null
|
||||||
svgCanvas.setDrawnPath(null)
|
svgCanvas.setDrawnPath(null)
|
||||||
svgCanvas.setStarted(false)
|
svgCanvas.setStarted(false)
|
||||||
} else if (svgCanvas.getCurrentMode() === 'pathedit') {
|
} else if (svgCanvas.getCurrentMode() === 'pathedit') {
|
||||||
this.toSelectMode()
|
this.toSelectMode()
|
||||||
}
|
}
|
||||||
if (path) { path.init().show(false) }
|
if (path) { path.init().show(false) }
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {?(Element|SVGPathElement)} pth
|
* @param {?(Element|SVGPathElement)} pth
|
||||||
* @returns {false|void}
|
* @returns {false|void}
|
||||||
@@ -899,20 +902,22 @@ export const pathActionsMethod = (function () {
|
|||||||
const seg = segList.getItem(i)
|
const seg = segList.getItem(i)
|
||||||
const type = seg.pathSegType
|
const type = seg.pathSegType
|
||||||
if (type === 1) { continue }
|
if (type === 1) { continue }
|
||||||
const pts = [];
|
const pts = []
|
||||||
['', 1, 2].forEach(function (n) {
|
for (const n of ['', 1, 2]) {
|
||||||
const x = seg['x' + n]; const y = seg['y' + n]
|
const x = seg['x' + n]
|
||||||
|
const y = seg['y' + n]
|
||||||
if (x !== undefined && y !== undefined) {
|
if (x !== undefined && y !== undefined) {
|
||||||
const pt = transformPoint(x, y, m)
|
const pt = transformPoint(x, y, m)
|
||||||
pts.splice(pts.length, 0, pt.x, pt.y)
|
pts.push(pt.x, pt.y)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
svgCanvas.replacePathSeg(type, i, pts, pth)
|
svgCanvas.replacePathSeg(type, i, pts, pth)
|
||||||
}
|
}
|
||||||
|
|
||||||
svgCanvas.reorientGrads(pth, m)
|
svgCanvas.reorientGrads(pth, m)
|
||||||
return undefined
|
return undefined
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
@@ -920,7 +925,8 @@ export const pathActionsMethod = (function () {
|
|||||||
if (svgCanvas.getCurrentMode() === 'pathedit') {
|
if (svgCanvas.getCurrentMode() === 'pathedit') {
|
||||||
path.update()
|
path.update()
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {PlainObject} module:path.NodePoint
|
* @typedef {PlainObject} module:path.NodePoint
|
||||||
* @property {Float} x
|
* @property {Float} x
|
||||||
@@ -939,14 +945,16 @@ export const pathActionsMethod = (function () {
|
|||||||
y: seg.item.y,
|
y: seg.item.y,
|
||||||
type: seg.type
|
type: seg.type
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {boolean} linkPoints
|
* @param {boolean} linkPoints
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
linkControlPoints (linkPoints) {
|
linkControlPoints (linkPoints) {
|
||||||
svgCanvas.setLinkControlPoints(linkPoints)
|
svgCanvas.setLinkControlPoints(linkPoints)
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
@@ -969,7 +977,8 @@ export const pathActionsMethod = (function () {
|
|||||||
path.init().addPtsToSelection(nums)
|
path.init().addPtsToSelection(nums)
|
||||||
|
|
||||||
path.endChanges('Clone path node(s)')
|
path.endChanges('Clone path node(s)')
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
@@ -993,7 +1002,7 @@ export const pathActionsMethod = (function () {
|
|||||||
if (this.type === 2 && i <= index) {
|
if (this.type === 2 && i <= index) {
|
||||||
startItem = this.item
|
startItem = this.item
|
||||||
}
|
}
|
||||||
if (i <= index) { return true }
|
if (i <= index) return true
|
||||||
if (this.type === 2) {
|
if (this.type === 2) {
|
||||||
// Found M first, so open
|
// Found M first, so open
|
||||||
openPt = i
|
openPt = i
|
||||||
@@ -1081,7 +1090,8 @@ export const pathActionsMethod = (function () {
|
|||||||
// i = index; // i is local here, so has no effect; what was the intent for this?
|
// i = index; // i is local here, so has no effect; what was the intent for this?
|
||||||
|
|
||||||
path.init().selectPt(0)
|
path.init().selectPt(0)
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
@@ -1098,11 +1108,11 @@ export const pathActionsMethod = (function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
const cleanup = function () {
|
const cleanup = () => {
|
||||||
const segList = path.elem.pathSegList
|
const segList = path.elem.pathSegList
|
||||||
let len = segList.numberOfItems
|
let len = segList.numberOfItems
|
||||||
|
|
||||||
const remItems = function (pos, count) {
|
const remItems = (pos, count) => {
|
||||||
while (count--) {
|
while (count--) {
|
||||||
segList.removeItem(pos)
|
segList.removeItem(pos)
|
||||||
}
|
}
|
||||||
@@ -1160,14 +1170,18 @@ export const pathActionsMethod = (function () {
|
|||||||
path.elem.setAttribute('d', path.elem.getAttribute('d'))
|
path.elem.setAttribute('d', path.elem.getAttribute('d'))
|
||||||
}
|
}
|
||||||
path.endChanges('Delete path node(s)')
|
path.endChanges('Delete path node(s)')
|
||||||
},
|
}
|
||||||
|
|
||||||
// Can't seem to use `@borrows` here, so using `@see`
|
// Can't seem to use `@borrows` here, so using `@see`
|
||||||
/**
|
/**
|
||||||
* Smooth polyline into path.
|
* Smooth polyline into path.
|
||||||
* @function module:path.pathActions.smoothPolylineIntoPath
|
* @function module:path.pathActions.smoothPolylineIntoPath
|
||||||
* @see module:path~smoothPolylineIntoPath
|
* @see module:path~smoothPolylineIntoPath
|
||||||
*/
|
*/
|
||||||
smoothPolylineIntoPath,
|
smoothPolylineIntoPath (element) {
|
||||||
|
return this.#smoothPolylineIntoPath(element)
|
||||||
|
}
|
||||||
|
|
||||||
/* eslint-enable */
|
/* eslint-enable */
|
||||||
/**
|
/**
|
||||||
* @param {?Integer} v See {@link https://www.w3.org/TR/SVG/single-page.html#paths-InterfaceSVGPathSeg}
|
* @param {?Integer} v See {@link https://www.w3.org/TR/SVG/single-page.html#paths-InterfaceSVGPathSeg}
|
||||||
@@ -1175,7 +1189,8 @@ export const pathActionsMethod = (function () {
|
|||||||
*/
|
*/
|
||||||
setSegType (v) {
|
setSegType (v) {
|
||||||
path?.setSegType(v)
|
path?.setSegType(v)
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} attr
|
* @param {string} attr
|
||||||
* @param {Float} newValue
|
* @param {Float} newValue
|
||||||
@@ -1194,7 +1209,8 @@ export const pathActionsMethod = (function () {
|
|||||||
|
|
||||||
seg.move(diff.x, diff.y)
|
seg.move(diff.x, diff.y)
|
||||||
path.endChanges('Move path point')
|
path.endChanges('Move path point')
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Element} elem
|
* @param {Element} elem
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
@@ -1224,14 +1240,19 @@ export const pathActionsMethod = (function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
// Can't seem to use `@borrows` here, so using `@see`
|
// Can't seem to use `@borrows` here, so using `@see`
|
||||||
/**
|
/**
|
||||||
* Convert a path to one with only absolute or relative values.
|
* Convert a path to one with only absolute or relative values.
|
||||||
* @function module:path.pathActions.convertPath
|
* @function module:path.pathActions.convertPath
|
||||||
* @see module:path.convertPath
|
* @see module:path.convertPath
|
||||||
*/
|
*/
|
||||||
convertPath
|
convertPath (pth, toRel) {
|
||||||
})
|
return convertPath(pth, toRel)
|
||||||
})()
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance for backward compatibility
|
||||||
|
export const pathActionsMethod = new PathActions()
|
||||||
// end pathActions
|
// end pathActions
|
||||||
|
|||||||
@@ -16,6 +16,190 @@ import {
|
|||||||
getElement
|
getElement
|
||||||
} from './utilities.js'
|
} from './utilities.js'
|
||||||
|
|
||||||
|
const TYPE_TO_CMD = {
|
||||||
|
1: 'Z',
|
||||||
|
2: 'M',
|
||||||
|
3: 'm',
|
||||||
|
4: 'L',
|
||||||
|
5: 'l',
|
||||||
|
6: 'C',
|
||||||
|
7: 'c',
|
||||||
|
8: 'Q',
|
||||||
|
9: 'q',
|
||||||
|
10: 'A',
|
||||||
|
11: 'a',
|
||||||
|
12: 'H',
|
||||||
|
13: 'h',
|
||||||
|
14: 'V',
|
||||||
|
15: 'v',
|
||||||
|
16: 'S',
|
||||||
|
17: 's',
|
||||||
|
18: 'T',
|
||||||
|
19: 't'
|
||||||
|
}
|
||||||
|
|
||||||
|
const CMD_TO_TYPE = Object.fromEntries(
|
||||||
|
Object.entries(TYPE_TO_CMD).map(([k, v]) => [v, Number(k)])
|
||||||
|
)
|
||||||
|
|
||||||
|
class PathDataListShim {
|
||||||
|
constructor (elem) {
|
||||||
|
this.elem = elem
|
||||||
|
}
|
||||||
|
|
||||||
|
_getData () {
|
||||||
|
return this.elem.getPathData()
|
||||||
|
}
|
||||||
|
|
||||||
|
_setData (data) {
|
||||||
|
this.elem.setPathData(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
get numberOfItems () {
|
||||||
|
return this._getData().length
|
||||||
|
}
|
||||||
|
|
||||||
|
_entryToSeg (entry) {
|
||||||
|
const { type, values = [] } = entry
|
||||||
|
const cmd = CMD_TO_TYPE[type] || CMD_TO_TYPE[type?.toUpperCase?.()]
|
||||||
|
const seg = { pathSegType: cmd }
|
||||||
|
const U = String(type).toUpperCase()
|
||||||
|
switch (U) {
|
||||||
|
case 'H':
|
||||||
|
[seg.x] = values
|
||||||
|
break
|
||||||
|
case 'V':
|
||||||
|
[seg.y] = values
|
||||||
|
break
|
||||||
|
case 'M':
|
||||||
|
case 'L':
|
||||||
|
case 'T':
|
||||||
|
[seg.x, seg.y] = values
|
||||||
|
break
|
||||||
|
case 'S':
|
||||||
|
[seg.x2, seg.y2, seg.x, seg.y] = values
|
||||||
|
break
|
||||||
|
case 'C':
|
||||||
|
[seg.x1, seg.y1, seg.x2, seg.y2, seg.x, seg.y] = values
|
||||||
|
break
|
||||||
|
case 'Q':
|
||||||
|
[seg.x1, seg.y1, seg.x, seg.y] = values
|
||||||
|
break
|
||||||
|
case 'A':
|
||||||
|
[
|
||||||
|
seg.r1,
|
||||||
|
seg.r2,
|
||||||
|
seg.angle,
|
||||||
|
seg.largeArcFlag,
|
||||||
|
seg.sweepFlag,
|
||||||
|
seg.x,
|
||||||
|
seg.y
|
||||||
|
] = values
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return seg
|
||||||
|
}
|
||||||
|
|
||||||
|
_segToEntry (seg) {
|
||||||
|
const type = TYPE_TO_CMD[seg.pathSegType] || seg.type
|
||||||
|
if (!type) {
|
||||||
|
return { type: 'Z', values: [] }
|
||||||
|
}
|
||||||
|
const U = String(type).toUpperCase()
|
||||||
|
let values = []
|
||||||
|
switch (U) {
|
||||||
|
case 'H':
|
||||||
|
values = [seg.x]
|
||||||
|
break
|
||||||
|
case 'V':
|
||||||
|
values = [seg.y]
|
||||||
|
break
|
||||||
|
case 'M':
|
||||||
|
case 'L':
|
||||||
|
case 'T':
|
||||||
|
values = [seg.x, seg.y]
|
||||||
|
break
|
||||||
|
case 'S':
|
||||||
|
values = [seg.x2, seg.y2, seg.x, seg.y]
|
||||||
|
break
|
||||||
|
case 'C':
|
||||||
|
values = [seg.x1, seg.y1, seg.x2, seg.y2, seg.x, seg.y]
|
||||||
|
break
|
||||||
|
case 'Q':
|
||||||
|
values = [seg.x1, seg.y1, seg.x, seg.y]
|
||||||
|
break
|
||||||
|
case 'A':
|
||||||
|
values = [
|
||||||
|
seg.r1,
|
||||||
|
seg.r2,
|
||||||
|
seg.angle,
|
||||||
|
Number(seg.largeArcFlag),
|
||||||
|
Number(seg.sweepFlag),
|
||||||
|
seg.x,
|
||||||
|
seg.y
|
||||||
|
]
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
values = []
|
||||||
|
}
|
||||||
|
return { type, values }
|
||||||
|
}
|
||||||
|
|
||||||
|
getItem (index) {
|
||||||
|
const entry = this._getData()[index]
|
||||||
|
return entry ? this._entryToSeg(entry) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceItem (seg, index) {
|
||||||
|
const data = this._getData()
|
||||||
|
data[index] = this._segToEntry(seg)
|
||||||
|
this._setData(data)
|
||||||
|
return seg
|
||||||
|
}
|
||||||
|
|
||||||
|
insertItemBefore (seg, index) {
|
||||||
|
const data = this._getData()
|
||||||
|
data.splice(index, 0, this._segToEntry(seg))
|
||||||
|
this._setData(data)
|
||||||
|
return seg
|
||||||
|
}
|
||||||
|
|
||||||
|
appendItem (seg) {
|
||||||
|
const data = this._getData()
|
||||||
|
data.push(this._segToEntry(seg))
|
||||||
|
this._setData(data)
|
||||||
|
return seg
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem (index) {
|
||||||
|
const data = this._getData()
|
||||||
|
data.splice(index, 1)
|
||||||
|
this._setData(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
clear () {
|
||||||
|
this._setData([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof SVGPathElement !== 'undefined' &&
|
||||||
|
typeof SVGPathElement.prototype.getPathData === 'function' &&
|
||||||
|
typeof SVGPathElement.prototype.setPathData === 'function' &&
|
||||||
|
!('pathSegList' in SVGPathElement.prototype)
|
||||||
|
) {
|
||||||
|
Object.defineProperty(SVGPathElement.prototype, 'pathSegList', {
|
||||||
|
get () {
|
||||||
|
if (!this._pathSegListShim) {
|
||||||
|
this._pathSegListShim = new PathDataListShim(this)
|
||||||
|
}
|
||||||
|
return this._pathSegListShim
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let svgCanvas = null
|
let svgCanvas = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,7 +220,7 @@ export const init = (canvas) => {
|
|||||||
* @returns {ArgumentsArray}
|
* @returns {ArgumentsArray}
|
||||||
*/
|
*/
|
||||||
/* eslint-enable max-len */
|
/* eslint-enable max-len */
|
||||||
export const ptObjToArrMethod = function (type, segItem) {
|
export const ptObjToArrMethod = (type, segItem) => {
|
||||||
const segData = svgCanvas.getSegData()
|
const segData = svgCanvas.getSegData()
|
||||||
const props = segData[type]
|
const props = segData[type]
|
||||||
return props.map((prop) => {
|
return props.map((prop) => {
|
||||||
@@ -50,7 +234,7 @@ export const ptObjToArrMethod = function (type, segItem) {
|
|||||||
* @param {module:math.XYObject} altPt
|
* @param {module:math.XYObject} altPt
|
||||||
* @returns {module:math.XYObject}
|
* @returns {module:math.XYObject}
|
||||||
*/
|
*/
|
||||||
export const getGripPtMethod = function (seg, altPt) {
|
export const getGripPtMethod = (seg, altPt) => {
|
||||||
const { path: pth } = seg
|
const { path: pth } = seg
|
||||||
let out = {
|
let out = {
|
||||||
x: altPt ? altPt.x : seg.item.x,
|
x: altPt ? altPt.x : seg.item.x,
|
||||||
@@ -73,7 +257,7 @@ export const getGripPtMethod = function (seg, altPt) {
|
|||||||
* @param {module:path.Path} pth
|
* @param {module:path.Path} pth
|
||||||
* @returns {module:math.XYObject}
|
* @returns {module:math.XYObject}
|
||||||
*/
|
*/
|
||||||
export const getPointFromGripMethod = function (pt, pth) {
|
export const getPointFromGripMethod = (pt, pth) => {
|
||||||
const out = {
|
const out = {
|
||||||
x: pt.x,
|
x: pt.x,
|
||||||
y: pt.y
|
y: pt.y
|
||||||
@@ -94,7 +278,7 @@ export const getPointFromGripMethod = function (pt, pth) {
|
|||||||
* @function module:path.getGripContainer
|
* @function module:path.getGripContainer
|
||||||
* @returns {Element}
|
* @returns {Element}
|
||||||
*/
|
*/
|
||||||
export const getGripContainerMethod = function () {
|
export const getGripContainerMethod = () => {
|
||||||
let c = getElement('pathpointgrip_container')
|
let c = getElement('pathpointgrip_container')
|
||||||
if (!c) {
|
if (!c) {
|
||||||
const parentElement = getElement('selectorParentGroup')
|
const parentElement = getElement('selectorParentGroup')
|
||||||
@@ -113,16 +297,16 @@ export const getGripContainerMethod = function () {
|
|||||||
* @param {Integer} y
|
* @param {Integer} y
|
||||||
* @returns {SVGCircleElement}
|
* @returns {SVGCircleElement}
|
||||||
*/
|
*/
|
||||||
export const addPointGripMethod = function (index, x, y) {
|
export const addPointGripMethod = (index, x, y) => {
|
||||||
// create the container of all the point grips
|
// create the container of all the point grips
|
||||||
const pointGripContainer = getGripContainerMethod()
|
const pointGripContainer = getGripContainerMethod()
|
||||||
|
|
||||||
let pointGrip = getElement('pathpointgrip_' + index)
|
let pointGrip = getElement(`pathpointgrip_${index}`)
|
||||||
// create it
|
// create it
|
||||||
if (!pointGrip) {
|
if (!pointGrip) {
|
||||||
pointGrip = document.createElementNS(NS.SVG, 'circle')
|
pointGrip = document.createElementNS(NS.SVG, 'circle')
|
||||||
const atts = {
|
const atts = {
|
||||||
id: 'pathpointgrip_' + index,
|
id: `pathpointgrip_${index}`,
|
||||||
display: 'none',
|
display: 'none',
|
||||||
r: 4,
|
r: 4,
|
||||||
fill: '#0FF',
|
fill: '#0FF',
|
||||||
@@ -163,7 +347,7 @@ export const addPointGripMethod = function (index, x, y) {
|
|||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @returns {SVGCircleElement}
|
* @returns {SVGCircleElement}
|
||||||
*/
|
*/
|
||||||
export const addCtrlGripMethod = function (id) {
|
export const addCtrlGripMethod = (id) => {
|
||||||
let pointGrip = getElement('ctrlpointgrip_' + id)
|
let pointGrip = getElement('ctrlpointgrip_' + id)
|
||||||
if (pointGrip) { return pointGrip }
|
if (pointGrip) { return pointGrip }
|
||||||
|
|
||||||
@@ -191,7 +375,7 @@ export const addCtrlGripMethod = function (id) {
|
|||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @returns {SVGLineElement}
|
* @returns {SVGLineElement}
|
||||||
*/
|
*/
|
||||||
export const getCtrlLineMethod = function (id) {
|
export const getCtrlLineMethod = (id) => {
|
||||||
let ctrlLine = getElement('ctrlLine_' + id)
|
let ctrlLine = getElement('ctrlLine_' + id)
|
||||||
if (ctrlLine) { return ctrlLine }
|
if (ctrlLine) { return ctrlLine }
|
||||||
|
|
||||||
@@ -211,7 +395,7 @@ export const getCtrlLineMethod = function (id) {
|
|||||||
* @param {boolean} update
|
* @param {boolean} update
|
||||||
* @returns {SVGCircleElement}
|
* @returns {SVGCircleElement}
|
||||||
*/
|
*/
|
||||||
export const getPointGripMethod = function (seg, update) {
|
export const getPointGripMethod = (seg, update) => {
|
||||||
const { index } = seg
|
const { index } = seg
|
||||||
const pointGrip = addPointGripMethod(index)
|
const pointGrip = addPointGripMethod(index)
|
||||||
|
|
||||||
@@ -231,7 +415,7 @@ export const getPointGripMethod = function (seg, update) {
|
|||||||
* @param {Segment} seg
|
* @param {Segment} seg
|
||||||
* @returns {PlainObject<string, SVGLineElement|SVGCircleElement>}
|
* @returns {PlainObject<string, SVGLineElement|SVGCircleElement>}
|
||||||
*/
|
*/
|
||||||
export const getControlPointsMethod = function (seg) {
|
export const getControlPointsMethod = (seg) => {
|
||||||
const { item, index } = seg
|
const { item, index } = seg
|
||||||
if (!('x1' in item) || !('x2' in item)) { return null }
|
if (!('x1' in item) || !('x2' in item)) { return null }
|
||||||
const cpt = {}
|
const cpt = {}
|
||||||
@@ -246,7 +430,7 @@ export const getControlPointsMethod = function (seg) {
|
|||||||
for (let i = 1; i < 3; i++) {
|
for (let i = 1; i < 3; i++) {
|
||||||
const id = index + 'c' + i
|
const id = index + 'c' + i
|
||||||
|
|
||||||
const ctrlLine = cpt['c' + i + '_line'] = getCtrlLineMethod(id)
|
const ctrlLine = cpt[`c${i}_line`] = getCtrlLineMethod(id)
|
||||||
|
|
||||||
const pt = getGripPtMethod(seg, { x: item['x' + i], y: item['y' + i] })
|
const pt = getGripPtMethod(seg, { x: item['x' + i], y: item['y' + i] })
|
||||||
const gpt = getGripPtMethod(seg, { x: segItems[i - 1].x, y: segItems[i - 1].y })
|
const gpt = getGripPtMethod(seg, { x: segItems[i - 1].x, y: segItems[i - 1].y })
|
||||||
@@ -259,10 +443,10 @@ export const getControlPointsMethod = function (seg) {
|
|||||||
display: 'inline'
|
display: 'inline'
|
||||||
})
|
})
|
||||||
|
|
||||||
cpt['c' + i + '_line'] = ctrlLine
|
cpt[`c${i}_line`] = ctrlLine
|
||||||
|
|
||||||
// create it
|
// create it
|
||||||
const pointGrip = cpt['c' + i] = addCtrlGripMethod(id)
|
const pointGrip = cpt[`c${i}`] = addCtrlGripMethod(id)
|
||||||
|
|
||||||
assignAttributes(pointGrip, {
|
assignAttributes(pointGrip, {
|
||||||
cx: pt.x,
|
cx: pt.x,
|
||||||
@@ -282,12 +466,29 @@ export const getControlPointsMethod = function (seg) {
|
|||||||
* @param {SVGPathElement} elem
|
* @param {SVGPathElement} elem
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export const replacePathSegMethod = function (type, index, pts, elem) {
|
export const replacePathSegMethod = (type, index, pts, elem) => {
|
||||||
const path = svgCanvas.getPathObj()
|
const path = svgCanvas.getPathObj()
|
||||||
const pth = elem || path.elem
|
const pth = elem || path.elem
|
||||||
const pathFuncs = svgCanvas.getPathFuncs()
|
const pathFuncs = svgCanvas.getPathFuncs()
|
||||||
const func = 'createSVGPathSeg' + pathFuncs[type]
|
const func = 'createSVGPathSeg' + pathFuncs[type]
|
||||||
const seg = pth[func](...pts)
|
const segData = svgCanvas.getSegData?.()
|
||||||
|
const props = segData?.[type] || segData?.[type - 1]
|
||||||
|
if (props && pts.length < props.length) {
|
||||||
|
const currentSeg = pth.pathSegList?.getItem?.(index)
|
||||||
|
if (currentSeg) {
|
||||||
|
pts = props.map((prop, i) => (pts[i] !== undefined ? pts[i] : currentSeg[prop]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let seg
|
||||||
|
if (typeof pth[func] === 'function') {
|
||||||
|
seg = pth[func](...pts)
|
||||||
|
} else {
|
||||||
|
const safeProps = props || []
|
||||||
|
seg = { pathSegType: type }
|
||||||
|
safeProps.forEach((prop, i) => {
|
||||||
|
seg[prop] = pts[i]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pth.pathSegList.replaceItem(seg, index)
|
pth.pathSegList.replaceItem(seg, index)
|
||||||
}
|
}
|
||||||
@@ -297,15 +498,15 @@ export const replacePathSegMethod = function (type, index, pts, elem) {
|
|||||||
* @param {boolean} update
|
* @param {boolean} update
|
||||||
* @returns {SVGPathElement}
|
* @returns {SVGPathElement}
|
||||||
*/
|
*/
|
||||||
export const getSegSelectorMethod = function (seg, update) {
|
export const getSegSelectorMethod = (seg, update) => {
|
||||||
const { index } = seg
|
const { index } = seg
|
||||||
let segLine = getElement('segline_' + index)
|
let segLine = getElement(`segline_${index}`)
|
||||||
if (!segLine) {
|
if (!segLine) {
|
||||||
const pointGripContainer = getGripContainerMethod()
|
const pointGripContainer = getGripContainerMethod()
|
||||||
// create segline
|
// create segline
|
||||||
segLine = document.createElementNS(NS.SVG, 'path')
|
segLine = document.createElementNS(NS.SVG, 'path')
|
||||||
assignAttributes(segLine, {
|
assignAttributes(segLine, {
|
||||||
id: 'segline_' + index,
|
id: `segline_${index}`,
|
||||||
display: 'none',
|
display: 'none',
|
||||||
fill: 'none',
|
fill: 'none',
|
||||||
stroke: '#0FF',
|
stroke: '#0FF',
|
||||||
@@ -353,7 +554,7 @@ export class Segment {
|
|||||||
this.item = item
|
this.item = item
|
||||||
this.type = item.pathSegType
|
this.type = item.pathSegType
|
||||||
|
|
||||||
this.ctrlpts = []
|
this.ctrlpts = null
|
||||||
this.ptgrip = null
|
this.ptgrip = null
|
||||||
this.segsel = null
|
this.segsel = null
|
||||||
}
|
}
|
||||||
@@ -375,8 +576,8 @@ export class Segment {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
selectCtrls (y) {
|
selectCtrls (y) {
|
||||||
document.getElementById('ctrlpointgrip_' + this.index + 'c1').setAttribute('fill', y ? '#0FF' : '#EEE')
|
document.getElementById(`ctrlpointgrip_${this.index}c1`)?.setAttribute('fill', y ? '#0FF' : '#EEE')
|
||||||
document.getElementById('ctrlpointgrip_' + this.index + 'c2').setAttribute('fill', y ? '#0FF' : '#EEE')
|
document.getElementById(`ctrlpointgrip_${this.index}c2`)?.setAttribute('fill', y ? '#0FF' : '#EEE')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -450,27 +651,25 @@ export class Segment {
|
|||||||
move (dx, dy) {
|
move (dx, dy) {
|
||||||
const { item } = this
|
const { item } = this
|
||||||
|
|
||||||
const curPts = this.ctrlpts
|
item.x += dx
|
||||||
? [
|
item.y += dy
|
||||||
item.x += dx, item.y += dy,
|
|
||||||
item.x1, item.y1, item.x2 += dx, item.y2 += dy
|
// `x2/y2` are the control point attached to this node (when present)
|
||||||
]
|
if ('x2' in item) { item.x2 += dx }
|
||||||
: [item.x += dx, item.y += dy]
|
if ('y2' in item) { item.y2 += dy }
|
||||||
|
|
||||||
replacePathSegMethod(
|
replacePathSegMethod(
|
||||||
this.type,
|
this.type,
|
||||||
this.index,
|
this.index,
|
||||||
// type 10 means ARC
|
ptObjToArrMethod(this.type, item)
|
||||||
this.type === 10 ? ptObjToArrMethod(this.type, item) : curPts
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (this.next?.ctrlpts) {
|
const next = this.next?.item
|
||||||
const next = this.next.item
|
// `x1/y1` are the control point attached to this node on the next segment (when present)
|
||||||
const nextPts = [
|
if (next && 'x1' in next && 'y1' in next) {
|
||||||
next.x, next.y,
|
next.x1 += dx
|
||||||
next.x1 += dx, next.y1 += dy, next.x2, next.y2
|
next.y1 += dy
|
||||||
]
|
replacePathSegMethod(this.next.type, this.next.index, ptObjToArrMethod(this.next.type, next))
|
||||||
replacePathSegMethod(this.next.type, this.next.index, nextPts)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.mate) {
|
if (this.mate) {
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ export const init = (canvas) => {
|
|||||||
svgCanvas.getPointFromGrip = getPointFromGripMethod
|
svgCanvas.getPointFromGrip = getPointFromGripMethod
|
||||||
svgCanvas.setLinkControlPoints = setLinkControlPoints
|
svgCanvas.setLinkControlPoints = setLinkControlPoints
|
||||||
svgCanvas.reorientGrads = reorientGrads
|
svgCanvas.reorientGrads = reorientGrads
|
||||||
|
svgCanvas.recalcRotatedPath = recalcRotatedPath
|
||||||
svgCanvas.getSegData = () => { return segData }
|
svgCanvas.getSegData = () => { return segData }
|
||||||
svgCanvas.getUIStrings = () => { return uiStrings }
|
svgCanvas.getUIStrings = () => { return uiStrings }
|
||||||
svgCanvas.getPathObj = () => { return path }
|
svgCanvas.getPathObj = () => { return path }
|
||||||
@@ -466,14 +467,17 @@ const getRotVals = (x, y) => {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export const recalcRotatedPath = () => {
|
export const recalcRotatedPath = () => {
|
||||||
const currentPath = path.elem
|
const currentPath = path?.elem
|
||||||
|
if (!currentPath) { return }
|
||||||
angle = getRotationAngle(currentPath, true)
|
angle = getRotationAngle(currentPath, true)
|
||||||
if (!angle) { return }
|
if (!angle) { return }
|
||||||
// selectedBBoxes[0] = path.oldbbox;
|
// selectedBBoxes[0] = path.oldbbox;
|
||||||
const oldbox = path.oldbbox // selectedBBoxes[0],
|
const oldbox = path.oldbbox // selectedBBoxes[0],
|
||||||
|
if (!oldbox) { return }
|
||||||
oldcx = oldbox.x + oldbox.width / 2
|
oldcx = oldbox.x + oldbox.width / 2
|
||||||
oldcy = oldbox.y + oldbox.height / 2
|
oldcy = oldbox.y + oldbox.height / 2
|
||||||
const box = getBBox(currentPath)
|
const box = getBBox(currentPath)
|
||||||
|
if (!box) { return }
|
||||||
newcx = box.x + box.width / 2
|
newcx = box.x + box.width / 2
|
||||||
newcy = box.y + box.height / 2
|
newcy = box.y + box.height / 2
|
||||||
|
|
||||||
@@ -487,6 +491,7 @@ export const recalcRotatedPath = () => {
|
|||||||
newcy = r * Math.sin(theta) + oldcy
|
newcy = r * Math.sin(theta) + oldcy
|
||||||
|
|
||||||
const list = currentPath.pathSegList
|
const list = currentPath.pathSegList
|
||||||
|
if (!list) { return }
|
||||||
|
|
||||||
let i = list.numberOfItems
|
let i = list.numberOfItems
|
||||||
while (i) {
|
while (i) {
|
||||||
@@ -495,13 +500,33 @@ export const recalcRotatedPath = () => {
|
|||||||
const type = seg.pathSegType
|
const type = seg.pathSegType
|
||||||
if (type === 1) { continue }
|
if (type === 1) { continue }
|
||||||
|
|
||||||
|
const props = segData[type]
|
||||||
|
if (!props) { continue }
|
||||||
|
|
||||||
|
const newVals = {}
|
||||||
|
if (seg.x !== null && seg.x !== undefined && seg.y !== null && seg.y !== undefined) {
|
||||||
const rvals = getRotVals(seg.x, seg.y)
|
const rvals = getRotVals(seg.x, seg.y)
|
||||||
const points = [rvals.x, rvals.y]
|
newVals.x = rvals.x
|
||||||
if (seg.x1 && seg.x2) {
|
newVals.y = rvals.y
|
||||||
const cVals1 = getRotVals(seg.x1, seg.y1)
|
|
||||||
const cVals2 = getRotVals(seg.x2, seg.y2)
|
|
||||||
points.splice(points.length, 0, cVals1.x, cVals1.y, cVals2.x, cVals2.y)
|
|
||||||
}
|
}
|
||||||
|
if (seg.x1 !== null && seg.x1 !== undefined && seg.y1 !== null && seg.y1 !== undefined) {
|
||||||
|
const cVals1 = getRotVals(seg.x1, seg.y1)
|
||||||
|
newVals.x1 = cVals1.x
|
||||||
|
newVals.y1 = cVals1.y
|
||||||
|
}
|
||||||
|
if (seg.x2 !== null && seg.x2 !== undefined && seg.y2 !== null && seg.y2 !== undefined) {
|
||||||
|
const cVals2 = getRotVals(seg.x2, seg.y2)
|
||||||
|
newVals.x2 = cVals2.x
|
||||||
|
newVals.y2 = cVals2.y
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = props.map((prop) => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(newVals, prop)) {
|
||||||
|
return newVals[prop]
|
||||||
|
}
|
||||||
|
const val = seg[prop]
|
||||||
|
return val === null || val === undefined ? 0 : val
|
||||||
|
})
|
||||||
replacePathSeg(type, i, points)
|
replacePathSeg(type, i, points)
|
||||||
} // loop for each point
|
} // loop for each point
|
||||||
|
|
||||||
@@ -512,8 +537,18 @@ export const recalcRotatedPath = () => {
|
|||||||
// now we must set the new transform to be rotated around the new center
|
// now we must set the new transform to be rotated around the new center
|
||||||
const Rnc = svgCanvas.getSvgRoot().createSVGTransform()
|
const Rnc = svgCanvas.getSvgRoot().createSVGTransform()
|
||||||
const tlist = getTransformList(currentPath)
|
const tlist = getTransformList(currentPath)
|
||||||
|
if (!tlist) { return }
|
||||||
Rnc.setRotate((angle * 180.0 / Math.PI), newcx, newcy)
|
Rnc.setRotate((angle * 180.0 / Math.PI), newcx, newcy)
|
||||||
|
if (tlist.numberOfItems) {
|
||||||
|
if (typeof tlist.replaceItem === 'function') {
|
||||||
tlist.replaceItem(Rnc, 0)
|
tlist.replaceItem(Rnc, 0)
|
||||||
|
} else {
|
||||||
|
tlist.removeItem(0)
|
||||||
|
tlist.insertItemBefore(Rnc, 0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tlist.appendItem(Rnc)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====================================
|
// ====================================
|
||||||
@@ -571,7 +606,7 @@ export const reorientGrads = (elem, m) => {
|
|||||||
}
|
}
|
||||||
newgrad.id = svgCanvas.getNextId()
|
newgrad.id = svgCanvas.getNextId()
|
||||||
findDefs().append(newgrad)
|
findDefs().append(newgrad)
|
||||||
elem.setAttribute(type, 'url(#' + newgrad.id + ')')
|
elem.setAttribute(type, `url(#${newgrad.id})`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -618,7 +653,7 @@ export const convertPath = (pth, toRel) => {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case 1: // z,Z closepath (Z/z)
|
case 1: // z,Z closepath (Z/z)
|
||||||
d += 'z'
|
d += 'z'
|
||||||
if (lastM && !toRel) {
|
if (lastM) {
|
||||||
curx = lastM[0]
|
curx = lastM[0]
|
||||||
cury = lastM[1]
|
cury = lastM[1]
|
||||||
}
|
}
|
||||||
@@ -765,10 +800,10 @@ const pathDSegment = (letter, points, morePoints, lastPoint) => {
|
|||||||
})
|
})
|
||||||
let segment = letter + points.join(' ')
|
let segment = letter + points.join(' ')
|
||||||
if (morePoints) {
|
if (morePoints) {
|
||||||
segment += ' ' + morePoints.join(' ')
|
segment += ` ${morePoints.join(' ')}`
|
||||||
}
|
}
|
||||||
if (lastPoint) {
|
if (lastPoint) {
|
||||||
segment += ' ' + shortFloat(lastPoint)
|
segment += ` ${shortFloat(lastPoint)}`
|
||||||
}
|
}
|
||||||
return segment
|
return segment
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { convertToNum } from './units.js'
|
import { convertToNum } from './units.js'
|
||||||
import { getRotationAngle, getBBox, getRefElem } from './utilities.js'
|
import { NS } from './namespaces.js'
|
||||||
|
import {
|
||||||
|
getRotationAngle,
|
||||||
|
getBBox,
|
||||||
|
getHref,
|
||||||
|
getRefElem,
|
||||||
|
findDefs
|
||||||
|
} from './utilities.js'
|
||||||
import { BatchCommand, ChangeElementCommand } from './history.js'
|
import { BatchCommand, ChangeElementCommand } from './history.js'
|
||||||
import { remapElement } from './coords.js'
|
import { remapElement } from './coords.js'
|
||||||
import {
|
import {
|
||||||
@@ -36,20 +43,93 @@ export const init = canvas => {
|
|||||||
* @param {string} attr - The clip-path attribute value containing the clipPath's ID
|
* @param {string} attr - The clip-path attribute value containing the clipPath's ID
|
||||||
* @param {number} tx - The translation's x value
|
* @param {number} tx - The translation's x value
|
||||||
* @param {number} ty - The translation's y value
|
* @param {number} ty - The translation's y value
|
||||||
* @returns {void}
|
* @param {Element} elem - The element referencing the clipPath
|
||||||
|
* @returns {string|undefined} The clip-path attribute used after updates.
|
||||||
*/
|
*/
|
||||||
export const updateClipPath = (attr, tx, ty) => {
|
export const updateClipPath = (attr, tx, ty, elem) => {
|
||||||
const clipPath = getRefElem(attr)
|
const clipPath = getRefElem(attr)
|
||||||
if (!clipPath) return
|
if (!clipPath) return undefined
|
||||||
const path = clipPath.firstChild
|
if (elem && clipPath.id) {
|
||||||
|
const svgContent = svgCanvas.getSvgContent?.()
|
||||||
|
if (svgContent) {
|
||||||
|
const refSelector = `[clip-path="url(#${clipPath.id})"]`
|
||||||
|
const users = svgContent.querySelectorAll(refSelector)
|
||||||
|
if (users.length > 1) {
|
||||||
|
const newClipPath = clipPath.cloneNode(true)
|
||||||
|
newClipPath.id = svgCanvas.getNextId()
|
||||||
|
findDefs().append(newClipPath)
|
||||||
|
elem.setAttribute('clip-path', `url(#${newClipPath.id})`)
|
||||||
|
return updateClipPath(`url(#${newClipPath.id})`, tx, ty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const path = clipPath.firstElementChild
|
||||||
|
if (!path) return attr
|
||||||
const cpXform = getTransformList(path)
|
const cpXform = getTransformList(path)
|
||||||
|
if (!cpXform) {
|
||||||
|
const tag = (path.tagName || '').toLowerCase()
|
||||||
|
if (tag === 'rect') {
|
||||||
|
const x = convertToNum('x', path.getAttribute('x') || 0) + tx
|
||||||
|
const y = convertToNum('y', path.getAttribute('y') || 0) + ty
|
||||||
|
path.setAttribute('x', x)
|
||||||
|
path.setAttribute('y', y)
|
||||||
|
} else if (tag === 'circle' || tag === 'ellipse') {
|
||||||
|
const cx = convertToNum('cx', path.getAttribute('cx') || 0) + tx
|
||||||
|
const cy = convertToNum('cy', path.getAttribute('cy') || 0) + ty
|
||||||
|
path.setAttribute('cx', cx)
|
||||||
|
path.setAttribute('cy', cy)
|
||||||
|
} else if (tag === 'line') {
|
||||||
|
path.setAttribute('x1', convertToNum('x1', path.getAttribute('x1') || 0) + tx)
|
||||||
|
path.setAttribute('y1', convertToNum('y1', path.getAttribute('y1') || 0) + ty)
|
||||||
|
path.setAttribute('x2', convertToNum('x2', path.getAttribute('x2') || 0) + tx)
|
||||||
|
path.setAttribute('y2', convertToNum('y2', path.getAttribute('y2') || 0) + ty)
|
||||||
|
} else if (tag === 'polyline' || tag === 'polygon') {
|
||||||
|
const points = (path.getAttribute('points') || '').trim()
|
||||||
|
if (points) {
|
||||||
|
const updated = points.split(/\s+/).map((pair) => {
|
||||||
|
const [x, y] = pair.split(',')
|
||||||
|
const nx = Number(x) + tx
|
||||||
|
const ny = Number(y) + ty
|
||||||
|
return `${nx},${ny}`
|
||||||
|
})
|
||||||
|
path.setAttribute('points', updated.join(' '))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
path.setAttribute('transform', `translate(${tx},${ty})`)
|
||||||
|
}
|
||||||
|
return attr
|
||||||
|
}
|
||||||
|
if (cpXform.numberOfItems) {
|
||||||
|
const translate = svgCanvas.getSvgRoot().createSVGMatrix()
|
||||||
|
translate.e = tx
|
||||||
|
translate.f = ty
|
||||||
|
const combined = matrixMultiply(transformListToTransform(cpXform).matrix, translate)
|
||||||
|
const merged = svgCanvas.getSvgRoot().createSVGTransform()
|
||||||
|
merged.setMatrix(combined)
|
||||||
|
cpXform.clear()
|
||||||
|
cpXform.appendItem(merged)
|
||||||
|
return attr
|
||||||
|
}
|
||||||
|
const tag = (path.tagName || '').toLowerCase()
|
||||||
|
if ((tag === 'polyline' || tag === 'polygon') && !path.points?.numberOfItems) {
|
||||||
|
const points = (path.getAttribute('points') || '').trim()
|
||||||
|
if (points) {
|
||||||
|
const updated = points.split(/\s+/).map((pair) => {
|
||||||
|
const [x, y] = pair.split(',')
|
||||||
|
const nx = Number(x) + tx
|
||||||
|
const ny = Number(y) + ty
|
||||||
|
return `${nx},${ny}`
|
||||||
|
})
|
||||||
|
path.setAttribute('points', updated.join(' '))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
const newTranslate = svgCanvas.getSvgRoot().createSVGTransform()
|
const newTranslate = svgCanvas.getSvgRoot().createSVGTransform()
|
||||||
newTranslate.setTranslate(tx, ty)
|
newTranslate.setTranslate(tx, ty)
|
||||||
|
|
||||||
cpXform.appendItem(newTranslate)
|
cpXform.appendItem(newTranslate)
|
||||||
|
|
||||||
// Update clipPath's dimensions
|
|
||||||
recalculateDimensions(path)
|
recalculateDimensions(path)
|
||||||
|
return attr
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +140,20 @@ export const updateClipPath = (attr, tx, ty) => {
|
|||||||
*/
|
*/
|
||||||
export const recalculateDimensions = selected => {
|
export const recalculateDimensions = selected => {
|
||||||
if (!selected) return null
|
if (!selected) return null
|
||||||
|
|
||||||
|
// Don't recalculate dimensions for groups - this would push their transforms down to children
|
||||||
|
// Groups should maintain their transform attribute on the group element itself
|
||||||
|
if (selected.tagName === 'g' || selected.tagName === 'a') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(selected.getAttribute?.('clip-path')) &&
|
||||||
|
selected.querySelector?.('[clip-path]')
|
||||||
|
) {
|
||||||
|
// Keep transforms when clip-paths are present to avoid mutating defs.
|
||||||
|
return null
|
||||||
|
}
|
||||||
const svgroot = svgCanvas.getSvgRoot()
|
const svgroot = svgCanvas.getSvgRoot()
|
||||||
const dataStorage = svgCanvas.getDataStorage()
|
const dataStorage = svgCanvas.getDataStorage()
|
||||||
const tlist = getTransformList(selected)
|
const tlist = getTransformList(selected)
|
||||||
@@ -211,14 +305,310 @@ export const recalculateDimensions = selected => {
|
|||||||
|
|
||||||
// Handle group elements ('g' or 'a')
|
// Handle group elements ('g' or 'a')
|
||||||
if ((selected.tagName === 'g' && !gsvg) || selected.tagName === 'a') {
|
if ((selected.tagName === 'g' && !gsvg) || selected.tagName === 'a') {
|
||||||
// Group handling code
|
const box = getBBox(selected)
|
||||||
// [Group handling code remains unchanged]
|
|
||||||
// For brevity, group handling code is not included here
|
oldcenter = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
||||||
// Ensure to handle group elements correctly as per original logic
|
newcenter = transformPoint(
|
||||||
// This includes processing child elements and applying transformations appropriately
|
box.x + box.width / 2,
|
||||||
// ... [Start of group handling code]
|
box.y + box.height / 2,
|
||||||
// The group handling code is complex and extensive; it remains the same as in the original code.
|
transformListToTransform(tlist).matrix
|
||||||
// ... [End of group handling code]
|
)
|
||||||
|
|
||||||
|
const gangle = getRotationAngle(selected)
|
||||||
|
if (gangle) {
|
||||||
|
const a = gangle * Math.PI / 180
|
||||||
|
const s = Math.abs(a) > (1.0e-10) ? Math.sin(a) / (1 - Math.cos(a)) : 2 / a
|
||||||
|
for (let i = 0; i < tlist.numberOfItems; ++i) {
|
||||||
|
const xform = tlist.getItem(i)
|
||||||
|
if (xform.type === SVGTransform.SVG_TRANSFORM_ROTATE) {
|
||||||
|
const rm = xform.matrix
|
||||||
|
oldcenter.y = (s * rm.e + rm.f) / 2
|
||||||
|
oldcenter.x = (rm.e - s * rm.f) / 2
|
||||||
|
tlist.removeItem(i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const N = tlist.numberOfItems
|
||||||
|
let tx = 0
|
||||||
|
let ty = 0
|
||||||
|
let operation = 0
|
||||||
|
|
||||||
|
let firstM
|
||||||
|
if (N) {
|
||||||
|
firstM = tlist.getItem(0).matrix
|
||||||
|
}
|
||||||
|
|
||||||
|
let oldStartTransform
|
||||||
|
if (
|
||||||
|
N >= 3 &&
|
||||||
|
tlist.getItem(N - 2).type === SVGTransform.SVG_TRANSFORM_SCALE &&
|
||||||
|
tlist.getItem(N - 3).type === SVGTransform.SVG_TRANSFORM_TRANSLATE &&
|
||||||
|
tlist.getItem(N - 1).type === SVGTransform.SVG_TRANSFORM_TRANSLATE
|
||||||
|
) {
|
||||||
|
operation = 3 // scale
|
||||||
|
|
||||||
|
const tm = tlist.getItem(N - 3).matrix
|
||||||
|
const sm = tlist.getItem(N - 2).matrix
|
||||||
|
const tmn = tlist.getItem(N - 1).matrix
|
||||||
|
|
||||||
|
const children = selected.childNodes
|
||||||
|
let c = children.length
|
||||||
|
while (c--) {
|
||||||
|
const child = children.item(c)
|
||||||
|
if (child.nodeType !== 1) continue
|
||||||
|
|
||||||
|
const childTlist = getTransformList(child)
|
||||||
|
if (!childTlist) continue
|
||||||
|
|
||||||
|
const m = transformListToTransform(childTlist).matrix
|
||||||
|
|
||||||
|
const angle = getRotationAngle(child)
|
||||||
|
oldStartTransform = svgCanvas.getStartTransform()
|
||||||
|
svgCanvas.setStartTransform(child.getAttribute('transform'))
|
||||||
|
|
||||||
|
if (angle || hasMatrixTransform(childTlist)) {
|
||||||
|
const e2t = svgroot.createSVGTransform()
|
||||||
|
e2t.setMatrix(matrixMultiply(tm, sm, tmn, m))
|
||||||
|
childTlist.clear()
|
||||||
|
childTlist.appendItem(e2t)
|
||||||
|
} else {
|
||||||
|
const t2n = matrixMultiply(m.inverse(), tmn, m)
|
||||||
|
const t2 = svgroot.createSVGMatrix()
|
||||||
|
t2.e = -t2n.e
|
||||||
|
t2.f = -t2n.f
|
||||||
|
|
||||||
|
const s2 = matrixMultiply(
|
||||||
|
t2.inverse(),
|
||||||
|
m.inverse(),
|
||||||
|
tm,
|
||||||
|
sm,
|
||||||
|
tmn,
|
||||||
|
m,
|
||||||
|
t2n.inverse()
|
||||||
|
)
|
||||||
|
|
||||||
|
const translateOrigin = svgroot.createSVGTransform()
|
||||||
|
const scale = svgroot.createSVGTransform()
|
||||||
|
const translateBack = svgroot.createSVGTransform()
|
||||||
|
translateOrigin.setTranslate(t2n.e, t2n.f)
|
||||||
|
scale.setScale(s2.a, s2.d)
|
||||||
|
translateBack.setTranslate(t2.e, t2.f)
|
||||||
|
childTlist.appendItem(translateBack)
|
||||||
|
childTlist.appendItem(scale)
|
||||||
|
childTlist.appendItem(translateOrigin)
|
||||||
|
}
|
||||||
|
|
||||||
|
const recalculatedDimensions = recalculateDimensions(child)
|
||||||
|
if (recalculatedDimensions) {
|
||||||
|
batchCmd.addSubCommand(recalculatedDimensions)
|
||||||
|
}
|
||||||
|
svgCanvas.setStartTransform(oldStartTransform)
|
||||||
|
}
|
||||||
|
|
||||||
|
tlist.removeItem(N - 1)
|
||||||
|
tlist.removeItem(N - 2)
|
||||||
|
tlist.removeItem(N - 3)
|
||||||
|
} else if (N >= 3 && tlist.getItem(N - 1).type === SVGTransform.SVG_TRANSFORM_MATRIX) {
|
||||||
|
operation = 3 // scale (matrix imposition)
|
||||||
|
const m = transformListToTransform(tlist).matrix
|
||||||
|
const e2t = svgroot.createSVGTransform()
|
||||||
|
e2t.setMatrix(m)
|
||||||
|
tlist.clear()
|
||||||
|
tlist.appendItem(e2t)
|
||||||
|
} else if (
|
||||||
|
(N === 1 ||
|
||||||
|
(N > 1 && tlist.getItem(1).type !== SVGTransform.SVG_TRANSFORM_SCALE)) &&
|
||||||
|
tlist.getItem(0).type === SVGTransform.SVG_TRANSFORM_TRANSLATE
|
||||||
|
) {
|
||||||
|
operation = 2 // translate
|
||||||
|
const tM = transformListToTransform(tlist).matrix
|
||||||
|
tlist.removeItem(0)
|
||||||
|
const mInv = transformListToTransform(tlist).matrix.inverse()
|
||||||
|
const m2 = matrixMultiply(mInv, tM)
|
||||||
|
|
||||||
|
tx = m2.e
|
||||||
|
ty = m2.f
|
||||||
|
|
||||||
|
if (tx !== 0 || ty !== 0) {
|
||||||
|
const selectedClipPath = selected.getAttribute?.('clip-path')
|
||||||
|
if (selectedClipPath) {
|
||||||
|
updateClipPath(selectedClipPath, tx, ty, selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = selected.childNodes
|
||||||
|
let c = children.length
|
||||||
|
|
||||||
|
const clipPathsDone = []
|
||||||
|
while (c--) {
|
||||||
|
const child = children.item(c)
|
||||||
|
if (child.nodeType !== 1) continue
|
||||||
|
|
||||||
|
const clipPathAttr = child.getAttribute('clip-path')
|
||||||
|
if (clipPathAttr && !clipPathsDone.includes(clipPathAttr)) {
|
||||||
|
const updatedAttr = updateClipPath(clipPathAttr, tx, ty, child)
|
||||||
|
clipPathsDone.push(updatedAttr || clipPathAttr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const childTlist = getTransformList(child)
|
||||||
|
if (!childTlist) continue
|
||||||
|
|
||||||
|
oldStartTransform = svgCanvas.getStartTransform()
|
||||||
|
svgCanvas.setStartTransform(child.getAttribute('transform'))
|
||||||
|
|
||||||
|
const newxlate = svgroot.createSVGTransform()
|
||||||
|
newxlate.setTranslate(tx, ty)
|
||||||
|
if (childTlist.numberOfItems) {
|
||||||
|
childTlist.insertItemBefore(newxlate, 0)
|
||||||
|
} else {
|
||||||
|
childTlist.appendItem(newxlate)
|
||||||
|
}
|
||||||
|
const recalculatedDimensions = recalculateDimensions(child)
|
||||||
|
if (recalculatedDimensions) {
|
||||||
|
batchCmd.addSubCommand(recalculatedDimensions)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uses = selected.getElementsByTagNameNS(NS.SVG, 'use')
|
||||||
|
const href = `#${child.id}`
|
||||||
|
let u = uses.length
|
||||||
|
while (u--) {
|
||||||
|
const useElem = uses.item(u)
|
||||||
|
if (href === getHref(useElem)) {
|
||||||
|
const usexlate = svgroot.createSVGTransform()
|
||||||
|
usexlate.setTranslate(-tx, -ty)
|
||||||
|
const useTlist = getTransformList(useElem)
|
||||||
|
useTlist?.insertItemBefore(usexlate, 0)
|
||||||
|
const useRecalc = recalculateDimensions(useElem)
|
||||||
|
if (useRecalc) {
|
||||||
|
batchCmd.addSubCommand(useRecalc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svgCanvas.setStartTransform(oldStartTransform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
N === 1 &&
|
||||||
|
tlist.getItem(0).type === SVGTransform.SVG_TRANSFORM_MATRIX &&
|
||||||
|
!gangle
|
||||||
|
) {
|
||||||
|
operation = 1
|
||||||
|
const m = tlist.getItem(0).matrix
|
||||||
|
const children = selected.childNodes
|
||||||
|
let c = children.length
|
||||||
|
while (c--) {
|
||||||
|
const child = children.item(c)
|
||||||
|
if (child.nodeType !== 1) continue
|
||||||
|
|
||||||
|
const childTlist = getTransformList(child)
|
||||||
|
if (!childTlist) continue
|
||||||
|
|
||||||
|
oldStartTransform = svgCanvas.getStartTransform()
|
||||||
|
svgCanvas.setStartTransform(child.getAttribute('transform'))
|
||||||
|
|
||||||
|
const em = matrixMultiply(m, transformListToTransform(childTlist).matrix)
|
||||||
|
const e2m = svgroot.createSVGTransform()
|
||||||
|
e2m.setMatrix(em)
|
||||||
|
childTlist.clear()
|
||||||
|
childTlist.appendItem(e2m)
|
||||||
|
|
||||||
|
const recalculatedDimensions = recalculateDimensions(child)
|
||||||
|
if (recalculatedDimensions) {
|
||||||
|
batchCmd.addSubCommand(recalculatedDimensions)
|
||||||
|
}
|
||||||
|
svgCanvas.setStartTransform(oldStartTransform)
|
||||||
|
|
||||||
|
const sw = child.getAttribute('stroke-width')
|
||||||
|
if (child.getAttribute('stroke') !== 'none' && !Number.isNaN(Number(sw))) {
|
||||||
|
const avg = (Math.abs(em.a) + Math.abs(em.d)) / 2
|
||||||
|
child.setAttribute('stroke-width', Number(sw) * avg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tlist.clear()
|
||||||
|
} else {
|
||||||
|
if (gangle) {
|
||||||
|
const newRot = svgroot.createSVGTransform()
|
||||||
|
newRot.setRotate(gangle, newcenter.x, newcenter.y)
|
||||||
|
if (tlist.numberOfItems) {
|
||||||
|
tlist.insertItemBefore(newRot, 0)
|
||||||
|
} else {
|
||||||
|
tlist.appendItem(newRot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tlist.numberOfItems === 0) {
|
||||||
|
selected.removeAttribute('transform')
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation === 2) {
|
||||||
|
if (gangle) {
|
||||||
|
newcenter = {
|
||||||
|
x: oldcenter.x + firstM.e,
|
||||||
|
y: oldcenter.y + firstM.f
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRot = svgroot.createSVGTransform()
|
||||||
|
newRot.setRotate(gangle, newcenter.x, newcenter.y)
|
||||||
|
if (tlist.numberOfItems) {
|
||||||
|
tlist.insertItemBefore(newRot, 0)
|
||||||
|
} else {
|
||||||
|
tlist.appendItem(newRot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (operation === 3) {
|
||||||
|
const m = transformListToTransform(tlist).matrix
|
||||||
|
const roldt = svgroot.createSVGTransform()
|
||||||
|
roldt.setRotate(gangle, oldcenter.x, oldcenter.y)
|
||||||
|
const rold = roldt.matrix
|
||||||
|
const rnew = svgroot.createSVGTransform()
|
||||||
|
rnew.setRotate(gangle, newcenter.x, newcenter.y)
|
||||||
|
const rnewInv = rnew.matrix.inverse()
|
||||||
|
const mInv = m.inverse()
|
||||||
|
const extrat = matrixMultiply(mInv, rnewInv, rold, m)
|
||||||
|
|
||||||
|
tx = extrat.e
|
||||||
|
ty = extrat.f
|
||||||
|
|
||||||
|
if (tx !== 0 || ty !== 0) {
|
||||||
|
const children = selected.childNodes
|
||||||
|
let c = children.length
|
||||||
|
while (c--) {
|
||||||
|
const child = children.item(c)
|
||||||
|
if (child.nodeType !== 1) continue
|
||||||
|
|
||||||
|
const childTlist = getTransformList(child)
|
||||||
|
if (!childTlist) continue
|
||||||
|
|
||||||
|
oldStartTransform = svgCanvas.getStartTransform()
|
||||||
|
svgCanvas.setStartTransform(child.getAttribute('transform'))
|
||||||
|
|
||||||
|
const newxlate = svgroot.createSVGTransform()
|
||||||
|
newxlate.setTranslate(tx, ty)
|
||||||
|
if (childTlist.numberOfItems) {
|
||||||
|
childTlist.insertItemBefore(newxlate, 0)
|
||||||
|
} else {
|
||||||
|
childTlist.appendItem(newxlate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const recalculatedDimensions = recalculateDimensions(child)
|
||||||
|
if (recalculatedDimensions) {
|
||||||
|
batchCmd.addSubCommand(recalculatedDimensions)
|
||||||
|
}
|
||||||
|
svgCanvas.setStartTransform(oldStartTransform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gangle) {
|
||||||
|
if (tlist.numberOfItems) {
|
||||||
|
tlist.insertItemBefore(rnew, 0)
|
||||||
|
} else {
|
||||||
|
tlist.appendItem(rnew)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Non-group elements
|
// Non-group elements
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import { getReverseNS, NS } from './namespaces.js'
|
import { getReverseNS, NS } from './namespaces.js'
|
||||||
import { getHref, getRefElem, setHref, getUrlFromAttr } from './utilities.js'
|
import { getHref, getRefElem, setHref, getUrlFromAttr } from './utilities.js'
|
||||||
|
import { warn } from '../common/logger.js'
|
||||||
|
|
||||||
const REVERSE_NS = getReverseNS()
|
const REVERSE_NS = getReverseNS()
|
||||||
|
|
||||||
@@ -130,22 +131,24 @@ const svgWhiteList_ = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// add generic attributes to all elements of the whitelist
|
// add generic attributes to all elements of the whitelist
|
||||||
Object.keys(svgWhiteList_).forEach((element) => { svgWhiteList_[element] = [...svgWhiteList_[element], ...svgGenericWhiteList] })
|
for (const [element, attrs] of Object.entries(svgWhiteList_)) {
|
||||||
|
svgWhiteList_[element] = [...attrs, ...svgGenericWhiteList]
|
||||||
|
}
|
||||||
|
|
||||||
// Produce a Namespace-aware version of svgWhitelist
|
// Produce a Namespace-aware version of svgWhitelist
|
||||||
const svgWhiteListNS_ = {}
|
const svgWhiteListNS_ = {}
|
||||||
Object.entries(svgWhiteList_).forEach(([elt, atts]) => {
|
for (const [elt, atts] of Object.entries(svgWhiteList_)) {
|
||||||
const attNS = {}
|
const attNS = {}
|
||||||
Object.entries(atts).forEach(([_i, att]) => {
|
for (const att of atts) {
|
||||||
if (att.includes(':')) {
|
if (att.includes(':')) {
|
||||||
const v = att.split(':')
|
const [prefix, localName] = att.split(':')
|
||||||
attNS[v[1]] = NS[(v[0]).toUpperCase()]
|
attNS[localName] = NS[prefix.toUpperCase()]
|
||||||
} else {
|
} else {
|
||||||
attNS[att] = att === 'xmlns' ? NS.XMLNS : null
|
attNS[att] = att === 'xmlns' ? NS.XMLNS : null
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
svgWhiteListNS_[elt] = attNS
|
svgWhiteListNS_[elt] = attNS
|
||||||
})
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitizes the input node and its children.
|
* Sanitizes the input node and its children.
|
||||||
@@ -205,7 +208,7 @@ export const sanitizeSvg = (node) => {
|
|||||||
const seAttrNS = (attrName.startsWith('se:')) ? NS.SE : ((attrName.startsWith('oi:')) ? NS.OI : null)
|
const seAttrNS = (attrName.startsWith('se:')) ? NS.SE : ((attrName.startsWith('oi:')) ? NS.OI : null)
|
||||||
seAttrs.push([attrName, attr.value, seAttrNS])
|
seAttrs.push([attrName, attr.value, seAttrNS])
|
||||||
} else {
|
} else {
|
||||||
console.warn(`sanitizeSvg: attribute ${attrName} in element ${node.nodeName} not in whitelist is removed: ${node.outerHTML}`)
|
warn(`attribute ${attrName} in element ${node.nodeName} not in whitelist is removed: ${node.outerHTML}`, null, 'sanitize')
|
||||||
node.removeAttributeNS(attrNsURI, attrLocalName)
|
node.removeAttributeNS(attrNsURI, attrLocalName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,14 +250,14 @@ export const sanitizeSvg = (node) => {
|
|||||||
'radialGradient', 'textPath', 'use'].includes(node.nodeName) && href[0] !== '#') {
|
'radialGradient', 'textPath', 'use'].includes(node.nodeName) && href[0] !== '#') {
|
||||||
// remove the attribute (but keep the element)
|
// remove the attribute (but keep the element)
|
||||||
setHref(node, '')
|
setHref(node, '')
|
||||||
console.warn(`sanitizeSvg: attribute href in element ${node.nodeName} pointing to a non-local reference (${href}) is removed: ${node.outerHTML}`)
|
warn(`attribute href in element ${node.nodeName} pointing to a non-local reference (${href}) is removed: ${node.outerHTML}`, null, 'sanitize')
|
||||||
node.removeAttributeNS(NS.XLINK, 'href')
|
node.removeAttributeNS(NS.XLINK, 'href')
|
||||||
node.removeAttribute('href')
|
node.removeAttribute('href')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safari crashes on a <use> without a xlink:href, so we just remove the node here
|
// Safari crashes on a <use> without a xlink:href, so we just remove the node here
|
||||||
if (node.nodeName === 'use' && !getHref(node)) {
|
if (node.nodeName === 'use' && !getHref(node)) {
|
||||||
console.warn(`sanitizeSvg: element ${node.nodeName} without a xlink:href or href is removed: ${node.outerHTML}`)
|
warn(`element ${node.nodeName} without a xlink:href or href is removed: ${node.outerHTML}`, null, 'sanitize')
|
||||||
node.remove()
|
node.remove()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -285,7 +288,7 @@ export const sanitizeSvg = (node) => {
|
|||||||
// simply check for first character being a '#'
|
// simply check for first character being a '#'
|
||||||
if (val && val[0] !== '#') {
|
if (val && val[0] !== '#') {
|
||||||
node.setAttribute(attr, '')
|
node.setAttribute(attr, '')
|
||||||
console.warn(`sanitizeSvg: attribute ${attr} in element ${node.nodeName} pointing to a non-local reference (${val}) is removed: ${node.outerHTML}`)
|
warn(`attribute ${attr} in element ${node.nodeName} pointing to a non-local reference (${val}) is removed: ${node.outerHTML}`, null, 'sanitize')
|
||||||
node.removeAttribute(attr)
|
node.removeAttribute(attr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,7 +301,7 @@ export const sanitizeSvg = (node) => {
|
|||||||
} else {
|
} else {
|
||||||
// remove all children from this node and insert them before this node
|
// remove all children from this node and insert them before this node
|
||||||
// TODO: in the case of animation elements this will hardly ever be correct
|
// TODO: in the case of animation elements this will hardly ever be correct
|
||||||
console.warn(`sanitizeSvg: element ${node.nodeName} not supported is removed: ${node.outerHTML}`)
|
warn(`element ${node.nodeName} not supported is removed: ${node.outerHTML}`, null, 'sanitize')
|
||||||
const children = []
|
const children = []
|
||||||
while (node.hasChildNodes()) {
|
while (node.hasChildNodes()) {
|
||||||
children.push(parent.insertBefore(node.firstChild, node))
|
children.push(parent.insertBefore(node.firstChild, node))
|
||||||
|
|||||||
@@ -10,12 +10,37 @@ import { isWebkit } from '../common/browser.js'
|
|||||||
import { getRotationAngle, getBBox, getStrokedBBox } from './utilities.js'
|
import { getRotationAngle, getBBox, getStrokedBBox } from './utilities.js'
|
||||||
import { transformListToTransform, transformBox, transformPoint, matrixMultiply, getTransformList } from './math.js'
|
import { transformListToTransform, transformBox, transformPoint, matrixMultiply, getTransformList } from './math.js'
|
||||||
import { NS } from './namespaces'
|
import { NS } from './namespaces'
|
||||||
|
import { warn } from '../common/logger.js'
|
||||||
|
|
||||||
let svgCanvas
|
let svgCanvas
|
||||||
let selectorManager_ // A Singleton
|
|
||||||
// change radius if touch screen
|
// change radius if touch screen
|
||||||
const gripRadius = window.ontouchstart ? 10 : 4
|
const gripRadius = window.ontouchstart ? 10 : 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private singleton manager for selector state
|
||||||
|
*/
|
||||||
|
class SelectModule {
|
||||||
|
#selectorManager = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the select module with canvas
|
||||||
|
* @param {Object} canvas - The SVG canvas instance
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
init (canvas) {
|
||||||
|
svgCanvas = canvas
|
||||||
|
this.#selectorManager = new SelectorManager()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the singleton SelectorManager instance
|
||||||
|
* @returns {SelectorManager} The SelectorManager instance
|
||||||
|
*/
|
||||||
|
getSelectorManager () {
|
||||||
|
return this.#selectorManager
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Private class for DOM element selection boxes.
|
* Private class for DOM element selection boxes.
|
||||||
*/
|
*/
|
||||||
@@ -38,14 +63,14 @@ export class Selector {
|
|||||||
// this holds a reference to the <g> element that holds all visual elements of the selector
|
// this holds a reference to the <g> element that holds all visual elements of the selector
|
||||||
this.selectorGroup = svgCanvas.createSVGElement({
|
this.selectorGroup = svgCanvas.createSVGElement({
|
||||||
element: 'g',
|
element: 'g',
|
||||||
attr: { id: ('selectorGroup' + this.id) }
|
attr: { id: `selectorGroup${this.id}` }
|
||||||
})
|
})
|
||||||
|
|
||||||
// this holds a reference to the path rect
|
// this holds a reference to the path rect
|
||||||
this.selectorRect = svgCanvas.createSVGElement({
|
this.selectorRect = svgCanvas.createSVGElement({
|
||||||
element: 'path',
|
element: 'path',
|
||||||
attr: {
|
attr: {
|
||||||
id: ('selectedBox' + this.id),
|
id: `selectedBox${this.id}`,
|
||||||
fill: 'none',
|
fill: 'none',
|
||||||
stroke: '#22C',
|
stroke: '#22C',
|
||||||
'stroke-width': '1',
|
'stroke-width': '1',
|
||||||
@@ -91,11 +116,11 @@ export class Selector {
|
|||||||
*/
|
*/
|
||||||
showGrips (show) {
|
showGrips (show) {
|
||||||
const bShow = show ? 'inline' : 'none'
|
const bShow = show ? 'inline' : 'none'
|
||||||
selectorManager_.selectorGripsGroup.setAttribute('display', bShow)
|
selectModule.getSelectorManager().selectorGripsGroup.setAttribute('display', bShow)
|
||||||
const elem = this.selectedElement
|
const elem = this.selectedElement
|
||||||
this.hasGrips = show
|
this.hasGrips = show
|
||||||
if (elem && show) {
|
if (elem && show) {
|
||||||
this.selectorGroup.append(selectorManager_.selectorGripsGroup)
|
this.selectorGroup.append(selectModule.getSelectorManager().selectorGripsGroup)
|
||||||
Selector.updateGripCursors(getRotationAngle(elem))
|
Selector.updateGripCursors(getRotationAngle(elem))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,7 +133,7 @@ export class Selector {
|
|||||||
resize (bbox) {
|
resize (bbox) {
|
||||||
const dataStorage = svgCanvas.getDataStorage()
|
const dataStorage = svgCanvas.getDataStorage()
|
||||||
const selectedBox = this.selectorRect
|
const selectedBox = this.selectorRect
|
||||||
const mgr = selectorManager_
|
const mgr = selectModule.getSelectorManager()
|
||||||
const selectedGrips = mgr.selectorGrips
|
const selectedGrips = mgr.selectorGrips
|
||||||
const selected = this.selectedElement
|
const selected = this.selectedElement
|
||||||
const zoom = svgCanvas.getZoom()
|
const zoom = svgCanvas.getZoom()
|
||||||
@@ -130,7 +155,7 @@ export class Selector {
|
|||||||
while (currentElt.parentNode) {
|
while (currentElt.parentNode) {
|
||||||
if (currentElt.parentNode && currentElt.parentNode.tagName === 'g' && currentElt.parentNode.transform) {
|
if (currentElt.parentNode && currentElt.parentNode.tagName === 'g' && currentElt.parentNode.transform) {
|
||||||
if (currentElt.parentNode.transform.baseVal.numberOfItems) {
|
if (currentElt.parentNode.transform.baseVal.numberOfItems) {
|
||||||
parentTransformationMatrix = matrixMultiply(transformListToTransform(getTransformList(selected.parentNode)).matrix, parentTransformationMatrix)
|
parentTransformationMatrix = matrixMultiply(transformListToTransform(getTransformList(currentElt.parentNode)).matrix, parentTransformationMatrix)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
currentElt = currentElt.parentNode
|
currentElt = currentElt.parentNode
|
||||||
@@ -213,10 +238,7 @@ export class Selector {
|
|||||||
nbah = (maxy - miny)
|
nbah = (maxy - miny)
|
||||||
}
|
}
|
||||||
|
|
||||||
const dstr = 'M' + nbax + ',' + nbay +
|
const dstr = `M${nbax},${nbay} L${nbax + nbaw},${nbay} ${nbax + nbaw},${nbay + nbah} ${nbax},${nbay + nbah}z`
|
||||||
' L' + (nbax + nbaw) + ',' + nbay +
|
|
||||||
' ' + (nbax + nbaw) + ',' + (nbay + nbah) +
|
|
||||||
' ' + nbax + ',' + (nbay + nbah) + 'z'
|
|
||||||
|
|
||||||
const xform = angle ? 'rotate(' + [angle, cx, cy].join(',') + ')' : ''
|
const xform = angle ? 'rotate(' + [angle, cx, cy].join(',') + ')' : ''
|
||||||
|
|
||||||
@@ -257,15 +279,15 @@ export class Selector {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
static updateGripCursors (angle) {
|
static updateGripCursors (angle) {
|
||||||
const dirArr = Object.keys(selectorManager_.selectorGrips)
|
const dirArr = Object.keys(selectModule.getSelectorManager().selectorGrips)
|
||||||
let steps = Math.round(angle / 45)
|
let steps = Math.round(angle / 45)
|
||||||
if (steps < 0) { steps += 8 }
|
if (steps < 0) { steps += 8 }
|
||||||
while (steps > 0) {
|
while (steps > 0) {
|
||||||
dirArr.push(dirArr.shift())
|
dirArr.push(dirArr.shift())
|
||||||
steps--
|
steps--
|
||||||
}
|
}
|
||||||
Object.values(selectorManager_.selectorGrips).forEach((gripElement, i) => {
|
Object.values(selectModule.getSelectorManager().selectorGrips).forEach((gripElement, i) => {
|
||||||
gripElement.setAttribute('style', ('cursor:' + dirArr[i] + '-resize'))
|
gripElement.setAttribute('style', `cursor:${dirArr[i]}-resize`)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,10 +363,10 @@ export class SelectorManager {
|
|||||||
const grip = svgCanvas.createSVGElement({
|
const grip = svgCanvas.createSVGElement({
|
||||||
element: 'circle',
|
element: 'circle',
|
||||||
attr: {
|
attr: {
|
||||||
id: ('selectorGrip_resize_' + dir),
|
id: `selectorGrip_resize_${dir}`,
|
||||||
fill: '#22C',
|
fill: '#22C',
|
||||||
r: gripRadius,
|
r: gripRadius,
|
||||||
style: ('cursor:' + dir + '-resize'),
|
style: `cursor:${dir}-resize`,
|
||||||
// This expands the mouse-able area of the grips making them
|
// This expands the mouse-able area of the grips making them
|
||||||
// easier to grab with the mouse.
|
// easier to grab with the mouse.
|
||||||
// This works in Opera and WebKit, but does not work in Firefox
|
// This works in Opera and WebKit, but does not work in Firefox
|
||||||
@@ -462,7 +484,7 @@ export class SelectorManager {
|
|||||||
const sel = this.selectorMap[elem.id]
|
const sel = this.selectorMap[elem.id]
|
||||||
if (!sel?.locked) {
|
if (!sel?.locked) {
|
||||||
// TODO(codedread): Ensure this exists in this module.
|
// TODO(codedread): Ensure this exists in this module.
|
||||||
console.warn('WARNING! selector was released but was already unlocked')
|
warn('WARNING! selector was released but was already unlocked', null, 'select')
|
||||||
}
|
}
|
||||||
for (let i = 0; i < N; ++i) {
|
for (let i = 0; i < N; ++i) {
|
||||||
if (this.selectors[i] && this.selectors[i] === sel) {
|
if (this.selectors[i] && this.selectors[i] === sel) {
|
||||||
@@ -541,6 +563,9 @@ export class SelectorManager {
|
|||||||
* @property {module:select.Dimensions} dimensions
|
* @property {module:select.Dimensions} dimensions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Export singleton instance for backward compatibility
|
||||||
|
const selectModule = new SelectModule()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes this module.
|
* Initializes this module.
|
||||||
* @function module:select.init
|
* @function module:select.init
|
||||||
@@ -549,12 +574,11 @@ export class SelectorManager {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export const init = (canvas) => {
|
export const init = (canvas) => {
|
||||||
svgCanvas = canvas
|
selectModule.init(canvas)
|
||||||
selectorManager_ = new SelectorManager()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @function module:select.getSelectorManager
|
* @function module:select.getSelectorManager
|
||||||
* @returns {module:select.SelectorManager} The SelectorManager instance.
|
* @returns {module:select.SelectorManager} The SelectorManager instance.
|
||||||
*/
|
*/
|
||||||
export const getSelectorManager = () => selectorManager_
|
export const getSelectorManager = () => selectModule.getSelectorManager()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { NS } from './namespaces.js'
|
import { NS } from './namespaces.js'
|
||||||
import * as hstry from './history.js'
|
import * as hstry from './history.js'
|
||||||
import * as pathModule from './path.js'
|
import * as pathModule from './path.js'
|
||||||
|
import { warn, error } from '../common/logger.js'
|
||||||
import {
|
import {
|
||||||
getStrokedBBoxDefaultVisible,
|
getStrokedBBoxDefaultVisible,
|
||||||
setHref,
|
setHref,
|
||||||
@@ -104,14 +105,17 @@ const moveToBottomSelectedElem = () => {
|
|||||||
let t = selected
|
let t = selected
|
||||||
const oldParent = t.parentNode
|
const oldParent = t.parentNode
|
||||||
const oldNextSibling = t.nextSibling
|
const oldNextSibling = t.nextSibling
|
||||||
let { firstChild } = t.parentNode
|
let firstChild = t.parentNode.firstElementChild
|
||||||
if (firstChild.tagName === 'title') {
|
if (firstChild?.tagName === 'title') {
|
||||||
firstChild = firstChild.nextSibling
|
firstChild = firstChild.nextElementSibling
|
||||||
}
|
}
|
||||||
// This can probably be removed, as the defs should not ever apppear
|
// This can probably be removed, as the defs should not ever apppear
|
||||||
// inside a layer group
|
// inside a layer group
|
||||||
if (firstChild.tagName === 'defs') {
|
if (firstChild?.tagName === 'defs') {
|
||||||
firstChild = firstChild.nextSibling
|
firstChild = firstChild.nextElementSibling
|
||||||
|
}
|
||||||
|
if (!firstChild) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
t = t.parentNode.insertBefore(t, firstChild)
|
t = t.parentNode.insertBefore(t, firstChild)
|
||||||
// If the element actually moved position, add the command and fire the changed
|
// If the element actually moved position, add the command and fire the changed
|
||||||
@@ -179,7 +183,7 @@ const moveUpDownSelected = dir => {
|
|||||||
// event handler.
|
// event handler.
|
||||||
if (oldNextSibling !== t.nextSibling) {
|
if (oldNextSibling !== t.nextSibling) {
|
||||||
svgCanvas.addCommandToHistory(
|
svgCanvas.addCommandToHistory(
|
||||||
new MoveElementCommand(t, oldNextSibling, oldParent, 'Move ' + dir)
|
new MoveElementCommand(t, oldNextSibling, oldParent, `Move ${dir}`)
|
||||||
)
|
)
|
||||||
svgCanvas.call('changed', [t])
|
svgCanvas.call('changed', [t])
|
||||||
}
|
}
|
||||||
@@ -208,6 +212,9 @@ const moveSelectedElements = (dx, dy, undoable = true) => {
|
|||||||
const batchCmd = new BatchCommand('position')
|
const batchCmd = new BatchCommand('position')
|
||||||
selectedElements.forEach((selected, i) => {
|
selectedElements.forEach((selected, i) => {
|
||||||
if (selected) {
|
if (selected) {
|
||||||
|
// Store the existing transform before modifying
|
||||||
|
const existingTransform = selected.getAttribute('transform') || ''
|
||||||
|
|
||||||
const xform = svgCanvas.getSvgRoot().createSVGTransform()
|
const xform = svgCanvas.getSvgRoot().createSVGTransform()
|
||||||
const tlist = getTransformList(selected)
|
const tlist = getTransformList(selected)
|
||||||
|
|
||||||
@@ -227,6 +234,12 @@ const moveSelectedElements = (dx, dy, undoable = true) => {
|
|||||||
const cmd = recalculateDimensions(selected)
|
const cmd = recalculateDimensions(selected)
|
||||||
if (cmd) {
|
if (cmd) {
|
||||||
batchCmd.addSubCommand(cmd)
|
batchCmd.addSubCommand(cmd)
|
||||||
|
} else if ((selected.getAttribute('transform') || '') !== existingTransform) {
|
||||||
|
// For groups and other elements where recalculateDimensions returns null,
|
||||||
|
// record the transform change directly
|
||||||
|
batchCmd.addSubCommand(
|
||||||
|
new ChangeElementCommand(selected, { transform: existingTransform })
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
svgCanvas
|
svgCanvas
|
||||||
@@ -265,9 +278,11 @@ const cloneSelectedElements = (x, y) => {
|
|||||||
const index = el => {
|
const index = el => {
|
||||||
if (!el) return -1
|
if (!el) return -1
|
||||||
let i = 0
|
let i = 0
|
||||||
|
let current = el
|
||||||
do {
|
do {
|
||||||
i++
|
i++
|
||||||
} while (el === el.previousElementSibling)
|
current = current.previousElementSibling
|
||||||
|
} while (current)
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -702,7 +717,7 @@ const flipSelectedElements = (scaleX, scaleY) => {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const copySelectedElements = () => {
|
const copySelectedElements = () => {
|
||||||
const selectedElements = svgCanvas.getSelectedElements()
|
const selectedElements = svgCanvas.getSelectedElements().filter(Boolean)
|
||||||
const data = JSON.stringify(
|
const data = JSON.stringify(
|
||||||
selectedElements.map(x => svgCanvas.getJsonFromSvgElements(x))
|
selectedElements.map(x => svgCanvas.getJsonFromSvgElements(x))
|
||||||
)
|
)
|
||||||
@@ -712,7 +727,7 @@ const copySelectedElements = () => {
|
|||||||
|
|
||||||
// Context menu might not exist (it is provided by editor.js).
|
// Context menu might not exist (it is provided by editor.js).
|
||||||
const canvMenu = document.getElementById('se-cmenu_canvas')
|
const canvMenu = document.getElementById('se-cmenu_canvas')
|
||||||
canvMenu.setAttribute('enablemenuitems', '#paste,#paste_in_place')
|
canvMenu?.setAttribute('enablemenuitems', '#paste,#paste_in_place')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -866,10 +881,10 @@ const pushGroupProperty = (g, undoable) => {
|
|||||||
// Change this in future for different filters
|
// Change this in future for different filters
|
||||||
const suffix =
|
const suffix =
|
||||||
blurElem?.tagName === 'feGaussianBlur' ? 'blur' : 'filter'
|
blurElem?.tagName === 'feGaussianBlur' ? 'blur' : 'filter'
|
||||||
gfilter.id = elem.id + '_' + suffix
|
gfilter.id = `${elem.id}_${suffix}`
|
||||||
svgCanvas.changeSelectedAttribute(
|
svgCanvas.changeSelectedAttribute(
|
||||||
'filter',
|
'filter',
|
||||||
'url(#' + gfilter.id + ')',
|
`url(#${gfilter.id})`,
|
||||||
[elem]
|
[elem]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -976,21 +991,30 @@ const pushGroupProperty = (g, undoable) => {
|
|||||||
changes = {}
|
changes = {}
|
||||||
changes.transform = oldxform || ''
|
changes.transform = oldxform || ''
|
||||||
|
|
||||||
|
// Simply prepend the group's transform to the child's transform list
|
||||||
|
// New transform = [group transform] [child transform]
|
||||||
|
// This preserves the correct application order
|
||||||
const newxform = svgCanvas.getSvgRoot().createSVGTransform()
|
const newxform = svgCanvas.getSvgRoot().createSVGTransform()
|
||||||
|
newxform.setMatrix(m)
|
||||||
|
|
||||||
// [ gm ] [ chm ] = [ chm ] [ gm' ]
|
// Insert group's transform at the beginning of child's transform list
|
||||||
// [ gm' ] = [ chmInv ] [ gm ] [ chm ]
|
if (chtlist.numberOfItems) {
|
||||||
const chm = transformListToTransform(chtlist).matrix
|
chtlist.insertItemBefore(newxform, 0)
|
||||||
const chmInv = chm.inverse()
|
} else {
|
||||||
const gm = matrixMultiply(chmInv, m, chm)
|
|
||||||
newxform.setMatrix(gm)
|
|
||||||
chtlist.appendItem(newxform)
|
chtlist.appendItem(newxform)
|
||||||
}
|
}
|
||||||
const cmd = recalculateDimensions(elem)
|
|
||||||
if (cmd) {
|
// Record the transform change for undo/redo
|
||||||
batchCmd.addSubCommand(cmd)
|
if (undoable) {
|
||||||
|
batchCmd.addSubCommand(new ChangeElementCommand(elem, changes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// NOTE: We intentionally do NOT call recalculateDimensions here because:
|
||||||
|
// 1. It reorders transforms (moves rotate before translate), changing the visual result
|
||||||
|
// 2. It recalculates rotation centers, causing elements to jump
|
||||||
|
// 3. The prepended group transform is already in the correct position
|
||||||
|
// Just leave the transforms as-is after prepending the group's transform
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove transform and make it undo-able
|
// remove transform and make it undo-able
|
||||||
@@ -1047,6 +1071,10 @@ const convertToGroup = elem => {
|
|||||||
svgCanvas.call('selected', [elem])
|
svgCanvas.call('selected', [elem])
|
||||||
} else if (dataStorage.has($elem, 'symbol')) {
|
} else if (dataStorage.has($elem, 'symbol')) {
|
||||||
elem = dataStorage.get($elem, 'symbol')
|
elem = dataStorage.get($elem, 'symbol')
|
||||||
|
if (!elem) {
|
||||||
|
warn('Unable to convert <use>: missing symbol reference', null, 'selected-elem')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ts = $elem.getAttribute('transform') || ''
|
ts = $elem.getAttribute('transform') || ''
|
||||||
const pos = {
|
const pos = {
|
||||||
@@ -1065,14 +1093,15 @@ const convertToGroup = elem => {
|
|||||||
// Not ideal, but works
|
// Not ideal, but works
|
||||||
ts += ' translate(' + (pos.x || 0) + ',' + (pos.y || 0) + ')'
|
ts += ' translate(' + (pos.x || 0) + ',' + (pos.y || 0) + ')'
|
||||||
|
|
||||||
const prev = $elem.previousElementSibling
|
const useParent = $elem.parentNode
|
||||||
|
const useNextSibling = $elem.nextSibling
|
||||||
|
|
||||||
// Remove <use> element
|
// Remove <use> element
|
||||||
batchCmd.addSubCommand(
|
batchCmd.addSubCommand(
|
||||||
new RemoveElementCommand(
|
new RemoveElementCommand(
|
||||||
$elem,
|
$elem,
|
||||||
$elem.nextElementSibling,
|
useNextSibling,
|
||||||
$elem.parentNode
|
useParent
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
$elem.remove()
|
$elem.remove()
|
||||||
@@ -1124,7 +1153,9 @@ const convertToGroup = elem => {
|
|||||||
// now give the g itself a new id
|
// now give the g itself a new id
|
||||||
g.id = svgCanvas.getNextId()
|
g.id = svgCanvas.getNextId()
|
||||||
|
|
||||||
prev.after(g)
|
if (useParent) {
|
||||||
|
useParent.insertBefore(g, useNextSibling)
|
||||||
|
}
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
if (!hasMore) {
|
if (!hasMore) {
|
||||||
@@ -1152,7 +1183,7 @@ const convertToGroup = elem => {
|
|||||||
try {
|
try {
|
||||||
recalculateDimensions(n)
|
recalculateDimensions(n)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
error('Error recalculating dimensions', e, 'selected-elem')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1173,7 +1204,7 @@ const convertToGroup = elem => {
|
|||||||
|
|
||||||
svgCanvas.addCommandToHistory(batchCmd)
|
svgCanvas.addCommandToHistory(batchCmd)
|
||||||
} else {
|
} else {
|
||||||
console.warn('Unexpected element to ungroup:', elem)
|
warn('Unexpected element to ungroup:', elem, 'selected-elem')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1197,7 +1228,16 @@ const ungroupSelectedElement = () => {
|
|||||||
}
|
}
|
||||||
if (g.tagName === 'use') {
|
if (g.tagName === 'use') {
|
||||||
// Somehow doesn't have data set, so retrieve
|
// Somehow doesn't have data set, so retrieve
|
||||||
const symbol = getElement(getHref(g).substr(1))
|
const href = getHref(g)
|
||||||
|
if (!href || !href.startsWith('#')) {
|
||||||
|
warn('Unexpected <use> without local reference:', g, 'selected-elem')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const symbol = getElement(href.slice(1))
|
||||||
|
if (!symbol) {
|
||||||
|
warn('Unexpected <use> without resolved reference:', g, 'selected-elem')
|
||||||
|
return
|
||||||
|
}
|
||||||
dataStorage.put(g, 'symbol', symbol)
|
dataStorage.put(g, 'symbol', symbol)
|
||||||
dataStorage.put(g, 'ref', symbol)
|
dataStorage.put(g, 'ref', symbol)
|
||||||
convertToGroup(g)
|
convertToGroup(g)
|
||||||
@@ -1281,7 +1321,7 @@ const updateCanvas = (w, h) => {
|
|||||||
height: svgCanvas.contentH * zoom,
|
height: svgCanvas.contentH * zoom,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
viewBox: '0 0 ' + svgCanvas.contentW + ' ' + svgCanvas.contentH
|
viewBox: `0 0 ${svgCanvas.contentW} ${svgCanvas.contentH}`
|
||||||
})
|
})
|
||||||
|
|
||||||
assignAttributes(bg, {
|
assignAttributes(bg, {
|
||||||
@@ -1301,7 +1341,7 @@ const updateCanvas = (w, h) => {
|
|||||||
|
|
||||||
svgCanvas.selectorManager.selectorParentGroup.setAttribute(
|
svgCanvas.selectorManager.selectorParentGroup.setAttribute(
|
||||||
'transform',
|
'transform',
|
||||||
'translate(' + x + ',' + y + ')'
|
`translate(${x},${y})`
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -409,8 +409,11 @@ const setRotationAngle = (val, preventUndo) => {
|
|||||||
cy,
|
cy,
|
||||||
transformListToTransform(tlist).matrix
|
transformListToTransform(tlist).matrix
|
||||||
)
|
)
|
||||||
|
// Safety check: if center coordinates are invalid (NaN), fall back to untransformed bbox center
|
||||||
|
const centerX = Number.isFinite(center.x) ? center.x : cx
|
||||||
|
const centerY = Number.isFinite(center.y) ? center.y : cy
|
||||||
const Rnc = svgCanvas.getSvgRoot().createSVGTransform()
|
const Rnc = svgCanvas.getSvgRoot().createSVGTransform()
|
||||||
Rnc.setRotate(val, center.x, center.y)
|
Rnc.setRotate(val, centerX, centerY)
|
||||||
if (tlist.numberOfItems) {
|
if (tlist.numberOfItems) {
|
||||||
tlist.insertItemBefore(Rnc, 0)
|
tlist.insertItemBefore(Rnc, 0)
|
||||||
} else {
|
} else {
|
||||||
@@ -424,13 +427,20 @@ const setRotationAngle = (val, preventUndo) => {
|
|||||||
// we need to undo it, then redo it so it can be undo-able! :)
|
// we need to undo it, then redo it so it can be undo-able! :)
|
||||||
// TODO: figure out how to make changes to transform list undo-able cross-browser?
|
// TODO: figure out how to make changes to transform list undo-able cross-browser?
|
||||||
let newTransform = elem.getAttribute('transform')
|
let newTransform = elem.getAttribute('transform')
|
||||||
|
|
||||||
// new transform is something like: 'rotate(5 1.39625e-8 -11)'
|
// new transform is something like: 'rotate(5 1.39625e-8 -11)'
|
||||||
// we round the x so it becomes 'rotate(5 0 -11)'
|
// we round the x so it becomes 'rotate(5 0 -11)'
|
||||||
if (newTransform) {
|
// Only do this manipulation if the first transform is actually a rotation
|
||||||
const newTransformArray = newTransform.split(/[ ,]+/)
|
if (newTransform && newTransform.startsWith('rotate(')) {
|
||||||
|
const match = newTransform.match(/^rotate\(([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)\)(.*)/)
|
||||||
|
if (match) {
|
||||||
|
const angle = Number.parseFloat(match[1])
|
||||||
const round = (num) => Math.round(Number(num) + Number.EPSILON)
|
const round = (num) => Math.round(Number(num) + Number.EPSILON)
|
||||||
const x = round(newTransformArray[1])
|
const x = round(match[2])
|
||||||
newTransform = `${newTransformArray[0]} ${x} ${newTransformArray[2]}`
|
const y = round(match[3])
|
||||||
|
const restOfTransform = match[4] || '' // Preserve any transforms after the rotate
|
||||||
|
newTransform = `rotate(${angle} ${x} ${y})${restOfTransform}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oldTransform) {
|
if (oldTransform) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { jsPDF as JsPDF } from 'jspdf'
|
import { jsPDF as JsPDF } from 'jspdf'
|
||||||
import 'svg2pdf.js'
|
import 'svg2pdf.js'
|
||||||
import * as history from './history.js'
|
import * as history from './history.js'
|
||||||
|
import { error } from '../common/logger.js'
|
||||||
import {
|
import {
|
||||||
text2xml,
|
text2xml,
|
||||||
cleanupElement,
|
cleanupElement,
|
||||||
@@ -131,7 +132,7 @@ const svgToString = (elem, indent) => {
|
|||||||
const nsMap = svgCanvas.getNsMap()
|
const nsMap = svgCanvas.getNsMap()
|
||||||
const out = []
|
const out = []
|
||||||
const unit = curConfig.baseUnit
|
const unit = curConfig.baseUnit
|
||||||
const unitRe = new RegExp('^-?[\\d\\.]+' + unit + '$')
|
const unitRe = new RegExp(`^-?[\\d\\.]+${unit}$`)
|
||||||
|
|
||||||
if (elem) {
|
if (elem) {
|
||||||
cleanupElement(elem)
|
cleanupElement(elem)
|
||||||
@@ -164,7 +165,10 @@ const svgToString = (elem, indent) => {
|
|||||||
// }
|
// }
|
||||||
if (curConfig.dynamicOutput) {
|
if (curConfig.dynamicOutput) {
|
||||||
vb = elem.getAttribute('viewBox')
|
vb = elem.getAttribute('viewBox')
|
||||||
out.push(' viewBox="' + vb + '" xmlns="' + NS.SVG + '"')
|
if (!vb) {
|
||||||
|
vb = [0, 0, res.w, res.h].join(' ')
|
||||||
|
}
|
||||||
|
out.push(` viewBox="${vb}" xmlns="${NS.SVG}"`)
|
||||||
} else {
|
} else {
|
||||||
if (unit !== 'px') {
|
if (unit !== 'px') {
|
||||||
res.w = convertUnit(res.w, unit) + unit
|
res.w = convertUnit(res.w, unit) + unit
|
||||||
@@ -193,14 +197,14 @@ const svgToString = (elem, indent) => {
|
|||||||
nsMap[uri] !== 'xml'
|
nsMap[uri] !== 'xml'
|
||||||
) {
|
) {
|
||||||
nsuris[uri] = true
|
nsuris[uri] = true
|
||||||
out.push(' xmlns:' + nsMap[uri] + '="' + uri + '"')
|
out.push(` xmlns:${nsMap[uri]}="${uri}"`)
|
||||||
}
|
}
|
||||||
if (el.attributes.length > 0) {
|
if (el.attributes.length > 0) {
|
||||||
for (const [, attr] of Object.entries(el.attributes)) {
|
for (const [, attr] of Object.entries(el.attributes)) {
|
||||||
const u = attr.namespaceURI
|
const u = attr.namespaceURI
|
||||||
if (u && !nsuris[u] && nsMap[u] !== 'xmlns' && nsMap[u] !== 'xml') {
|
if (u && !nsuris[u] && nsMap[u] !== 'xmlns' && nsMap[u] !== 'xml') {
|
||||||
nsuris[u] = true
|
nsuris[u] = true
|
||||||
out.push(' xmlns:' + nsMap[u] + '="' + u + '"')
|
out.push(` xmlns:${nsMap[u]}="${u}"`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -469,7 +473,7 @@ const setSvgString = (xmlString, preventUndo) => {
|
|||||||
|
|
||||||
Object.entries(ids).forEach(([key, value]) => {
|
Object.entries(ids).forEach(([key, value]) => {
|
||||||
if (value > 1) {
|
if (value > 1) {
|
||||||
const nodes = content.querySelectorAll('[id="' + key + '"]')
|
const nodes = content.querySelectorAll(`[id="${key}"]`)
|
||||||
for (let i = 1; i < nodes.length; i++) {
|
for (let i = 1; i < nodes.length; i++) {
|
||||||
nodes[i].setAttribute('id', svgCanvas.getNextId())
|
nodes[i].setAttribute('id', svgCanvas.getNextId())
|
||||||
}
|
}
|
||||||
@@ -525,14 +529,20 @@ const setSvgString = (xmlString, preventUndo) => {
|
|||||||
if (content.getAttribute('viewBox')) {
|
if (content.getAttribute('viewBox')) {
|
||||||
const viBox = content.getAttribute('viewBox')
|
const viBox = content.getAttribute('viewBox')
|
||||||
const vb = viBox.split(/[ ,]+/)
|
const vb = viBox.split(/[ ,]+/)
|
||||||
attrs.width = vb[2]
|
const vbWidth = Number(vb[2])
|
||||||
attrs.height = vb[3]
|
const vbHeight = Number(vb[3])
|
||||||
|
if (Number.isFinite(vbWidth)) {
|
||||||
|
attrs.width = vbWidth
|
||||||
|
}
|
||||||
|
if (Number.isFinite(vbHeight)) {
|
||||||
|
attrs.height = vbHeight
|
||||||
|
}
|
||||||
// handle content that doesn't have a viewBox
|
// handle content that doesn't have a viewBox
|
||||||
} else {
|
} else {
|
||||||
;['width', 'height'].forEach(dim => {
|
;['width', 'height'].forEach(dim => {
|
||||||
// Set to 100 if not given
|
// Set to 100 if not given
|
||||||
const val = content.getAttribute(dim) || '100%'
|
const val = content.getAttribute(dim) || '100%'
|
||||||
if (String(val).substr(-1) === '%') {
|
if (String(val).slice(-1) === '%') {
|
||||||
// Use user units if percentage given
|
// Use user units if percentage given
|
||||||
percs = true
|
percs = true
|
||||||
} else {
|
} else {
|
||||||
@@ -558,16 +568,25 @@ const setSvgString = (xmlString, preventUndo) => {
|
|||||||
// Percentage width/height, so let's base it on visible elements
|
// Percentage width/height, so let's base it on visible elements
|
||||||
if (percs) {
|
if (percs) {
|
||||||
const bb = getStrokedBBoxDefaultVisible()
|
const bb = getStrokedBBoxDefaultVisible()
|
||||||
|
if (bb && typeof bb === 'object') {
|
||||||
attrs.width = bb.width + bb.x
|
attrs.width = bb.width + bb.x
|
||||||
attrs.height = bb.height + bb.y
|
attrs.height = bb.height + bb.y
|
||||||
|
} else {
|
||||||
|
if (attrs.width === null || attrs.width === undefined) {
|
||||||
|
attrs.width = 100
|
||||||
|
}
|
||||||
|
if (attrs.height === null || attrs.height === undefined) {
|
||||||
|
attrs.height = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Just in case negative numbers are given or
|
// Just in case negative numbers are given or
|
||||||
// result from the percs calculation
|
// result from the percs calculation
|
||||||
if (attrs.width <= 0) {
|
if (!Number.isFinite(attrs.width) || attrs.width <= 0) {
|
||||||
attrs.width = 100
|
attrs.width = 100
|
||||||
}
|
}
|
||||||
if (attrs.height <= 0) {
|
if (!Number.isFinite(attrs.height) || attrs.height <= 0) {
|
||||||
attrs.height = 100
|
attrs.height = 100
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,7 +615,7 @@ const setSvgString = (xmlString, preventUndo) => {
|
|||||||
if (!preventUndo) svgCanvas.addCommandToHistory(batchCmd)
|
if (!preventUndo) svgCanvas.addCommandToHistory(batchCmd)
|
||||||
svgCanvas.call('sourcechanged', [svgCanvas.getSvgContent()])
|
svgCanvas.call('sourcechanged', [svgCanvas.getSvgContent()])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
error('Error setting SVG string', e, 'svg-exec')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,16 +685,26 @@ const importSvgString = (xmlString, preserveDimension) => {
|
|||||||
|
|
||||||
// TODO: properly handle preserveAspectRatio
|
// TODO: properly handle preserveAspectRatio
|
||||||
const // canvasw = +svgContent.getAttribute('width'),
|
const // canvasw = +svgContent.getAttribute('width'),
|
||||||
canvash = Number(svgCanvas.getSvgContent().getAttribute('height'))
|
rawCanvash = Number(svgCanvas.getSvgContent().getAttribute('height'))
|
||||||
|
const canvash =
|
||||||
|
Number.isFinite(rawCanvash) && rawCanvash > 0
|
||||||
|
? rawCanvash
|
||||||
|
: (Number(svgCanvas.getCurConfig().dimensions?.[1]) || 100)
|
||||||
// imported content should be 1/3 of the canvas on its largest dimension
|
// imported content should be 1/3 of the canvas on its largest dimension
|
||||||
|
|
||||||
|
const vbWidth = vb[2]
|
||||||
|
const vbHeight = vb[3]
|
||||||
|
const importW = Number.isFinite(vbWidth) && vbWidth > 0 ? vbWidth : (innerw > 0 ? innerw : 100)
|
||||||
|
const importH = Number.isFinite(vbHeight) && vbHeight > 0 ? vbHeight : (innerh > 0 ? innerh : 100)
|
||||||
|
const safeImportW = Number.isFinite(importW) && importW > 0 ? importW : 100
|
||||||
|
const safeImportH = Number.isFinite(importH) && importH > 0 ? importH : 100
|
||||||
ts =
|
ts =
|
||||||
innerh > innerw
|
safeImportH > safeImportW
|
||||||
? 'scale(' + canvash / 3 / vb[3] + ')'
|
? 'scale(' + canvash / 3 / safeImportH + ')'
|
||||||
: 'scale(' + canvash / 3 / vb[2] + ')'
|
: 'scale(' + canvash / 3 / safeImportW + ')'
|
||||||
|
|
||||||
// Hack to make recalculateDimensions understand how to scale
|
// Hack to make recalculateDimensions understand how to scale
|
||||||
ts = 'translate(0) ' + ts + ' translate(0)'
|
ts = `translate(0) ${ts} translate(0)`
|
||||||
|
|
||||||
symbol = svgCanvas.getDOMDocument().createElementNS(NS.SVG, 'symbol')
|
symbol = svgCanvas.getDOMDocument().createElementNS(NS.SVG, 'symbol')
|
||||||
const defs = findDefs()
|
const defs = findDefs()
|
||||||
@@ -738,7 +767,7 @@ const importSvgString = (xmlString, preserveDimension) => {
|
|||||||
svgCanvas.addCommandToHistory(batchCmd)
|
svgCanvas.addCommandToHistory(batchCmd)
|
||||||
svgCanvas.call('changed', [svgCanvas.getSvgContent()])
|
svgCanvas.call('changed', [svgCanvas.getSvgContent()])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
error('Error importing SVG string', e, 'svg-exec')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -865,8 +894,8 @@ const convertImagesToBase64 = async svgElement => {
|
|||||||
}
|
}
|
||||||
reader.readAsDataURL(blob)
|
reader.readAsDataURL(blob)
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch image:', error)
|
error('Failed to fetch image', err, 'svg-exec')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -905,10 +934,14 @@ const rasterExport = (
|
|||||||
|
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
const ctx = canvas.getContext('2d')
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) {
|
||||||
|
reject(new Error('Canvas 2D context not available'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const width = svgElement.clientWidth || svgElement.getAttribute('width')
|
const res = svgCanvas.getResolution()
|
||||||
const height =
|
const width = res.w
|
||||||
svgElement.clientHeight || svgElement.getAttribute('height')
|
const height = res.h
|
||||||
canvas.width = width
|
canvas.width = width
|
||||||
canvas.height = height
|
canvas.height = height
|
||||||
|
|
||||||
@@ -1013,7 +1046,7 @@ const exportPDF = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
img.onerror = err => {
|
img.onerror = err => {
|
||||||
console.error('Failed to load SVG into image element:', err)
|
error('Failed to load SVG into image element', err, 'svg-exec')
|
||||||
reject(err)
|
reject(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1112,7 +1145,7 @@ const uniquifyElemsMethod = g => {
|
|||||||
let j = attrs.length
|
let j = attrs.length
|
||||||
while (j--) {
|
while (j--) {
|
||||||
const attr = attrs[j]
|
const attr = attrs[j]
|
||||||
attr.ownerElement.setAttribute(attr.name, 'url(#' + newid + ')')
|
attr.ownerElement.setAttribute(attr.name, `url(#${newid})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// remap all href attributes
|
// remap all href attributes
|
||||||
@@ -1142,7 +1175,11 @@ const setUseDataMethod = parent => {
|
|||||||
|
|
||||||
Array.prototype.forEach.call(elems, (el, _) => {
|
Array.prototype.forEach.call(elems, (el, _) => {
|
||||||
const dataStorage = svgCanvas.getDataStorage()
|
const dataStorage = svgCanvas.getDataStorage()
|
||||||
const id = svgCanvas.getHref(el).substr(1)
|
const href = svgCanvas.getHref(el)
|
||||||
|
if (!href || !href.startsWith('#')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = href.substr(1)
|
||||||
const refElem = svgCanvas.getElement(id)
|
const refElem = svgCanvas.getElement(id)
|
||||||
if (!refElem) {
|
if (!refElem) {
|
||||||
return
|
return
|
||||||
@@ -1301,6 +1338,41 @@ const convertGradientsMethod = elem => {
|
|||||||
grad.setAttribute('x2', (gCoords.x2 - bb.x) / bb.width)
|
grad.setAttribute('x2', (gCoords.x2 - bb.x) / bb.width)
|
||||||
grad.setAttribute('y2', (gCoords.y2 - bb.y) / bb.height)
|
grad.setAttribute('y2', (gCoords.y2 - bb.y) / bb.height)
|
||||||
grad.removeAttribute('gradientUnits')
|
grad.removeAttribute('gradientUnits')
|
||||||
|
} else if (grad.tagName === 'radialGradient') {
|
||||||
|
const getNum = (value, fallback) => {
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isFinite(num) ? num : fallback
|
||||||
|
}
|
||||||
|
let cx = getNum(grad.getAttribute('cx'), 0.5)
|
||||||
|
let cy = getNum(grad.getAttribute('cy'), 0.5)
|
||||||
|
let r = getNum(grad.getAttribute('r'), 0.5)
|
||||||
|
let fx = getNum(grad.getAttribute('fx'), cx)
|
||||||
|
let fy = getNum(grad.getAttribute('fy'), cy)
|
||||||
|
|
||||||
|
// If has transform, convert
|
||||||
|
const tlist = getTransformList(grad)
|
||||||
|
if (tlist?.numberOfItems > 0) {
|
||||||
|
const m = transformListToTransform(tlist).matrix
|
||||||
|
const cpt = transformPoint(cx, cy, m)
|
||||||
|
const fpt = transformPoint(fx, fy, m)
|
||||||
|
const rpt = transformPoint(cx + r, cy, m)
|
||||||
|
cx = cpt.x
|
||||||
|
cy = cpt.y
|
||||||
|
fx = fpt.x
|
||||||
|
fy = fpt.y
|
||||||
|
r = Math.hypot(rpt.x - cpt.x, rpt.y - cpt.y)
|
||||||
|
grad.removeAttribute('gradientTransform')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bb.width || !bb.height) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
grad.setAttribute('cx', (cx - bb.x) / bb.width)
|
||||||
|
grad.setAttribute('cy', (cy - bb.y) / bb.height)
|
||||||
|
grad.setAttribute('fx', (fx - bb.x) / bb.width)
|
||||||
|
grad.setAttribute('fy', (fy - bb.y) / bb.height)
|
||||||
|
grad.setAttribute('r', r / Math.max(bb.width, bb.height))
|
||||||
|
grad.removeAttribute('gradientUnits')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { text2xml } from './utilities.js'
|
|||||||
* @param {ArgumentsArray} dimensions - dimensions of width and height
|
* @param {ArgumentsArray} dimensions - dimensions of width and height
|
||||||
* @returns {svgRootElement}
|
* @returns {svgRootElement}
|
||||||
*/
|
*/
|
||||||
export const svgRootElement = function (svgdoc, dimensions) {
|
export const svgRootElement = (svgdoc, dimensions) => {
|
||||||
return svgdoc.importNode(
|
return svgdoc.importNode(
|
||||||
text2xml(
|
text2xml(
|
||||||
`<svg id="svgroot" xmlns="${NS.SVG}" xlinkns="${NS.XLINK}" width="${dimensions[0]}"
|
`<svg id="svgroot" xmlns="${NS.SVG}" xlinkns="${NS.XLINK}" width="${dimensions[0]}"
|
||||||
|
|||||||
@@ -28,68 +28,69 @@ export const init = canvas => {
|
|||||||
/**
|
/**
|
||||||
* Group: Text edit functions
|
* Group: Text edit functions
|
||||||
* Functions relating to editing text elements.
|
* Functions relating to editing text elements.
|
||||||
* @namespace {PlainObject} textActions
|
* @class TextActions
|
||||||
* @memberof module:svgcanvas.SvgCanvas#
|
* @memberof module:svgcanvas.SvgCanvas#
|
||||||
*/
|
*/
|
||||||
export const textActionsMethod = (function () {
|
class TextActions {
|
||||||
let curtext
|
#curtext = null
|
||||||
let textinput
|
#textinput = null
|
||||||
let cursor
|
#cursor = null
|
||||||
let selblock
|
#selblock = null
|
||||||
let blinker
|
#blinker = null
|
||||||
let chardata = []
|
#chardata = []
|
||||||
let textbb // , transbb;
|
#textbb = null // , transbb;
|
||||||
let matrix
|
#matrix = null
|
||||||
let lastX
|
#lastX = null
|
||||||
let lastY
|
#lastY = null
|
||||||
let allowDbl
|
#allowDbl = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {Integer} index
|
* @param {Integer} index
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
|
* @private
|
||||||
*/
|
*/
|
||||||
function setCursor (index) {
|
#setCursor = (index = undefined) => {
|
||||||
const empty = textinput.value === ''
|
const empty = this.#textinput.value === ''
|
||||||
textinput.focus()
|
this.#textinput.focus()
|
||||||
|
|
||||||
if (!arguments.length) {
|
if (index === undefined) {
|
||||||
if (empty) {
|
if (empty) {
|
||||||
index = 0
|
index = 0
|
||||||
} else {
|
} else {
|
||||||
if (textinput.selectionEnd !== textinput.selectionStart) {
|
if (this.#textinput.selectionEnd !== this.#textinput.selectionStart) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
index = textinput.selectionEnd
|
index = this.#textinput.selectionEnd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const charbb = chardata[index]
|
const charbb = this.#chardata[index]
|
||||||
if (!empty) {
|
if (!empty) {
|
||||||
textinput.setSelectionRange(index, index)
|
this.#textinput.setSelectionRange(index, index)
|
||||||
}
|
}
|
||||||
cursor = getElement('text_cursor')
|
this.#cursor = getElement('text_cursor')
|
||||||
if (!cursor) {
|
if (!this.#cursor) {
|
||||||
cursor = document.createElementNS(NS.SVG, 'line')
|
this.#cursor = document.createElementNS(NS.SVG, 'line')
|
||||||
assignAttributes(cursor, {
|
assignAttributes(this.#cursor, {
|
||||||
id: 'text_cursor',
|
id: 'text_cursor',
|
||||||
stroke: '#333',
|
stroke: '#333',
|
||||||
'stroke-width': 1
|
'stroke-width': 1
|
||||||
})
|
})
|
||||||
getElement('selectorParentGroup').append(cursor)
|
getElement('selectorParentGroup').append(this.#cursor)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!blinker) {
|
if (!this.#blinker) {
|
||||||
blinker = setInterval(function () {
|
this.#blinker = setInterval(() => {
|
||||||
const show = cursor.getAttribute('display') === 'none'
|
const show = this.#cursor.getAttribute('display') === 'none'
|
||||||
cursor.setAttribute('display', show ? 'inline' : 'none')
|
this.#cursor.setAttribute('display', show ? 'inline' : 'none')
|
||||||
}, 600)
|
}, 600)
|
||||||
}
|
}
|
||||||
|
|
||||||
const startPt = ptToScreen(charbb.x, textbb.y)
|
const startPt = this.#ptToScreen(charbb.x, this.#textbb.y)
|
||||||
const endPt = ptToScreen(charbb.x, textbb.y + textbb.height)
|
const endPt = this.#ptToScreen(charbb.x, this.#textbb.y + this.#textbb.height)
|
||||||
|
|
||||||
assignAttributes(cursor, {
|
assignAttributes(this.#cursor, {
|
||||||
x1: startPt.x,
|
x1: startPt.x,
|
||||||
y1: startPt.y,
|
y1: startPt.y,
|
||||||
x2: endPt.x,
|
x2: endPt.x,
|
||||||
@@ -98,8 +99,8 @@ export const textActionsMethod = (function () {
|
|||||||
display: 'inline'
|
display: 'inline'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (selblock) {
|
if (this.#selblock) {
|
||||||
selblock.setAttribute('d', '')
|
this.#selblock.setAttribute('d', '')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,40 +110,41 @@ export const textActionsMethod = (function () {
|
|||||||
* @param {Integer} end
|
* @param {Integer} end
|
||||||
* @param {boolean} skipInput
|
* @param {boolean} skipInput
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
|
* @private
|
||||||
*/
|
*/
|
||||||
function setSelection (start, end, skipInput) {
|
#setSelection = (start, end, skipInput) => {
|
||||||
if (start === end) {
|
if (start === end) {
|
||||||
setCursor(end)
|
this.#setCursor(end)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skipInput) {
|
if (!skipInput) {
|
||||||
textinput.setSelectionRange(start, end)
|
this.#textinput.setSelectionRange(start, end)
|
||||||
}
|
}
|
||||||
|
|
||||||
selblock = getElement('text_selectblock')
|
this.#selblock = getElement('text_selectblock')
|
||||||
if (!selblock) {
|
if (!this.#selblock) {
|
||||||
selblock = document.createElementNS(NS.SVG, 'path')
|
this.#selblock = document.createElementNS(NS.SVG, 'path')
|
||||||
assignAttributes(selblock, {
|
assignAttributes(this.#selblock, {
|
||||||
id: 'text_selectblock',
|
id: 'text_selectblock',
|
||||||
fill: 'green',
|
fill: 'green',
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
style: 'pointer-events:none'
|
style: 'pointer-events:none'
|
||||||
})
|
})
|
||||||
getElement('selectorParentGroup').append(selblock)
|
getElement('selectorParentGroup').append(this.#selblock)
|
||||||
}
|
}
|
||||||
|
|
||||||
const startbb = chardata[start]
|
const startbb = this.#chardata[start]
|
||||||
const endbb = chardata[end]
|
const endbb = this.#chardata[end]
|
||||||
|
|
||||||
cursor.setAttribute('visibility', 'hidden')
|
this.#cursor.setAttribute('visibility', 'hidden')
|
||||||
|
|
||||||
const tl = ptToScreen(startbb.x, textbb.y)
|
const tl = this.#ptToScreen(startbb.x, this.#textbb.y)
|
||||||
const tr = ptToScreen(startbb.x + (endbb.x - startbb.x), textbb.y)
|
const tr = this.#ptToScreen(startbb.x + (endbb.x - startbb.x), this.#textbb.y)
|
||||||
const bl = ptToScreen(startbb.x, textbb.y + textbb.height)
|
const bl = this.#ptToScreen(startbb.x, this.#textbb.y + this.#textbb.height)
|
||||||
const br = ptToScreen(
|
const br = this.#ptToScreen(
|
||||||
startbb.x + (endbb.x - startbb.x),
|
startbb.x + (endbb.x - startbb.x),
|
||||||
textbb.y + textbb.height
|
this.#textbb.y + this.#textbb.height
|
||||||
)
|
)
|
||||||
|
|
||||||
const dstr =
|
const dstr =
|
||||||
@@ -164,7 +166,7 @@ export const textActionsMethod = (function () {
|
|||||||
bl.y +
|
bl.y +
|
||||||
'z'
|
'z'
|
||||||
|
|
||||||
assignAttributes(selblock, {
|
assignAttributes(this.#selblock, {
|
||||||
d: dstr,
|
d: dstr,
|
||||||
display: 'inline'
|
display: 'inline'
|
||||||
})
|
})
|
||||||
@@ -175,29 +177,30 @@ export const textActionsMethod = (function () {
|
|||||||
* @param {Float} mouseX
|
* @param {Float} mouseX
|
||||||
* @param {Float} mouseY
|
* @param {Float} mouseY
|
||||||
* @returns {Integer}
|
* @returns {Integer}
|
||||||
|
* @private
|
||||||
*/
|
*/
|
||||||
function getIndexFromPoint (mouseX, mouseY) {
|
#getIndexFromPoint = (mouseX, mouseY) => {
|
||||||
// Position cursor here
|
// Position cursor here
|
||||||
const pt = svgCanvas.getSvgRoot().createSVGPoint()
|
const pt = svgCanvas.getSvgRoot().createSVGPoint()
|
||||||
pt.x = mouseX
|
pt.x = mouseX
|
||||||
pt.y = mouseY
|
pt.y = mouseY
|
||||||
|
|
||||||
// No content, so return 0
|
// No content, so return 0
|
||||||
if (chardata.length === 1) {
|
if (this.#chardata.length === 1) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
// Determine if cursor should be on left or right of character
|
// Determine if cursor should be on left or right of character
|
||||||
let charpos = curtext.getCharNumAtPosition(pt)
|
let charpos = this.#curtext.getCharNumAtPosition(pt)
|
||||||
if (charpos < 0) {
|
if (charpos < 0) {
|
||||||
// Out of text range, look at mouse coords
|
// Out of text range, look at mouse coords
|
||||||
charpos = chardata.length - 2
|
charpos = this.#chardata.length - 2
|
||||||
if (mouseX <= chardata[0].x) {
|
if (mouseX <= this.#chardata[0].x) {
|
||||||
charpos = 0
|
charpos = 0
|
||||||
}
|
}
|
||||||
} else if (charpos >= chardata.length - 2) {
|
} else if (charpos >= this.#chardata.length - 2) {
|
||||||
charpos = chardata.length - 2
|
charpos = this.#chardata.length - 2
|
||||||
}
|
}
|
||||||
const charbb = chardata[charpos]
|
const charbb = this.#chardata[charpos]
|
||||||
const mid = charbb.x + charbb.width / 2
|
const mid = charbb.x + charbb.width / 2
|
||||||
if (mouseX > mid) {
|
if (mouseX > mid) {
|
||||||
charpos++
|
charpos++
|
||||||
@@ -210,9 +213,10 @@ export const textActionsMethod = (function () {
|
|||||||
* @param {Float} mouseX
|
* @param {Float} mouseX
|
||||||
* @param {Float} mouseY
|
* @param {Float} mouseY
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
|
* @private
|
||||||
*/
|
*/
|
||||||
function setCursorFromPoint (mouseX, mouseY) {
|
#setCursorFromPoint = (mouseX, mouseY) => {
|
||||||
setCursor(getIndexFromPoint(mouseX, mouseY))
|
this.#setCursor(this.#getIndexFromPoint(mouseX, mouseY))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -221,14 +225,15 @@ export const textActionsMethod = (function () {
|
|||||||
* @param {Float} y
|
* @param {Float} y
|
||||||
* @param {boolean} apply
|
* @param {boolean} apply
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
|
* @private
|
||||||
*/
|
*/
|
||||||
function setEndSelectionFromPoint (x, y, apply) {
|
#setEndSelectionFromPoint = (x, y, apply) => {
|
||||||
const i1 = textinput.selectionStart
|
const i1 = this.#textinput.selectionStart
|
||||||
const i2 = getIndexFromPoint(x, y)
|
const i2 = this.#getIndexFromPoint(x, y)
|
||||||
|
|
||||||
const start = Math.min(i1, i2)
|
const start = Math.min(i1, i2)
|
||||||
const end = Math.max(i1, i2)
|
const end = Math.max(i1, i2)
|
||||||
setSelection(start, end, !apply)
|
this.#setSelection(start, end, !apply)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -236,8 +241,9 @@ export const textActionsMethod = (function () {
|
|||||||
* @param {Float} xIn
|
* @param {Float} xIn
|
||||||
* @param {Float} yIn
|
* @param {Float} yIn
|
||||||
* @returns {module:math.XYObject}
|
* @returns {module:math.XYObject}
|
||||||
|
* @private
|
||||||
*/
|
*/
|
||||||
function screenToPt (xIn, yIn) {
|
#screenToPt = (xIn, yIn) => {
|
||||||
const out = {
|
const out = {
|
||||||
x: xIn,
|
x: xIn,
|
||||||
y: yIn
|
y: yIn
|
||||||
@@ -246,8 +252,8 @@ export const textActionsMethod = (function () {
|
|||||||
out.x /= zoom
|
out.x /= zoom
|
||||||
out.y /= zoom
|
out.y /= zoom
|
||||||
|
|
||||||
if (matrix) {
|
if (this.#matrix) {
|
||||||
const pt = transformPoint(out.x, out.y, matrix.inverse())
|
const pt = transformPoint(out.x, out.y, this.#matrix.inverse())
|
||||||
out.x = pt.x
|
out.x = pt.x
|
||||||
out.y = pt.y
|
out.y = pt.y
|
||||||
}
|
}
|
||||||
@@ -260,15 +266,16 @@ export const textActionsMethod = (function () {
|
|||||||
* @param {Float} xIn
|
* @param {Float} xIn
|
||||||
* @param {Float} yIn
|
* @param {Float} yIn
|
||||||
* @returns {module:math.XYObject}
|
* @returns {module:math.XYObject}
|
||||||
|
* @private
|
||||||
*/
|
*/
|
||||||
function ptToScreen (xIn, yIn) {
|
#ptToScreen = (xIn, yIn) => {
|
||||||
const out = {
|
const out = {
|
||||||
x: xIn,
|
x: xIn,
|
||||||
y: yIn
|
y: yIn
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matrix) {
|
if (this.#matrix) {
|
||||||
const pt = transformPoint(out.x, out.y, matrix)
|
const pt = transformPoint(out.x, out.y, this.#matrix)
|
||||||
out.x = pt.x
|
out.x = pt.x
|
||||||
out.y = pt.y
|
out.y = pt.y
|
||||||
}
|
}
|
||||||
@@ -283,43 +290,44 @@ export const textActionsMethod = (function () {
|
|||||||
*
|
*
|
||||||
* @param {Event} evt
|
* @param {Event} evt
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
|
* @private
|
||||||
*/
|
*/
|
||||||
function selectAll (evt) {
|
#selectAll = (evt) => {
|
||||||
setSelection(0, curtext.textContent.length)
|
this.#setSelection(0, this.#curtext.textContent.length)
|
||||||
evt.target.removeEventListener('click', selectAll)
|
evt.target.removeEventListener('click', this.#selectAll)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {Event} evt
|
* @param {Event} evt
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
|
* @private
|
||||||
*/
|
*/
|
||||||
function selectWord (evt) {
|
#selectWord = (evt) => {
|
||||||
if (!allowDbl || !curtext) {
|
if (!this.#allowDbl || !this.#curtext) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const zoom = svgCanvas.getZoom()
|
const zoom = svgCanvas.getZoom()
|
||||||
const ept = transformPoint(evt.pageX, evt.pageY, svgCanvas.getrootSctm())
|
const ept = transformPoint(evt.pageX, evt.pageY, svgCanvas.getrootSctm())
|
||||||
const mouseX = ept.x * zoom
|
const mouseX = ept.x * zoom
|
||||||
const mouseY = ept.y * zoom
|
const mouseY = ept.y * zoom
|
||||||
const pt = screenToPt(mouseX, mouseY)
|
const pt = this.#screenToPt(mouseX, mouseY)
|
||||||
|
|
||||||
const index = getIndexFromPoint(pt.x, pt.y)
|
const index = this.#getIndexFromPoint(pt.x, pt.y)
|
||||||
const str = curtext.textContent
|
const str = this.#curtext.textContent
|
||||||
const first = str.substr(0, index).replace(/[a-z\d]+$/i, '').length
|
const first = str.slice(0, index).replace(/[a-z\d]+$/i, '').length
|
||||||
const m = str.substr(index).match(/^[a-z\d]+/i)
|
const m = str.slice(index).match(/^[a-z\d]+/i)
|
||||||
const last = (m ? m[0].length : 0) + index
|
const last = (m ? m[0].length : 0) + index
|
||||||
setSelection(first, last)
|
this.#setSelection(first, last)
|
||||||
|
|
||||||
// Set tripleclick
|
// Set tripleclick
|
||||||
svgCanvas.$click(evt.target, selectAll)
|
svgCanvas.$click(evt.target, this.#selectAll)
|
||||||
|
|
||||||
setTimeout(function () {
|
setTimeout(() => {
|
||||||
evt.target.removeEventListener('click', selectAll)
|
evt.target.removeEventListener('click', this.#selectAll)
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
return /** @lends module:svgcanvas.SvgCanvas#textActions */ {
|
|
||||||
/**
|
/**
|
||||||
* @param {Element} target
|
* @param {Element} target
|
||||||
* @param {Float} x
|
* @param {Float} x
|
||||||
@@ -327,17 +335,19 @@ export const textActionsMethod = (function () {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
select (target, x, y) {
|
select (target, x, y) {
|
||||||
curtext = target
|
this.#curtext = target
|
||||||
svgCanvas.textActions.toEditMode(x, y)
|
svgCanvas.textActions.toEditMode(x, y)
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Element} elem
|
* @param {Element} elem
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
start (elem) {
|
start (elem) {
|
||||||
curtext = elem
|
this.#curtext = elem
|
||||||
svgCanvas.textActions.toEditMode()
|
svgCanvas.textActions.toEditMode()
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {external:MouseEvent} evt
|
* @param {external:MouseEvent} evt
|
||||||
* @param {Element} mouseTarget
|
* @param {Element} mouseTarget
|
||||||
@@ -346,24 +356,26 @@ export const textActionsMethod = (function () {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
mouseDown (evt, mouseTarget, startX, startY) {
|
mouseDown (evt, mouseTarget, startX, startY) {
|
||||||
const pt = screenToPt(startX, startY)
|
const pt = this.#screenToPt(startX, startY)
|
||||||
|
|
||||||
textinput.focus()
|
this.#textinput.focus()
|
||||||
setCursorFromPoint(pt.x, pt.y)
|
this.#setCursorFromPoint(pt.x, pt.y)
|
||||||
lastX = startX
|
this.#lastX = startX
|
||||||
lastY = startY
|
this.#lastY = startY
|
||||||
|
|
||||||
// TODO: Find way to block native selection
|
// TODO: Find way to block native selection
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Float} mouseX
|
* @param {Float} mouseX
|
||||||
* @param {Float} mouseY
|
* @param {Float} mouseY
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
mouseMove (mouseX, mouseY) {
|
mouseMove (mouseX, mouseY) {
|
||||||
const pt = screenToPt(mouseX, mouseY)
|
const pt = this.#screenToPt(mouseX, mouseY)
|
||||||
setEndSelectionFromPoint(pt.x, pt.y)
|
this.#setEndSelectionFromPoint(pt.x, pt.y)
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {external:MouseEvent} evt
|
* @param {external:MouseEvent} evt
|
||||||
* @param {Float} mouseX
|
* @param {Float} mouseX
|
||||||
@@ -371,9 +383,9 @@ export const textActionsMethod = (function () {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
mouseUp (evt, mouseX, mouseY) {
|
mouseUp (evt, mouseX, mouseY) {
|
||||||
const pt = screenToPt(mouseX, mouseY)
|
const pt = this.#screenToPt(mouseX, mouseY)
|
||||||
|
|
||||||
setEndSelectionFromPoint(pt.x, pt.y, true)
|
this.#setEndSelectionFromPoint(pt.x, pt.y, true)
|
||||||
|
|
||||||
// TODO: Find a way to make this work: Use transformed BBox instead of evt.target
|
// TODO: Find a way to make this work: Use transformed BBox instead of evt.target
|
||||||
// if (lastX === mouseX && lastY === mouseY
|
// if (lastX === mouseX && lastY === mouseY
|
||||||
@@ -382,54 +394,58 @@ export const textActionsMethod = (function () {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
if (
|
if (
|
||||||
evt.target !== curtext &&
|
evt.target !== this.#curtext &&
|
||||||
mouseX < lastX + 2 &&
|
mouseX < this.#lastX + 2 &&
|
||||||
mouseX > lastX - 2 &&
|
mouseX > this.#lastX - 2 &&
|
||||||
mouseY < lastY + 2 &&
|
mouseY < this.#lastY + 2 &&
|
||||||
mouseY > lastY - 2
|
mouseY > this.#lastY - 2
|
||||||
) {
|
) {
|
||||||
svgCanvas.textActions.toSelectMode(true)
|
svgCanvas.textActions.toSelectMode(true)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @function
|
|
||||||
* @param {Integer} index
|
* @param {Integer} index
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
setCursor,
|
setCursor (index) {
|
||||||
|
this.#setCursor(index)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Float} x
|
* @param {Float} x
|
||||||
* @param {Float} y
|
* @param {Float} y
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
toEditMode (x, y) {
|
toEditMode (x, y) {
|
||||||
allowDbl = false
|
this.#allowDbl = false
|
||||||
svgCanvas.setCurrentMode('textedit')
|
svgCanvas.setCurrentMode('textedit')
|
||||||
svgCanvas.selectorManager.requestSelector(curtext).showGrips(false)
|
svgCanvas.selectorManager.requestSelector(this.#curtext).showGrips(false)
|
||||||
// Make selector group accept clicks
|
// Make selector group accept clicks
|
||||||
/* const selector = */ svgCanvas.selectorManager.requestSelector(curtext) // Do we need this? Has side effect of setting lock, so keeping for now, but next line wasn't being used
|
/* const selector = */ svgCanvas.selectorManager.requestSelector(this.#curtext) // Do we need this? Has side effect of setting lock, so keeping for now, but next line wasn't being used
|
||||||
// const sel = selector.selectorRect;
|
// const sel = selector.selectorRect;
|
||||||
|
|
||||||
svgCanvas.textActions.init()
|
svgCanvas.textActions.init()
|
||||||
|
|
||||||
curtext.style.cursor = 'text'
|
this.#curtext.style.cursor = 'text'
|
||||||
|
|
||||||
// if (supportsEditableText()) {
|
// if (supportsEditableText()) {
|
||||||
// curtext.setAttribute('editable', 'simple');
|
// curtext.setAttribute('editable', 'simple');
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
if (!arguments.length) {
|
if (arguments.length === 0) {
|
||||||
setCursor()
|
this.#setCursor()
|
||||||
} else {
|
} else {
|
||||||
const pt = screenToPt(x, y)
|
const pt = this.#screenToPt(x, y)
|
||||||
setCursorFromPoint(pt.x, pt.y)
|
this.#setCursorFromPoint(pt.x, pt.y)
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(function () {
|
setTimeout(() => {
|
||||||
allowDbl = true
|
this.#allowDbl = true
|
||||||
}, 300)
|
}, 300)
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {boolean|Element} selectElem
|
* @param {boolean|Element} selectElem
|
||||||
* @fires module:svgcanvas.SvgCanvas#event:selected
|
* @fires module:svgcanvas.SvgCanvas#event:selected
|
||||||
@@ -437,43 +453,45 @@ export const textActionsMethod = (function () {
|
|||||||
*/
|
*/
|
||||||
toSelectMode (selectElem) {
|
toSelectMode (selectElem) {
|
||||||
svgCanvas.setCurrentMode('select')
|
svgCanvas.setCurrentMode('select')
|
||||||
clearInterval(blinker)
|
clearInterval(this.#blinker)
|
||||||
blinker = null
|
this.#blinker = null
|
||||||
if (selblock) {
|
if (this.#selblock) {
|
||||||
selblock.setAttribute('display', 'none')
|
this.#selblock.setAttribute('display', 'none')
|
||||||
}
|
}
|
||||||
if (cursor) {
|
if (this.#cursor) {
|
||||||
cursor.setAttribute('visibility', 'hidden')
|
this.#cursor.setAttribute('visibility', 'hidden')
|
||||||
}
|
}
|
||||||
curtext.style.cursor = 'move'
|
this.#curtext.style.cursor = 'move'
|
||||||
|
|
||||||
if (selectElem) {
|
if (selectElem) {
|
||||||
svgCanvas.clearSelection()
|
svgCanvas.clearSelection()
|
||||||
curtext.style.cursor = 'move'
|
this.#curtext.style.cursor = 'move'
|
||||||
|
|
||||||
svgCanvas.call('selected', [curtext])
|
svgCanvas.call('selected', [this.#curtext])
|
||||||
svgCanvas.addToSelection([curtext], true)
|
svgCanvas.addToSelection([this.#curtext], true)
|
||||||
}
|
}
|
||||||
if (!curtext?.textContent.length) {
|
if (!this.#curtext?.textContent.length) {
|
||||||
// No content, so delete
|
// No content, so delete
|
||||||
svgCanvas.deleteSelectedElements()
|
svgCanvas.deleteSelectedElements()
|
||||||
}
|
}
|
||||||
|
|
||||||
textinput.blur()
|
this.#textinput.blur()
|
||||||
|
|
||||||
curtext = false
|
this.#curtext = false
|
||||||
|
|
||||||
// if (supportsEditableText()) {
|
// if (supportsEditableText()) {
|
||||||
// curtext.removeAttribute('editable');
|
// curtext.removeAttribute('editable');
|
||||||
// }
|
// }
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Element} elem
|
* @param {Element} elem
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
setInputElem (elem) {
|
setInputElem (elem) {
|
||||||
textinput = elem
|
this.#textinput = elem
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
@@ -481,13 +499,14 @@ export const textActionsMethod = (function () {
|
|||||||
if (svgCanvas.getCurrentMode() === 'textedit') {
|
if (svgCanvas.getCurrentMode() === 'textedit') {
|
||||||
svgCanvas.textActions.toSelectMode()
|
svgCanvas.textActions.toSelectMode()
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Element} _inputElem Not in use
|
* @param {Element} _inputElem Not in use
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
init (_inputElem) {
|
init (_inputElem) {
|
||||||
if (!curtext) {
|
if (!this.#curtext) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let i
|
let i
|
||||||
@@ -497,36 +516,36 @@ export const textActionsMethod = (function () {
|
|||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
if (!curtext.parentNode) {
|
if (!this.#curtext.parentNode) {
|
||||||
// Result of the ffClone, need to get correct element
|
// Result of the ffClone, need to get correct element
|
||||||
const selectedElements = svgCanvas.getSelectedElements()
|
const selectedElements = svgCanvas.getSelectedElements()
|
||||||
curtext = selectedElements[0]
|
this.#curtext = selectedElements[0]
|
||||||
svgCanvas.selectorManager.requestSelector(curtext).showGrips(false)
|
svgCanvas.selectorManager.requestSelector(this.#curtext).showGrips(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const str = curtext.textContent
|
const str = this.#curtext.textContent
|
||||||
const len = str.length
|
const len = str.length
|
||||||
|
|
||||||
const xform = curtext.getAttribute('transform')
|
const xform = this.#curtext.getAttribute('transform')
|
||||||
|
|
||||||
textbb = utilsGetBBox(curtext)
|
this.#textbb = utilsGetBBox(this.#curtext)
|
||||||
|
|
||||||
matrix = xform ? getMatrix(curtext) : null
|
this.#matrix = xform ? getMatrix(this.#curtext) : null
|
||||||
|
|
||||||
chardata = []
|
this.#chardata = []
|
||||||
chardata.length = len
|
this.#chardata.length = len
|
||||||
textinput.focus()
|
this.#textinput.focus()
|
||||||
|
|
||||||
curtext.removeEventListener('dblclick', selectWord)
|
this.#curtext.removeEventListener('dblclick', this.#selectWord)
|
||||||
curtext.addEventListener('dblclick', selectWord)
|
this.#curtext.addEventListener('dblclick', this.#selectWord)
|
||||||
|
|
||||||
if (!len) {
|
if (!len) {
|
||||||
end = { x: textbb.x + textbb.width / 2, width: 0 }
|
end = { x: this.#textbb.x + this.#textbb.width / 2, width: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
for (i = 0; i < len; i++) {
|
for (i = 0; i < len; i++) {
|
||||||
const start = curtext.getStartPositionOfChar(i)
|
const start = this.#curtext.getStartPositionOfChar(i)
|
||||||
end = curtext.getEndPositionOfChar(i)
|
end = this.#curtext.getEndPositionOfChar(i)
|
||||||
|
|
||||||
if (!supportsGoodTextCharPos()) {
|
if (!supportsGoodTextCharPos()) {
|
||||||
const zoom = svgCanvas.getZoom()
|
const zoom = svgCanvas.getZoom()
|
||||||
@@ -542,20 +561,22 @@ export const textActionsMethod = (function () {
|
|||||||
// bbox data of the actual text for y, height purposes
|
// bbox data of the actual text for y, height purposes
|
||||||
|
|
||||||
// TODO: Decide if y, width and height are actually necessary
|
// TODO: Decide if y, width and height are actually necessary
|
||||||
chardata[i] = {
|
this.#chardata[i] = {
|
||||||
x: start.x,
|
x: start.x,
|
||||||
y: textbb.y, // start.y?
|
y: this.#textbb.y, // start.y?
|
||||||
width: end.x - start.x,
|
width: end.x - start.x,
|
||||||
height: textbb.height
|
height: this.#textbb.height
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a last bbox for cursor at end of text
|
// Add a last bbox for cursor at end of text
|
||||||
chardata.push({
|
this.#chardata.push({
|
||||||
x: end.x,
|
x: end.x,
|
||||||
width: 0
|
width: 0
|
||||||
})
|
})
|
||||||
setSelection(textinput.selectionStart, textinput.selectionEnd, true)
|
this.#setSelection(this.#textinput.selectionStart, this.#textinput.selectionEnd, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()
|
|
||||||
|
// Export singleton instance for backward compatibility
|
||||||
|
export const textActionsMethod = new TextActions()
|
||||||
|
|||||||
@@ -46,11 +46,26 @@ export const getUndoManager = () => {
|
|||||||
if (eventType === EventTypes.BEFORE_UNAPPLY || eventType === EventTypes.BEFORE_APPLY) {
|
if (eventType === EventTypes.BEFORE_UNAPPLY || eventType === EventTypes.BEFORE_APPLY) {
|
||||||
svgCanvas.clearSelection()
|
svgCanvas.clearSelection()
|
||||||
} else if (eventType === EventTypes.AFTER_APPLY || eventType === EventTypes.AFTER_UNAPPLY) {
|
} else if (eventType === EventTypes.AFTER_APPLY || eventType === EventTypes.AFTER_UNAPPLY) {
|
||||||
|
const cmdType = cmd.type()
|
||||||
|
const isApply = (eventType === EventTypes.AFTER_APPLY)
|
||||||
|
if (cmdType === 'ChangeElementCommand' && cmd.elem === svgCanvas.getSvgContent()) {
|
||||||
|
const values = isApply ? cmd.newValues : cmd.oldValues
|
||||||
|
if (values.width !== null && values.width !== undefined && values.width !== '') {
|
||||||
|
const newContentW = Number(values.width)
|
||||||
|
if (Number.isFinite(newContentW) && newContentW > 0) {
|
||||||
|
svgCanvas.contentW = newContentW
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (values.height !== null && values.height !== undefined && values.height !== '') {
|
||||||
|
const newContentH = Number(values.height)
|
||||||
|
if (Number.isFinite(newContentH) && newContentH > 0) {
|
||||||
|
svgCanvas.contentH = newContentH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const elems = cmd.elements()
|
const elems = cmd.elements()
|
||||||
svgCanvas.pathActions.clear()
|
svgCanvas.pathActions.clear()
|
||||||
svgCanvas.call('changed', elems)
|
svgCanvas.call('changed', elems)
|
||||||
const cmdType = cmd.type()
|
|
||||||
const isApply = (eventType === EventTypes.AFTER_APPLY)
|
|
||||||
if (cmdType === 'MoveElementCommand') {
|
if (cmdType === 'MoveElementCommand') {
|
||||||
const parent = isApply ? cmd.newParent : cmd.oldParent
|
const parent = isApply ? cmd.newParent : cmd.oldParent
|
||||||
if (parent === svgCanvas.getSvgContent()) {
|
if (parent === svgCanvas.getSvgContent()) {
|
||||||
@@ -116,7 +131,7 @@ export const getUndoManager = () => {
|
|||||||
* @param {Element} elem - The (text) DOM element to clone
|
* @param {Element} elem - The (text) DOM element to clone
|
||||||
* @returns {Element} Cloned element
|
* @returns {Element} Cloned element
|
||||||
*/
|
*/
|
||||||
export const ffClone = function (elem) {
|
export const ffClone = (elem) => {
|
||||||
if (!isGecko()) { return elem }
|
if (!isGecko()) { return elem }
|
||||||
const clone = elem.cloneNode(true)
|
const clone = elem.cloneNode(true)
|
||||||
elem.before(clone)
|
elem.before(clone)
|
||||||
@@ -213,7 +228,7 @@ export const changeSelectedAttributeNoUndoMethod = (attr, newValue, elems) => {
|
|||||||
elem = ffClone(elem)
|
elem = ffClone(elem)
|
||||||
}
|
}
|
||||||
// Timeout needed for Opera & Firefox
|
// Timeout needed for Opera & Firefox
|
||||||
// codedread: it is now possible for this function to be called with elements
|
// codedread: it is now possible for this to be called with elements
|
||||||
// that are not in the selectedElements array, we need to only request a
|
// that are not in the selectedElements array, we need to only request a
|
||||||
// selector if the element is in that array
|
// selector if the element is in that array
|
||||||
if (selectedElements.includes(elem)) {
|
if (selectedElements.includes(elem)) {
|
||||||
@@ -264,7 +279,7 @@ export const changeSelectedAttributeNoUndoMethod = (attr, newValue, elems) => {
|
|||||||
* @param {Element[]} elems - The DOM elements to apply the change to
|
* @param {Element[]} elems - The DOM elements to apply the change to
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export const changeSelectedAttributeMethod = function (attr, val, elems) {
|
export const changeSelectedAttributeMethod = (attr, val, elems) => {
|
||||||
const selectedElements = svgCanvas.getSelectedElements()
|
const selectedElements = svgCanvas.getSelectedElements()
|
||||||
elems = elems || selectedElements
|
elems = elems || selectedElements
|
||||||
svgCanvas.undoMgr.beginUndoableChange(attr, elems)
|
svgCanvas.undoMgr.beginUndoableChange(attr, elems)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
* @copyright 2010 Alexis Deveria, 2010 Jeff Schiller
|
* @copyright 2010 Alexis Deveria, 2010 Jeff Schiller
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { error } from '../common/logger.js'
|
||||||
|
|
||||||
const NSSVG = 'http://www.w3.org/2000/svg'
|
const NSSVG = 'http://www.w3.org/2000/svg'
|
||||||
|
|
||||||
const wAttrs = ['x', 'x1', 'cx', 'rx', 'width']
|
const wAttrs = ['x', 'x1', 'cx', 'rx', 'width']
|
||||||
@@ -62,7 +64,7 @@ let typeMap_ = {}
|
|||||||
* @param {module:units.ElementContainer} elementContainer - An object implementing the ElementContainer interface.
|
* @param {module:units.ElementContainer} elementContainer - An object implementing the ElementContainer interface.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export const init = function (elementContainer) {
|
export const init = (elementContainer) => {
|
||||||
elementContainer_ = elementContainer
|
elementContainer_ = elementContainer
|
||||||
|
|
||||||
// Get correct em/ex values by creating a temporary SVG.
|
// Get correct em/ex values by creating a temporary SVG.
|
||||||
@@ -124,7 +126,7 @@ export const shortFloat = (val) => {
|
|||||||
return Number(Number(val).toFixed(digits))
|
return Number(Number(val).toFixed(digits))
|
||||||
}
|
}
|
||||||
if (Array.isArray(val)) {
|
if (Array.isArray(val)) {
|
||||||
return shortFloat(val[0]) + ',' + shortFloat(val[1])
|
return `${shortFloat(val[0])},${shortFloat(val[1])}`
|
||||||
}
|
}
|
||||||
return Number.parseFloat(val).toFixed(digits) - 0
|
return Number.parseFloat(val).toFixed(digits) - 0
|
||||||
}
|
}
|
||||||
@@ -214,8 +216,8 @@ export const convertToNum = (attr, val) => {
|
|||||||
}
|
}
|
||||||
return num * Math.sqrt((width * width) + (height * height)) / Math.sqrt(2)
|
return num * Math.sqrt((width * width) + (height * height)) / Math.sqrt(2)
|
||||||
}
|
}
|
||||||
const unit = val.substr(-2)
|
const unit = val.slice(-2)
|
||||||
const num = val.substr(0, val.length - 2)
|
const num = val.slice(0, -2)
|
||||||
// Note that this multiplication turns the string into a number
|
// Note that this multiplication turns the string into a number
|
||||||
return num * typeMap_[unit]
|
return num * typeMap_[unit]
|
||||||
}
|
}
|
||||||
@@ -237,7 +239,7 @@ export const isValidUnit = (attr, val, selectedElement) => {
|
|||||||
// Not a number, check if it has a valid unit
|
// Not a number, check if it has a valid unit
|
||||||
val = val.toLowerCase()
|
val = val.toLowerCase()
|
||||||
return Object.keys(typeMap_).some((unit) => {
|
return Object.keys(typeMap_).some((unit) => {
|
||||||
const re = new RegExp('^-?[\\d\\.]+' + unit + '$')
|
const re = new RegExp(`^-?[\\d\\.]+${unit}$`)
|
||||||
return re.test(val)
|
return re.test(val)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -253,7 +255,7 @@ export const isValidUnit = (attr, val, selectedElement) => {
|
|||||||
try {
|
try {
|
||||||
const elem = elementContainer_.getElement(val)
|
const elem = elementContainer_.getElement(val)
|
||||||
result = (!elem || elem === selectedElement)
|
result = (!elem || elem === selectedElement)
|
||||||
} catch (e) { console.error(e) }
|
} catch (e) { error('Error getting element by ID', e, 'units') }
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -108,15 +108,16 @@ export const dropXMLInternalSubset = str => {
|
|||||||
* @param {string} str - The string to be converted
|
* @param {string} str - The string to be converted
|
||||||
* @returns {string} The converted string
|
* @returns {string} The converted string
|
||||||
*/
|
*/
|
||||||
export const toXml = str => {
|
export const toXml = (str) => {
|
||||||
// ' is ok in XML, but not HTML
|
const xmlEntities = {
|
||||||
// > does not normally need escaping, though it can if within a CDATA expression (and preceded by "]]")
|
'&': '&',
|
||||||
return str
|
'<': '<',
|
||||||
.replace(/&/g, '&')
|
'>': '>',
|
||||||
.replace(/</g, '<')
|
'"': '"',
|
||||||
.replace(/>/g, '>')
|
"'": ''' // Note: `'` is XML only
|
||||||
.replace(/"/g, '"')
|
}
|
||||||
.replace(/'/g, ''') // Note: `'` is XML only
|
|
||||||
|
return str.replace(/[&<>"']/g, (char) => xmlEntities[char])
|
||||||
}
|
}
|
||||||
|
|
||||||
// This code was written by Tyler Akins and has been placed in the
|
// This code was written by Tyler Akins and has been placed in the
|
||||||
@@ -132,10 +133,9 @@ export const toXml = str => {
|
|||||||
* @param {string} input
|
* @param {string} input
|
||||||
* @returns {string} Base64 output
|
* @returns {string} Base64 output
|
||||||
*/
|
*/
|
||||||
export function encode64 (input) {
|
export const encode64 = (input) => {
|
||||||
// base64 strings are 4/3 larger than the original string
|
const encoded = encodeUTF8(input) // convert non-ASCII characters
|
||||||
input = encodeUTF8(input) // convert non-ASCII characters
|
return window.btoa(encoded) // Use native if available
|
||||||
return window.btoa(input) // Use native if available
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,23 +144,20 @@ export function encode64 (input) {
|
|||||||
* @param {string} input Base64-encoded input
|
* @param {string} input Base64-encoded input
|
||||||
* @returns {string} Decoded output
|
* @returns {string} Decoded output
|
||||||
*/
|
*/
|
||||||
export function decode64 (input) {
|
export const decode64 = (input) => decodeUTF8(window.atob(input))
|
||||||
return decodeUTF8(window.atob(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute a hashcode from a given string
|
* Compute a hashcode from a given string
|
||||||
* @param word : the string, we want to compute the hashcode
|
* @param {string} word - The string we want to compute the hashcode from
|
||||||
* @returns {number}: Hascode of the given string
|
* @returns {number} Hashcode of the given string
|
||||||
*/
|
*/
|
||||||
export function hashCode (word) {
|
export const hashCode = (word) => {
|
||||||
|
if (word.length === 0) return 0
|
||||||
|
|
||||||
let hash = 0
|
let hash = 0
|
||||||
let chr
|
|
||||||
if (word.length === 0) return hash
|
|
||||||
for (let i = 0; i < word.length; i++) {
|
for (let i = 0; i < word.length; i++) {
|
||||||
chr = word.charCodeAt(i)
|
const chr = word.charCodeAt(i)
|
||||||
hash = (hash << 5) - hash + chr
|
hash = ((hash << 5) - hash + chr) | 0 // Convert to 32bit integer
|
||||||
hash |= 0 // Convert to 32bit integer
|
|
||||||
}
|
}
|
||||||
return hash
|
return hash
|
||||||
}
|
}
|
||||||
@@ -170,19 +167,14 @@ export function hashCode (word) {
|
|||||||
* @param {string} argString
|
* @param {string} argString
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function decodeUTF8 (argString) {
|
export const decodeUTF8 = (argString) => decodeURIComponent(escape(argString))
|
||||||
return decodeURIComponent(escape(argString))
|
|
||||||
}
|
|
||||||
|
|
||||||
// codedread:does not seem to work with webkit-based browsers on OSX // Brettz9: please test again as function upgraded
|
|
||||||
/**
|
/**
|
||||||
* @function module:utilities.encodeUTF8
|
* @function module:utilities.encodeUTF8
|
||||||
* @param {string} argString
|
* @param {string} argString
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export const encodeUTF8 = argString => {
|
export const encodeUTF8 = (argString) => unescape(encodeURIComponent(argString))
|
||||||
return unescape(encodeURIComponent(argString))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert dataURL to object URL.
|
* Convert dataURL to object URL.
|
||||||
@@ -190,7 +182,7 @@ export const encodeUTF8 = argString => {
|
|||||||
* @param {string} dataurl
|
* @param {string} dataurl
|
||||||
* @returns {string} object URL or empty string
|
* @returns {string} object URL or empty string
|
||||||
*/
|
*/
|
||||||
export const dataURLToObjectURL = dataurl => {
|
export const dataURLToObjectURL = (dataurl) => {
|
||||||
if (
|
if (
|
||||||
typeof Uint8Array === 'undefined' ||
|
typeof Uint8Array === 'undefined' ||
|
||||||
typeof Blob === 'undefined' ||
|
typeof Blob === 'undefined' ||
|
||||||
@@ -199,19 +191,22 @@ export const dataURLToObjectURL = dataurl => {
|
|||||||
) {
|
) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
const arr = dataurl.split(',')
|
|
||||||
const mime = arr[0].match(/:(.*?);/)[1]
|
const [prefix, suffix] = dataurl.split(',')
|
||||||
const bstr = atob(arr[1])
|
const mimeMatch = prefix?.match(/:(.*?);/)
|
||||||
/*
|
|
||||||
const [prefix, suffix] = dataurl.split(','),
|
if (!mimeMatch?.[1] || !suffix) {
|
||||||
{groups: {mime}} = prefix.match(/:(?<mime>.*?);/),
|
return ''
|
||||||
bstr = atob(suffix);
|
|
||||||
*/
|
|
||||||
let n = bstr.length
|
|
||||||
const u8arr = new Uint8Array(n)
|
|
||||||
while (n--) {
|
|
||||||
u8arr[n] = bstr.charCodeAt(n)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mime = mimeMatch[1]
|
||||||
|
const bstr = atob(suffix)
|
||||||
|
const u8arr = new Uint8Array(bstr.length)
|
||||||
|
|
||||||
|
for (let i = 0; i < bstr.length; i++) {
|
||||||
|
u8arr[i] = bstr.charCodeAt(i)
|
||||||
|
}
|
||||||
|
|
||||||
const blob = new Blob([u8arr], { type: mime })
|
const blob = new Blob([u8arr], { type: mime })
|
||||||
return URL.createObjectURL(blob)
|
return URL.createObjectURL(blob)
|
||||||
}
|
}
|
||||||
@@ -222,7 +217,7 @@ export const dataURLToObjectURL = dataurl => {
|
|||||||
* @param {Blob} blob A Blob object or File object
|
* @param {Blob} blob A Blob object or File object
|
||||||
* @returns {string} object URL or empty string
|
* @returns {string} object URL or empty string
|
||||||
*/
|
*/
|
||||||
export const createObjectURL = blob => {
|
export const createObjectURL = (blob) => {
|
||||||
if (!blob || typeof URL === 'undefined' || !URL.createObjectURL) {
|
if (!blob || typeof URL === 'undefined' || !URL.createObjectURL) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -266,25 +261,28 @@ export const convertToXMLReferences = input => {
|
|||||||
* @throws {Error}
|
* @throws {Error}
|
||||||
* @returns {XMLDocument}
|
* @returns {XMLDocument}
|
||||||
*/
|
*/
|
||||||
export const text2xml = sXML => {
|
export const text2xml = (sXML) => {
|
||||||
if (sXML.includes('<svg:svg')) {
|
let xmlString = sXML
|
||||||
sXML = sXML.replace(/<(\/?)svg:/g, '<$1').replace('xmlns:svg', 'xmlns')
|
|
||||||
|
if (xmlString.includes('<svg:svg')) {
|
||||||
|
xmlString = xmlString
|
||||||
|
.replace(/<(\/?)svg:/g, '<$1')
|
||||||
|
.replace('xmlns:svg', 'xmlns')
|
||||||
}
|
}
|
||||||
|
|
||||||
let out
|
let parser
|
||||||
let dXML
|
|
||||||
try {
|
try {
|
||||||
dXML = new DOMParser()
|
parser = new DOMParser()
|
||||||
dXML.async = false
|
parser.async = false
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error('XML Parser could not be instantiated')
|
throw new Error('XML Parser could not be instantiated')
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
out = dXML.parseFromString(sXML, 'text/xml')
|
return parser.parseFromString(xmlString, 'text/xml')
|
||||||
} catch (e2) {
|
} catch (e) {
|
||||||
throw new Error('Error parsing XML string')
|
throw new Error(`Error parsing XML string: ${e.message}`)
|
||||||
}
|
}
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -354,22 +352,24 @@ export const walkTreePost = (elem, cbFn) => {
|
|||||||
* - `<circle fill='url("someFile.svg#foo")' />`
|
* - `<circle fill='url("someFile.svg#foo")' />`
|
||||||
* @function module:utilities.getUrlFromAttr
|
* @function module:utilities.getUrlFromAttr
|
||||||
* @param {string} attrVal The attribute value as a string
|
* @param {string} attrVal The attribute value as a string
|
||||||
* @returns {string} String with just the URL, like "someFile.svg#foo"
|
* @returns {string|null} String with just the URL, like "someFile.svg#foo"
|
||||||
*/
|
*/
|
||||||
export const getUrlFromAttr = function (attrVal) {
|
export const getUrlFromAttr = (attrVal) => {
|
||||||
if (attrVal) {
|
if (!attrVal?.startsWith('url(')) return null
|
||||||
// url('#somegrad')
|
|
||||||
if (attrVal.startsWith('url("')) {
|
const patterns = [
|
||||||
return attrVal.substring(5, attrVal.indexOf('"', 6))
|
{ start: 'url("', end: '"', offset: 5 },
|
||||||
}
|
{ start: "url('", end: "'", offset: 5 },
|
||||||
// url('#somegrad')
|
{ start: 'url(', end: ')', offset: 4 }
|
||||||
if (attrVal.startsWith("url('")) {
|
]
|
||||||
return attrVal.substring(5, attrVal.indexOf("'", 6))
|
|
||||||
}
|
for (const { start, end, offset } of patterns) {
|
||||||
if (attrVal.startsWith('url(')) {
|
if (attrVal.startsWith(start)) {
|
||||||
return attrVal.substring(4, attrVal.indexOf(')'))
|
const endIndex = attrVal.indexOf(end, offset + 1)
|
||||||
|
return endIndex > 0 ? attrVal.substring(offset, endIndex) : null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,10 +378,8 @@ export const getUrlFromAttr = function (attrVal) {
|
|||||||
* @param {Element} elem
|
* @param {Element} elem
|
||||||
* @returns {string} The given element's `href` value
|
* @returns {string} The given element's `href` value
|
||||||
*/
|
*/
|
||||||
export let getHref = function (elem) {
|
export let getHref = (elem) =>
|
||||||
// Prefer 'href', fallback to 'xlink:href'
|
elem.getAttribute('href') ?? elem.getAttributeNS(NS.XLINK, 'href')
|
||||||
return elem.getAttribute('href') || elem.getAttributeNS(NS.XLINK, 'href')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the given element's `href` value.
|
* Sets the given element's `href` value.
|
||||||
@@ -390,7 +388,7 @@ export let getHref = function (elem) {
|
|||||||
* @param {string} val
|
* @param {string} val
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export let setHref = function (elem, val) {
|
export let setHref = (elem, val) => {
|
||||||
elem.setAttribute('href', val)
|
elem.setAttribute('href', val)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,21 +396,23 @@ export let setHref = function (elem, val) {
|
|||||||
* @function module:utilities.findDefs
|
* @function module:utilities.findDefs
|
||||||
* @returns {SVGDefsElement} The document's `<defs>` element, creating it first if necessary
|
* @returns {SVGDefsElement} The document's `<defs>` element, creating it first if necessary
|
||||||
*/
|
*/
|
||||||
export const findDefs = function () {
|
export const findDefs = () => {
|
||||||
const svgElement = svgCanvas.getSvgContent()
|
const svgElement = svgCanvas.getSvgContent()
|
||||||
let defs = svgElement.getElementsByTagNameNS(NS.SVG, 'defs')
|
const existingDefs = svgElement.getElementsByTagNameNS(NS.SVG, 'defs')
|
||||||
if (defs.length > 0) {
|
|
||||||
defs = defs[0]
|
if (existingDefs.length > 0) {
|
||||||
} else {
|
return existingDefs[0]
|
||||||
defs = svgElement.ownerDocument.createElementNS(NS.SVG, 'defs')
|
}
|
||||||
if (svgElement.firstChild) {
|
|
||||||
// first child is a comment, so call nextSibling
|
const defs = svgElement.ownerDocument.createElementNS(NS.SVG, 'defs')
|
||||||
svgElement.insertBefore(defs, svgElement.firstChild.nextSibling)
|
const insertTarget = svgElement.firstChild?.nextSibling
|
||||||
// svgElement.firstChild.nextSibling.before(defs); // Not safe
|
|
||||||
|
if (insertTarget) {
|
||||||
|
svgElement.insertBefore(defs, insertTarget)
|
||||||
} else {
|
} else {
|
||||||
svgElement.append(defs)
|
svgElement.append(defs)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return defs
|
return defs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,33 +425,28 @@ export const findDefs = function () {
|
|||||||
* @param {SVGPathElement} path - The path DOM element to get the BBox for
|
* @param {SVGPathElement} path - The path DOM element to get the BBox for
|
||||||
* @returns {module:utilities.BBoxObject} A BBox-like object
|
* @returns {module:utilities.BBoxObject} A BBox-like object
|
||||||
*/
|
*/
|
||||||
export const getPathBBox = function (path) {
|
export const getPathBBox = (path) => {
|
||||||
const seglist = path.pathSegList
|
const seglist = path.pathSegList
|
||||||
const tot = seglist.numberOfItems
|
const totalSegments = seglist.numberOfItems
|
||||||
|
|
||||||
const bounds = [[], []]
|
const bounds = [[], []]
|
||||||
const start = seglist.getItem(0)
|
const start = seglist.getItem(0)
|
||||||
let P0 = [start.x, start.y]
|
let P0 = [start.x, start.y]
|
||||||
|
|
||||||
const getCalc = function (j, P1, P2, P3) {
|
const getCalc = (j, P1, P2, P3) => (t) => {
|
||||||
return function (t) {
|
const oneMinusT = 1 - t
|
||||||
return (
|
return (
|
||||||
1 -
|
oneMinusT ** 3 * P0[j] +
|
||||||
t ** 3 * P0[j] +
|
3 * oneMinusT ** 2 * t * P1[j] +
|
||||||
3 * 1 -
|
3 * oneMinusT * t ** 2 * P2[j] +
|
||||||
t ** 2 * t * P1[j] +
|
|
||||||
3 * (1 - t) * t ** 2 * P2[j] +
|
|
||||||
t ** 3 * P3[j]
|
t ** 3 * P3[j]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < tot; i++) {
|
for (let i = 0; i < totalSegments; i++) {
|
||||||
const seg = seglist.getItem(i)
|
const seg = seglist.getItem(i)
|
||||||
|
|
||||||
if (seg.x === undefined) {
|
if (seg.x === undefined) continue
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add actual points to limits
|
// Add actual points to limits
|
||||||
bounds[0].push(P0[0])
|
bounds[0].push(P0[0])
|
||||||
@@ -499,15 +494,14 @@ export const getPathBBox = function (path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const x = Math.min.apply(null, bounds[0])
|
const x = Math.min(...bounds[0])
|
||||||
const w = Math.max.apply(null, bounds[0]) - x
|
const y = Math.min(...bounds[1])
|
||||||
const y = Math.min.apply(null, bounds[1])
|
|
||||||
const h = Math.max.apply(null, bounds[1]) - y
|
|
||||||
return {
|
return {
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
width: w,
|
width: Math.max(...bounds[0]) - x,
|
||||||
height: h
|
height: Math.max(...bounds[1]) - y
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,13 +510,12 @@ export const getPathBBox = function (path) {
|
|||||||
* usable when necessary.
|
* usable when necessary.
|
||||||
* @function module:utilities.getBBox
|
* @function module:utilities.getBBox
|
||||||
* @param {Element} elem - Optional DOM element to get the BBox for
|
* @param {Element} elem - Optional DOM element to get the BBox for
|
||||||
* @returns {module:utilities.BBoxObject} Bounding box object
|
* @returns {module:utilities.BBoxObject|null} Bounding box object
|
||||||
*/
|
*/
|
||||||
export const getBBox = function (elem) {
|
export const getBBox = (elem) => {
|
||||||
const selected = elem || svgCanvas.getSelectedElements()[0]
|
const selected = elem ?? svgCanvas.getSelectedElements()[0]
|
||||||
if (elem.nodeType !== 1) {
|
if (elem.nodeType !== 1) return null
|
||||||
return null
|
|
||||||
}
|
|
||||||
const elname = selected.nodeName
|
const elname = selected.nodeName
|
||||||
|
|
||||||
let ret = null
|
let ret = null
|
||||||
@@ -642,26 +635,23 @@ export const getBBox = function (elem) {
|
|||||||
* @param {module:utilities.PathSegmentArray[]} pathSegments - An array of path segments to be converted
|
* @param {module:utilities.PathSegmentArray[]} pathSegments - An array of path segments to be converted
|
||||||
* @returns {string} The converted path d attribute.
|
* @returns {string} The converted path d attribute.
|
||||||
*/
|
*/
|
||||||
export const getPathDFromSegments = function (pathSegments) {
|
export const getPathDFromSegments = (pathSegments) => {
|
||||||
let d = ''
|
return pathSegments.map(([command, points]) => {
|
||||||
|
const coords = []
|
||||||
pathSegments.forEach(function ([singleChar, pts], _j) {
|
for (let i = 0; i < points.length; i += 2) {
|
||||||
d += singleChar
|
coords.push(`${points[i]},${points[i + 1]}`)
|
||||||
for (let i = 0; i < pts.length; i += 2) {
|
|
||||||
d += pts[i] + ',' + pts[i + 1] + ' '
|
|
||||||
}
|
}
|
||||||
})
|
return command + coords.join(' ')
|
||||||
|
}).join(' ')
|
||||||
return d
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make a path 'd' attribute from a simple SVG element shape.
|
* Make a path 'd' attribute from a simple SVG element shape.
|
||||||
* @function module:utilities.getPathDFromElement
|
* @function module:utilities.getPathDFromElement
|
||||||
* @param {Element} elem - The element to be converted
|
* @param {Element} elem - The element to be converted
|
||||||
* @returns {string} The path d attribute or `undefined` if the element type is unknown.
|
* @returns {string|undefined} The path d attribute or `undefined` if the element type is unknown.
|
||||||
*/
|
*/
|
||||||
export const getPathDFromElement = function (elem) {
|
export const getPathDFromElement = (elem) => {
|
||||||
// Possibly the cubed root of 6, but 1.81 works best
|
// Possibly the cubed root of 6, but 1.81 works best
|
||||||
let num = 1.81
|
let num = 1.81
|
||||||
let d
|
let d
|
||||||
@@ -691,20 +681,19 @@ export const getPathDFromElement = function (elem) {
|
|||||||
case 'path':
|
case 'path':
|
||||||
d = elem.getAttribute('d')
|
d = elem.getAttribute('d')
|
||||||
break
|
break
|
||||||
case 'line':
|
case 'line': {
|
||||||
{
|
|
||||||
const x1 = elem.getAttribute('x1')
|
const x1 = elem.getAttribute('x1')
|
||||||
const y1 = elem.getAttribute('y1')
|
const y1 = elem.getAttribute('y1')
|
||||||
const x2 = elem.getAttribute('x2')
|
const x2 = elem.getAttribute('x2')
|
||||||
const y2 = elem.getAttribute('y2')
|
const y2 = elem.getAttribute('y2')
|
||||||
d = 'M' + x1 + ',' + y1 + 'L' + x2 + ',' + y2
|
d = `M${x1},${y1}L${x2},${y2}`
|
||||||
}
|
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case 'polyline':
|
case 'polyline':
|
||||||
d = 'M' + elem.getAttribute('points')
|
d = `M${elem.getAttribute('points')}`
|
||||||
break
|
break
|
||||||
case 'polygon':
|
case 'polygon':
|
||||||
d = 'M' + elem.getAttribute('points') + ' Z'
|
d = `M${elem.getAttribute('points')} Z`
|
||||||
break
|
break
|
||||||
case 'rect': {
|
case 'rect': {
|
||||||
rx = Number(elem.getAttribute('rx'))
|
rx = Number(elem.getAttribute('rx'))
|
||||||
@@ -762,19 +751,16 @@ export const getPathDFromElement = function (elem) {
|
|||||||
* @param {Element} elem - The element to be probed
|
* @param {Element} elem - The element to be probed
|
||||||
* @returns {PlainObject<"marker-start"|"marker-end"|"marker-mid"|"filter"|"clip-path", string>} An object with attributes.
|
* @returns {PlainObject<"marker-start"|"marker-end"|"marker-mid"|"filter"|"clip-path", string>} An object with attributes.
|
||||||
*/
|
*/
|
||||||
export const getExtraAttributesForConvertToPath = function (elem) {
|
export const getExtraAttributesForConvertToPath = (elem) => {
|
||||||
const attrs = {}
|
|
||||||
// TODO: make this list global so that we can properly maintain it
|
// TODO: make this list global so that we can properly maintain it
|
||||||
// TODO: what about @transform, @clip-rule, @fill-rule, etc?
|
// TODO: what about @transform, @clip-rule, @fill-rule, etc?
|
||||||
;['marker-start', 'marker-end', 'marker-mid', 'filter', 'clip-path'].forEach(
|
const attributeNames = ['marker-start', 'marker-end', 'marker-mid', 'filter', 'clip-path']
|
||||||
function (item) {
|
|
||||||
const a = elem.getAttribute(item)
|
return attributeNames.reduce((attrs, name) => {
|
||||||
if (a) {
|
const value = elem.getAttribute(name)
|
||||||
attrs[item] = a
|
if (value) attrs[name] = value
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return attrs
|
return attrs
|
||||||
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -785,11 +771,11 @@ export const getExtraAttributesForConvertToPath = function (elem) {
|
|||||||
* @param {module:path.pathActions} pathActions - If a transform exists, `pathActions.resetOrientation()` is used. See: canvas.pathActions.
|
* @param {module:path.pathActions} pathActions - If a transform exists, `pathActions.resetOrientation()` is used. See: canvas.pathActions.
|
||||||
* @returns {DOMRect|false} The resulting path's bounding box object.
|
* @returns {DOMRect|false} The resulting path's bounding box object.
|
||||||
*/
|
*/
|
||||||
export const getBBoxOfElementAsPath = function (
|
export const getBBoxOfElementAsPath = (
|
||||||
elem,
|
elem,
|
||||||
addSVGElementsFromJson,
|
addSVGElementsFromJson,
|
||||||
pathActions
|
pathActions
|
||||||
) {
|
) => {
|
||||||
const path = addSVGElementsFromJson({
|
const path = addSVGElementsFromJson({
|
||||||
element: 'path',
|
element: 'path',
|
||||||
attr: getExtraAttributesForConvertToPath(elem)
|
attr: getExtraAttributesForConvertToPath(elem)
|
||||||
@@ -801,11 +787,7 @@ export const getBBoxOfElementAsPath = function (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { parentNode } = elem
|
const { parentNode } = elem
|
||||||
if (elem.nextSibling) {
|
elem.nextSibling ? elem.before(path) : parentNode.append(path)
|
||||||
elem.before(path)
|
|
||||||
} else {
|
|
||||||
parentNode.append(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const d = getPathDFromElement(elem)
|
const d = getPathDFromElement(elem)
|
||||||
if (d) {
|
if (d) {
|
||||||
@@ -936,7 +918,7 @@ export const convertToPath = (elem, attrs, svgCanvas) => {
|
|||||||
* @param {boolean} hasAMatrixTransform - True if there is a matrix transform
|
* @param {boolean} hasAMatrixTransform - True if there is a matrix transform
|
||||||
* @returns {boolean} True if the bbox can be optimized.
|
* @returns {boolean} True if the bbox can be optimized.
|
||||||
*/
|
*/
|
||||||
function bBoxCanBeOptimizedOverNativeGetBBox (angle, hasAMatrixTransform) {
|
const bBoxCanBeOptimizedOverNativeGetBBox = (angle, hasAMatrixTransform) => {
|
||||||
const angleModulo90 = angle % 90
|
const angleModulo90 = angle % 90
|
||||||
const closeTo90 = angleModulo90 < -89.99 || angleModulo90 > 89.99
|
const closeTo90 = angleModulo90 < -89.99 || angleModulo90 > 89.99
|
||||||
const closeTo0 = angleModulo90 > -0.001 && angleModulo90 < 0.001
|
const closeTo0 = angleModulo90 > -0.001 && angleModulo90 < 0.001
|
||||||
@@ -949,24 +931,21 @@ function bBoxCanBeOptimizedOverNativeGetBBox (angle, hasAMatrixTransform) {
|
|||||||
* @param {Element} elem - The DOM element to be converted
|
* @param {Element} elem - The DOM element to be converted
|
||||||
* @param {module:utilities.EditorContext#addSVGElementsFromJson} addSVGElementsFromJson - Function to add the path element to the current layer. See canvas.addSVGElementsFromJson
|
* @param {module:utilities.EditorContext#addSVGElementsFromJson} addSVGElementsFromJson - Function to add the path element to the current layer. See canvas.addSVGElementsFromJson
|
||||||
* @param {module:path.pathActions} pathActions - If a transform exists, pathActions.resetOrientation() is used. See: canvas.pathActions.
|
* @param {module:path.pathActions} pathActions - If a transform exists, pathActions.resetOrientation() is used. See: canvas.pathActions.
|
||||||
* @returns {module:utilities.BBoxObject|module:math.TransformedBox|DOMRect} A single bounding box object
|
* @returns {module:utilities.BBoxObject|module:math.TransformedBox|DOMRect|null} A single bounding box object
|
||||||
*/
|
*/
|
||||||
export const getBBoxWithTransform = function (
|
export const getBBoxWithTransform = (
|
||||||
elem,
|
elem,
|
||||||
addSVGElementsFromJson,
|
addSVGElementsFromJson,
|
||||||
pathActions
|
pathActions
|
||||||
) {
|
) => {
|
||||||
// TODO: Fix issue with rotated groups. Currently they work
|
// TODO: Fix issue with rotated groups. Currently they work
|
||||||
// fine in FF, but not in other browsers (same problem mentioned
|
// fine in FF, but not in other browsers (same problem mentioned
|
||||||
// in Issue 339 comment #2).
|
// in Issue 339 comment #2).
|
||||||
|
|
||||||
let bb = getBBox(elem)
|
let bb = getBBox(elem)
|
||||||
|
if (!bb) return null
|
||||||
|
|
||||||
if (!bb) {
|
const transformAttr = elem.getAttribute?.('transform') ?? ''
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const transformAttr = elem.getAttribute?.('transform') || ''
|
|
||||||
const hasMatrixAttr = transformAttr.includes('matrix(')
|
const hasMatrixAttr = transformAttr.includes('matrix(')
|
||||||
if (transformAttr.includes('rotate(') && !hasMatrixAttr) {
|
if (transformAttr.includes('rotate(') && !hasMatrixAttr) {
|
||||||
const nums = transformAttr.match(/-?\d*\.?\d+/g)?.map(Number) || []
|
const nums = transformAttr.match(/-?\d*\.?\d+/g)?.map(Number) || []
|
||||||
@@ -1263,7 +1242,7 @@ export const getRefElem = attrVal => {
|
|||||||
if (!attrVal) return null
|
if (!attrVal) return null
|
||||||
const url = getUrlFromAttr(attrVal)
|
const url = getUrlFromAttr(attrVal)
|
||||||
if (!url) return null
|
if (!url) return null
|
||||||
const id = url[0] === '#' ? url.substr(1) : url
|
const id = url[0] === '#' ? url.slice(1) : url
|
||||||
return getElement(id)
|
return getElement(id)
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -1295,7 +1274,7 @@ export const getFeGaussianBlur = ele => {
|
|||||||
*/
|
*/
|
||||||
export const getElement = id => {
|
export const getElement = id => {
|
||||||
// querySelector lookup
|
// querySelector lookup
|
||||||
return svgroot_.querySelector('#' + id)
|
return svgroot_.querySelector(`#${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1310,9 +1289,9 @@ export const getElement = id => {
|
|||||||
export const assignAttributes = (elem, attrs, suspendLength, unitCheck) => {
|
export const assignAttributes = (elem, attrs, suspendLength, unitCheck) => {
|
||||||
for (const [key, value] of Object.entries(attrs)) {
|
for (const [key, value] of Object.entries(attrs)) {
|
||||||
const ns =
|
const ns =
|
||||||
key.substr(0, 4) === 'xml:'
|
key.startsWith('xml:')
|
||||||
? NS.XML
|
? NS.XML
|
||||||
: key.substr(0, 6) === 'xlink:'
|
: key.startsWith('xlink:')
|
||||||
? NS.XLINK
|
? NS.XLINK
|
||||||
: null
|
: null
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@svgedit/svgcanvas",
|
"name": "@svgedit/svgcanvas",
|
||||||
"version": "7.4.0",
|
"version": "7.4.1",
|
||||||
"description": "SVG Canvas",
|
"description": "SVG Canvas",
|
||||||
"main": "dist/svgcanvas.js",
|
"main": "dist/svgcanvas.js",
|
||||||
|
"types": "svgcanvas.d.ts",
|
||||||
"author": "Narendra Sisodiya",
|
"author": "Narendra Sisodiya",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
225
packages/svgcanvas/svgcanvas.d.ts
vendored
Normal file
225
packages/svgcanvas/svgcanvas.d.ts
vendored
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* TypeScript definitions for @svgedit/svgcanvas
|
||||||
|
* @module @svgedit/svgcanvas
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Core types
|
||||||
|
export interface SVGElementJSON {
|
||||||
|
element: string
|
||||||
|
attr: Record<string, string>
|
||||||
|
curStyles?: boolean
|
||||||
|
children?: SVGElementJSON[]
|
||||||
|
namespace?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
canvasName?: string
|
||||||
|
canvas_expansion?: number
|
||||||
|
initFill?: {
|
||||||
|
color?: string
|
||||||
|
opacity?: number
|
||||||
|
}
|
||||||
|
initStroke?: {
|
||||||
|
width?: number
|
||||||
|
color?: string
|
||||||
|
opacity?: number
|
||||||
|
}
|
||||||
|
text?: {
|
||||||
|
stroke_width?: number
|
||||||
|
font_size?: number
|
||||||
|
font_family?: string
|
||||||
|
}
|
||||||
|
selectionColor?: string
|
||||||
|
imgPath?: string
|
||||||
|
extensions?: string[]
|
||||||
|
initTool?: string
|
||||||
|
wireframe?: boolean
|
||||||
|
showlayers?: boolean
|
||||||
|
no_save_warning?: boolean
|
||||||
|
imgImport?: boolean
|
||||||
|
baseUnit?: string
|
||||||
|
snappingStep?: number
|
||||||
|
gridSnapping?: boolean
|
||||||
|
gridColor?: string
|
||||||
|
dimensions?: [number, number]
|
||||||
|
initOpacity?: number
|
||||||
|
colorPickerCSS?: string | null
|
||||||
|
initRight?: string
|
||||||
|
initBottom?: string
|
||||||
|
show_outside_canvas?: boolean
|
||||||
|
selectNew?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Resolution {
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
zoom?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BBox {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorContext {
|
||||||
|
getSvgContent(): SVGSVGElement
|
||||||
|
addSVGElementsFromJson(data: SVGElementJSON): Element
|
||||||
|
getSelectedElements(): Element[]
|
||||||
|
getDOMDocument(): HTMLDocument
|
||||||
|
getDOMContainer(): HTMLElement
|
||||||
|
getSvgRoot(): SVGSVGElement
|
||||||
|
getBaseUnit(): string
|
||||||
|
getSnappingStep(): number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paint types
|
||||||
|
export interface PaintOptions {
|
||||||
|
alpha?: number
|
||||||
|
solidColor?: string
|
||||||
|
type?: 'solidColor' | 'linearGradient' | 'radialGradient' | 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
// History command types
|
||||||
|
export interface HistoryCommand {
|
||||||
|
apply(handler: HistoryEventHandler): void | true
|
||||||
|
unapply(handler: HistoryEventHandler): void | true
|
||||||
|
elements(): Element[]
|
||||||
|
type(): string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryEventHandler {
|
||||||
|
handleHistoryEvent(eventType: string, cmd: HistoryCommand): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UndoManager {
|
||||||
|
getUndoStackSize(): number
|
||||||
|
getRedoStackSize(): number
|
||||||
|
getNextUndoCommandText(): string
|
||||||
|
getNextRedoCommandText(): string
|
||||||
|
resetUndoStack(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger types
|
||||||
|
export enum LogLevel {
|
||||||
|
NONE = 0,
|
||||||
|
ERROR = 1,
|
||||||
|
WARN = 2,
|
||||||
|
INFO = 3,
|
||||||
|
DEBUG = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Logger {
|
||||||
|
LogLevel: typeof LogLevel
|
||||||
|
setLogLevel(level: LogLevel): void
|
||||||
|
setLoggingEnabled(enabled: boolean): void
|
||||||
|
setLogPrefix(prefix: string): void
|
||||||
|
error(message: string, error?: Error | any, context?: string): void
|
||||||
|
warn(message: string, data?: any, context?: string): void
|
||||||
|
info(message: string, data?: any, context?: string): void
|
||||||
|
debug(message: string, data?: any, context?: string): void
|
||||||
|
getConfig(): { currentLevel: LogLevel; enabled: boolean; prefix: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main SvgCanvas class
|
||||||
|
export default class SvgCanvas {
|
||||||
|
constructor(container: HTMLElement, config?: Partial<Config>)
|
||||||
|
|
||||||
|
// Core methods
|
||||||
|
getSvgContent(): SVGSVGElement
|
||||||
|
getSvgRoot(): SVGSVGElement
|
||||||
|
getSvgString(): string
|
||||||
|
setSvgString(xmlString: string, preventUndo?: boolean): boolean
|
||||||
|
clearSelection(noCall?: boolean): void
|
||||||
|
selectOnly(elements: Element[], showGrips?: boolean): void
|
||||||
|
getResolution(): Resolution
|
||||||
|
setResolution(width: number | string, height: number | string): boolean
|
||||||
|
getZoom(): number
|
||||||
|
setZoom(zoomLevel: number): void
|
||||||
|
|
||||||
|
// Element manipulation
|
||||||
|
moveSelectedElements(dx: number, dy: number, undoable?: boolean): void
|
||||||
|
deleteSelectedElements(): void
|
||||||
|
cutSelectedElements(): void
|
||||||
|
copySelectedElements(): void
|
||||||
|
pasteElements(type?: string, x?: number, y?: number): void
|
||||||
|
groupSelectedElements(type?: string, urlArg?: string): Element | null
|
||||||
|
ungroupSelectedElement(): void
|
||||||
|
moveToTopSelectedElement(): void
|
||||||
|
moveToBottomSelectedElement(): void
|
||||||
|
moveUpDownSelected(dir: 'Up' | 'Down'): void
|
||||||
|
|
||||||
|
// Path operations
|
||||||
|
pathActions: {
|
||||||
|
clear: () => void
|
||||||
|
resetOrientation: (path: SVGPathElement) => boolean
|
||||||
|
zoomChange: () => void
|
||||||
|
getNodePoint: () => {x: number, y: number}
|
||||||
|
linkControlPoints: (linkPoints: boolean) => void
|
||||||
|
clonePathNode: () => void
|
||||||
|
deletePathNode: () => void
|
||||||
|
smoothPolylineIntoPath: () => void
|
||||||
|
setSegType: (type: number) => void
|
||||||
|
moveNode: (attr: string, newValue: number) => void
|
||||||
|
selectNode: (node?: Element) => void
|
||||||
|
opencloseSubPath: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer operations
|
||||||
|
getCurrentDrawing(): any
|
||||||
|
getNumLayers(): number
|
||||||
|
getLayer(name: string): any
|
||||||
|
getCurrentLayerName(): string
|
||||||
|
setCurrentLayer(name: string): boolean
|
||||||
|
renameCurrentLayer(newName: string): boolean
|
||||||
|
setCurrentLayerPosition(newPos: number): boolean
|
||||||
|
setLayerVisibility(name: string, bVisible: boolean): void
|
||||||
|
moveSelectedToLayer(layerName: string): void
|
||||||
|
cloneLayer(name?: string): void
|
||||||
|
deleteCurrentLayer(): boolean
|
||||||
|
|
||||||
|
// Drawing modes
|
||||||
|
setMode(name: string): void
|
||||||
|
getMode(): string
|
||||||
|
|
||||||
|
// Undo/Redo
|
||||||
|
undoMgr: UndoManager
|
||||||
|
undo(): void
|
||||||
|
redo(): void
|
||||||
|
|
||||||
|
// Events
|
||||||
|
call(event: string, args?: any[]): void
|
||||||
|
bind(event: string, callback: Function): void
|
||||||
|
unbind(event: string, callback: Function): void
|
||||||
|
|
||||||
|
// Attribute manipulation
|
||||||
|
changeSelectedAttribute(attr: string, val: string | number, elems?: Element[]): void
|
||||||
|
changeSelectedAttributeNoUndo(attr: string, val: string | number, elems?: Element[]): void
|
||||||
|
|
||||||
|
// Canvas properties
|
||||||
|
contentW: number
|
||||||
|
contentH: number
|
||||||
|
|
||||||
|
// Text operations
|
||||||
|
textActions: any
|
||||||
|
|
||||||
|
// Extensions
|
||||||
|
addExtension(name: string, extFunc: Function): void
|
||||||
|
|
||||||
|
// Export
|
||||||
|
getSvgString(): string
|
||||||
|
embedImage(dataURI: string): Promise<Element>
|
||||||
|
|
||||||
|
// Other utilities
|
||||||
|
getPrivateMethods(): any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export additional utilities
|
||||||
|
export * from './common/logger.js'
|
||||||
|
export { NS } from './core/namespaces.js'
|
||||||
|
export * from './core/math.js'
|
||||||
|
export * from './core/units.js'
|
||||||
|
export * from './core/utilities.js'
|
||||||
|
export { sanitizeSvg } from './core/sanitize.js'
|
||||||
|
export { default as dataStorage } from './core/dataStorage.js'
|
||||||
@@ -201,7 +201,7 @@ class SvgCanvas {
|
|||||||
this.curConfig.initFill.color,
|
this.curConfig.initFill.color,
|
||||||
fill_paint: null,
|
fill_paint: null,
|
||||||
fill_opacity: this.curConfig.initFill.opacity,
|
fill_opacity: this.curConfig.initFill.opacity,
|
||||||
stroke: '#' + this.curConfig.initStroke.color,
|
stroke: `#${this.curConfig.initStroke.color}`,
|
||||||
stroke_paint: null,
|
stroke_paint: null,
|
||||||
stroke_opacity: this.curConfig.initStroke.opacity,
|
stroke_opacity: this.curConfig.initStroke.opacity,
|
||||||
stroke_width: this.curConfig.initStroke.width,
|
stroke_width: this.curConfig.initStroke.width,
|
||||||
@@ -288,9 +288,9 @@ class SvgCanvas {
|
|||||||
*/
|
*/
|
||||||
const storageChange = ev => {
|
const storageChange = ev => {
|
||||||
if (!ev.newValue) return // This is a call from removeItem.
|
if (!ev.newValue) return // This is a call from removeItem.
|
||||||
if (ev.key === CLIPBOARD_ID + '_startup') {
|
if (ev.key === `${CLIPBOARD_ID}_startup`) {
|
||||||
// Another tab asked for our sessionStorage.
|
// Another tab asked for our sessionStorage.
|
||||||
localStorage.removeItem(CLIPBOARD_ID + '_startup')
|
localStorage.removeItem(`${CLIPBOARD_ID}_startup`)
|
||||||
this.flashStorage()
|
this.flashStorage()
|
||||||
} else if (ev.key === CLIPBOARD_ID) {
|
} else if (ev.key === CLIPBOARD_ID) {
|
||||||
// Another tab sent data.
|
// Another tab sent data.
|
||||||
@@ -301,7 +301,7 @@ class SvgCanvas {
|
|||||||
// Listen for changes to localStorage.
|
// Listen for changes to localStorage.
|
||||||
window.addEventListener('storage', storageChange, false)
|
window.addEventListener('storage', storageChange, false)
|
||||||
// Ask other tabs for sessionStorage (this is ONLY to trigger event).
|
// Ask other tabs for sessionStorage (this is ONLY to trigger event).
|
||||||
localStorage.setItem(CLIPBOARD_ID + '_startup', Math.random())
|
localStorage.setItem(`${CLIPBOARD_ID}_startup`, Math.random())
|
||||||
|
|
||||||
pasteInit(this)
|
pasteInit(this)
|
||||||
|
|
||||||
@@ -902,7 +902,7 @@ class SvgCanvas {
|
|||||||
})
|
})
|
||||||
Object.values(attrs).forEach(val => {
|
Object.values(attrs).forEach(val => {
|
||||||
if (val?.startsWith('url(')) {
|
if (val?.startsWith('url(')) {
|
||||||
const id = getUrlFromAttr(val).substr(1)
|
const id = getUrlFromAttr(val).slice(1)
|
||||||
const ref = getElement(id)
|
const ref = getElement(id)
|
||||||
if (!ref) {
|
if (!ref) {
|
||||||
findDefs().append(this.removedElements[id])
|
findDefs().append(this.removedElements[id])
|
||||||
@@ -1138,11 +1138,11 @@ class SvgCanvas {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
setPaintOpacity (type, val, preventUndo) {
|
setPaintOpacity (type, val, preventUndo) {
|
||||||
this.curShape[type + '_opacity'] = val
|
this.curShape[`${type}_opacity`] = val
|
||||||
if (!preventUndo) {
|
if (!preventUndo) {
|
||||||
this.changeSelectedAttribute(type + '-opacity', val)
|
this.changeSelectedAttribute(`${type}-opacity`, val)
|
||||||
} else {
|
} else {
|
||||||
this.changeSelectedAttributeNoUndo(type + '-opacity', val)
|
this.changeSelectedAttributeNoUndo(`${type}-opacity`, val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1167,7 +1167,7 @@ class SvgCanvas {
|
|||||||
if (elem) {
|
if (elem) {
|
||||||
const filterUrl = elem.getAttribute('filter')
|
const filterUrl = elem.getAttribute('filter')
|
||||||
if (filterUrl) {
|
if (filterUrl) {
|
||||||
const blur = getElement(elem.id + '_blur')
|
const blur = getElement(`${elem.id}_blur`)
|
||||||
if (blur) {
|
if (blur) {
|
||||||
val = blur.firstChild.getAttribute('stdDeviation')
|
val = blur.firstChild.getAttribute('stdDeviation')
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -138,8 +138,11 @@ async function main () {
|
|||||||
}
|
}
|
||||||
run(`git tag ${quoteArg(releaseName)}`)
|
run(`git tag ${quoteArg(releaseName)}`)
|
||||||
|
|
||||||
console.log('\nPublishing packages to npm...')
|
console.log('\nPublishing workspace packages to npm...')
|
||||||
run('npm publish --workspaces --include-workspace-root')
|
run('npm publish --workspaces')
|
||||||
|
|
||||||
|
console.log('\nPublishing root package to npm...')
|
||||||
|
run('npm publish')
|
||||||
|
|
||||||
console.log(`\nDone. Published ${releaseName}.`)
|
console.log(`\nDone. Published ${releaseName}.`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
import { copyFile, mkdir } from 'node:fs/promises'
|
import { copyFile, mkdir, readdir, readFile, stat } from 'node:fs/promises'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
|
|
||||||
@@ -37,12 +37,57 @@ const ensureBrowser = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getLatestMtime = async (root) => {
|
||||||
|
let latest = 0
|
||||||
|
const entries = await readdir(root, { withFileTypes: true })
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = join(root, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const childLatest = await getLatestMtime(fullPath)
|
||||||
|
if (childLatest > latest) latest = childLatest
|
||||||
|
} else {
|
||||||
|
const fileStat = await stat(fullPath)
|
||||||
|
if (fileStat.mtimeMs > latest) latest = fileStat.mtimeMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
const ensureBuild = async () => {
|
const ensureBuild = async () => {
|
||||||
const distIndex = join(process.cwd(), 'dist', 'editor', 'index.html')
|
const distIndex = join(process.cwd(), 'dist', 'editor', 'index.html')
|
||||||
if (existsSync(distIndex)) return
|
const distEditor = join(process.cwd(), 'dist', 'editor', 'Editor.js')
|
||||||
|
|
||||||
console.log('Building dist/editor for Playwright preview (missing build output)...')
|
// Check if build exists and has coverage instrumentation
|
||||||
|
let needsBuild = !existsSync(distIndex)
|
||||||
|
|
||||||
|
if (!needsBuild && existsSync(distEditor)) {
|
||||||
|
// Check if build has coverage instrumentation
|
||||||
|
const editorContent = await readFile(distEditor, 'utf-8')
|
||||||
|
const hasCoverage = editorContent.includes('__coverage__')
|
||||||
|
if (!hasCoverage) {
|
||||||
|
console.log('Existing build lacks coverage instrumentation, rebuilding...')
|
||||||
|
needsBuild = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needsBuild) {
|
||||||
|
const distStat = await stat(distIndex)
|
||||||
|
const roots = [
|
||||||
|
join(process.cwd(), 'packages', 'svgcanvas', 'core'),
|
||||||
|
join(process.cwd(), 'src')
|
||||||
|
]
|
||||||
|
const latestSource = Math.max(
|
||||||
|
...(await Promise.all(roots.map(getLatestMtime)))
|
||||||
|
)
|
||||||
|
if (latestSource > distStat.mtimeMs) {
|
||||||
|
needsBuild = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsBuild) {
|
||||||
|
console.log('Building dist/editor for Playwright preview...')
|
||||||
await run('npm', ['run', 'build'])
|
await run('npm', ['run', 'build'])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seedNycFromVitest = async () => {
|
const seedNycFromVitest = async () => {
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ function bumpVersion (version, type) {
|
|||||||
throw new Error(`Unknown bump type: ${type}`)
|
throw new Error(`Unknown bump type: ${type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateWorkspaceDependencyVersions (pkg, workspaceNames, newVersion) {
|
||||||
|
const dependencyFields = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
|
||||||
|
for (const field of dependencyFields) {
|
||||||
|
const dependencies = pkg[field]
|
||||||
|
if (!dependencies) continue
|
||||||
|
for (const dependencyName of Object.keys(dependencies)) {
|
||||||
|
if (!workspaceNames.has(dependencyName)) continue
|
||||||
|
dependencies[dependencyName] = newVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isGreaterVersion (a, b) {
|
function isGreaterVersion (a, b) {
|
||||||
const pa = parseSemver(a)
|
const pa = parseSemver(a)
|
||||||
const pb = parseSemver(b)
|
const pb = parseSemver(b)
|
||||||
@@ -121,6 +133,7 @@ async function chooseVersion (current) {
|
|||||||
|
|
||||||
async function main () {
|
async function main () {
|
||||||
const { rootPackage, workspaces } = loadPackages()
|
const { rootPackage, workspaces } = loadPackages()
|
||||||
|
const workspaceNames = new Set(workspaces.map(({ pkg }) => pkg.name))
|
||||||
console.log('Current versions:')
|
console.log('Current versions:')
|
||||||
console.log(`- ${rootPackage.name} (root): ${rootPackage.version}`)
|
console.log(`- ${rootPackage.name} (root): ${rootPackage.version}`)
|
||||||
for (const ws of workspaces) {
|
for (const ws of workspaces) {
|
||||||
@@ -131,9 +144,11 @@ async function main () {
|
|||||||
|
|
||||||
console.log(`\nUpdating all packages to ${newVersion}...`)
|
console.log(`\nUpdating all packages to ${newVersion}...`)
|
||||||
rootPackage.version = newVersion
|
rootPackage.version = newVersion
|
||||||
|
updateWorkspaceDependencyVersions(rootPackage, workspaceNames, newVersion)
|
||||||
writeJson(rootPackagePath, rootPackage)
|
writeJson(rootPackagePath, rootPackage)
|
||||||
for (const ws of workspaces) {
|
for (const ws of workspaces) {
|
||||||
ws.pkg.version = newVersion
|
ws.pkg.version = newVersion
|
||||||
|
updateWorkspaceDependencyVersions(ws.pkg, workspaceNames, newVersion)
|
||||||
writeJson(ws.packagePath, ws.pkg)
|
writeJson(ws.packagePath, ws.pkg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
322
tests/e2e/group-transforms.spec.js
Normal file
322
tests/e2e/group-transforms.spec.js
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
import { test, expect } from './fixtures.js'
|
||||||
|
import { setSvgSource, visitAndApproveStorage } from './helpers.js'
|
||||||
|
|
||||||
|
test.describe('Group transform preservation', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await visitAndApproveStorage(page)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserve group translate transform on click, move, and rotate', async ({ page }) => {
|
||||||
|
// Load SVG with group containing translate transform
|
||||||
|
await setSvgSource(page, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1290 810">
|
||||||
|
<g transform="translate(91.56,99.67)">
|
||||||
|
<path
|
||||||
|
transform="matrix(0,-1,-1,0,30.1,68.3)"
|
||||||
|
d="M 58.3,0 C 58.3,0 57.8,30.2 29.1,30.2 0.3,30.2 0,0 0,0 Z"
|
||||||
|
fill="none"
|
||||||
|
stroke="#000000"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
transform="rotate(-90,167.15,-98.85)"
|
||||||
|
d="M 58.3,0 C 58.3,0 57.8,30.2 29.1,30.2 0.3,30.2 0,0 0,0 Z"
|
||||||
|
fill="none"
|
||||||
|
stroke="#000000"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
transform="rotate(-90,49.3,19)"
|
||||||
|
d="M 0,0 H 58.3 V 235.7 H 0 Z"
|
||||||
|
fill="none"
|
||||||
|
stroke="#000000"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>`)
|
||||||
|
|
||||||
|
// Wait for SVG to be loaded
|
||||||
|
await page.waitForSelector('#svgroot', { timeout: 5000 })
|
||||||
|
|
||||||
|
// Click on one of the paths inside the group
|
||||||
|
// This should select the parent group
|
||||||
|
const firstPath = page.locator('#svg_2')
|
||||||
|
await firstPath.click()
|
||||||
|
|
||||||
|
// Verify the group was selected (not the individual path)
|
||||||
|
const selectedGroup = page.locator('#svg_1')
|
||||||
|
await expect(selectedGroup).toBeVisible()
|
||||||
|
|
||||||
|
// Test 1: Verify group transform is preserved after click
|
||||||
|
let groupTransform = await selectedGroup.getAttribute('transform')
|
||||||
|
expect(groupTransform).toContain('translate(91.56')
|
||||||
|
expect(groupTransform).toContain('99.67')
|
||||||
|
|
||||||
|
// Test 2: Move 100 pixels to the left using arrow keys
|
||||||
|
// Press Left arrow 10 times (each press moves 10 pixels with grid snapping)
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await page.keyboard.press('ArrowLeft')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify group transform still contains the original translate
|
||||||
|
groupTransform = await selectedGroup.getAttribute('transform')
|
||||||
|
expect(groupTransform).toContain('translate(91.56')
|
||||||
|
expect(groupTransform).toContain('99.67')
|
||||||
|
// And now also has a translate for the movement
|
||||||
|
expect(groupTransform).toMatch(/translate\([^)]+\).*translate\([^)]+\)/)
|
||||||
|
|
||||||
|
// Test 3: Rotate the group
|
||||||
|
await page.locator('#angle').evaluate(el => {
|
||||||
|
const input = el.shadowRoot.querySelector('elix-number-spin-box')
|
||||||
|
input.value = '5'
|
||||||
|
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify group transform has both rotate and original translate
|
||||||
|
groupTransform = await selectedGroup.getAttribute('transform')
|
||||||
|
expect(groupTransform).toContain('rotate(5')
|
||||||
|
expect(groupTransform).toContain('translate(91.56')
|
||||||
|
expect(groupTransform).toContain('99.67')
|
||||||
|
|
||||||
|
// Verify child paths still have their own transforms
|
||||||
|
const path1Transform = await page.locator('#svg_2').getAttribute('transform')
|
||||||
|
const path2Transform = await page.locator('#svg_3').getAttribute('transform')
|
||||||
|
const path3Transform = await page.locator('#svg_4').getAttribute('transform')
|
||||||
|
|
||||||
|
expect(path1Transform).toContain('matrix')
|
||||||
|
expect(path2Transform).toContain('rotate(-90')
|
||||||
|
expect(path3Transform).toContain('rotate(-90')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('multiple arrow key movements preserve group transform', async ({ page }) => {
|
||||||
|
await setSvgSource(page, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
|
||||||
|
<g id="testGroup" transform="translate(100,100)">
|
||||||
|
<rect id="testRect" x="0" y="0" width="50" height="50" fill="red"/>
|
||||||
|
</g>
|
||||||
|
</svg>`)
|
||||||
|
|
||||||
|
await page.waitForSelector('#svgroot', { timeout: 5000 })
|
||||||
|
|
||||||
|
// Select the group by clicking the rect
|
||||||
|
await page.locator('#testRect').click()
|
||||||
|
|
||||||
|
// Move right 5 times
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await page.keyboard.press('ArrowRight')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move down 3 times
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await page.keyboard.press('ArrowDown')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify original transform is still there
|
||||||
|
const groupTransform = await page.locator('#testGroup').getAttribute('transform')
|
||||||
|
expect(groupTransform).toContain('translate(100')
|
||||||
|
expect(groupTransform).toContain('100)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rotation followed by movement preserves both transforms', async ({ page }) => {
|
||||||
|
await setSvgSource(page, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
|
||||||
|
<g id="testGroup" transform="translate(200,150)">
|
||||||
|
<circle id="testCircle" cx="25" cy="25" r="20" fill="blue"/>
|
||||||
|
</g>
|
||||||
|
</svg>`)
|
||||||
|
|
||||||
|
await page.waitForSelector('#svgroot', { timeout: 5000 })
|
||||||
|
|
||||||
|
// Select the group
|
||||||
|
await page.locator('#testCircle').click()
|
||||||
|
|
||||||
|
// Rotate first
|
||||||
|
await page.locator('#angle').evaluate(el => {
|
||||||
|
const input = el.shadowRoot.querySelector('elix-number-spin-box')
|
||||||
|
input.value = '45'
|
||||||
|
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Then move
|
||||||
|
await page.keyboard.press('ArrowLeft')
|
||||||
|
await page.keyboard.press('ArrowLeft')
|
||||||
|
|
||||||
|
// Verify both rotate and translate are present
|
||||||
|
const groupTransform = await page.locator('#testGroup').getAttribute('transform')
|
||||||
|
expect(groupTransform).toContain('rotate(45')
|
||||||
|
expect(groupTransform).toContain('translate(200')
|
||||||
|
expect(groupTransform).toContain('150)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('multiple movements preserve group structure without flattening', async ({ page }) => {
|
||||||
|
await setSvgSource(page, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
|
||||||
|
<g id="testGroup" transform="translate(100,100)">
|
||||||
|
<rect id="testRect" x="0" y="0" width="50" height="50" fill="green"/>
|
||||||
|
</g>
|
||||||
|
</svg>`)
|
||||||
|
|
||||||
|
await page.waitForSelector('#svgroot', { timeout: 5000 })
|
||||||
|
|
||||||
|
// Click to select the group
|
||||||
|
const rect = page.locator('#testRect')
|
||||||
|
await rect.click()
|
||||||
|
|
||||||
|
// Store original transform
|
||||||
|
const originalTransform = await page.locator('#testGroup').getAttribute('transform')
|
||||||
|
expect(originalTransform).toContain('translate(100')
|
||||||
|
|
||||||
|
// First movement: move right and down using keyboard
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await page.keyboard.press('ArrowRight')
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await page.keyboard.press('ArrowDown')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify group still has transform attribute (not flattened to children)
|
||||||
|
let groupTransform = await page.locator('#testGroup').getAttribute('transform')
|
||||||
|
expect(groupTransform).toContain('translate')
|
||||||
|
// Verify original transform is preserved
|
||||||
|
expect(groupTransform).toContain('100')
|
||||||
|
|
||||||
|
// Most importantly: verify child has no transform (not flattened)
|
||||||
|
let rectTransform = await rect.getAttribute('transform')
|
||||||
|
expect(rectTransform).toBeNull()
|
||||||
|
|
||||||
|
// Second movement: move left and up
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await page.keyboard.press('ArrowLeft')
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await page.keyboard.press('ArrowUp')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify group still has transform
|
||||||
|
groupTransform = await page.locator('#testGroup').getAttribute('transform')
|
||||||
|
expect(groupTransform).toContain('translate')
|
||||||
|
|
||||||
|
// Critical: child should STILL have no transform
|
||||||
|
rectTransform = await rect.getAttribute('transform')
|
||||||
|
expect(rectTransform).toBeNull()
|
||||||
|
|
||||||
|
// Third movement: ensure consistency
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
await page.keyboard.press('ArrowRight')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final verification: group has transforms, child does not
|
||||||
|
groupTransform = await page.locator('#testGroup').getAttribute('transform')
|
||||||
|
expect(groupTransform).toContain('translate')
|
||||||
|
rectTransform = await rect.getAttribute('transform')
|
||||||
|
expect(rectTransform).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ungroup preserves element positions without jumping', async ({ page }) => {
|
||||||
|
// Test the real bug case: group with translate containing paths with complex transforms
|
||||||
|
await setSvgSource(page, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
|
||||||
|
<g id="testGroup" transform="translate(100,50)">
|
||||||
|
<path id="path1" transform="matrix(0,-1,-1,0,30,60)" d="M 10,0 L 30,0 L 30,40 L 10,40 Z" fill="blue"/>
|
||||||
|
<path id="path2" transform="rotate(-90,80,-50)" d="M 10,0 L 30,0 L 30,40 L 10,40 Z" fill="red"/>
|
||||||
|
<path id="path3" transform="rotate(-90,30,10)" d="M 0,0 H 50 V 200 H 0 Z" fill="green"/>
|
||||||
|
</g>
|
||||||
|
</svg>`)
|
||||||
|
|
||||||
|
await page.waitForSelector('#svgroot', { timeout: 5000 })
|
||||||
|
|
||||||
|
// Get initial bounding boxes before ungrouping
|
||||||
|
const path1Box = await page.locator('#path1').boundingBox()
|
||||||
|
const path2Box = await page.locator('#path2').boundingBox()
|
||||||
|
const path3Box = await page.locator('#path3').boundingBox()
|
||||||
|
|
||||||
|
// Click to select the group
|
||||||
|
await page.locator('#testGroup').click()
|
||||||
|
|
||||||
|
// Ungroup via keyboard shortcut or UI
|
||||||
|
await page.keyboard.press('Control+Shift+G')
|
||||||
|
|
||||||
|
// Wait for ungroup to complete
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
// Verify paths still exist and have transforms
|
||||||
|
const path1Transform = await page.locator('#path1').getAttribute('transform')
|
||||||
|
const path2Transform = await page.locator('#path2').getAttribute('transform')
|
||||||
|
const path3Transform = await page.locator('#path3').getAttribute('transform')
|
||||||
|
|
||||||
|
// All paths should have transforms (group's translate prepended to original)
|
||||||
|
expect(path1Transform).toBeTruthy()
|
||||||
|
expect(path2Transform).toBeTruthy()
|
||||||
|
expect(path3Transform).toBeTruthy()
|
||||||
|
|
||||||
|
// Critical: bounding boxes should not change (no visual jump)
|
||||||
|
const path1BoxAfter = await page.locator('#path1').boundingBox()
|
||||||
|
const path2BoxAfter = await page.locator('#path2').boundingBox()
|
||||||
|
const path3BoxAfter = await page.locator('#path3').boundingBox()
|
||||||
|
|
||||||
|
// Allow 1px tolerance for rounding
|
||||||
|
expect(Math.abs(path1BoxAfter.x - path1Box.x)).toBeLessThan(2)
|
||||||
|
expect(Math.abs(path1BoxAfter.y - path1Box.y)).toBeLessThan(2)
|
||||||
|
expect(Math.abs(path2BoxAfter.x - path2Box.x)).toBeLessThan(2)
|
||||||
|
expect(Math.abs(path2BoxAfter.y - path2Box.y)).toBeLessThan(2)
|
||||||
|
expect(Math.abs(path3BoxAfter.x - path3Box.x)).toBeLessThan(2)
|
||||||
|
expect(Math.abs(path3BoxAfter.y - path3Box.y)).toBeLessThan(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drag after ungroup works correctly without jumps', async ({ page }) => {
|
||||||
|
await setSvgSource(page, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
|
||||||
|
<g id="testGroup" transform="translate(100,100)">
|
||||||
|
<rect id="rect1" x="0" y="0" width="50" height="50" fill="red"/>
|
||||||
|
<rect id="rect2" x="60" y="0" width="50" height="50" fill="blue"/>
|
||||||
|
<rect id="rect3" x="120" y="0" width="50" height="50" fill="green"/>
|
||||||
|
</g>
|
||||||
|
</svg>`)
|
||||||
|
|
||||||
|
await page.waitForSelector('#svgroot', { timeout: 5000 })
|
||||||
|
|
||||||
|
// Select the group by clicking one of its children
|
||||||
|
await page.locator('#rect1').click()
|
||||||
|
|
||||||
|
// Ungroup
|
||||||
|
await page.keyboard.press('Control+Shift+G')
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
// All elements should still be selected after ungroup
|
||||||
|
// Get their positions before drag
|
||||||
|
const rect1Before = await page.locator('#rect1').boundingBox()
|
||||||
|
const rect2Before = await page.locator('#rect2').boundingBox()
|
||||||
|
const rect3Before = await page.locator('#rect3').boundingBox()
|
||||||
|
|
||||||
|
// Drag all selected elements using arrow keys
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await page.keyboard.press('ArrowRight')
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await page.keyboard.press('ArrowDown')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get positions after drag
|
||||||
|
const rect1After = await page.locator('#rect1').boundingBox()
|
||||||
|
const rect2After = await page.locator('#rect2').boundingBox()
|
||||||
|
const rect3After = await page.locator('#rect3').boundingBox()
|
||||||
|
|
||||||
|
// All elements should have moved by approximately the same amount
|
||||||
|
const rect1Delta = { x: rect1After.x - rect1Before.x, y: rect1After.y - rect1Before.y }
|
||||||
|
const rect2Delta = { x: rect2After.x - rect2Before.x, y: rect2After.y - rect2Before.y }
|
||||||
|
const rect3Delta = { x: rect3After.x - rect3Before.x, y: rect3After.y - rect3Before.y }
|
||||||
|
|
||||||
|
// All should have moved approximately 50px right and 50px down (with grid snapping)
|
||||||
|
expect(rect1Delta.x).toBeGreaterThan(40)
|
||||||
|
expect(rect1Delta.y).toBeGreaterThan(40)
|
||||||
|
|
||||||
|
// Deltas should be similar for all elements (moved together)
|
||||||
|
expect(Math.abs(rect1Delta.x - rect2Delta.x)).toBeLessThan(5)
|
||||||
|
expect(Math.abs(rect1Delta.y - rect2Delta.y)).toBeLessThan(5)
|
||||||
|
expect(Math.abs(rect1Delta.x - rect3Delta.x)).toBeLessThan(5)
|
||||||
|
expect(Math.abs(rect1Delta.y - rect3Delta.y)).toBeLessThan(5)
|
||||||
|
|
||||||
|
// Verify transforms are consolidated (not accumulating)
|
||||||
|
const rect1Transform = await page.locator('#rect1').getAttribute('transform')
|
||||||
|
|
||||||
|
// Should have single consolidated transforms, not multiple stacked
|
||||||
|
// Transform can be null (no transform) or contain at most one translate
|
||||||
|
if (rect1Transform) {
|
||||||
|
expect((rect1Transform.match(/translate/g) || []).length).toBeLessThanOrEqual(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -126,4 +126,165 @@ test.describe('Regression issues', () => {
|
|||||||
})
|
})
|
||||||
expect(Number(widthPx)).toBeGreaterThan(0)
|
expect(Number(widthPx)).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('issue 462: dragging element with complex matrix transforms stays stable', async ({ page }) => {
|
||||||
|
// This tests the fix for issue #462 where elements with complex matrix transforms
|
||||||
|
// in nested groups would jump around when dragged
|
||||||
|
await setSvgSource(page, `<svg width="640" height="480" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g class="layer">
|
||||||
|
<title>Layer 1</title>
|
||||||
|
<g id="svg_1" transform="skewX(30) translate(-3,4) rotate(3)">
|
||||||
|
<g id="svg_2" transform="skewX(10) translate(-3,4) rotate(10)">
|
||||||
|
<circle cx="40.61157" cy="40" fill="blue" id="svg_3" r="20" stroke="#000000" stroke-width="2" transform="translate(250,-50) rotate(45) scale(1.5)"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>`)
|
||||||
|
|
||||||
|
await page.waitForSelector('#svgroot', { timeout: 5000 })
|
||||||
|
|
||||||
|
// Get the circle element and its initial bounding box
|
||||||
|
const circle = page.locator('#svg_3')
|
||||||
|
await circle.click()
|
||||||
|
|
||||||
|
// Get initial position via getBoundingClientRect
|
||||||
|
const initialBBox = await circle.evaluate(el => {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Move using arrow keys (small movements to test stability)
|
||||||
|
await page.keyboard.press('ArrowRight')
|
||||||
|
await page.keyboard.press('ArrowDown')
|
||||||
|
|
||||||
|
// Get position after movement
|
||||||
|
const afterMoveBBox = await circle.evaluate(el => {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
||||||
|
})
|
||||||
|
|
||||||
|
// The element should have moved roughly in the expected direction
|
||||||
|
// Due to transforms, the actual pixel movement may vary, but it should be reasonable
|
||||||
|
// Key check: The element should NOT have jumped wildly (e.g., more than 200px difference)
|
||||||
|
const deltaX = Math.abs(afterMoveBBox.x - initialBBox.x)
|
||||||
|
const deltaY = Math.abs(afterMoveBBox.y - initialBBox.y)
|
||||||
|
|
||||||
|
// Movement should be small and controlled (less than 100px for a single arrow key press)
|
||||||
|
expect(deltaX).toBeLessThan(100)
|
||||||
|
expect(deltaY).toBeLessThan(100)
|
||||||
|
|
||||||
|
// Element dimensions should remain stable (not get distorted)
|
||||||
|
expect(Math.abs(afterMoveBBox.width - initialBBox.width)).toBeLessThan(5)
|
||||||
|
expect(Math.abs(afterMoveBBox.height - initialBBox.height)).toBeLessThan(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('issue 391: selection box position after ungrouping and path edit', async ({ page }) => {
|
||||||
|
// This tests the fix for issue #391 where selection boxes and path edit points
|
||||||
|
// were not at correct positions after ungrouping and double-clicking to edit a path
|
||||||
|
// Uses a simplified version of a complex SVG with nested groups
|
||||||
|
await setSvgSource(page, `<svg width="640" height="480" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g class="layer">
|
||||||
|
<title>Layer 1</title>
|
||||||
|
<g id="svg_1" transform="translate(100, 100)">
|
||||||
|
<path id="svg_2" d="M 0,0 L 50,0 L 50,50 L 0,50 Z" fill="#ff0000" stroke="#000000" stroke-width="2"/>
|
||||||
|
<path id="svg_3" d="M 60,0 L 110,0 L 110,50 L 60,50 Z" fill="#00ff00" stroke="#000000" stroke-width="2"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>`)
|
||||||
|
|
||||||
|
await page.waitForSelector('#svgroot', { timeout: 5000 })
|
||||||
|
|
||||||
|
// Select the group using force click to bypass svgroot intercept
|
||||||
|
const group = page.locator('#svg_1')
|
||||||
|
await group.click({ force: true })
|
||||||
|
|
||||||
|
// Ungroup using keyboard shortcut Ctrl+Shift+G
|
||||||
|
await page.keyboard.press('Control+Shift+g')
|
||||||
|
|
||||||
|
// Wait for ungrouping to complete
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
// Select the first path
|
||||||
|
const path = page.locator('#svg_2')
|
||||||
|
await path.click({ force: true })
|
||||||
|
|
||||||
|
// Wait for selection to be processed
|
||||||
|
await page.waitForTimeout(200)
|
||||||
|
|
||||||
|
// Get the path's screen position
|
||||||
|
const pathBBox = await path.evaluate(el => {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, cx: rect.x + rect.width / 2, cy: rect.y + rect.height / 2 }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify the path still has reasonable coordinates after ungrouping
|
||||||
|
// The path should now have its transform baked in (translated by 100,100)
|
||||||
|
expect(pathBBox.width).toBeGreaterThan(0)
|
||||||
|
expect(pathBBox.height).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Double-click to enter path edit mode
|
||||||
|
await path.dblclick({ force: true })
|
||||||
|
|
||||||
|
// Wait for path edit mode
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
// Check for path point grips (pointgrip_0 is the first control point)
|
||||||
|
const pointGrip = page.locator('#pathpointgrip_0')
|
||||||
|
const pointGripVisible = await pointGrip.isVisible().catch(() => false)
|
||||||
|
|
||||||
|
// If path edit mode activated, verify control point positions
|
||||||
|
if (pointGripVisible) {
|
||||||
|
const pointGripBBox = await pointGrip.evaluate(el => {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
return { x: rect.x, y: rect.y }
|
||||||
|
})
|
||||||
|
|
||||||
|
// The first point should be near the top-left of the path
|
||||||
|
// After ungrouping with translate(100,100), the path moves
|
||||||
|
// Allow reasonable tolerance
|
||||||
|
const tolerance = 100
|
||||||
|
expect(Math.abs(pointGripBBox.x - pathBBox.x)).toBeLessThan(tolerance)
|
||||||
|
expect(Math.abs(pointGripBBox.y - pathBBox.y)).toBeLessThan(tolerance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the path's d attribute was updated correctly after ungrouping
|
||||||
|
const dAttr = await path.getAttribute('d')
|
||||||
|
expect(dAttr).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('issue 404: border width during resize at zoom', async ({ page }) => {
|
||||||
|
// This tests the fix for issue #404 where border width appeared incorrect
|
||||||
|
// during resize when zoom was not at 100%
|
||||||
|
await setSvgSource(page, `<svg width="640" height="480" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g class="layer">
|
||||||
|
<title>Layer 1</title>
|
||||||
|
<rect id="svg_1" x="100" y="100" width="200" height="150" fill="#00ff00" stroke="#000000" stroke-width="10"/>
|
||||||
|
</g>
|
||||||
|
</svg>`)
|
||||||
|
|
||||||
|
await page.waitForSelector('#svgroot', { timeout: 5000 })
|
||||||
|
|
||||||
|
// Set zoom to 150%
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.svgEditor.svgCanvas.setZoom(1.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for zoom to apply
|
||||||
|
await page.waitForTimeout(200)
|
||||||
|
|
||||||
|
// Select the rectangle
|
||||||
|
const rect = page.locator('#svg_1')
|
||||||
|
await rect.click({ force: true })
|
||||||
|
|
||||||
|
// Get the initial stroke-width
|
||||||
|
const initialStrokeWidth = await rect.getAttribute('stroke-width')
|
||||||
|
expect(initialStrokeWidth).toBe('10')
|
||||||
|
|
||||||
|
// After any interaction, stroke-width should remain constant
|
||||||
|
await page.keyboard.press('ArrowRight')
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const afterMoveStrokeWidth = await rect.getAttribute('stroke-width')
|
||||||
|
expect(afterMoveStrokeWidth).toBe('10')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -59,4 +59,20 @@ describe('locale loader', () => {
|
|||||||
expect(result.langParam).toBe('en')
|
expect(result.langParam).toBe('en')
|
||||||
expect(t('common.ok')).toBe('OK')
|
expect(t('common.ok')).toBe('OK')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('uses navigator.language with supported locale', async () => {
|
||||||
|
Reflect.deleteProperty(navigator, 'userLanguage')
|
||||||
|
setNavigatorProp('language', 'de')
|
||||||
|
|
||||||
|
const result = await putLocale('', goodLangs)
|
||||||
|
expect(result.langParam).toBe('de')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses explicit lang parameter over navigator', async () => {
|
||||||
|
setNavigatorProp('userLanguage', 'de')
|
||||||
|
setNavigatorProp('language', 'de')
|
||||||
|
|
||||||
|
const result = await putLocale('en', goodLangs)
|
||||||
|
expect(result.langParam).toBe('en')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
124
tests/unit/blur-event.test.js
Normal file
124
tests/unit/blur-event.test.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import SvgCanvas from '../../packages/svgcanvas/svgcanvas.js'
|
||||||
|
|
||||||
|
describe('blur-event', () => {
|
||||||
|
let svgCanvas
|
||||||
|
|
||||||
|
const createSvgCanvas = () => {
|
||||||
|
document.body.textContent = ''
|
||||||
|
const svgEditor = document.createElement('div')
|
||||||
|
svgEditor.id = 'svg_editor'
|
||||||
|
const svgcanvas = document.createElement('div')
|
||||||
|
svgcanvas.style.visibility = 'hidden'
|
||||||
|
svgcanvas.id = 'svgcanvas'
|
||||||
|
const workarea = document.createElement('div')
|
||||||
|
workarea.id = 'workarea'
|
||||||
|
workarea.append(svgcanvas)
|
||||||
|
const toolsLeft = document.createElement('div')
|
||||||
|
toolsLeft.id = 'tools_left'
|
||||||
|
svgEditor.append(workarea, toolsLeft)
|
||||||
|
document.body.append(svgEditor)
|
||||||
|
|
||||||
|
svgCanvas = new SvgCanvas(document.getElementById('svgcanvas'), {
|
||||||
|
canvas_expansion: 3,
|
||||||
|
dimensions: [640, 480],
|
||||||
|
initFill: {
|
||||||
|
color: 'FF0000',
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
initStroke: {
|
||||||
|
width: 5,
|
||||||
|
color: '000000',
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
initOpacity: 1,
|
||||||
|
imgPath: '../editor/images',
|
||||||
|
langPath: 'locale/',
|
||||||
|
extPath: 'extensions/',
|
||||||
|
extensions: [],
|
||||||
|
initTool: 'select',
|
||||||
|
wireframe: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
createSvgCanvas()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.textContent = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not create a filter or history when setting blur to 0 on a new element', () => {
|
||||||
|
const rect = svgCanvas.addSVGElementsFromJson({
|
||||||
|
element: 'rect',
|
||||||
|
attr: {
|
||||||
|
id: 'rect-blur-zero',
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 30,
|
||||||
|
height: 40
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
svgCanvas.selectOnly([rect], true)
|
||||||
|
const undoSize = svgCanvas.undoMgr.getUndoStackSize()
|
||||||
|
|
||||||
|
svgCanvas.setBlur(0, true)
|
||||||
|
|
||||||
|
expect(rect.hasAttribute('filter')).toBe(false)
|
||||||
|
expect(svgCanvas.getSvgContent().querySelector('#rect-blur-zero_blur')).toBeNull()
|
||||||
|
expect(svgCanvas.undoMgr.getUndoStackSize()).toBe(undoSize)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a blur filter and records a single history entry', () => {
|
||||||
|
const rect = svgCanvas.addSVGElementsFromJson({
|
||||||
|
element: 'rect',
|
||||||
|
attr: {
|
||||||
|
id: 'rect-blur-create',
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 30,
|
||||||
|
height: 40
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
svgCanvas.selectOnly([rect], true)
|
||||||
|
const undoSize = svgCanvas.undoMgr.getUndoStackSize()
|
||||||
|
|
||||||
|
svgCanvas.setBlur(1.2, true)
|
||||||
|
|
||||||
|
expect(rect.getAttribute('filter')).toBe('url(#rect-blur-create_blur)')
|
||||||
|
const filter = svgCanvas.getSvgContent().querySelector('#rect-blur-create_blur')
|
||||||
|
expect(filter).toBeTruthy()
|
||||||
|
expect(filter.querySelector('feGaussianBlur').getAttribute('stdDeviation')).toBe('1.2')
|
||||||
|
expect(svgCanvas.undoMgr.getUndoStackSize()).toBe(undoSize + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes blur and supports undo/redo', () => {
|
||||||
|
const rect = svgCanvas.addSVGElementsFromJson({
|
||||||
|
element: 'rect',
|
||||||
|
attr: {
|
||||||
|
id: 'rect-blur-undo',
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 30,
|
||||||
|
height: 40
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
svgCanvas.selectOnly([rect], true)
|
||||||
|
svgCanvas.setBlur(2, true)
|
||||||
|
|
||||||
|
const undoSize = svgCanvas.undoMgr.getUndoStackSize()
|
||||||
|
svgCanvas.setBlur(0, true)
|
||||||
|
|
||||||
|
expect(rect.hasAttribute('filter')).toBe(false)
|
||||||
|
expect(svgCanvas.undoMgr.getUndoStackSize()).toBe(undoSize + 1)
|
||||||
|
|
||||||
|
svgCanvas.undoMgr.undo()
|
||||||
|
expect(rect.getAttribute('filter')).toBe('url(#rect-blur-undo_blur)')
|
||||||
|
|
||||||
|
svgCanvas.undoMgr.redo()
|
||||||
|
expect(rect.hasAttribute('filter')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -38,6 +38,18 @@ describe('clearSvgContentElementInit', () => {
|
|||||||
expect(svgContent.getAttribute('xmlns')).toBe('http://www.w3.org/2000/svg')
|
expect(svgContent.getAttribute('xmlns')).toBe('http://www.w3.org/2000/svg')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resets stale svgcontent attributes', () => {
|
||||||
|
const { canvas, svgContent } = buildCanvas(false)
|
||||||
|
svgContent.setAttribute('viewBox', '0 0 10 10')
|
||||||
|
svgContent.setAttribute('class', 'stale')
|
||||||
|
initClear(canvas)
|
||||||
|
|
||||||
|
clearSvgContentElementInit()
|
||||||
|
|
||||||
|
expect(svgContent.getAttribute('viewBox')).toBe(null)
|
||||||
|
expect(svgContent.getAttribute('class')).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
it('honors show_outside_canvas by leaving overflow visible', () => {
|
it('honors show_outside_canvas by leaving overflow visible', () => {
|
||||||
const { canvas, svgContent } = buildCanvas(true)
|
const { canvas, svgContent } = buildCanvas(true)
|
||||||
initClear(canvas)
|
initClear(canvas)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ describe('coords', function () {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
|
elemId = 1
|
||||||
const svgroot = document.createElementNS(NS.SVG, 'svg')
|
const svgroot = document.createElementNS(NS.SVG, 'svg')
|
||||||
svgroot.id = 'svgroot'
|
svgroot.id = 'svgroot'
|
||||||
root.append(svgroot)
|
root.append(svgroot)
|
||||||
@@ -28,21 +29,28 @@ describe('coords', function () {
|
|||||||
*/
|
*/
|
||||||
{
|
{
|
||||||
getSvgRoot: () => { return svg },
|
getSvgRoot: () => { return svg },
|
||||||
|
getSvgContent: () => { return svg },
|
||||||
getDOMDocument () { return null },
|
getDOMDocument () { return null },
|
||||||
getDOMContainer () { return null }
|
getDOMContainer () { return null }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
const drawing = {
|
||||||
|
getNextId () { return String(elemId++) }
|
||||||
|
}
|
||||||
|
const mockDataStorage = {
|
||||||
|
get (elem, key) { return null },
|
||||||
|
has (elem, key) { return false }
|
||||||
|
}
|
||||||
coords.init(
|
coords.init(
|
||||||
/**
|
/**
|
||||||
* @implements {module:coords.EditorContext}
|
* @implements {module:coords.EditorContext}
|
||||||
*/
|
*/
|
||||||
{
|
{
|
||||||
getGridSnapping () { return false },
|
getGridSnapping () { return false },
|
||||||
getDrawing () {
|
getDrawing () { return drawing },
|
||||||
return {
|
getCurrentDrawing () { return drawing },
|
||||||
getNextId () { return String(elemId++) }
|
getDataStorage () { return mockDataStorage },
|
||||||
}
|
getSvgRoot () { return svg }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -166,6 +174,41 @@ describe('coords', function () {
|
|||||||
assert.equal(circle.getAttribute('r'), '125')
|
assert.equal(circle.getAttribute('r'), '125')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Test remapElement flips radial gradients on negative scale', function () {
|
||||||
|
const defs = document.createElementNS(NS.SVG, 'defs')
|
||||||
|
svg.append(defs)
|
||||||
|
|
||||||
|
const grad = document.createElementNS(NS.SVG, 'radialGradient')
|
||||||
|
grad.id = 'grad1'
|
||||||
|
grad.setAttribute('cx', '0.2')
|
||||||
|
grad.setAttribute('cy', '0.3')
|
||||||
|
grad.setAttribute('fx', '0.4')
|
||||||
|
grad.setAttribute('fy', '0.5')
|
||||||
|
defs.append(grad)
|
||||||
|
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rect.setAttribute('x', '0')
|
||||||
|
rect.setAttribute('y', '0')
|
||||||
|
rect.setAttribute('width', '10')
|
||||||
|
rect.setAttribute('height', '10')
|
||||||
|
rect.setAttribute('fill', 'url(#grad1)')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = -1
|
||||||
|
m.d = 1
|
||||||
|
m.e = 0
|
||||||
|
m.f = 0
|
||||||
|
|
||||||
|
coords.remapElement(rect, { x: 0, y: 0, width: 10, height: 10 }, m)
|
||||||
|
|
||||||
|
const newId = rect.getAttribute('fill').replace('url(#', '').replace(')', '')
|
||||||
|
const mirrored = defs.ownerDocument.getElementById(newId)
|
||||||
|
assert.ok(mirrored)
|
||||||
|
assert.equal(mirrored.getAttribute('cx'), '0.8')
|
||||||
|
assert.equal(mirrored.getAttribute('fx'), '0.6')
|
||||||
|
})
|
||||||
|
|
||||||
it('Test remapElement(translate) for ellipse', function () {
|
it('Test remapElement(translate) for ellipse', function () {
|
||||||
const ellipse = document.createElementNS(NS.SVG, 'ellipse')
|
const ellipse = document.createElementNS(NS.SVG, 'ellipse')
|
||||||
ellipse.setAttribute('cx', '200')
|
ellipse.setAttribute('cx', '200')
|
||||||
@@ -304,4 +347,669 @@ describe('coords', function () {
|
|||||||
assert.equal(text.getAttribute('x'), '150')
|
assert.equal(text.getAttribute('x'), '150')
|
||||||
assert.equal(text.getAttribute('y'), '50')
|
assert.equal(text.getAttribute('y'), '50')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Does not throw with grid snapping enabled and detached elements', function () {
|
||||||
|
coords.init({
|
||||||
|
getGridSnapping () { return true },
|
||||||
|
getDrawing () {
|
||||||
|
return {
|
||||||
|
getNextId () { return String(elemId++) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getCurrentDrawing () {
|
||||||
|
return {
|
||||||
|
getNextId () { return String(elemId++) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rect.setAttribute('width', '10')
|
||||||
|
rect.setAttribute('height', '10')
|
||||||
|
const attrs = { x: 0, y: 0, width: 10, height: 10 }
|
||||||
|
const m = svg.createSVGMatrix().translate(5, 5)
|
||||||
|
coords.remapElement(rect, attrs, m)
|
||||||
|
assert.equal(rect.getAttribute('x'), '5')
|
||||||
|
assert.equal(rect.getAttribute('y'), '5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Clones and flips linearGradient on horizontal flip', function () {
|
||||||
|
const defs = document.createElementNS(NS.SVG, 'defs')
|
||||||
|
svg.append(defs)
|
||||||
|
const grad = document.createElementNS(NS.SVG, 'linearGradient')
|
||||||
|
grad.id = 'grad1'
|
||||||
|
grad.setAttribute('x1', '0')
|
||||||
|
grad.setAttribute('x2', '1')
|
||||||
|
grad.setAttribute('y1', '0')
|
||||||
|
grad.setAttribute('y2', '0')
|
||||||
|
defs.append(grad)
|
||||||
|
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rect.setAttribute('fill', 'url(#grad1)')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
const attrs = { x: 0, y: 0, width: 10, height: 10 }
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = -1
|
||||||
|
m.d = 1
|
||||||
|
coords.remapElement(rect, attrs, m)
|
||||||
|
|
||||||
|
const grads = defs.querySelectorAll('linearGradient')
|
||||||
|
assert.equal(grads.length, 2)
|
||||||
|
const cloned = [...grads].find(g => g.id !== 'grad1')
|
||||||
|
assert.ok(cloned)
|
||||||
|
assert.equal(rect.getAttribute('fill'), `url(#${cloned.id})`)
|
||||||
|
assert.equal(cloned.getAttribute('x1'), '1')
|
||||||
|
assert.equal(cloned.getAttribute('x2'), '0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Skips gradient cloning for external URL references', function () {
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rect.setAttribute('fill', 'url(external.svg#grad)')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
const attrs = { x: 0, y: 0, width: 10, height: 10 }
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = -1
|
||||||
|
m.d = 1
|
||||||
|
coords.remapElement(rect, attrs, m)
|
||||||
|
|
||||||
|
assert.equal(rect.getAttribute('fill'), 'url(external.svg#grad)')
|
||||||
|
assert.equal(svg.querySelectorAll('linearGradient').length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Keeps arc radii positive and toggles sweep on reflection', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0 0 A10 5 30 0 0 30 20')
|
||||||
|
svg.append(path)
|
||||||
|
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = -2
|
||||||
|
m.d = 1
|
||||||
|
coords.remapElement(path, {}, m)
|
||||||
|
|
||||||
|
const d = path.getAttribute('d')
|
||||||
|
const match = /A\s*([-\d.]+),([-\d.]+)\s+([-\d.]+)\s+(\d+)\s+(\d+)\s+([-\d.]+),([-\d.]+)/.exec(d)
|
||||||
|
assert.ok(match, `Unexpected path d: ${d}`)
|
||||||
|
const [, rx, ry, angle, largeArc, sweep, x, y] = match
|
||||||
|
assert.equal(Number(rx), 20)
|
||||||
|
assert.equal(Number(ry), 5)
|
||||||
|
assert.equal(Number(angle), -30)
|
||||||
|
assert.equal(Number(largeArc), 0)
|
||||||
|
assert.equal(Number(sweep), 1)
|
||||||
|
assert.equal(Number(x), -60)
|
||||||
|
assert.equal(Number(y), 20)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Additional tests for branch coverage
|
||||||
|
it('Test remapElement with radial gradient and negative scale', function () {
|
||||||
|
const defs = document.createElementNS(NS.SVG, 'defs')
|
||||||
|
svg.append(defs)
|
||||||
|
|
||||||
|
const grad = document.createElementNS(NS.SVG, 'radialGradient')
|
||||||
|
grad.id = 'radialGrad1'
|
||||||
|
grad.setAttribute('cx', '50%')
|
||||||
|
grad.setAttribute('cy', '50%')
|
||||||
|
grad.setAttribute('r', '50%')
|
||||||
|
grad.setAttribute('fx', '30%')
|
||||||
|
grad.setAttribute('fy', '30%')
|
||||||
|
defs.append(grad)
|
||||||
|
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rect.setAttribute('fill', 'url(#radialGrad1)')
|
||||||
|
rect.setAttribute('width', '100')
|
||||||
|
rect.setAttribute('height', '100')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
const attrs = { x: 0, y: 0, width: 100, height: 100 }
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = -1
|
||||||
|
m.d = -1
|
||||||
|
coords.remapElement(rect, attrs, m)
|
||||||
|
|
||||||
|
// Should create a mirrored gradient or keep original
|
||||||
|
assert.ok(svg.querySelectorAll('radialGradient').length >= 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with image and negative scale', function () {
|
||||||
|
const image = document.createElementNS(NS.SVG, 'image')
|
||||||
|
image.setAttribute('x', '10')
|
||||||
|
image.setAttribute('y', '10')
|
||||||
|
image.setAttribute('width', '100')
|
||||||
|
image.setAttribute('height', '80')
|
||||||
|
svg.append(image)
|
||||||
|
|
||||||
|
const attrs = { x: 10, y: 10, width: 100, height: 80 }
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = -1
|
||||||
|
m.d = 1
|
||||||
|
coords.remapElement(image, attrs, m)
|
||||||
|
|
||||||
|
// Image with negative scale should get matrix transform or have updated attributes
|
||||||
|
assert.ok(image.transform.baseVal.numberOfItems > 0 || image.getAttribute('width') !== '100')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with foreignObject', function () {
|
||||||
|
const fo = document.createElementNS(NS.SVG, 'foreignObject')
|
||||||
|
fo.setAttribute('x', '10')
|
||||||
|
fo.setAttribute('y', '10')
|
||||||
|
fo.setAttribute('width', '100')
|
||||||
|
fo.setAttribute('height', '80')
|
||||||
|
svg.append(fo)
|
||||||
|
|
||||||
|
const attrs = { x: 10, y: 10, width: 100, height: 80 }
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 2
|
||||||
|
m.d = 2
|
||||||
|
m.e = 50
|
||||||
|
m.f = 50
|
||||||
|
coords.remapElement(fo, attrs, m)
|
||||||
|
|
||||||
|
assert.equal(Number.parseFloat(fo.getAttribute('x')), 70)
|
||||||
|
assert.equal(Number.parseFloat(fo.getAttribute('y')), 70)
|
||||||
|
assert.equal(Number.parseFloat(fo.getAttribute('width')), 200)
|
||||||
|
assert.equal(Number.parseFloat(fo.getAttribute('height')), 160)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with use element (should skip)', function () {
|
||||||
|
const use = document.createElementNS(NS.SVG, 'use')
|
||||||
|
use.setAttribute('x', '10')
|
||||||
|
use.setAttribute('y', '10')
|
||||||
|
use.setAttribute('href', '#someId')
|
||||||
|
svg.append(use)
|
||||||
|
|
||||||
|
const attrs = { x: 10, y: 10 }
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 2
|
||||||
|
m.d = 2
|
||||||
|
m.e = 50
|
||||||
|
m.f = 50
|
||||||
|
coords.remapElement(use, attrs, m)
|
||||||
|
|
||||||
|
// Use elements should not be remapped, attributes remain unchanged
|
||||||
|
assert.equal(use.getAttribute('x'), '10')
|
||||||
|
assert.equal(use.getAttribute('y'), '10')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with text element', function () {
|
||||||
|
const text = document.createElementNS(NS.SVG, 'text')
|
||||||
|
text.setAttribute('x', '50')
|
||||||
|
text.setAttribute('y', '50')
|
||||||
|
text.textContent = 'Test'
|
||||||
|
svg.append(text)
|
||||||
|
|
||||||
|
const attrs = { x: 50, y: 50 }
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 1
|
||||||
|
m.d = 1
|
||||||
|
m.e = 10
|
||||||
|
m.f = 20
|
||||||
|
coords.remapElement(text, attrs, m)
|
||||||
|
|
||||||
|
assert.equal(Number.parseFloat(text.getAttribute('x')), 60)
|
||||||
|
assert.equal(Number.parseFloat(text.getAttribute('y')), 70)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with tspan element', function () {
|
||||||
|
const text = document.createElementNS(NS.SVG, 'text')
|
||||||
|
text.setAttribute('x', '50')
|
||||||
|
text.setAttribute('y', '50')
|
||||||
|
const tspan = document.createElementNS(NS.SVG, 'tspan')
|
||||||
|
tspan.setAttribute('x', '55')
|
||||||
|
tspan.setAttribute('y', '55')
|
||||||
|
tspan.textContent = 'Test'
|
||||||
|
text.append(tspan)
|
||||||
|
svg.append(text)
|
||||||
|
|
||||||
|
const attrs = { x: 55, y: 55 }
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 1
|
||||||
|
m.d = 1
|
||||||
|
m.e = 5
|
||||||
|
m.f = 10
|
||||||
|
coords.remapElement(tspan, attrs, m)
|
||||||
|
|
||||||
|
assert.equal(Number.parseFloat(tspan.getAttribute('x')), 60)
|
||||||
|
assert.equal(Number.parseFloat(tspan.getAttribute('y')), 65)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with gradient in userSpaceOnUse mode', function () {
|
||||||
|
const defs = document.createElementNS(NS.SVG, 'defs')
|
||||||
|
svg.append(defs)
|
||||||
|
|
||||||
|
const grad = document.createElementNS(NS.SVG, 'linearGradient')
|
||||||
|
grad.id = 'userSpaceGrad'
|
||||||
|
grad.setAttribute('gradientUnits', 'userSpaceOnUse')
|
||||||
|
grad.setAttribute('x1', '0%')
|
||||||
|
grad.setAttribute('x2', '100%')
|
||||||
|
defs.append(grad)
|
||||||
|
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rect.setAttribute('fill', 'url(#userSpaceGrad)')
|
||||||
|
rect.setAttribute('width', '100')
|
||||||
|
rect.setAttribute('height', '100')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
const initialGradCount = svg.querySelectorAll('linearGradient').length
|
||||||
|
|
||||||
|
const attrs = { x: 0, y: 0, width: 100, height: 100 }
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = -1
|
||||||
|
m.d = 1
|
||||||
|
coords.remapElement(rect, attrs, m)
|
||||||
|
|
||||||
|
// userSpaceOnUse gradients should not be mirrored
|
||||||
|
assert.equal(svg.querySelectorAll('linearGradient').length, initialGradCount)
|
||||||
|
assert.equal(rect.getAttribute('fill'), 'url(#userSpaceGrad)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with polyline', function () {
|
||||||
|
const polyline = document.createElementNS(NS.SVG, 'polyline')
|
||||||
|
polyline.setAttribute('points', '10,10 20,20 30,10')
|
||||||
|
svg.append(polyline)
|
||||||
|
|
||||||
|
const attrs = {
|
||||||
|
points: [
|
||||||
|
{ x: 10, y: 10 },
|
||||||
|
{ x: 20, y: 20 },
|
||||||
|
{ x: 30, y: 10 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 2
|
||||||
|
m.d = 2
|
||||||
|
m.e = 5
|
||||||
|
m.f = 5
|
||||||
|
coords.remapElement(polyline, attrs, m)
|
||||||
|
|
||||||
|
const points = polyline.getAttribute('points')
|
||||||
|
// Points should be transformed
|
||||||
|
assert.ok(points !== '10,10 20,20 30,10')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with polygon', function () {
|
||||||
|
const polygon = document.createElementNS(NS.SVG, 'polygon')
|
||||||
|
polygon.setAttribute('points', '10,10 20,10 15,20')
|
||||||
|
svg.append(polygon)
|
||||||
|
|
||||||
|
const attrs = {
|
||||||
|
points: [
|
||||||
|
{ x: 10, y: 10 },
|
||||||
|
{ x: 20, y: 10 },
|
||||||
|
{ x: 15, y: 20 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 2
|
||||||
|
m.d = 2
|
||||||
|
m.e = 10
|
||||||
|
m.f = 10
|
||||||
|
coords.remapElement(polygon, attrs, m)
|
||||||
|
|
||||||
|
const points = polygon.getAttribute('points')
|
||||||
|
// Points should be transformed
|
||||||
|
assert.ok(points !== '10,10 20,10 15,20')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with g (group) element', function () {
|
||||||
|
const g = document.createElementNS(NS.SVG, 'g')
|
||||||
|
svg.append(g)
|
||||||
|
|
||||||
|
const attrs = {}
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 2
|
||||||
|
m.d = 2
|
||||||
|
coords.remapElement(g, attrs, m)
|
||||||
|
|
||||||
|
// Group elements get handled (may or may not add transform)
|
||||||
|
// Just verify it doesn't crash
|
||||||
|
assert.ok(g !== null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test flipBoxCoordinate with percentage values', function () {
|
||||||
|
const defs = document.createElementNS(NS.SVG, 'defs')
|
||||||
|
svg.append(defs)
|
||||||
|
|
||||||
|
const grad = document.createElementNS(NS.SVG, 'linearGradient')
|
||||||
|
grad.id = 'percentGrad'
|
||||||
|
grad.setAttribute('x1', '25%')
|
||||||
|
grad.setAttribute('x2', '75%')
|
||||||
|
defs.append(grad)
|
||||||
|
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rect.setAttribute('fill', 'url(#percentGrad)')
|
||||||
|
rect.setAttribute('width', '100')
|
||||||
|
rect.setAttribute('height', '100')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
const attrs = { x: 0, y: 0, width: 100, height: 100 }
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = -1
|
||||||
|
m.d = 1
|
||||||
|
coords.remapElement(rect, attrs, m)
|
||||||
|
|
||||||
|
// Should create a new gradient with flipped percentages or keep original
|
||||||
|
const newGrads = svg.querySelectorAll('linearGradient')
|
||||||
|
assert.ok(newGrads.length >= 1)
|
||||||
|
// Verify rect still has gradient
|
||||||
|
assert.ok(rect.getAttribute('fill').includes('url'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with negative width/height', function () {
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rect.setAttribute('x', '100')
|
||||||
|
rect.setAttribute('y', '100')
|
||||||
|
rect.setAttribute('width', '50')
|
||||||
|
rect.setAttribute('height', '50')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
const attrs = { x: 100, y: 100, width: 50, height: 50 }
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = -1
|
||||||
|
m.d = -1
|
||||||
|
coords.remapElement(rect, attrs, m)
|
||||||
|
|
||||||
|
// Width and height should remain positive
|
||||||
|
assert.ok(Number.parseFloat(rect.getAttribute('width')) > 0)
|
||||||
|
assert.ok(Number.parseFloat(rect.getAttribute('height')) > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with path containing curves', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M10,10 C20,20 30,30 40,40')
|
||||||
|
svg.append(path)
|
||||||
|
|
||||||
|
const attrs = {}
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 2
|
||||||
|
m.d = 2
|
||||||
|
m.e = 5
|
||||||
|
m.f = 5
|
||||||
|
coords.remapElement(path, attrs, m)
|
||||||
|
|
||||||
|
const d = path.getAttribute('d')
|
||||||
|
// Path should be transformed (coordinates change)
|
||||||
|
assert.ok(d !== 'M10,10 C20,20 30,30 40,40')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with stroke gradient', function () {
|
||||||
|
const defs = document.createElementNS(NS.SVG, 'defs')
|
||||||
|
svg.append(defs)
|
||||||
|
|
||||||
|
const grad = document.createElementNS(NS.SVG, 'linearGradient')
|
||||||
|
grad.id = 'strokeGrad'
|
||||||
|
grad.setAttribute('x1', '0%')
|
||||||
|
grad.setAttribute('x2', '100%')
|
||||||
|
defs.append(grad)
|
||||||
|
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rect.setAttribute('stroke', 'url(#strokeGrad)')
|
||||||
|
rect.setAttribute('width', '100')
|
||||||
|
rect.setAttribute('height', '100')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
const attrs = { x: 0, y: 0, width: 100, height: 100 }
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = -1
|
||||||
|
m.d = 1
|
||||||
|
coords.remapElement(rect, attrs, m)
|
||||||
|
|
||||||
|
// Should mirror the stroke gradient or keep original
|
||||||
|
assert.ok(svg.querySelectorAll('linearGradient').length >= 1)
|
||||||
|
// Verify stroke attribute is preserved
|
||||||
|
assert.ok(rect.getAttribute('stroke').includes('url'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with invalid gradient reference', function () {
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rect.setAttribute('fill', 'url(#nonexistentGrad)')
|
||||||
|
rect.setAttribute('width', '100')
|
||||||
|
rect.setAttribute('height', '100')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
const attrs = { x: 0, y: 0, width: 100, height: 100 }
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = -1
|
||||||
|
m.d = 1
|
||||||
|
coords.remapElement(rect, attrs, m)
|
||||||
|
|
||||||
|
// Should not crash, gradient stays as is
|
||||||
|
assert.equal(rect.getAttribute('fill'), 'url(#nonexistentGrad)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with rect and skewX transform', function () {
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rect.setAttribute('x', '10')
|
||||||
|
rect.setAttribute('y', '10')
|
||||||
|
rect.setAttribute('width', '50')
|
||||||
|
rect.setAttribute('height', '50')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 1
|
||||||
|
m.b = 0.5
|
||||||
|
m.c = 0
|
||||||
|
m.d = 1
|
||||||
|
|
||||||
|
const changes = { x: 10, y: 10, width: 50, height: 50 }
|
||||||
|
coords.remapElement(rect, changes, m)
|
||||||
|
|
||||||
|
// Should apply transform for skew
|
||||||
|
assert.ok(true) // Just test it doesn't crash
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with ellipse and negative radii', function () {
|
||||||
|
const ellipse = document.createElementNS(NS.SVG, 'ellipse')
|
||||||
|
ellipse.setAttribute('cx', '50')
|
||||||
|
ellipse.setAttribute('cy', '50')
|
||||||
|
ellipse.setAttribute('rx', '30')
|
||||||
|
ellipse.setAttribute('ry', '20')
|
||||||
|
svg.append(ellipse)
|
||||||
|
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = -1
|
||||||
|
m.d = -1
|
||||||
|
|
||||||
|
const changes = { cx: 50, cy: 50, rx: 30, ry: 20 }
|
||||||
|
coords.remapElement(ellipse, changes, m)
|
||||||
|
|
||||||
|
// Radii should remain positive
|
||||||
|
assert.ok(Number.parseFloat(ellipse.getAttribute('rx')) > 0)
|
||||||
|
assert.ok(Number.parseFloat(ellipse.getAttribute('ry')) > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with circle and scale', function () {
|
||||||
|
const circle = document.createElementNS(NS.SVG, 'circle')
|
||||||
|
circle.setAttribute('cx', '50')
|
||||||
|
circle.setAttribute('cy', '50')
|
||||||
|
circle.setAttribute('r', '25')
|
||||||
|
svg.append(circle)
|
||||||
|
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 2
|
||||||
|
m.d = 2
|
||||||
|
|
||||||
|
const changes = { cx: 50, cy: 50, r: 25 }
|
||||||
|
coords.remapElement(circle, changes, m)
|
||||||
|
|
||||||
|
assert.ok(circle.getAttribute('cx') !== '50' ||
|
||||||
|
circle.getAttribute('r') !== '25')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with line and rotation', function () {
|
||||||
|
const line = document.createElementNS(NS.SVG, 'line')
|
||||||
|
line.setAttribute('x1', '0')
|
||||||
|
line.setAttribute('y1', '0')
|
||||||
|
line.setAttribute('x2', '10')
|
||||||
|
line.setAttribute('y2', '10')
|
||||||
|
svg.append(line)
|
||||||
|
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 0
|
||||||
|
m.b = 1
|
||||||
|
m.c = -1
|
||||||
|
m.d = 0
|
||||||
|
|
||||||
|
const changes = { x1: 0, y1: 0, x2: 10, y2: 10 }
|
||||||
|
coords.remapElement(line, changes, m)
|
||||||
|
|
||||||
|
// Line should be remapped
|
||||||
|
assert.ok(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with path d attribute update', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M 10,10 L 20,20')
|
||||||
|
svg.append(path)
|
||||||
|
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 2
|
||||||
|
m.d = 2
|
||||||
|
|
||||||
|
const changes = { d: 'M 10,10 L 20,20' }
|
||||||
|
coords.remapElement(path, changes, m)
|
||||||
|
|
||||||
|
assert.ok(path.getAttribute('d') !== null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with rect having both fill and stroke gradients', function () {
|
||||||
|
const fillGrad = document.createElementNS(NS.SVG, 'linearGradient')
|
||||||
|
fillGrad.setAttribute('id', 'fillGradientTest')
|
||||||
|
fillGrad.setAttribute('x1', '0')
|
||||||
|
fillGrad.setAttribute('x2', '1')
|
||||||
|
svg.append(fillGrad)
|
||||||
|
|
||||||
|
const strokeGrad = document.createElementNS(NS.SVG, 'linearGradient')
|
||||||
|
strokeGrad.setAttribute('id', 'strokeGradientTest')
|
||||||
|
strokeGrad.setAttribute('y1', '0')
|
||||||
|
strokeGrad.setAttribute('y2', '1')
|
||||||
|
svg.append(strokeGrad)
|
||||||
|
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rect.setAttribute('fill', 'url(#fillGradientTest)')
|
||||||
|
rect.setAttribute('stroke', 'url(#strokeGradientTest)')
|
||||||
|
rect.setAttribute('width', '100')
|
||||||
|
rect.setAttribute('height', '100')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
const attrs = { x: 0, y: 0, width: 100, height: 100 }
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = -1
|
||||||
|
m.d = 1
|
||||||
|
coords.remapElement(rect, attrs, m)
|
||||||
|
|
||||||
|
assert.ok(svg.querySelectorAll('linearGradient').length >= 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with zero-width rect', function () {
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rect.setAttribute('x', '10')
|
||||||
|
rect.setAttribute('y', '10')
|
||||||
|
rect.setAttribute('width', '0')
|
||||||
|
rect.setAttribute('height', '50')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 2
|
||||||
|
m.d = 2
|
||||||
|
|
||||||
|
const changes = { x: 10, y: 10, width: 0, height: 50 }
|
||||||
|
coords.remapElement(rect, changes, m)
|
||||||
|
|
||||||
|
assert.ok(true) // Should not crash
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with zero-height rect', function () {
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rect.setAttribute('x', '10')
|
||||||
|
rect.setAttribute('y', '10')
|
||||||
|
rect.setAttribute('width', '50')
|
||||||
|
rect.setAttribute('height', '0')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 2
|
||||||
|
m.d = 2
|
||||||
|
|
||||||
|
const changes = { x: 10, y: 10, width: 50, height: 0 }
|
||||||
|
coords.remapElement(rect, changes, m)
|
||||||
|
|
||||||
|
assert.ok(true) // Should not crash
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with zero-radius circle', function () {
|
||||||
|
const circle = document.createElementNS(NS.SVG, 'circle')
|
||||||
|
circle.setAttribute('cx', '50')
|
||||||
|
circle.setAttribute('cy', '50')
|
||||||
|
circle.setAttribute('r', '0')
|
||||||
|
svg.append(circle)
|
||||||
|
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 2
|
||||||
|
m.d = 2
|
||||||
|
|
||||||
|
const changes = { cx: 50, cy: 50, r: 0 }
|
||||||
|
coords.remapElement(circle, changes, m)
|
||||||
|
|
||||||
|
assert.ok(true) // Should not crash
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with symbol element', function () {
|
||||||
|
const symbol = document.createElementNS(NS.SVG, 'symbol')
|
||||||
|
symbol.setAttribute('viewBox', '0 0 100 100')
|
||||||
|
svg.append(symbol)
|
||||||
|
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 2
|
||||||
|
m.d = 2
|
||||||
|
|
||||||
|
const changes = {}
|
||||||
|
coords.remapElement(symbol, changes, m)
|
||||||
|
|
||||||
|
assert.ok(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with defs element', function () {
|
||||||
|
const defs = document.createElementNS(NS.SVG, 'defs')
|
||||||
|
svg.append(defs)
|
||||||
|
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 2
|
||||||
|
m.d = 2
|
||||||
|
|
||||||
|
const changes = {}
|
||||||
|
coords.remapElement(defs, changes, m)
|
||||||
|
|
||||||
|
assert.ok(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with marker element', function () {
|
||||||
|
const marker = document.createElementNS(NS.SVG, 'marker')
|
||||||
|
marker.setAttribute('markerWidth', '10')
|
||||||
|
marker.setAttribute('markerHeight', '10')
|
||||||
|
svg.append(marker)
|
||||||
|
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 2
|
||||||
|
m.d = 2
|
||||||
|
|
||||||
|
const changes = {}
|
||||||
|
coords.remapElement(marker, changes, m)
|
||||||
|
|
||||||
|
assert.ok(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test remapElement with style element', function () {
|
||||||
|
const style = document.createElementNS(NS.SVG, 'style')
|
||||||
|
style.textContent = '.cls { fill: red; }'
|
||||||
|
svg.append(style)
|
||||||
|
|
||||||
|
const m = svg.createSVGMatrix()
|
||||||
|
m.a = 2
|
||||||
|
m.d = 2
|
||||||
|
|
||||||
|
const changes = {}
|
||||||
|
coords.remapElement(style, changes, m)
|
||||||
|
|
||||||
|
assert.ok(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
89
tests/unit/copy-elem.test.js
Normal file
89
tests/unit/copy-elem.test.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { copyElem } from '../../packages/svgcanvas/core/copy-elem.js'
|
||||||
|
import dataStorage from '../../packages/svgcanvas/core/dataStorage.js'
|
||||||
|
|
||||||
|
const NS_SVG = 'http://www.w3.org/2000/svg'
|
||||||
|
|
||||||
|
const buildIdGenerator = () => {
|
||||||
|
let next = 0
|
||||||
|
return () => `svg_${++next}`
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('copyElem', () => {
|
||||||
|
it('clones elements and assigns new ids', () => {
|
||||||
|
const getNextId = buildIdGenerator()
|
||||||
|
const group = document.createElementNS(NS_SVG, 'g')
|
||||||
|
group.id = 'old_group'
|
||||||
|
group.setAttribute('fill', 'red')
|
||||||
|
const rect = document.createElementNS(NS_SVG, 'rect')
|
||||||
|
rect.id = 'old_rect'
|
||||||
|
group.append(rect)
|
||||||
|
|
||||||
|
const cloned = copyElem(group, getNextId)
|
||||||
|
|
||||||
|
expect(cloned.id).toBe('svg_1')
|
||||||
|
expect(cloned.getAttribute('fill')).toBe('red')
|
||||||
|
expect(cloned.querySelector('rect')?.id).toBe('svg_2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves mixed content order', () => {
|
||||||
|
const getNextId = buildIdGenerator()
|
||||||
|
const text = document.createElementNS(NS_SVG, 'text')
|
||||||
|
text.append(document.createTextNode('hello '))
|
||||||
|
const tspan = document.createElementNS(NS_SVG, 'tspan')
|
||||||
|
tspan.append(document.createTextNode('world'))
|
||||||
|
text.append(tspan)
|
||||||
|
text.append(document.createTextNode('!'))
|
||||||
|
|
||||||
|
const cloned = copyElem(text, getNextId)
|
||||||
|
|
||||||
|
expect(cloned.childNodes[0].nodeType).toBe(Node.TEXT_NODE)
|
||||||
|
expect(cloned.childNodes[0].nodeValue).toBe('hello ')
|
||||||
|
expect(cloned.childNodes[1].nodeName.toLowerCase()).toBe('tspan')
|
||||||
|
expect(cloned.childNodes[2].nodeType).toBe(Node.TEXT_NODE)
|
||||||
|
expect(cloned.childNodes[2].nodeValue).toBe('!')
|
||||||
|
expect(cloned.textContent).toBe('hello world!')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copies gsvg dataStorage to the cloned element', () => {
|
||||||
|
const getNextId = buildIdGenerator()
|
||||||
|
const group = document.createElementNS(NS_SVG, 'g')
|
||||||
|
const innerSvg = document.createElementNS(NS_SVG, 'svg')
|
||||||
|
innerSvg.append(document.createElementNS(NS_SVG, 'rect'))
|
||||||
|
group.append(innerSvg)
|
||||||
|
dataStorage.put(group, 'gsvg', innerSvg)
|
||||||
|
|
||||||
|
const cloned = copyElem(group, getNextId)
|
||||||
|
const clonedSvg = cloned.firstElementChild
|
||||||
|
|
||||||
|
expect(dataStorage.has(cloned, 'gsvg')).toBe(true)
|
||||||
|
expect(dataStorage.get(cloned, 'gsvg')).toBe(clonedSvg)
|
||||||
|
expect(dataStorage.get(cloned, 'gsvg')).not.toBe(innerSvg)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copies symbol/ref dataStorage to the cloned element', () => {
|
||||||
|
const getNextId = buildIdGenerator()
|
||||||
|
const symbol = document.createElementNS(NS_SVG, 'symbol')
|
||||||
|
symbol.id = 'sym1'
|
||||||
|
const use = document.createElementNS(NS_SVG, 'use')
|
||||||
|
use.setAttribute('href', '#sym1')
|
||||||
|
dataStorage.put(use, 'ref', symbol)
|
||||||
|
dataStorage.put(use, 'symbol', symbol)
|
||||||
|
|
||||||
|
const cloned = copyElem(use, getNextId)
|
||||||
|
|
||||||
|
expect(dataStorage.get(cloned, 'ref')).toBe(symbol)
|
||||||
|
expect(dataStorage.get(cloned, 'symbol')).toBe(symbol)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prevents default click behaviour on cloned images', () => {
|
||||||
|
const getNextId = buildIdGenerator()
|
||||||
|
const image = document.createElementNS(NS_SVG, 'image')
|
||||||
|
|
||||||
|
const cloned = copyElem(image, getNextId)
|
||||||
|
const evt = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||||
|
cloned.dispatchEvent(evt)
|
||||||
|
|
||||||
|
expect(evt.defaultPrevented).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -17,6 +17,20 @@ describe('dataStorage', () => {
|
|||||||
expect(dataStorage.get(el2, 'color')).toBe('blue')
|
expect(dataStorage.get(el2, 'color')).toBe('blue')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns safe defaults for missing or invalid elements', () => {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
|
||||||
|
expect(dataStorage.get(el, 'missing')).toBeUndefined()
|
||||||
|
expect(dataStorage.has(el, 'missing')).toBe(false)
|
||||||
|
expect(dataStorage.remove(el, 'missing')).toBe(false)
|
||||||
|
|
||||||
|
expect(dataStorage.get(null, 'missing')).toBeUndefined()
|
||||||
|
expect(dataStorage.has(null, 'missing')).toBe(false)
|
||||||
|
expect(dataStorage.remove(null, 'missing')).toBe(false)
|
||||||
|
|
||||||
|
expect(() => dataStorage.put(null, 'key', 'value')).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
it('removes values and cleans up empty element maps', () => {
|
it('removes values and cleans up empty element maps', () => {
|
||||||
const el = document.createElement('span')
|
const el = document.createElement('span')
|
||||||
dataStorage.put(el, 'foo', 1)
|
dataStorage.put(el, 'foo', 1)
|
||||||
|
|||||||
75
tests/unit/draw-context.test.js
Normal file
75
tests/unit/draw-context.test.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import * as draw from '../../packages/svgcanvas/core/draw.js'
|
||||||
|
import dataStorage from '../../packages/svgcanvas/core/dataStorage.js'
|
||||||
|
|
||||||
|
const NS_SVG = 'http://www.w3.org/2000/svg'
|
||||||
|
|
||||||
|
describe('draw context', () => {
|
||||||
|
let currentGroup = null
|
||||||
|
/** @type {{event: string, arg: any}[]} */
|
||||||
|
const calls = []
|
||||||
|
let svgContent
|
||||||
|
let editGroup
|
||||||
|
let sibling
|
||||||
|
|
||||||
|
const canvas = {
|
||||||
|
getDataStorage: () => dataStorage,
|
||||||
|
getSvgContent: () => svgContent,
|
||||||
|
clearSelection: () => {},
|
||||||
|
call: (event, arg) => {
|
||||||
|
calls.push({ event, arg })
|
||||||
|
},
|
||||||
|
getCurrentGroup: () => currentGroup,
|
||||||
|
setCurrentGroup: (group) => {
|
||||||
|
currentGroup = group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
draw.init(canvas)
|
||||||
|
draw.leaveContext()
|
||||||
|
|
||||||
|
currentGroup = null
|
||||||
|
calls.length = 0
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
|
||||||
|
svgContent = document.createElementNS(NS_SVG, 'svg')
|
||||||
|
svgContent.id = 'svgcontent'
|
||||||
|
editGroup = document.createElementNS(NS_SVG, 'g')
|
||||||
|
editGroup.id = 'edit'
|
||||||
|
sibling = document.createElementNS(NS_SVG, 'rect')
|
||||||
|
sibling.id = 'sib'
|
||||||
|
sibling.setAttribute('opacity', 'inherit')
|
||||||
|
svgContent.append(editGroup, sibling)
|
||||||
|
document.body.append(svgContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
draw.leaveContext()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores unknown element ids', () => {
|
||||||
|
expect(() => draw.setContext('does-not-exist')).not.toThrow()
|
||||||
|
expect(currentGroup).toBe(null)
|
||||||
|
expect(calls.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles non-numeric opacity and restores it', () => {
|
||||||
|
draw.setContext(editGroup)
|
||||||
|
|
||||||
|
expect(currentGroup).toBe(editGroup)
|
||||||
|
expect(calls[0]).toStrictEqual({ event: 'contextset', arg: editGroup })
|
||||||
|
expect(sibling.getAttribute('opacity')).toBe('0.33')
|
||||||
|
expect(sibling.getAttribute('style')).toBe('pointer-events: none')
|
||||||
|
expect(dataStorage.get(sibling, 'orig_opac')).toBe('inherit')
|
||||||
|
|
||||||
|
draw.leaveContext()
|
||||||
|
|
||||||
|
expect(currentGroup).toBe(null)
|
||||||
|
expect(calls[1]).toStrictEqual({ event: 'contextset', arg: null })
|
||||||
|
expect(sibling.getAttribute('opacity')).toBe('inherit')
|
||||||
|
expect(sibling.getAttribute('style')).toBe('pointer-events: inherit')
|
||||||
|
expect(dataStorage.has(sibling, 'orig_opac')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -670,6 +670,24 @@ describe('draw.Drawing', function () {
|
|||||||
cleanupSVG(svg)
|
cleanupSVG(svg)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Test cloneLayer() with non-element nodes', function () {
|
||||||
|
const drawing = new draw.Drawing(svg)
|
||||||
|
const layers = setupSVGWith3Layers(svg)
|
||||||
|
const layer3 = layers[2]
|
||||||
|
createSomeElementsInGroup(layer3)
|
||||||
|
layer3.insertBefore(document.createTextNode('\n '), layer3.childNodes[1])
|
||||||
|
layer3.append(document.createComment('test-comment'))
|
||||||
|
drawing.identifyLayers()
|
||||||
|
|
||||||
|
const clone = drawing.cloneLayer('clone2')
|
||||||
|
|
||||||
|
assert.ok(clone)
|
||||||
|
assert.ok([...clone.childNodes].some(node => node.nodeType === Node.TEXT_NODE))
|
||||||
|
assert.ok([...clone.childNodes].some(node => node.nodeType === Node.COMMENT_NODE))
|
||||||
|
|
||||||
|
cleanupSVG(svg)
|
||||||
|
})
|
||||||
|
|
||||||
it('Test getLayerVisibility()', function () {
|
it('Test getLayerVisibility()', function () {
|
||||||
const drawing = new draw.Drawing(svg)
|
const drawing = new draw.Drawing(svg)
|
||||||
setupSVGWith3Layers(svg)
|
setupSVGWith3Layers(svg)
|
||||||
|
|||||||
235
tests/unit/elem-get-set.test.js
Normal file
235
tests/unit/elem-get-set.test.js
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { beforeEach, afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||||
|
import * as history from '../../packages/svgcanvas/core/history.js'
|
||||||
|
import dataStorage from '../../packages/svgcanvas/core/dataStorage.js'
|
||||||
|
import { init as initElemGetSet } from '../../packages/svgcanvas/core/elem-get-set.js'
|
||||||
|
import * as undo from '../../packages/svgcanvas/core/undo.js'
|
||||||
|
|
||||||
|
const createSvgElement = (name) => {
|
||||||
|
return document.createElementNS(NS.SVG, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('elem-get-set', () => {
|
||||||
|
/** @type {any} */
|
||||||
|
let canvas
|
||||||
|
/** @type {any[]} */
|
||||||
|
let historyStack
|
||||||
|
/** @type {SVGSVGElement} */
|
||||||
|
let svgContent
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
historyStack = []
|
||||||
|
svgContent = /** @type {SVGSVGElement} */ (createSvgElement('svg'))
|
||||||
|
canvas = {
|
||||||
|
history,
|
||||||
|
zoom: 1,
|
||||||
|
contentW: 100,
|
||||||
|
contentH: 100,
|
||||||
|
selectorManager: {
|
||||||
|
requestSelector () {
|
||||||
|
return { resize () {} }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pathActions: {
|
||||||
|
zoomChange () {},
|
||||||
|
clear () {}
|
||||||
|
},
|
||||||
|
runExtensions () {},
|
||||||
|
call () {},
|
||||||
|
getDOMDocument () { return document },
|
||||||
|
getSvgContent () { return svgContent },
|
||||||
|
getSelectedElements () { return this.selectedElements || [] },
|
||||||
|
getDataStorage () { return dataStorage },
|
||||||
|
getZoom () { return this.zoom },
|
||||||
|
setZoom (value) { this.zoom = value },
|
||||||
|
getResolution () {
|
||||||
|
return {
|
||||||
|
w: Number(svgContent.getAttribute('width')) / this.zoom,
|
||||||
|
h: Number(svgContent.getAttribute('height')) / this.zoom,
|
||||||
|
zoom: this.zoom
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addCommandToHistory (cmd) {
|
||||||
|
historyStack.push(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svgContent.setAttribute('width', '100')
|
||||||
|
svgContent.setAttribute('height', '100')
|
||||||
|
initElemGetSet(canvas)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (svgContent.firstChild) {
|
||||||
|
svgContent.firstChild.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setGroupTitle() inserts title and undo removes it', () => {
|
||||||
|
const g = createSvgElement('g')
|
||||||
|
svgContent.append(g)
|
||||||
|
canvas.selectedElements = [g]
|
||||||
|
|
||||||
|
canvas.setGroupTitle('Hello')
|
||||||
|
expect(g.firstChild?.nodeName).toBe('title')
|
||||||
|
expect(g.firstChild?.textContent).toBe('Hello')
|
||||||
|
expect(historyStack).toHaveLength(1)
|
||||||
|
|
||||||
|
historyStack[0].unapply(null)
|
||||||
|
expect(g.querySelector('title')).toBeNull()
|
||||||
|
|
||||||
|
historyStack[0].apply(null)
|
||||||
|
expect(g.querySelector('title')?.textContent).toBe('Hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setGroupTitle() updates title text with undo/redo', () => {
|
||||||
|
const g = createSvgElement('g')
|
||||||
|
const title = createSvgElement('title')
|
||||||
|
title.textContent = 'Old'
|
||||||
|
g.append(title)
|
||||||
|
svgContent.append(g)
|
||||||
|
canvas.selectedElements = [g]
|
||||||
|
|
||||||
|
canvas.setGroupTitle('New')
|
||||||
|
expect(g.querySelector('title')?.textContent).toBe('New')
|
||||||
|
expect(historyStack).toHaveLength(1)
|
||||||
|
|
||||||
|
historyStack[0].unapply(null)
|
||||||
|
expect(g.querySelector('title')?.textContent).toBe('Old')
|
||||||
|
|
||||||
|
historyStack[0].apply(null)
|
||||||
|
expect(g.querySelector('title')?.textContent).toBe('New')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setGroupTitle() removes title and undo restores it', () => {
|
||||||
|
const g = createSvgElement('g')
|
||||||
|
const title = createSvgElement('title')
|
||||||
|
title.textContent = 'Label'
|
||||||
|
g.append(title)
|
||||||
|
svgContent.append(g)
|
||||||
|
canvas.selectedElements = [g]
|
||||||
|
|
||||||
|
canvas.setGroupTitle('')
|
||||||
|
expect(g.querySelector('title')).toBeNull()
|
||||||
|
expect(historyStack).toHaveLength(1)
|
||||||
|
|
||||||
|
historyStack[0].unapply(null)
|
||||||
|
expect(g.querySelector('title')?.textContent).toBe('Label')
|
||||||
|
|
||||||
|
historyStack[0].apply(null)
|
||||||
|
expect(g.querySelector('title')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setDocumentTitle() inserts and removes title with undo/redo', () => {
|
||||||
|
canvas.setDocumentTitle('Doc')
|
||||||
|
const docTitle = svgContent.querySelector(':scope > title')
|
||||||
|
expect(docTitle?.textContent).toBe('Doc')
|
||||||
|
expect(historyStack).toHaveLength(1)
|
||||||
|
|
||||||
|
historyStack[0].unapply(null)
|
||||||
|
expect(svgContent.querySelector(':scope > title')).toBeNull()
|
||||||
|
|
||||||
|
historyStack[0].apply(null)
|
||||||
|
expect(svgContent.querySelector(':scope > title')?.textContent).toBe('Doc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setDocumentTitle() does nothing when empty and no title exists', () => {
|
||||||
|
canvas.setDocumentTitle('')
|
||||||
|
expect(svgContent.querySelector(':scope > title')).toBeNull()
|
||||||
|
expect(historyStack).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setBBoxZoom() returns the computed zoom for zero-size bbox', () => {
|
||||||
|
canvas.zoom = 1
|
||||||
|
canvas.selectedElements = [createSvgElement('rect')]
|
||||||
|
|
||||||
|
const bbox = { width: 0, height: 0, x: 0, y: 0, factor: 2 }
|
||||||
|
const result = canvas.setBBoxZoom(bbox, 100, 100)
|
||||||
|
|
||||||
|
expect(result?.zoom).toBe(2)
|
||||||
|
expect(canvas.getZoom()).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setImageURL() records undo even when image fails to load', () => {
|
||||||
|
const originalImage = globalThis.Image
|
||||||
|
try {
|
||||||
|
globalThis.Image = class FakeImage {
|
||||||
|
constructor () {
|
||||||
|
this.width = 10
|
||||||
|
this.height = 10
|
||||||
|
this.onload = null
|
||||||
|
this.onerror = null
|
||||||
|
}
|
||||||
|
|
||||||
|
get src () {
|
||||||
|
return this._src
|
||||||
|
}
|
||||||
|
|
||||||
|
set src (value) {
|
||||||
|
this._src = value
|
||||||
|
this.onerror && this.onerror(new Error('load failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = createSvgElement('image')
|
||||||
|
image.setAttribute('href', 'old.png')
|
||||||
|
svgContent.append(image)
|
||||||
|
canvas.selectedElements = [image]
|
||||||
|
|
||||||
|
canvas.setImageURL('bad.png')
|
||||||
|
expect(image.getAttribute('href')).toBe('bad.png')
|
||||||
|
expect(historyStack).toHaveLength(1)
|
||||||
|
|
||||||
|
historyStack[0].unapply(null)
|
||||||
|
expect(image.getAttribute('href')).toBe('old.png')
|
||||||
|
|
||||||
|
historyStack[0].apply(null)
|
||||||
|
expect(image.getAttribute('href')).toBe('bad.png')
|
||||||
|
} finally {
|
||||||
|
globalThis.Image = originalImage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setRectRadius() preserves attribute absence on undo', () => {
|
||||||
|
const rect = createSvgElement('rect')
|
||||||
|
svgContent.append(rect)
|
||||||
|
canvas.selectedElements = [rect]
|
||||||
|
|
||||||
|
canvas.setRectRadius('5')
|
||||||
|
expect(rect.getAttribute('rx')).toBe('5')
|
||||||
|
expect(rect.getAttribute('ry')).toBe('5')
|
||||||
|
expect(historyStack).toHaveLength(1)
|
||||||
|
|
||||||
|
historyStack[0].unapply(null)
|
||||||
|
expect(rect.hasAttribute('rx')).toBe(false)
|
||||||
|
expect(rect.hasAttribute('ry')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('undo updates contentW/contentH for svgContent size changes', () => {
|
||||||
|
const svg = createSvgElement('svg')
|
||||||
|
|
||||||
|
const localCanvas = {
|
||||||
|
contentW: 100,
|
||||||
|
contentH: 100,
|
||||||
|
getSvgContent () { return svg },
|
||||||
|
clearSelection () {},
|
||||||
|
pathActions: { clear () {} },
|
||||||
|
call () {}
|
||||||
|
}
|
||||||
|
undo.init(localCanvas)
|
||||||
|
|
||||||
|
svg.setAttribute('width', '200')
|
||||||
|
svg.setAttribute('height', '150')
|
||||||
|
localCanvas.contentW = 200
|
||||||
|
localCanvas.contentH = 150
|
||||||
|
const cmd = new history.ChangeElementCommand(svg, { width: 100, height: 100 })
|
||||||
|
localCanvas.undoMgr.addCommandToHistory(cmd)
|
||||||
|
|
||||||
|
localCanvas.undoMgr.undo()
|
||||||
|
expect(localCanvas.contentW).toBe(100)
|
||||||
|
expect(localCanvas.contentH).toBe(100)
|
||||||
|
|
||||||
|
localCanvas.undoMgr.redo()
|
||||||
|
expect(localCanvas.contentW).toBe(200)
|
||||||
|
expect(localCanvas.contentH).toBe(150)
|
||||||
|
})
|
||||||
|
})
|
||||||
194
tests/unit/event.test.js
Normal file
194
tests/unit/event.test.js
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||||
|
import { init as initEvent } from '../../packages/svgcanvas/core/event.js'
|
||||||
|
|
||||||
|
const createSvgElement = (name) => {
|
||||||
|
return document.createElementNS(NS.SVG, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('event', () => {
|
||||||
|
/** @type {HTMLDivElement} */
|
||||||
|
let root
|
||||||
|
/** @type {any} */
|
||||||
|
let canvas
|
||||||
|
/** @type {HTMLDivElement} */
|
||||||
|
let svgcanvas
|
||||||
|
/** @type {SVGSVGElement} */
|
||||||
|
let svgcontent
|
||||||
|
/** @type {SVGGElement} */
|
||||||
|
let contentGroup
|
||||||
|
/** @type {SVGRectElement} */
|
||||||
|
let rubberBox
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
root = document.createElement('div')
|
||||||
|
root.id = 'root'
|
||||||
|
document.body.append(root)
|
||||||
|
|
||||||
|
svgcanvas = document.createElement('div')
|
||||||
|
svgcanvas.id = 'svgcanvas'
|
||||||
|
root.append(svgcanvas)
|
||||||
|
|
||||||
|
svgcontent = /** @type {SVGSVGElement} */ (createSvgElement('svg'))
|
||||||
|
svgcontent.id = 'svgcontent'
|
||||||
|
root.append(svgcontent)
|
||||||
|
|
||||||
|
contentGroup = /** @type {SVGGElement} */ (createSvgElement('g'))
|
||||||
|
svgcontent.append(contentGroup)
|
||||||
|
|
||||||
|
contentGroup.getScreenCTM = () => ({
|
||||||
|
inverse: () => ({
|
||||||
|
a: 1,
|
||||||
|
b: 0,
|
||||||
|
c: 0,
|
||||||
|
d: 1,
|
||||||
|
e: 0,
|
||||||
|
f: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.defineProperty(contentGroup, 'transform', {
|
||||||
|
value: { baseVal: { numberOfItems: 0 } },
|
||||||
|
configurable: true
|
||||||
|
})
|
||||||
|
|
||||||
|
rubberBox = /** @type {SVGRectElement} */ (createSvgElement('rect'))
|
||||||
|
|
||||||
|
canvas = {
|
||||||
|
spaceKey: false,
|
||||||
|
started: false,
|
||||||
|
rootSctm: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
|
||||||
|
rubberBox: null,
|
||||||
|
selectorManager: {
|
||||||
|
selectorParentGroup: createSvgElement('g'),
|
||||||
|
getRubberBandBox () {
|
||||||
|
return rubberBox
|
||||||
|
}
|
||||||
|
},
|
||||||
|
$id (id) {
|
||||||
|
return document.getElementById(id)
|
||||||
|
},
|
||||||
|
getDataStorage () {
|
||||||
|
return { get () {} }
|
||||||
|
},
|
||||||
|
getSelectedElements () {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
getZoom () {
|
||||||
|
return 1
|
||||||
|
},
|
||||||
|
getStyle () {
|
||||||
|
return { opacity: 1 }
|
||||||
|
},
|
||||||
|
getSvgRoot () {
|
||||||
|
return svgcontent
|
||||||
|
},
|
||||||
|
getCurConfig () {
|
||||||
|
return { gridSnapping: false, showRulers: false }
|
||||||
|
},
|
||||||
|
setRootSctm (m) {
|
||||||
|
this.rootSctm = m
|
||||||
|
},
|
||||||
|
getrootSctm () {
|
||||||
|
return this.rootSctm
|
||||||
|
},
|
||||||
|
getStarted () {
|
||||||
|
return this.started
|
||||||
|
},
|
||||||
|
setStarted (started) {
|
||||||
|
this.started = started
|
||||||
|
},
|
||||||
|
setStartX (x) {
|
||||||
|
this.startX = x
|
||||||
|
},
|
||||||
|
setStartY (y) {
|
||||||
|
this.startY = y
|
||||||
|
},
|
||||||
|
getStartX () {
|
||||||
|
return this.startX
|
||||||
|
},
|
||||||
|
getStartY () {
|
||||||
|
return this.startY
|
||||||
|
},
|
||||||
|
setRStartX (x) {
|
||||||
|
this.rStartX = x
|
||||||
|
},
|
||||||
|
setRStartY (y) {
|
||||||
|
this.rStartY = y
|
||||||
|
},
|
||||||
|
getMouseTarget () {
|
||||||
|
return contentGroup
|
||||||
|
},
|
||||||
|
getCurrentMode () {
|
||||||
|
return this.currentMode || 'zoom'
|
||||||
|
},
|
||||||
|
setCurrentMode (mode) {
|
||||||
|
this.currentMode = mode
|
||||||
|
},
|
||||||
|
setMode () {},
|
||||||
|
setLastClickPoint () {},
|
||||||
|
setStartTransform () {},
|
||||||
|
clearSelection () {},
|
||||||
|
setCurrentResizeMode () {},
|
||||||
|
setJustSelected () {},
|
||||||
|
pathActions: {
|
||||||
|
clear () {}
|
||||||
|
},
|
||||||
|
setRubberBox (box) {
|
||||||
|
this.rubberBox = box
|
||||||
|
},
|
||||||
|
getRubberBox () {
|
||||||
|
return this.rubberBox
|
||||||
|
},
|
||||||
|
runExtensions () {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initEvent(canvas)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
root.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mouseDownEvent() zoom mode uses clientY for rubberbox y', () => {
|
||||||
|
canvas.setCurrentMode('zoom')
|
||||||
|
canvas.mouseDownEvent({
|
||||||
|
clientX: 10,
|
||||||
|
clientY: 20,
|
||||||
|
button: 0,
|
||||||
|
altKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
preventDefault () {},
|
||||||
|
target: contentGroup
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rubberBox.getAttribute('x')).toBe('10')
|
||||||
|
expect(rubberBox.getAttribute('y')).toBe('20')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mouseOutEvent() dispatches mouseup with coordinates', () => {
|
||||||
|
canvas.setCurrentMode('rect')
|
||||||
|
canvas.setStarted(true)
|
||||||
|
|
||||||
|
/** @type {{ x: number, y: number }|null} */
|
||||||
|
let received = null
|
||||||
|
svgcanvas.addEventListener('mouseup', (evt) => {
|
||||||
|
received = { x: evt.clientX, y: evt.clientY }
|
||||||
|
})
|
||||||
|
|
||||||
|
canvas.mouseOutEvent(new MouseEvent('mouseleave', { clientX: 15, clientY: 25 }))
|
||||||
|
|
||||||
|
expect(received).toEqual({ x: 15, y: 25 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mouseDownEvent() returns early if root group is missing', () => {
|
||||||
|
while (svgcontent.firstChild) {
|
||||||
|
svgcontent.firstChild.remove()
|
||||||
|
}
|
||||||
|
expect(() => {
|
||||||
|
canvas.mouseDownEvent({ button: 0 })
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -474,6 +474,47 @@ describe('history', function () {
|
|||||||
change.apply()
|
change.apply()
|
||||||
assert.equal(justCalled, 'setHref')
|
assert.equal(justCalled, 'setHref')
|
||||||
|
|
||||||
|
// Ensure numeric zero values are not treated like "remove attribute".
|
||||||
|
const rectZero = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rectZero.setAttribute('x', '5')
|
||||||
|
change = new history.ChangeElementCommand(rectZero, { x: 0 })
|
||||||
|
change.unapply()
|
||||||
|
assert.equal(rectZero.getAttribute('x'), '0')
|
||||||
|
change.apply()
|
||||||
|
assert.equal(rectZero.getAttribute('x'), '5')
|
||||||
|
|
||||||
|
// Ensure "#href" can be removed when the previous value was null.
|
||||||
|
const rectHref = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rectHref.setAttribute('href', '#newhref')
|
||||||
|
let calls = []
|
||||||
|
utilities.mock({
|
||||||
|
getHref (elem) {
|
||||||
|
assert.equal(elem, rectHref)
|
||||||
|
calls.push('getHref')
|
||||||
|
return rectHref.getAttribute('href')
|
||||||
|
},
|
||||||
|
setHref (elem, val) {
|
||||||
|
assert.equal(elem, rectHref)
|
||||||
|
calls.push('setHref')
|
||||||
|
rectHref.setAttribute('href', val)
|
||||||
|
},
|
||||||
|
getRotationAngle () { return 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
change = new history.ChangeElementCommand(rectHref, { '#href': null })
|
||||||
|
assert.deepEqual(calls, ['getHref'])
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
change.unapply()
|
||||||
|
assert.equal(rectHref.hasAttribute('href'), false)
|
||||||
|
assert.deepEqual(calls, [])
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
change.apply()
|
||||||
|
assert.equal(rectHref.getAttribute('href'), '#newhref')
|
||||||
|
assert.deepEqual(calls, ['setHref'])
|
||||||
|
|
||||||
const line = document.createElementNS(NS.SVG, 'line')
|
const line = document.createElementNS(NS.SVG, 'line')
|
||||||
line.setAttribute('class', 'newClass')
|
line.setAttribute('class', 'newClass')
|
||||||
change = new history.ChangeElementCommand(line, { class: 'oldClass' })
|
change = new history.ChangeElementCommand(line, { class: 'oldClass' })
|
||||||
@@ -524,4 +565,29 @@ describe('history', function () {
|
|||||||
|
|
||||||
MockCommand.prototype.unapply = function () { /* empty fn */ }
|
MockCommand.prototype.unapply = function () { /* empty fn */ }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Test BatchCommand with elements() method', function () {
|
||||||
|
const batch = new history.BatchCommand('test batch with elements')
|
||||||
|
|
||||||
|
// Create some mock commands that reference elements
|
||||||
|
class MockElementCommand {
|
||||||
|
constructor (elem) { this.elem = elem }
|
||||||
|
elements () { return [this.elem] }
|
||||||
|
apply () { /* empty fn */ }
|
||||||
|
unapply () { /* empty fn */ }
|
||||||
|
getText () { return 'mock' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const elem1 = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
const cmd1 = new MockElementCommand(elem1)
|
||||||
|
batch.addSubCommand(cmd1)
|
||||||
|
|
||||||
|
const elems = batch.elements()
|
||||||
|
assert.ok(Array.isArray(elems))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test BatchCommand getText()', function () {
|
||||||
|
const batch = new history.BatchCommand('my test batch')
|
||||||
|
assert.equal(batch.getText(), 'my test batch')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
60
tests/unit/historyrecording.test.js
Normal file
60
tests/unit/historyrecording.test.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||||
|
import HistoryRecordingService from '../../packages/svgcanvas/core/historyrecording.js'
|
||||||
|
|
||||||
|
const createSvgElement = (name) => document.createElementNS(NS.SVG, name)
|
||||||
|
|
||||||
|
describe('HistoryRecordingService', () => {
|
||||||
|
it('does not record empty batch commands', () => {
|
||||||
|
const stack = []
|
||||||
|
const hrService = new HistoryRecordingService({
|
||||||
|
addCommandToHistory (cmd) {
|
||||||
|
stack.push(cmd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hrService.startBatchCommand('Empty').endBatchCommand()
|
||||||
|
expect(stack).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not record nested empty batch commands', () => {
|
||||||
|
const stack = []
|
||||||
|
const hrService = new HistoryRecordingService({
|
||||||
|
addCommandToHistory (cmd) {
|
||||||
|
stack.push(cmd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hrService.startBatchCommand('Outer').startBatchCommand('Inner').endBatchCommand().endBatchCommand()
|
||||||
|
expect(stack).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records subcommands as a single batch command', () => {
|
||||||
|
const stack = []
|
||||||
|
const hrService = new HistoryRecordingService({
|
||||||
|
addCommandToHistory (cmd) {
|
||||||
|
stack.push(cmd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const svg = createSvgElement('svg')
|
||||||
|
const rect = createSvgElement('rect')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
hrService.startBatchCommand('Batch').insertElement(rect).endBatchCommand()
|
||||||
|
expect(stack).toHaveLength(1)
|
||||||
|
expect(stack[0].type()).toBe('BatchCommand')
|
||||||
|
expect(stack[0].stack).toHaveLength(1)
|
||||||
|
expect(stack[0].stack[0].type()).toBe('InsertElementCommand')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('NO_HISTORY does not throw and does not record', () => {
|
||||||
|
const svg = createSvgElement('svg')
|
||||||
|
const rect = createSvgElement('rect')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
HistoryRecordingService.NO_HISTORY.startBatchCommand('Noop').insertElement(rect).endBatchCommand()
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
131
tests/unit/json.test.js
Normal file
131
tests/unit/json.test.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||||
|
import * as utilities from '../../packages/svgcanvas/core/utilities.js'
|
||||||
|
import {
|
||||||
|
init as initJson,
|
||||||
|
addSVGElementsFromJson,
|
||||||
|
getJsonFromSvgElements
|
||||||
|
} from '../../packages/svgcanvas/core/json.js'
|
||||||
|
|
||||||
|
const createSvgElement = (name) => document.createElementNS(NS.SVG, name)
|
||||||
|
|
||||||
|
describe('json', () => {
|
||||||
|
/** @type {HTMLDivElement} */
|
||||||
|
let root
|
||||||
|
/** @type {SVGSVGElement} */
|
||||||
|
let svgRoot
|
||||||
|
/** @type {SVGGElement} */
|
||||||
|
let layer
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
root = document.createElement('div')
|
||||||
|
root.id = 'root'
|
||||||
|
document.body.append(root)
|
||||||
|
|
||||||
|
svgRoot = /** @type {SVGSVGElement} */ (createSvgElement('svg'))
|
||||||
|
svgRoot.id = 'svgroot'
|
||||||
|
root.append(svgRoot)
|
||||||
|
|
||||||
|
layer = /** @type {SVGGElement} */ (createSvgElement('g'))
|
||||||
|
layer.id = 'layer1'
|
||||||
|
svgRoot.append(layer)
|
||||||
|
|
||||||
|
utilities.init({
|
||||||
|
getSvgRoot: () => svgRoot
|
||||||
|
})
|
||||||
|
|
||||||
|
initJson({
|
||||||
|
getDOMDocument: () => document,
|
||||||
|
getSvgRoot: () => svgRoot,
|
||||||
|
getDrawing: () => ({ getCurrentLayer: () => layer }),
|
||||||
|
getCurrentGroup: () => null,
|
||||||
|
getCurShape: () => ({
|
||||||
|
fill: 'none',
|
||||||
|
stroke: '#000000',
|
||||||
|
stroke_width: 1,
|
||||||
|
stroke_dasharray: 'none',
|
||||||
|
stroke_linejoin: 'miter',
|
||||||
|
stroke_linecap: 'butt',
|
||||||
|
stroke_opacity: 1,
|
||||||
|
fill_opacity: 1,
|
||||||
|
opacity: 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
root.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getJsonFromSvgElements() ignores comment nodes', () => {
|
||||||
|
const g = createSvgElement('g')
|
||||||
|
const comment = document.createComment('hi')
|
||||||
|
const rect = createSvgElement('rect')
|
||||||
|
rect.setAttribute('x', '1')
|
||||||
|
g.append(comment, rect)
|
||||||
|
|
||||||
|
const json = getJsonFromSvgElements(g)
|
||||||
|
expect(json.element).toBe('g')
|
||||||
|
expect(json.children).toHaveLength(1)
|
||||||
|
expect(json.children[0].element).toBe('rect')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addSVGElementsFromJson() does not treat missing id as "undefined"', () => {
|
||||||
|
const existing = createSvgElement('rect')
|
||||||
|
existing.id = 'undefined'
|
||||||
|
layer.append(existing)
|
||||||
|
|
||||||
|
const circle = addSVGElementsFromJson({
|
||||||
|
element: 'circle',
|
||||||
|
attr: {
|
||||||
|
cx: 0,
|
||||||
|
cy: 0,
|
||||||
|
r: 5
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(layer.querySelector('#undefined')).toBe(existing)
|
||||||
|
expect(circle?.tagName).toBe('circle')
|
||||||
|
expect(layer.contains(circle)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addSVGElementsFromJson() replaces children when reusing an element by id', () => {
|
||||||
|
const group = createSvgElement('g')
|
||||||
|
group.id = 'reuse'
|
||||||
|
const oldChild = createSvgElement('rect')
|
||||||
|
group.append(oldChild)
|
||||||
|
layer.append(group)
|
||||||
|
|
||||||
|
addSVGElementsFromJson({
|
||||||
|
element: 'g',
|
||||||
|
attr: { id: 'reuse' },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
element: 'circle',
|
||||||
|
attr: { id: 'newChild' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(group.children).toHaveLength(1)
|
||||||
|
expect(group.firstElementChild?.tagName).toBe('circle')
|
||||||
|
expect(group.querySelector('rect')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addSVGElementsFromJson() handles ids that are not valid CSS selectors', () => {
|
||||||
|
const rect = createSvgElement('rect')
|
||||||
|
rect.id = 'a:b'
|
||||||
|
layer.append(rect)
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
addSVGElementsFromJson({
|
||||||
|
element: 'rect',
|
||||||
|
attr: {
|
||||||
|
id: 'a:b',
|
||||||
|
x: 10
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}).not.toThrow()
|
||||||
|
expect(rect.getAttribute('x')).toBe('10')
|
||||||
|
})
|
||||||
|
})
|
||||||
99
tests/unit/layer.test.js
Normal file
99
tests/unit/layer.test.js
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { strict as assert } from 'node:assert'
|
||||||
|
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||||
|
import Layer from '../../packages/svgcanvas/core/layer.js'
|
||||||
|
|
||||||
|
describe('Layer', function () {
|
||||||
|
it('preserves inline styles while applying pointer-events', function () {
|
||||||
|
const svg = document.createElementNS(NS.SVG, 'svg')
|
||||||
|
const group = document.createElementNS(NS.SVG, 'g')
|
||||||
|
group.setAttribute('style', 'fill: red; opacity: 0.5; pointer-events: none;')
|
||||||
|
|
||||||
|
const child = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
child.setAttribute('style', 'stroke: blue; opacity: 0.25; pointer-events: none;')
|
||||||
|
group.append(child)
|
||||||
|
svg.append(group)
|
||||||
|
|
||||||
|
const layer = new Layer('Layer 1', group)
|
||||||
|
|
||||||
|
assert.equal(group.style.getPropertyValue('fill'), 'red')
|
||||||
|
assert.equal(group.style.getPropertyValue('opacity'), '0.5')
|
||||||
|
assert.equal(group.style.getPropertyValue('pointer-events'), 'none')
|
||||||
|
assert.equal(child.style.getPropertyValue('stroke'), 'blue')
|
||||||
|
assert.equal(child.style.getPropertyValue('opacity'), '0.25')
|
||||||
|
assert.equal(child.style.getPropertyValue('pointer-events'), 'inherit')
|
||||||
|
|
||||||
|
layer.activate()
|
||||||
|
assert.equal(group.style.getPropertyValue('fill'), 'red')
|
||||||
|
assert.equal(group.style.getPropertyValue('opacity'), '0.5')
|
||||||
|
assert.equal(group.style.getPropertyValue('pointer-events'), 'all')
|
||||||
|
|
||||||
|
layer.deactivate()
|
||||||
|
assert.equal(group.style.getPropertyValue('fill'), 'red')
|
||||||
|
assert.equal(group.style.getPropertyValue('opacity'), '0.5')
|
||||||
|
assert.equal(group.style.getPropertyValue('pointer-events'), 'none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('manages layer metadata and lifecycle helpers', function () {
|
||||||
|
const svg = document.createElementNS(NS.SVG, 'svg')
|
||||||
|
const anchor = document.createElementNS(NS.SVG, 'g')
|
||||||
|
anchor.setAttribute('class', 'anchor')
|
||||||
|
svg.append(anchor)
|
||||||
|
|
||||||
|
const layer = new Layer('Layer 1', anchor, svg)
|
||||||
|
const group = layer.getGroup()
|
||||||
|
|
||||||
|
assert.equal(layer.getName(), 'Layer 1')
|
||||||
|
assert.equal(group.previousSibling, anchor)
|
||||||
|
assert.ok(group.classList.contains('layer'))
|
||||||
|
assert.equal(group.style.getPropertyValue('pointer-events'), 'all')
|
||||||
|
|
||||||
|
const title = layer.getTitleElement()
|
||||||
|
assert.ok(title)
|
||||||
|
assert.equal(title.textContent, 'Layer 1')
|
||||||
|
|
||||||
|
layer.setVisible(false)
|
||||||
|
assert.equal(group.getAttribute('display'), 'none')
|
||||||
|
assert.equal(layer.isVisible(), false)
|
||||||
|
|
||||||
|
layer.setVisible(true)
|
||||||
|
assert.equal(group.getAttribute('display'), 'inline')
|
||||||
|
assert.equal(layer.isVisible(), true)
|
||||||
|
|
||||||
|
assert.equal(layer.getOpacity(), 1)
|
||||||
|
layer.setOpacity(0.25)
|
||||||
|
assert.equal(layer.getOpacity(), 0.25)
|
||||||
|
layer.setOpacity(2)
|
||||||
|
assert.equal(layer.getOpacity(), 0.25)
|
||||||
|
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
const circle = document.createElementNS(NS.SVG, 'circle')
|
||||||
|
layer.appendChildren([rect, circle])
|
||||||
|
assert.ok(group.contains(rect))
|
||||||
|
assert.ok(group.contains(circle))
|
||||||
|
|
||||||
|
const hrCalls = []
|
||||||
|
const hrService = {
|
||||||
|
changeElement: (...args) => {
|
||||||
|
hrCalls.push(args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const renamed = layer.setName('Renamed', hrService)
|
||||||
|
assert.equal(renamed, 'Renamed')
|
||||||
|
assert.equal(layer.getName(), 'Renamed')
|
||||||
|
assert.equal(title.textContent, 'Renamed')
|
||||||
|
assert.equal(hrCalls.length, 1)
|
||||||
|
assert.equal(hrCalls[0][0], title)
|
||||||
|
assert.deepEqual(hrCalls[0][1], { '#text': 'Layer 1' })
|
||||||
|
|
||||||
|
assert.equal(Layer.isLayer(group), true)
|
||||||
|
assert.equal(Layer.isLayer(document.createElementNS(NS.SVG, 'rect')), false)
|
||||||
|
|
||||||
|
const appended = new Layer('Layer 2', null, svg)
|
||||||
|
assert.equal(svg.lastChild, appended.getGroup())
|
||||||
|
|
||||||
|
const removedGroup = layer.removeGroup()
|
||||||
|
assert.equal(removedGroup, group)
|
||||||
|
assert.equal(group.parentNode, null)
|
||||||
|
assert.equal(layer.getGroup(), undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -93,6 +93,14 @@ describe('math', function () {
|
|||||||
'Modified matrix matching identity values should be identity'
|
'Modified matrix matching identity values should be identity'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const mAlmostIdentity = svg.createSVGMatrix()
|
||||||
|
mAlmostIdentity.a = 1 + 5e-11
|
||||||
|
mAlmostIdentity.f = 5e-11
|
||||||
|
assert.ok(
|
||||||
|
isIdentity(mAlmostIdentity),
|
||||||
|
'Matrix close to identity should be considered identity'
|
||||||
|
)
|
||||||
|
|
||||||
m.e = 10
|
m.e = 10
|
||||||
assert.notOk(isIdentity(m), 'Matrix with translation is not identity')
|
assert.notOk(isIdentity(m), 'Matrix with translation is not identity')
|
||||||
})
|
})
|
||||||
@@ -107,6 +115,22 @@ describe('math', function () {
|
|||||||
'No arguments should return identity matrix'
|
'No arguments should return identity matrix'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Ensure single matrix returns a new matrix and does not mutate the input
|
||||||
|
const tiny = svg.createSVGMatrix()
|
||||||
|
tiny.b = 1e-12
|
||||||
|
const tinyResult = matrixMultiply(tiny)
|
||||||
|
assert.notStrictEqual(
|
||||||
|
tinyResult,
|
||||||
|
tiny,
|
||||||
|
'Single-argument call should return a new matrix instance'
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
tiny.b,
|
||||||
|
1e-12,
|
||||||
|
'Input matrix should not be mutated by rounding'
|
||||||
|
)
|
||||||
|
assert.equal(tinyResult.b, 0, 'Result should round near-zero values to 0')
|
||||||
|
|
||||||
// Translate there and back
|
// Translate there and back
|
||||||
const tr1 = svg.createSVGMatrix().translate(100, 50)
|
const tr1 = svg.createSVGMatrix().translate(100, 50)
|
||||||
const tr2 = svg.createSVGMatrix().translate(-90, 0)
|
const tr2 = svg.createSVGMatrix().translate(-90, 0)
|
||||||
@@ -317,4 +341,30 @@ describe('math', function () {
|
|||||||
'Rectangles touching at the edge should not be considered intersecting'
|
'Rectangles touching at the edge should not be considered intersecting'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Test svgedit.math.rectsIntersect() with zero width', function () {
|
||||||
|
const { rectsIntersect } = math
|
||||||
|
const r1 = { x: 0, y: 0, width: 0, height: 50 }
|
||||||
|
const r2 = { x: 0, y: 0, width: 50, height: 50 }
|
||||||
|
|
||||||
|
const result = rectsIntersect(r1, r2)
|
||||||
|
assert.ok(result !== undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test svgedit.math.rectsIntersect() with zero height', function () {
|
||||||
|
const { rectsIntersect } = math
|
||||||
|
const r1 = { x: 0, y: 0, width: 50, height: 0 }
|
||||||
|
const r2 = { x: 0, y: 0, width: 50, height: 50 }
|
||||||
|
|
||||||
|
const result = rectsIntersect(r1, r2)
|
||||||
|
assert.ok(result !== undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test svgedit.math.rectsIntersect() with negative coords', function () {
|
||||||
|
const { rectsIntersect } = math
|
||||||
|
const r1 = { x: -50, y: -50, width: 100, height: 100 }
|
||||||
|
const r2 = { x: 0, y: 0, width: 50, height: 50 }
|
||||||
|
|
||||||
|
assert.ok(rectsIntersect(r1, r2), 'Should intersect with negative coordinates')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
23
tests/unit/namespaces.test.js
Normal file
23
tests/unit/namespaces.test.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { NS, getReverseNS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||||
|
|
||||||
|
describe('namespaces', function () {
|
||||||
|
it('exposes common namespace constants', function () {
|
||||||
|
assert.equal(NS.SVG, 'http://www.w3.org/2000/svg')
|
||||||
|
assert.equal(NS.XLINK, 'http://www.w3.org/1999/xlink')
|
||||||
|
assert.equal(NS.XML, 'http://www.w3.org/XML/1998/namespace')
|
||||||
|
assert.equal(NS.XMLNS, 'http://www.w3.org/2000/xmlns/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a reverse namespace lookup', function () {
|
||||||
|
const reverse = getReverseNS()
|
||||||
|
|
||||||
|
assert.equal(reverse[NS.SVG], 'svg')
|
||||||
|
assert.equal(reverse[NS.XLINK], 'xlink')
|
||||||
|
assert.equal(reverse[NS.SE], 'se')
|
||||||
|
assert.equal(reverse[NS.OI], 'oi')
|
||||||
|
assert.equal(reverse[NS.XML], 'xml')
|
||||||
|
assert.equal(reverse[NS.XMLNS], 'xmlns')
|
||||||
|
assert.equal(reverse[NS.HTML], 'html')
|
||||||
|
assert.equal(reverse[NS.MATH], 'math')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,8 +4,6 @@ import Paint from '../../packages/svgcanvas/core/paint.js'
|
|||||||
const createLinear = (id) => {
|
const createLinear = (id) => {
|
||||||
const grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient')
|
const grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient')
|
||||||
if (id) grad.id = id
|
if (id) grad.id = id
|
||||||
grad.setAttribute('x1', '0')
|
|
||||||
grad.setAttribute('x2', '1')
|
|
||||||
return grad
|
return grad
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,13 +25,13 @@ describe('Paint', () => {
|
|||||||
expect(paint.radialGradient).toBeNull()
|
expect(paint.radialGradient).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('copies a solid color paint including alpha', () => {
|
it('normalizes solid colors and copies alpha', () => {
|
||||||
const base = new Paint({ solidColor: '#00ff00', alpha: 65 })
|
const base = new Paint({ solidColor: '#00ff00', alpha: 65 })
|
||||||
const copy = new Paint({ copy: base })
|
const copy = new Paint({ copy: base })
|
||||||
|
|
||||||
expect(copy.type).toBe('solidColor')
|
expect(copy.type).toBe('solidColor')
|
||||||
expect(copy.alpha).toBe(65)
|
expect(copy.alpha).toBe(65)
|
||||||
expect(copy.solidColor).toBe('#00ff00')
|
expect(copy.solidColor).toBe('00ff00')
|
||||||
expect(copy.linearGradient).toBeNull()
|
expect(copy.linearGradient).toBeNull()
|
||||||
expect(copy.radialGradient).toBeNull()
|
expect(copy.radialGradient).toBeNull()
|
||||||
})
|
})
|
||||||
@@ -50,14 +48,28 @@ describe('Paint', () => {
|
|||||||
|
|
||||||
it('resolves linked linear gradients via href/xlink:href', () => {
|
it('resolves linked linear gradients via href/xlink:href', () => {
|
||||||
const referenced = createLinear('refGrad')
|
const referenced = createLinear('refGrad')
|
||||||
|
referenced.setAttribute('gradientUnits', 'userSpaceOnUse')
|
||||||
|
const stop0 = document.createElementNS('http://www.w3.org/2000/svg', 'stop')
|
||||||
|
stop0.setAttribute('offset', '0')
|
||||||
|
stop0.setAttribute('stop-color', '#000000')
|
||||||
|
stop0.setAttribute('stop-opacity', '1')
|
||||||
|
const stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop')
|
||||||
|
stop1.setAttribute('offset', '1')
|
||||||
|
stop1.setAttribute('stop-color', '#ffffff')
|
||||||
|
stop1.setAttribute('stop-opacity', '1')
|
||||||
|
referenced.append(stop0, stop1)
|
||||||
document.body.append(referenced)
|
document.body.append(referenced)
|
||||||
const referencing = createLinear('linkGrad')
|
const referencing = createLinear('linkGrad')
|
||||||
referencing.setAttribute('xlink:href', '#refGrad')
|
referencing.setAttribute('xlink:href', '#refGrad')
|
||||||
|
referencing.setAttribute('x2', '0.5')
|
||||||
|
|
||||||
const paint = new Paint({ linearGradient: referencing })
|
const paint = new Paint({ linearGradient: referencing })
|
||||||
expect(paint.type).toBe('linearGradient')
|
expect(paint.type).toBe('linearGradient')
|
||||||
expect(paint.linearGradient).not.toBeNull()
|
expect(paint.linearGradient).not.toBeNull()
|
||||||
expect(paint.linearGradient?.id).toBe('refGrad')
|
expect(paint.linearGradient?.getAttribute('gradientUnits')).toBe('userSpaceOnUse')
|
||||||
|
expect(paint.linearGradient?.getAttribute('x2')).toBe('0.5')
|
||||||
|
expect(paint.linearGradient?.querySelectorAll('stop')).toHaveLength(2)
|
||||||
|
expect(paint.linearGradient?.hasAttribute('xlink:href')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates radial gradients from provided element when no href is set', () => {
|
it('creates radial gradients from provided element when no href is set', () => {
|
||||||
@@ -69,4 +81,490 @@ describe('Paint', () => {
|
|||||||
expect(paint.radialGradient?.id).toBe('rad1')
|
expect(paint.radialGradient?.id).toBe('rad1')
|
||||||
expect(paint.linearGradient).toBeNull()
|
expect(paint.linearGradient).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resolves multi-level gradient chains and strips href', () => {
|
||||||
|
const base = createLinear('baseGrad')
|
||||||
|
base.setAttribute('gradientUnits', 'userSpaceOnUse')
|
||||||
|
base.setAttribute('y2', '0.75')
|
||||||
|
const baseStop = document.createElementNS('http://www.w3.org/2000/svg', 'stop')
|
||||||
|
baseStop.setAttribute('offset', '0')
|
||||||
|
baseStop.setAttribute('stop-color', '#111111')
|
||||||
|
base.append(baseStop)
|
||||||
|
|
||||||
|
const mid = createLinear('midGrad')
|
||||||
|
mid.setAttribute('href', '#baseGrad')
|
||||||
|
mid.setAttribute('x1', '0.2')
|
||||||
|
document.body.append(base, mid)
|
||||||
|
|
||||||
|
const top = createLinear('topGrad')
|
||||||
|
top.setAttribute('xlink:href', '#midGrad')
|
||||||
|
top.setAttribute('x2', '0.9')
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: top })
|
||||||
|
expect(paint.linearGradient?.getAttribute('x2')).toBe('0.9')
|
||||||
|
expect(paint.linearGradient?.getAttribute('x1')).toBe('0.2')
|
||||||
|
expect(paint.linearGradient?.getAttribute('y2')).toBe('0.75')
|
||||||
|
expect(paint.linearGradient?.getAttribute('gradientUnits')).toBe('userSpaceOnUse')
|
||||||
|
expect(paint.linearGradient?.querySelectorAll('stop')).toHaveLength(1)
|
||||||
|
expect(paint.linearGradient?.hasAttribute('href')).toBe(false)
|
||||||
|
expect(paint.linearGradient?.hasAttribute('xlink:href')).toBe(false)
|
||||||
|
|
||||||
|
base.remove()
|
||||||
|
mid.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle paint with null linearGradient', () => {
|
||||||
|
const paint = new Paint({ linearGradient: null })
|
||||||
|
expect(paint.type).toBe('none')
|
||||||
|
expect(paint.linearGradient).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle paint with undefined radialGradient', () => {
|
||||||
|
const paint = new Paint({ radialGradient: undefined })
|
||||||
|
expect(paint.type).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle paint with solidColor', () => {
|
||||||
|
const paint = new Paint({ solidColor: '#ff0000' })
|
||||||
|
expect(paint.type).toBe('solidColor')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle paint with alpha value', () => {
|
||||||
|
const paint = new Paint({ alpha: 0.5 })
|
||||||
|
expect(paint.alpha).toBe(0.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle radialGradient with href chain', () => {
|
||||||
|
const base = createRadial('baseRadialGrad')
|
||||||
|
base.setAttribute('cx', '0.5')
|
||||||
|
base.setAttribute('cy', '0.5')
|
||||||
|
base.setAttribute('r', '0.5')
|
||||||
|
document.body.append(base)
|
||||||
|
|
||||||
|
const top = createRadial('topRadialGrad')
|
||||||
|
top.setAttribute('href', '#baseRadialGrad')
|
||||||
|
top.setAttribute('fx', '0.3')
|
||||||
|
|
||||||
|
const paint = new Paint({ radialGradient: top })
|
||||||
|
expect(paint.radialGradient?.getAttribute('fx')).toBe('0.3')
|
||||||
|
expect(paint.radialGradient?.getAttribute('cx')).toBe('0.5')
|
||||||
|
|
||||||
|
base.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle linearGradient with no stops', () => {
|
||||||
|
const grad = createLinear('noStopsGrad')
|
||||||
|
const paint = new Paint({ linearGradient: grad })
|
||||||
|
expect(paint.linearGradient?.querySelectorAll('stop')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should copy paint object with type none', () => {
|
||||||
|
const original = new Paint({})
|
||||||
|
const copy = new Paint({ copy: original })
|
||||||
|
expect(copy.type).toBe('none')
|
||||||
|
expect(copy.solidColor).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should copy paint object with solidColor', () => {
|
||||||
|
const original = new Paint({ solidColor: '#ff0000' })
|
||||||
|
const copy = new Paint({ copy: original, alpha: 75 })
|
||||||
|
expect(copy.type).toBe('solidColor')
|
||||||
|
expect(copy.solidColor).toBe('ff0000')
|
||||||
|
expect(copy.alpha).toBe(original.alpha)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should copy paint object with linearGradient', () => {
|
||||||
|
const grad = createLinear('copyLinearGrad')
|
||||||
|
const original = new Paint({ linearGradient: grad })
|
||||||
|
const copy = new Paint({ copy: original })
|
||||||
|
expect(copy.type).toBe('linearGradient')
|
||||||
|
expect(copy.linearGradient).not.toBe(original.linearGradient)
|
||||||
|
expect(copy.linearGradient?.id).toBe('copyLinearGrad')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should copy paint object with radialGradient', () => {
|
||||||
|
const grad = createRadial('copyRadialGrad')
|
||||||
|
document.body.append(grad)
|
||||||
|
const original = new Paint({ radialGradient: grad })
|
||||||
|
const copy = new Paint({ copy: original })
|
||||||
|
expect(copy.type).toBe('radialGradient')
|
||||||
|
expect(copy.radialGradient).not.toBe(original.radialGradient)
|
||||||
|
expect(copy.radialGradient?.id).toBe('copyRadialGrad')
|
||||||
|
grad.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle gradient with invalid href reference', () => {
|
||||||
|
const grad = createLinear('invalidHrefGrad')
|
||||||
|
grad.setAttribute('href', '#nonExistentGradient')
|
||||||
|
const paint = new Paint({ linearGradient: grad })
|
||||||
|
expect(paint.linearGradient?.id).toBe('invalidHrefGrad')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should normalize alpha values correctly', () => {
|
||||||
|
const paint1 = new Paint({ alpha: 150 })
|
||||||
|
expect(paint1.alpha).toBe(100)
|
||||||
|
const paint2 = new Paint({ alpha: -10 })
|
||||||
|
expect(paint2.alpha).toBe(0)
|
||||||
|
const paint3 = new Paint({ alpha: 'invalid' })
|
||||||
|
expect(paint3.alpha).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle solidColor with none value', () => {
|
||||||
|
const paint = new Paint({ solidColor: 'none' })
|
||||||
|
expect(paint.type).toBe('solidColor')
|
||||||
|
expect(paint.solidColor).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should normalize solidColor without hash', () => {
|
||||||
|
const paint = new Paint({ solidColor: 'red' })
|
||||||
|
expect(paint.type).toBe('solidColor')
|
||||||
|
expect(paint.solidColor).toBe('red')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle linearGradient with url() format in href', () => {
|
||||||
|
const base = createLinear('baseUrlGrad')
|
||||||
|
base.setAttribute('x1', '0')
|
||||||
|
base.setAttribute('x2', '1')
|
||||||
|
document.body.append(base)
|
||||||
|
|
||||||
|
const top = createLinear('topUrlGrad')
|
||||||
|
top.setAttribute('href', 'url(#baseUrlGrad)')
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: top })
|
||||||
|
expect(paint.linearGradient?.getAttribute('x1')).toBe('0')
|
||||||
|
expect(paint.linearGradient?.getAttribute('x2')).toBe('1')
|
||||||
|
|
||||||
|
base.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle gradient with empty string attributes', () => {
|
||||||
|
const base = createLinear('baseEmptyGrad')
|
||||||
|
base.setAttribute('x1', '0.5')
|
||||||
|
document.body.append(base)
|
||||||
|
|
||||||
|
const top = createLinear('topEmptyGrad')
|
||||||
|
top.setAttribute('href', '#baseEmptyGrad')
|
||||||
|
top.setAttribute('x1', '')
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: top })
|
||||||
|
// Empty attribute should be replaced by inherited value
|
||||||
|
expect(paint.linearGradient?.getAttribute('x1')).toBe('0.5')
|
||||||
|
|
||||||
|
base.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle gradient with stops inheritance', () => {
|
||||||
|
const base = createLinear('baseStopsGrad')
|
||||||
|
const stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop')
|
||||||
|
stop1.setAttribute('offset', '0')
|
||||||
|
const stop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop')
|
||||||
|
stop2.setAttribute('offset', '1')
|
||||||
|
base.append(stop1, stop2)
|
||||||
|
document.body.append(base)
|
||||||
|
|
||||||
|
const top = createLinear('topNoStopsGrad')
|
||||||
|
top.setAttribute('href', '#baseStopsGrad')
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: top })
|
||||||
|
expect(paint.linearGradient?.querySelectorAll('stop')).toHaveLength(2)
|
||||||
|
|
||||||
|
base.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle mismatched gradient types', () => {
|
||||||
|
const base = createLinear('baseMismatchGrad')
|
||||||
|
document.body.append(base)
|
||||||
|
|
||||||
|
const top = createRadial('topMismatchGrad')
|
||||||
|
top.setAttribute('href', '#baseMismatchGrad')
|
||||||
|
|
||||||
|
const paint = new Paint({ radialGradient: top })
|
||||||
|
// Should not inherit from mismatched type
|
||||||
|
expect(paint.radialGradient?.id).toBe('topMismatchGrad')
|
||||||
|
|
||||||
|
base.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle circular gradient references', () => {
|
||||||
|
const grad1 = createLinear('circularGrad1')
|
||||||
|
grad1.setAttribute('href', '#circularGrad2')
|
||||||
|
document.body.append(grad1)
|
||||||
|
|
||||||
|
const grad2 = createLinear('circularGrad2')
|
||||||
|
grad2.setAttribute('href', '#circularGrad1')
|
||||||
|
document.body.append(grad2)
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: grad1 })
|
||||||
|
// Should handle circular reference without infinite loop
|
||||||
|
expect(paint.linearGradient?.id).toBe('circularGrad1')
|
||||||
|
|
||||||
|
grad1.remove()
|
||||||
|
grad2.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should normalize alpha with null value', () => {
|
||||||
|
const paint = new Paint({ alpha: null })
|
||||||
|
expect(paint.alpha).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should normalize alpha with undefined', () => {
|
||||||
|
const paint = new Paint({ alpha: undefined })
|
||||||
|
expect(paint.alpha).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should normalize solidColor with empty string', () => {
|
||||||
|
const paint = new Paint({ solidColor: '' })
|
||||||
|
expect(paint.type).toBe('none')
|
||||||
|
expect(paint.solidColor).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should normalize solidColor with whitespace', () => {
|
||||||
|
const paint = new Paint({ solidColor: ' ' })
|
||||||
|
expect(paint.type).toBe('solidColor')
|
||||||
|
expect(paint.solidColor).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle extractHrefId with path in URL', () => {
|
||||||
|
const grad = createLinear('pathGrad')
|
||||||
|
grad.setAttribute('href', 'file.svg#targetGrad')
|
||||||
|
document.body.append(grad)
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: grad })
|
||||||
|
expect(paint.linearGradient?.id).toBe('pathGrad')
|
||||||
|
|
||||||
|
grad.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle gradient without ownerDocument', () => {
|
||||||
|
const grad = createLinear('noDocGrad')
|
||||||
|
const paint = new Paint({ linearGradient: grad })
|
||||||
|
expect(paint.linearGradient?.id).toBe('noDocGrad')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should copy paint with null linearGradient', () => {
|
||||||
|
const original = new Paint({ linearGradient: null })
|
||||||
|
const copy = new Paint({ copy: original })
|
||||||
|
expect(copy.type).toBe('none')
|
||||||
|
expect(copy.linearGradient).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle href with double quotes in url()', () => {
|
||||||
|
const base = createLinear('doubleQuoteGrad')
|
||||||
|
base.setAttribute('x1', '0.25')
|
||||||
|
document.body.append(base)
|
||||||
|
|
||||||
|
const top = createLinear('topDoubleQuoteGrad')
|
||||||
|
top.setAttribute('href', 'url("#doubleQuoteGrad")')
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: top })
|
||||||
|
expect(paint.linearGradient?.getAttribute('x1')).toBe('0.25')
|
||||||
|
|
||||||
|
base.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle href with single quotes in url()', () => {
|
||||||
|
const base = createLinear('singleQuoteGrad')
|
||||||
|
base.setAttribute('y1', '0.75')
|
||||||
|
document.body.append(base)
|
||||||
|
|
||||||
|
const top = createLinear('topSingleQuoteGrad')
|
||||||
|
top.setAttribute('href', "url('#singleQuoteGrad')")
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: top })
|
||||||
|
expect(paint.linearGradient?.getAttribute('y1')).toBe('0.75')
|
||||||
|
|
||||||
|
base.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle gradient with non-matching tagName case', () => {
|
||||||
|
const base = createLinear('baseCaseGrad')
|
||||||
|
document.body.append(base)
|
||||||
|
|
||||||
|
const top = createRadial('topCaseGrad')
|
||||||
|
top.setAttribute('href', '#baseCaseGrad')
|
||||||
|
|
||||||
|
const paint = new Paint({ radialGradient: top })
|
||||||
|
// Should not inherit from wrong gradient type
|
||||||
|
expect(paint.radialGradient?.id).toBe('topCaseGrad')
|
||||||
|
|
||||||
|
base.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle gradient href with just hash', () => {
|
||||||
|
const base = createLinear('hashOnlyGrad')
|
||||||
|
base.setAttribute('x2', '1')
|
||||||
|
document.body.append(base)
|
||||||
|
|
||||||
|
const top = createLinear('topHashGrad')
|
||||||
|
top.setAttribute('href', '#hashOnlyGrad')
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: top })
|
||||||
|
expect(paint.linearGradient?.getAttribute('x2')).toBe('1')
|
||||||
|
|
||||||
|
base.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle invalid alpha values', () => {
|
||||||
|
const paint1 = new Paint({ alpha: NaN })
|
||||||
|
expect(paint1.alpha).toBe(100)
|
||||||
|
|
||||||
|
const paint2 = new Paint({ alpha: Infinity })
|
||||||
|
expect(paint2.alpha).toBe(100)
|
||||||
|
|
||||||
|
const paint3 = new Paint({ alpha: -Infinity })
|
||||||
|
expect(paint3.alpha).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle copy with missing clone method', () => {
|
||||||
|
const original = new Paint({ linearGradient: createLinear('copyGrad') })
|
||||||
|
original.linearGradient = { id: 'fake', cloneNode: null }
|
||||||
|
const copy = new Paint({ copy: original })
|
||||||
|
expect(copy.linearGradient).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle alpha at exact boundaries', () => {
|
||||||
|
const paint1 = new Paint({ alpha: 0 })
|
||||||
|
expect(paint1.alpha).toBe(0)
|
||||||
|
|
||||||
|
const paint2 = new Paint({ alpha: 100 })
|
||||||
|
expect(paint2.alpha).toBe(100)
|
||||||
|
|
||||||
|
const paint3 = new Paint({ alpha: 50 })
|
||||||
|
expect(paint3.alpha).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle gradient with null getAttribute', () => {
|
||||||
|
const grad = createLinear('nullAttrGrad')
|
||||||
|
const paint = new Paint({ linearGradient: grad })
|
||||||
|
expect(paint.linearGradient?.id).toBe('nullAttrGrad')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle referenced gradient with no attributes', () => {
|
||||||
|
const base = createLinear('emptyAttrGrad')
|
||||||
|
document.body.append(base)
|
||||||
|
|
||||||
|
const top = createLinear('topEmptyAttrGrad')
|
||||||
|
top.setAttribute('href', '#emptyAttrGrad')
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: top })
|
||||||
|
expect(paint.linearGradient?.id).toBe('topEmptyAttrGrad')
|
||||||
|
|
||||||
|
base.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle href with spaces in url()', () => {
|
||||||
|
const base = createLinear('spacesGrad')
|
||||||
|
base.setAttribute('gradientUnits', 'userSpaceOnUse')
|
||||||
|
document.body.append(base)
|
||||||
|
|
||||||
|
const top = createLinear('topSpacesGrad')
|
||||||
|
top.setAttribute('href', 'url( #spacesGrad )')
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: top })
|
||||||
|
expect(paint.linearGradient?.getAttribute('gradientUnits')).toBe('userSpaceOnUse')
|
||||||
|
|
||||||
|
base.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle solidColor with hash prefix', () => {
|
||||||
|
const paint = new Paint({ solidColor: '#ff0000' })
|
||||||
|
expect(paint.type).toBe('solidColor')
|
||||||
|
expect(paint.solidColor).toBe('ff0000')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle solidColor without hash prefix', () => {
|
||||||
|
const paint = new Paint({ solidColor: 'blue' })
|
||||||
|
expect(paint.type).toBe('solidColor')
|
||||||
|
expect(paint.solidColor).toBe('blue')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle gradient with id attribute skip', () => {
|
||||||
|
const base = createLinear('idTestGrad')
|
||||||
|
base.setAttribute('x1', '0.1')
|
||||||
|
base.setAttribute('id', 'differentId')
|
||||||
|
document.body.append(base)
|
||||||
|
|
||||||
|
const top = createLinear('topIdTestGrad')
|
||||||
|
top.setAttribute('href', '#idTestGrad')
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: top })
|
||||||
|
// Should not copy id attribute
|
||||||
|
expect(paint.linearGradient?.id).not.toBe('differentId')
|
||||||
|
|
||||||
|
base.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle gradient with xlink:href attribute skip', () => {
|
||||||
|
const base = createLinear('xlinkTestGrad')
|
||||||
|
base.setAttribute('y1', '0.2')
|
||||||
|
document.body.append(base)
|
||||||
|
|
||||||
|
const top = createLinear('topXlinkTestGrad')
|
||||||
|
top.setAttribute('xlink:href', '#xlinkTestGrad')
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: top })
|
||||||
|
expect(paint.linearGradient?.getAttribute('y1')).toBe('0.2')
|
||||||
|
// xlink:href should be removed
|
||||||
|
expect(paint.linearGradient?.hasAttribute('xlink:href')).toBe(false)
|
||||||
|
|
||||||
|
base.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle href pointing to path with hash', () => {
|
||||||
|
const grad = createLinear('pathHashGrad')
|
||||||
|
grad.setAttribute('href', 'images/file.svg#someGrad')
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: grad })
|
||||||
|
expect(paint.linearGradient?.id).toBe('pathHashGrad')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle href ending with just hash', () => {
|
||||||
|
const grad = createLinear('trailingHashGrad')
|
||||||
|
grad.setAttribute('href', 'file.svg#')
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: grad })
|
||||||
|
expect(paint.linearGradient?.id).toBe('trailingHashGrad')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle href with no hash', () => {
|
||||||
|
const grad = createLinear('noHashGrad')
|
||||||
|
grad.setAttribute('href', 'file.svg')
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: grad })
|
||||||
|
expect(paint.linearGradient?.id).toBe('noHashGrad')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty href attribute', () => {
|
||||||
|
const grad = createLinear('emptyHrefGrad')
|
||||||
|
grad.setAttribute('href', '')
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: grad })
|
||||||
|
expect(paint.linearGradient?.id).toBe('emptyHrefGrad')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle gradient with null ownerDocument fallback', () => {
|
||||||
|
const grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient')
|
||||||
|
grad.setAttribute('id', 'nullDocGrad2')
|
||||||
|
// Don't append to document
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: grad })
|
||||||
|
expect(paint.linearGradient?.id).toBe('nullDocGrad2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle radialGradient with xlink:href', () => {
|
||||||
|
const grad = createRadial('xlinkRadial')
|
||||||
|
grad.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#baseRadial')
|
||||||
|
|
||||||
|
const paint = new Paint({ radialGradient: grad })
|
||||||
|
expect(paint.radialGradient?.id).toBe('xlinkRadial')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle gradient with both href and xlink:href', () => {
|
||||||
|
const grad = createLinear('dualHref')
|
||||||
|
grad.setAttribute('href', '#newer')
|
||||||
|
grad.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#older')
|
||||||
|
|
||||||
|
const paint = new Paint({ linearGradient: grad })
|
||||||
|
expect(paint.linearGradient?.id).toBe('dualHref')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
135
tests/unit/paste-elem.test.js
Normal file
135
tests/unit/paste-elem.test.js
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import SvgCanvas from '../../packages/svgcanvas/svgcanvas.js'
|
||||||
|
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||||
|
|
||||||
|
describe('paste-elem', () => {
|
||||||
|
let svgCanvas
|
||||||
|
|
||||||
|
const createSvgCanvas = () => {
|
||||||
|
document.body.textContent = ''
|
||||||
|
const svgEditor = document.createElement('div')
|
||||||
|
svgEditor.id = 'svg_editor'
|
||||||
|
const svgcanvas = document.createElement('div')
|
||||||
|
svgcanvas.style.visibility = 'hidden'
|
||||||
|
svgcanvas.id = 'svgcanvas'
|
||||||
|
const workarea = document.createElement('div')
|
||||||
|
workarea.id = 'workarea'
|
||||||
|
workarea.append(svgcanvas)
|
||||||
|
const toolsLeft = document.createElement('div')
|
||||||
|
toolsLeft.id = 'tools_left'
|
||||||
|
svgEditor.append(workarea, toolsLeft)
|
||||||
|
document.body.append(svgEditor)
|
||||||
|
|
||||||
|
svgCanvas = new SvgCanvas(document.getElementById('svgcanvas'), {
|
||||||
|
canvas_expansion: 3,
|
||||||
|
dimensions: [640, 480],
|
||||||
|
initFill: {
|
||||||
|
color: 'FF0000',
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
initStroke: {
|
||||||
|
width: 5,
|
||||||
|
color: '000000',
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
initOpacity: 1,
|
||||||
|
imgPath: '../editor/images',
|
||||||
|
langPath: 'locale/',
|
||||||
|
extPath: 'extensions/',
|
||||||
|
extensions: [],
|
||||||
|
initTool: 'select',
|
||||||
|
wireframe: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
createSvgCanvas()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.textContent = ''
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pastes copied elements and assigns new IDs', () => {
|
||||||
|
const rect = svgCanvas.addSVGElementsFromJson({
|
||||||
|
element: 'rect',
|
||||||
|
attr: {
|
||||||
|
id: 'rect-original',
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 30,
|
||||||
|
height: 40
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
svgCanvas.selectOnly([rect], true)
|
||||||
|
svgCanvas.copySelectedElements()
|
||||||
|
|
||||||
|
const undoSize = svgCanvas.undoMgr.getUndoStackSize()
|
||||||
|
svgCanvas.pasteElements('in_place')
|
||||||
|
|
||||||
|
expect(svgCanvas.undoMgr.getUndoStackSize()).toBe(undoSize + 1)
|
||||||
|
const pasted = svgCanvas.getSelectedElements()[0]
|
||||||
|
expect(pasted).toBeTruthy()
|
||||||
|
expect(pasted.tagName).toBe('rect')
|
||||||
|
expect(pasted.id).not.toBe('rect-original')
|
||||||
|
|
||||||
|
expect(svgCanvas.getSvgContent().querySelector('#rect-original')).toBeTruthy()
|
||||||
|
expect(svgCanvas.getSvgContent().querySelector('#' + pasted.id)).toBe(pasted)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remaps internal url(#id) references when pasting', () => {
|
||||||
|
const group = svgCanvas.addSVGElementsFromJson({
|
||||||
|
element: 'g',
|
||||||
|
attr: { id: 'group-original' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const defs = document.createElementNS(NS.SVG, 'defs')
|
||||||
|
const gradient = document.createElementNS(NS.SVG, 'linearGradient')
|
||||||
|
gradient.id = 'grad-original'
|
||||||
|
const stop = document.createElementNS(NS.SVG, 'stop')
|
||||||
|
stop.setAttribute('offset', '0%')
|
||||||
|
stop.setAttribute('stop-color', '#000')
|
||||||
|
gradient.append(stop)
|
||||||
|
defs.append(gradient)
|
||||||
|
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rect.id = 'rect-with-fill'
|
||||||
|
rect.setAttribute('x', '0')
|
||||||
|
rect.setAttribute('y', '0')
|
||||||
|
rect.setAttribute('width', '10')
|
||||||
|
rect.setAttribute('height', '10')
|
||||||
|
rect.setAttribute('fill', 'url(#grad-original)')
|
||||||
|
group.append(defs, rect)
|
||||||
|
|
||||||
|
svgCanvas.selectOnly([group], true)
|
||||||
|
svgCanvas.copySelectedElements()
|
||||||
|
svgCanvas.pasteElements('in_place')
|
||||||
|
|
||||||
|
const pastedGroup = svgCanvas.getSelectedElements()[0]
|
||||||
|
const pastedGradient = pastedGroup.querySelector('linearGradient')
|
||||||
|
const pastedRect = pastedGroup.querySelector('rect')
|
||||||
|
|
||||||
|
expect(pastedGradient).toBeTruthy()
|
||||||
|
expect(pastedRect).toBeTruthy()
|
||||||
|
expect(pastedGradient.id).not.toBe('grad-original')
|
||||||
|
expect(pastedRect.getAttribute('fill')).toBe('url(#' + pastedGradient.id + ')')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not throw on invalid clipboard JSON', () => {
|
||||||
|
sessionStorage.setItem(svgCanvas.getClipboardID(), 'not-json')
|
||||||
|
const undoSize = svgCanvas.undoMgr.getUndoStackSize()
|
||||||
|
|
||||||
|
expect(() => svgCanvas.pasteElements('in_place')).not.toThrow()
|
||||||
|
expect(svgCanvas.undoMgr.getUndoStackSize()).toBe(undoSize)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not throw on empty clipboard', () => {
|
||||||
|
sessionStorage.setItem(svgCanvas.getClipboardID(), '[]')
|
||||||
|
const undoSize = svgCanvas.undoMgr.getUndoStackSize()
|
||||||
|
|
||||||
|
expect(() => svgCanvas.pasteElements('in_place')).not.toThrow()
|
||||||
|
expect(svgCanvas.undoMgr.getUndoStackSize()).toBe(undoSize)
|
||||||
|
})
|
||||||
|
})
|
||||||
613
tests/unit/path-actions.test.js
Normal file
613
tests/unit/path-actions.test.js
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
import 'pathseg'
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { init as pathActionsInit, pathActionsMethod } from '../../packages/svgcanvas/core/path-actions.js'
|
||||||
|
import { init as utilitiesInit } from '../../packages/svgcanvas/core/utilities.js'
|
||||||
|
import { init as unitsInit } from '../../packages/svgcanvas/core/units.js'
|
||||||
|
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||||
|
|
||||||
|
describe('PathActions', () => {
|
||||||
|
let svgRoot
|
||||||
|
let pathElement
|
||||||
|
let svgCanvas
|
||||||
|
let mockPath
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create mock SVG elements
|
||||||
|
svgRoot = document.createElementNS(NS.SVG, 'svg')
|
||||||
|
svgRoot.setAttribute('width', '640')
|
||||||
|
svgRoot.setAttribute('height', '480')
|
||||||
|
document.body.append(svgRoot)
|
||||||
|
|
||||||
|
pathElement = document.createElementNS(NS.SVG, 'path')
|
||||||
|
pathElement.setAttribute('id', 'path1')
|
||||||
|
pathElement.setAttribute('d', 'M10,10 L50,50 L90,10 z')
|
||||||
|
svgRoot.append(pathElement)
|
||||||
|
|
||||||
|
// Create mock path object (simulating the path module's internal Path class)
|
||||||
|
mockPath = {
|
||||||
|
elem: pathElement,
|
||||||
|
segs: [
|
||||||
|
{ index: 0, item: { x: 10, y: 10 }, type: 2, selected: false, move: vi.fn() },
|
||||||
|
{ index: 1, item: { x: 50, y: 50 }, type: 4, selected: false, move: vi.fn() },
|
||||||
|
{ index: 2, item: { x: 90, y: 10 }, type: 4, selected: false, move: vi.fn() }
|
||||||
|
],
|
||||||
|
selected_pts: [],
|
||||||
|
matrix: null,
|
||||||
|
show: vi.fn(() => mockPath),
|
||||||
|
update: vi.fn(() => mockPath),
|
||||||
|
init: vi.fn(() => mockPath),
|
||||||
|
setPathContext: vi.fn(),
|
||||||
|
storeD: vi.fn(),
|
||||||
|
selectPt: vi.fn(),
|
||||||
|
addPtsToSelection: vi.fn(),
|
||||||
|
removePtFromSelection: vi.fn(),
|
||||||
|
clearSelection: vi.fn(),
|
||||||
|
setSegType: vi.fn(),
|
||||||
|
movePts: vi.fn(),
|
||||||
|
moveCtrl: vi.fn(),
|
||||||
|
addSeg: vi.fn(),
|
||||||
|
deleteSeg: vi.fn(),
|
||||||
|
endChanges: vi.fn(),
|
||||||
|
dragctrl: false,
|
||||||
|
dragging: null,
|
||||||
|
cur_pt: null,
|
||||||
|
oldbbox: { x: 0, y: 0, width: 100, height: 100 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock svgCanvas
|
||||||
|
svgCanvas = {
|
||||||
|
getSvgRoot: () => svgRoot,
|
||||||
|
getZoom: () => 1,
|
||||||
|
setCurrentMode: vi.fn(),
|
||||||
|
getCurrentMode: vi.fn(() => 'select'),
|
||||||
|
clearSelection: vi.fn(),
|
||||||
|
addToSelection: vi.fn(),
|
||||||
|
deleteSelectedElements: vi.fn(),
|
||||||
|
call: vi.fn(),
|
||||||
|
getSelectedElements: vi.fn(() => [pathElement]),
|
||||||
|
getDrawnPath: vi.fn(() => null),
|
||||||
|
setDrawnPath: vi.fn(),
|
||||||
|
getPath_: vi.fn(() => mockPath),
|
||||||
|
getId: vi.fn(() => 'svg_1'),
|
||||||
|
getNextId: vi.fn(() => 'svg_2'),
|
||||||
|
setStarted: vi.fn(),
|
||||||
|
addPointGrip: vi.fn(),
|
||||||
|
addCtrlGrip: vi.fn(() => {
|
||||||
|
const grip = document.createElementNS(NS.SVG, 'circle')
|
||||||
|
grip.setAttribute('cx', '0')
|
||||||
|
grip.setAttribute('cy', '0')
|
||||||
|
grip.setAttribute('r', '4')
|
||||||
|
return grip
|
||||||
|
}),
|
||||||
|
getCtrlLine: vi.fn(() => {
|
||||||
|
const line = document.createElementNS(NS.SVG, 'line')
|
||||||
|
return line
|
||||||
|
}),
|
||||||
|
replacePathSeg: vi.fn(),
|
||||||
|
getGridSnapping: vi.fn(() => false),
|
||||||
|
getOpacity: vi.fn(() => 1),
|
||||||
|
round: (val) => Math.round(val),
|
||||||
|
getRoundDigits: vi.fn(() => 2),
|
||||||
|
addSVGElementsFromJson: vi.fn((json) => {
|
||||||
|
const elem = document.createElementNS(NS.SVG, json.element)
|
||||||
|
if (json.attr) {
|
||||||
|
Object.entries(json.attr).forEach(([key, value]) => {
|
||||||
|
elem.setAttribute(key, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return elem
|
||||||
|
}),
|
||||||
|
createSVGElement: vi.fn((config) => {
|
||||||
|
const elem = document.createElementNS(NS.SVG, config.element)
|
||||||
|
if (config.attr) {
|
||||||
|
Object.entries(config.attr).forEach(([key, value]) => {
|
||||||
|
elem.setAttribute(key, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return elem
|
||||||
|
}),
|
||||||
|
selectorManager: {
|
||||||
|
getRubberBandBox: vi.fn(() => {
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rect.setAttribute('id', 'selectorRubberBand')
|
||||||
|
return rect
|
||||||
|
}),
|
||||||
|
requestSelector: vi.fn(() => ({
|
||||||
|
showGrips: vi.fn()
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
getRubberBox: vi.fn(() => null),
|
||||||
|
setRubberBox: vi.fn((box) => box),
|
||||||
|
getPointFromGrip: vi.fn((point) => point),
|
||||||
|
getGripPt: vi.fn((seg) => ({ x: seg.item.x, y: seg.item.y })),
|
||||||
|
getContainer: vi.fn(() => svgRoot),
|
||||||
|
getMouseTarget: vi.fn(() => pathElement),
|
||||||
|
smoothControlPoints: vi.fn(),
|
||||||
|
removePath_: vi.fn(),
|
||||||
|
recalcRotatedPath: vi.fn(),
|
||||||
|
remapElement: vi.fn(),
|
||||||
|
addCommandToHistory: vi.fn(),
|
||||||
|
reorientGrads: vi.fn(),
|
||||||
|
setLinkControlPoints: vi.fn(),
|
||||||
|
contentW: 640
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create selector parent group
|
||||||
|
const selectorParentGroup = document.createElementNS(NS.SVG, 'g')
|
||||||
|
selectorParentGroup.id = 'selectorParentGroup'
|
||||||
|
svgRoot.append(selectorParentGroup)
|
||||||
|
|
||||||
|
// Create pathpointgrip container
|
||||||
|
const pathpointgripContainer = document.createElementNS(NS.SVG, 'g')
|
||||||
|
pathpointgripContainer.id = 'pathpointgrip_container'
|
||||||
|
svgRoot.append(pathpointgripContainer)
|
||||||
|
|
||||||
|
// Initialize modules
|
||||||
|
utilitiesInit(svgCanvas)
|
||||||
|
unitsInit(svgCanvas)
|
||||||
|
pathActionsInit(svgCanvas)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.textContent = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Class instantiation', () => {
|
||||||
|
it('should export pathActionsMethod as singleton instance', () => {
|
||||||
|
expect(pathActionsMethod).toBeDefined()
|
||||||
|
expect(typeof pathActionsMethod.mouseDown).toBe('function')
|
||||||
|
expect(typeof pathActionsMethod.mouseMove).toBe('function')
|
||||||
|
expect(typeof pathActionsMethod.mouseUp).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have all public methods', () => {
|
||||||
|
const publicMethods = [
|
||||||
|
'mouseDown',
|
||||||
|
'mouseMove',
|
||||||
|
'mouseUp',
|
||||||
|
'toEditMode',
|
||||||
|
'toSelectMode',
|
||||||
|
'addSubPath',
|
||||||
|
'select',
|
||||||
|
'reorient',
|
||||||
|
'clear',
|
||||||
|
'resetOrientation',
|
||||||
|
'zoomChange',
|
||||||
|
'getNodePoint',
|
||||||
|
'linkControlPoints',
|
||||||
|
'clonePathNode',
|
||||||
|
'opencloseSubPath',
|
||||||
|
'deletePathNode',
|
||||||
|
'smoothPolylineIntoPath',
|
||||||
|
'setSegType',
|
||||||
|
'moveNode',
|
||||||
|
'fixEnd',
|
||||||
|
'convertPath'
|
||||||
|
]
|
||||||
|
|
||||||
|
publicMethods.forEach(method => {
|
||||||
|
expect(typeof pathActionsMethod[method]).toBe('function')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mouseDown', () => {
|
||||||
|
it('should handle mouse down in path mode', () => {
|
||||||
|
svgCanvas.getCurrentMode.mockReturnValue('path')
|
||||||
|
svgCanvas.getDrawnPath.mockReturnValue(null)
|
||||||
|
|
||||||
|
const mockEvent = { target: pathElement, shiftKey: false }
|
||||||
|
const result = pathActionsMethod.mouseDown(mockEvent, pathElement, 100, 100)
|
||||||
|
|
||||||
|
expect(svgCanvas.addPointGrip).toHaveBeenCalled()
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle mouse down on existing path point', () => {
|
||||||
|
// First enter edit mode to initialize path
|
||||||
|
pathActionsMethod.toEditMode(pathElement)
|
||||||
|
svgCanvas.getCurrentMode.mockReturnValue('pathedit')
|
||||||
|
|
||||||
|
const grip = document.createElementNS(NS.SVG, 'circle')
|
||||||
|
grip.id = 'pathpointgrip_0'
|
||||||
|
const mockEvent = { target: grip, shiftKey: false }
|
||||||
|
|
||||||
|
pathActionsMethod.mouseDown(mockEvent, grip, 100, 100)
|
||||||
|
|
||||||
|
expect(mockPath.clearSelection).toHaveBeenCalled()
|
||||||
|
expect(mockPath.addPtsToSelection).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mouseMove', () => {
|
||||||
|
it('should handle mouse move in path mode', () => {
|
||||||
|
svgCanvas.getCurrentMode.mockReturnValue('path')
|
||||||
|
const drawnPath = document.createElementNS(NS.SVG, 'path')
|
||||||
|
drawnPath.setAttribute('d', 'M10,10 L50,50')
|
||||||
|
svgCanvas.getDrawnPath.mockReturnValue(drawnPath)
|
||||||
|
|
||||||
|
pathActionsMethod.mouseMove(120, 120)
|
||||||
|
|
||||||
|
// Should update path stretchy line
|
||||||
|
expect(svgCanvas.replacePathSeg).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle dragging path points', () => {
|
||||||
|
pathActionsMethod.toEditMode(pathElement)
|
||||||
|
svgCanvas.getCurrentMode.mockReturnValue('pathedit')
|
||||||
|
mockPath.dragging = [100, 100]
|
||||||
|
|
||||||
|
pathActionsMethod.mouseMove(110, 110)
|
||||||
|
|
||||||
|
expect(mockPath.movePts).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mouseUp', () => {
|
||||||
|
it('should handle mouse up in path mode', () => {
|
||||||
|
svgCanvas.getCurrentMode.mockReturnValue('path')
|
||||||
|
const drawnPath = document.createElementNS(NS.SVG, 'path')
|
||||||
|
svgCanvas.getDrawnPath.mockReturnValue(drawnPath)
|
||||||
|
|
||||||
|
const mockEvent = { target: pathElement }
|
||||||
|
const result = pathActionsMethod.mouseUp(mockEvent, drawnPath, 100, 100)
|
||||||
|
|
||||||
|
expect(result).toEqual({ keep: true, element: drawnPath })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should finalize path point dragging', () => {
|
||||||
|
pathActionsMethod.toEditMode(pathElement)
|
||||||
|
svgCanvas.getCurrentMode.mockReturnValue('pathedit')
|
||||||
|
mockPath.dragging = [100, 100]
|
||||||
|
mockPath.cur_pt = 1
|
||||||
|
|
||||||
|
const mockEvent = { target: pathElement, shiftKey: false }
|
||||||
|
pathActionsMethod.mouseUp(mockEvent, pathElement, 105, 105)
|
||||||
|
|
||||||
|
expect(mockPath.update).toHaveBeenCalled()
|
||||||
|
expect(mockPath.endChanges).toHaveBeenCalledWith('Move path point(s)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('toEditMode', () => {
|
||||||
|
it('should switch to path edit mode', () => {
|
||||||
|
pathActionsMethod.toEditMode(pathElement)
|
||||||
|
|
||||||
|
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('pathedit')
|
||||||
|
expect(svgCanvas.clearSelection).toHaveBeenCalled()
|
||||||
|
expect(mockPath.show).toHaveBeenCalledWith(true)
|
||||||
|
expect(mockPath.update).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('toSelectMode', () => {
|
||||||
|
it('should switch to select mode', () => {
|
||||||
|
pathActionsMethod.toEditMode(pathElement)
|
||||||
|
pathActionsMethod.toSelectMode(pathElement)
|
||||||
|
|
||||||
|
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
|
||||||
|
expect(mockPath.show).toHaveBeenCalledWith(false)
|
||||||
|
expect(svgCanvas.clearSelection).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should select element if it was the path element', () => {
|
||||||
|
pathActionsMethod.toEditMode(pathElement)
|
||||||
|
pathActionsMethod.toSelectMode(pathElement)
|
||||||
|
|
||||||
|
expect(svgCanvas.call).toHaveBeenCalledWith('selected', [pathElement])
|
||||||
|
expect(svgCanvas.addToSelection).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('addSubPath', () => {
|
||||||
|
it('should enable subpath mode', () => {
|
||||||
|
pathActionsMethod.addSubPath(true)
|
||||||
|
|
||||||
|
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('path')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disable subpath mode', () => {
|
||||||
|
pathActionsMethod.toEditMode(pathElement)
|
||||||
|
pathActionsMethod.addSubPath(false)
|
||||||
|
|
||||||
|
expect(mockPath.init).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('select', () => {
|
||||||
|
it('should select a path and enter edit mode if already current', () => {
|
||||||
|
pathActionsMethod.select(pathElement)
|
||||||
|
pathActionsMethod.select(pathElement)
|
||||||
|
|
||||||
|
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('pathedit')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reorient', () => {
|
||||||
|
it('should reorient a rotated path', () => {
|
||||||
|
pathElement.setAttribute('transform', 'rotate(45 50 50)')
|
||||||
|
svgCanvas.getSelectedElements.mockReturnValue([pathElement])
|
||||||
|
|
||||||
|
pathActionsMethod.reorient()
|
||||||
|
|
||||||
|
expect(svgCanvas.addCommandToHistory).toHaveBeenCalled()
|
||||||
|
expect(svgCanvas.call).toHaveBeenCalledWith('changed', [pathElement])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should do nothing if no element selected', () => {
|
||||||
|
svgCanvas.getSelectedElements.mockReturnValue([])
|
||||||
|
|
||||||
|
pathActionsMethod.reorient()
|
||||||
|
|
||||||
|
expect(svgCanvas.addCommandToHistory).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clear', () => {
|
||||||
|
it('should clear drawn path', () => {
|
||||||
|
const drawnPath = document.createElementNS(NS.SVG, 'path')
|
||||||
|
drawnPath.id = 'svg_1'
|
||||||
|
const stretchy = document.createElementNS(NS.SVG, 'path')
|
||||||
|
stretchy.id = 'path_stretch_line'
|
||||||
|
svgRoot.append(drawnPath)
|
||||||
|
svgRoot.append(stretchy)
|
||||||
|
|
||||||
|
svgCanvas.getDrawnPath.mockReturnValue(drawnPath)
|
||||||
|
|
||||||
|
pathActionsMethod.clear()
|
||||||
|
|
||||||
|
expect(svgCanvas.setDrawnPath).toHaveBeenCalledWith(null)
|
||||||
|
expect(svgCanvas.setStarted).toHaveBeenCalledWith(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should switch to select mode if in pathedit mode', () => {
|
||||||
|
svgCanvas.getCurrentMode.mockReturnValue('pathedit')
|
||||||
|
svgCanvas.getDrawnPath.mockReturnValue(null)
|
||||||
|
|
||||||
|
pathActionsMethod.clear()
|
||||||
|
|
||||||
|
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resetOrientation', () => {
|
||||||
|
it('should reset path orientation', () => {
|
||||||
|
pathElement.setAttribute('transform', 'rotate(45 50 50)')
|
||||||
|
|
||||||
|
const result = pathActionsMethod.resetOrientation(pathElement)
|
||||||
|
|
||||||
|
expect(svgCanvas.reorientGrads).toHaveBeenCalled()
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false for non-path elements', () => {
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
|
||||||
|
const result = pathActionsMethod.resetOrientation(rect)
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('zoomChange', () => {
|
||||||
|
it('should update path on zoom change in pathedit mode', () => {
|
||||||
|
pathActionsMethod.toEditMode(pathElement)
|
||||||
|
svgCanvas.getCurrentMode.mockReturnValue('pathedit')
|
||||||
|
|
||||||
|
pathActionsMethod.zoomChange()
|
||||||
|
|
||||||
|
expect(mockPath.update).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should do nothing if not in pathedit mode', () => {
|
||||||
|
svgCanvas.getCurrentMode.mockReturnValue('select')
|
||||||
|
|
||||||
|
pathActionsMethod.zoomChange()
|
||||||
|
|
||||||
|
expect(mockPath.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getNodePoint', () => {
|
||||||
|
it('should return selected node point', () => {
|
||||||
|
mockPath.selected_pts = [1]
|
||||||
|
svgCanvas.getPath_.mockReturnValue(mockPath)
|
||||||
|
|
||||||
|
const result = pathActionsMethod.getNodePoint()
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
type: 4
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return first point if no selection', () => {
|
||||||
|
mockPath.selected_pts = []
|
||||||
|
svgCanvas.getPath_.mockReturnValue(mockPath)
|
||||||
|
|
||||||
|
const result = pathActionsMethod.getNodePoint()
|
||||||
|
|
||||||
|
expect(result.x).toBeDefined()
|
||||||
|
expect(result.y).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('linkControlPoints', () => {
|
||||||
|
it('should set link control points flag', () => {
|
||||||
|
pathActionsMethod.linkControlPoints(true)
|
||||||
|
|
||||||
|
expect(svgCanvas.setLinkControlPoints).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clonePathNode', () => {
|
||||||
|
it('should clone selected path nodes', () => {
|
||||||
|
pathActionsMethod.toEditMode(pathElement)
|
||||||
|
mockPath.selected_pts = [1]
|
||||||
|
|
||||||
|
pathActionsMethod.clonePathNode()
|
||||||
|
|
||||||
|
expect(mockPath.storeD).toHaveBeenCalled()
|
||||||
|
expect(mockPath.addSeg).toHaveBeenCalled()
|
||||||
|
expect(mockPath.init).toHaveBeenCalled()
|
||||||
|
expect(mockPath.endChanges).toHaveBeenCalledWith('Clone path node(s)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deletePathNode', () => {
|
||||||
|
it('should delete selected path nodes', () => {
|
||||||
|
pathActionsMethod.toEditMode(pathElement)
|
||||||
|
mockPath.selected_pts = [1]
|
||||||
|
|
||||||
|
// Mock canDeleteNodes property
|
||||||
|
Object.defineProperty(pathActionsMethod, 'canDeleteNodes', {
|
||||||
|
get: () => true,
|
||||||
|
configurable: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock pathSegList on the element
|
||||||
|
Object.defineProperty(pathElement, 'pathSegList', {
|
||||||
|
value: {
|
||||||
|
numberOfItems: 3,
|
||||||
|
getItem: vi.fn((i) => ({
|
||||||
|
pathSegType: i === 0 ? 2 : 4 // M then L segments
|
||||||
|
})),
|
||||||
|
removeItem: vi.fn()
|
||||||
|
},
|
||||||
|
configurable: true
|
||||||
|
})
|
||||||
|
|
||||||
|
pathActionsMethod.deletePathNode()
|
||||||
|
|
||||||
|
expect(mockPath.storeD).toHaveBeenCalled()
|
||||||
|
expect(mockPath.deleteSeg).toHaveBeenCalled()
|
||||||
|
expect(mockPath.init).toHaveBeenCalled()
|
||||||
|
expect(mockPath.clearSelection).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('smoothPolylineIntoPath', () => {
|
||||||
|
it('should convert polyline to smooth path', () => {
|
||||||
|
const polyline = document.createElementNS(NS.SVG, 'polyline')
|
||||||
|
polyline.setAttribute('points', '10,10 50,50 90,10 130,50')
|
||||||
|
|
||||||
|
const mockPoints = {
|
||||||
|
numberOfItems: 4,
|
||||||
|
getItem: vi.fn((i) => {
|
||||||
|
const points = [[10, 10], [50, 50], [90, 10], [130, 50]]
|
||||||
|
return { x: points[i][0], y: points[i][1] }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Object.defineProperty(polyline, 'points', {
|
||||||
|
get: () => mockPoints,
|
||||||
|
configurable: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = pathActionsMethod.smoothPolylineIntoPath(polyline)
|
||||||
|
|
||||||
|
expect(svgCanvas.addSVGElementsFromJson).toHaveBeenCalled()
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setSegType', () => {
|
||||||
|
it('should set path segment type', () => {
|
||||||
|
pathActionsMethod.toEditMode(pathElement)
|
||||||
|
|
||||||
|
pathActionsMethod.setSegType(6)
|
||||||
|
|
||||||
|
expect(mockPath.setSegType).toHaveBeenCalledWith(6)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('moveNode', () => {
|
||||||
|
it('should move selected path node', () => {
|
||||||
|
pathActionsMethod.toEditMode(pathElement)
|
||||||
|
mockPath.selected_pts = [1]
|
||||||
|
|
||||||
|
pathActionsMethod.moveNode('x', 60)
|
||||||
|
|
||||||
|
expect(mockPath.segs[1].move).toHaveBeenCalled()
|
||||||
|
expect(mockPath.endChanges).toHaveBeenCalledWith('Move path point')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should do nothing if no points selected', () => {
|
||||||
|
pathActionsMethod.toEditMode(pathElement)
|
||||||
|
mockPath.selected_pts = []
|
||||||
|
|
||||||
|
// When no points selected, should return early
|
||||||
|
pathActionsMethod.moveNode('x', 60)
|
||||||
|
|
||||||
|
// Verify no seg.move was called
|
||||||
|
mockPath.segs.forEach(seg => {
|
||||||
|
expect(seg.move).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('convertPath', () => {
|
||||||
|
it('should convert path to relative coordinates', () => {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M10,10 L50,50 L90,10 z')
|
||||||
|
|
||||||
|
const result = pathActionsMethod.convertPath(path, true)
|
||||||
|
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(typeof result).toBe('string')
|
||||||
|
expect(result).toContain('m') // Should have relative move command
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should convert path to absolute coordinates', () => {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'm10,10 l40,40 l40,-40 z')
|
||||||
|
|
||||||
|
const result = pathActionsMethod.convertPath(path, false)
|
||||||
|
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(typeof result).toBe('string')
|
||||||
|
expect(result).toContain('M') // Should have absolute move command
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Private field encapsulation', () => {
|
||||||
|
it('should not expose private fields', () => {
|
||||||
|
const privateFields = ['subpath', 'newPoint', 'firstCtrl', 'currentPath', 'hasMoved']
|
||||||
|
|
||||||
|
privateFields.forEach(field => {
|
||||||
|
expect(pathActionsMethod[field]).toBeUndefined()
|
||||||
|
expect(pathActionsMethod[`#${field}`]).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Integration scenarios', () => {
|
||||||
|
it('should handle complete path drawing workflow', () => {
|
||||||
|
// Start drawing
|
||||||
|
svgCanvas.getCurrentMode.mockReturnValue('path')
|
||||||
|
svgCanvas.getDrawnPath.mockReturnValue(null)
|
||||||
|
|
||||||
|
// First point
|
||||||
|
pathActionsMethod.mouseDown({ target: svgRoot }, svgRoot, 10, 10)
|
||||||
|
expect(svgCanvas.addPointGrip).toHaveBeenCalled()
|
||||||
|
|
||||||
|
// Add more points
|
||||||
|
const drawnPath = document.createElementNS(NS.SVG, 'path')
|
||||||
|
drawnPath.setAttribute('d', 'M10,10 L50,50')
|
||||||
|
svgCanvas.getDrawnPath.mockReturnValue(drawnPath)
|
||||||
|
|
||||||
|
pathActionsMethod.mouseMove(50, 50)
|
||||||
|
expect(svgCanvas.replacePathSeg).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle path editing with transform', () => {
|
||||||
|
pathElement.setAttribute('transform', 'translate(10,10) rotate(45)')
|
||||||
|
mockPath.matrix = { a: 0.707, b: 0.707, c: -0.707, d: 0.707, e: 10, f: 10 }
|
||||||
|
|
||||||
|
pathActionsMethod.toEditMode(pathElement)
|
||||||
|
|
||||||
|
expect(mockPath.show).toHaveBeenCalledWith(true)
|
||||||
|
expect(mockPath.update).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import 'pathseg'
|
import 'pathseg'
|
||||||
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||||
import * as utilities from '../../packages/svgcanvas/core/utilities.js'
|
import * as utilities from '../../packages/svgcanvas/core/utilities.js'
|
||||||
|
import { convertPath as convertPathActions } from '../../packages/svgcanvas/core/path-actions.js'
|
||||||
import * as pathModule from '../../packages/svgcanvas/core/path.js'
|
import * as pathModule from '../../packages/svgcanvas/core/path.js'
|
||||||
import { Path, Segment } from '../../packages/svgcanvas/core/path-method.js'
|
import { Path, Segment } from '../../packages/svgcanvas/core/path-method.js'
|
||||||
import { init as unitsInit } from '../../packages/svgcanvas/core/units.js'
|
import { init as unitsInit } from '../../packages/svgcanvas/core/units.js'
|
||||||
@@ -41,6 +42,12 @@ describe('path', function () {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('Test svgedit.path.init exposes recalcRotatedPath', function () {
|
||||||
|
const [mockPathContext] = getMockContexts()
|
||||||
|
pathModule.init(mockPathContext)
|
||||||
|
assert.equal(typeof mockPathContext.recalcRotatedPath, 'function')
|
||||||
|
})
|
||||||
|
|
||||||
it('Test svgedit.path.replacePathSeg', function () {
|
it('Test svgedit.path.replacePathSeg', function () {
|
||||||
const path = document.createElementNS(NS.SVG, 'path')
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
path.setAttribute('d', 'M0,0 L10,11 L20,21Z')
|
path.setAttribute('d', 'M0,0 L10,11 L20,21Z')
|
||||||
@@ -137,6 +144,63 @@ describe('path', function () {
|
|||||||
assert.equal(path.pathSegList.getItem(1).y, 15)
|
assert.equal(path.pathSegList.getItem(1).y, 15)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Test svgedit.path.Segment.move for quadratic curve', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 Q11,12 15,16')
|
||||||
|
|
||||||
|
const [mockPathContext, mockUtilitiesContext] = getMockContexts()
|
||||||
|
pathModule.init(mockPathContext)
|
||||||
|
utilities.init(mockUtilitiesContext)
|
||||||
|
const pathObj = new Path(path)
|
||||||
|
|
||||||
|
pathObj.segs[1].move(-3, 4)
|
||||||
|
const seg = path.pathSegList.getItem(1)
|
||||||
|
|
||||||
|
assert.equal(seg.pathSegTypeAsLetter, 'Q')
|
||||||
|
assert.equal(seg.x, 12)
|
||||||
|
assert.equal(seg.y, 20)
|
||||||
|
assert.equal(seg.x1, 11)
|
||||||
|
assert.equal(seg.y1, 12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test svgedit.path.Segment.move for smooth cubic curve', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 S13,14 15,16')
|
||||||
|
|
||||||
|
const [mockPathContext, mockUtilitiesContext] = getMockContexts()
|
||||||
|
pathModule.init(mockPathContext)
|
||||||
|
utilities.init(mockUtilitiesContext)
|
||||||
|
const pathObj = new Path(path)
|
||||||
|
|
||||||
|
pathObj.segs[1].move(5, -6)
|
||||||
|
const seg = path.pathSegList.getItem(1)
|
||||||
|
|
||||||
|
assert.equal(seg.pathSegTypeAsLetter, 'S')
|
||||||
|
assert.equal(seg.x, 20)
|
||||||
|
assert.equal(seg.y, 10)
|
||||||
|
assert.equal(seg.x2, 18)
|
||||||
|
assert.equal(seg.y2, 8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test moving start point moves next quadratic control point', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 Q10,0 20,0')
|
||||||
|
|
||||||
|
const [mockPathContext, mockUtilitiesContext] = getMockContexts()
|
||||||
|
pathModule.init(mockPathContext)
|
||||||
|
utilities.init(mockUtilitiesContext)
|
||||||
|
const pathObj = new Path(path)
|
||||||
|
|
||||||
|
pathObj.segs[0].move(5, 5)
|
||||||
|
const seg = path.pathSegList.getItem(1)
|
||||||
|
|
||||||
|
assert.equal(seg.pathSegTypeAsLetter, 'Q')
|
||||||
|
assert.equal(seg.x, 20)
|
||||||
|
assert.equal(seg.y, 0)
|
||||||
|
assert.equal(seg.x1, 15)
|
||||||
|
assert.equal(seg.y1, 5)
|
||||||
|
})
|
||||||
|
|
||||||
it('Test svgedit.path.Segment.moveCtrl', function () {
|
it('Test svgedit.path.Segment.moveCtrl', function () {
|
||||||
const path = document.createElementNS(NS.SVG, 'path')
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
path.setAttribute('d', 'M0,0 C11,12 13,14 15,16 Z')
|
path.setAttribute('d', 'M0,0 C11,12 13,14 15,16 Z')
|
||||||
@@ -179,4 +243,269 @@ describe('path', function () {
|
|||||||
const rel = pathModule.convertPath(path, true)
|
const rel = pathModule.convertPath(path, true)
|
||||||
assert.equal(rel, 'm40,55l20,0l0,20')
|
assert.equal(rel, 'm40,55l20,0l0,20')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Test convertPath resets after closepath when relative', function () {
|
||||||
|
unitsInit({
|
||||||
|
getRoundDigits () { return 5 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M10,10 L20,10 Z L15,10')
|
||||||
|
const expected = 'm10,10l10,0zl5,0'
|
||||||
|
|
||||||
|
assert.equal(pathModule.convertPath(path, true), expected)
|
||||||
|
assert.equal(convertPathActions(path, true), expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test recalcRotatedPath preserves zero control points', function () {
|
||||||
|
const svg = document.createElementNS(NS.SVG, 'svg')
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 C0,10 0,20 30,30')
|
||||||
|
path.setAttribute('transform', 'rotate(45 0 0)')
|
||||||
|
svg.append(path)
|
||||||
|
|
||||||
|
const [mockPathContext, mockUtilitiesContext] = getMockContexts(svg)
|
||||||
|
pathModule.init(mockPathContext)
|
||||||
|
utilities.init(mockUtilitiesContext)
|
||||||
|
const pathObj = new Path(path)
|
||||||
|
pathObj.oldbbox = utilities.getBBox(path)
|
||||||
|
|
||||||
|
pathModule.recalcRotatedPath()
|
||||||
|
|
||||||
|
const seg = path.pathSegList.getItem(1)
|
||||||
|
assert.equal(seg.pathSegTypeAsLetter, 'C')
|
||||||
|
assert.closeTo(seg.x1, 0, 1e-6)
|
||||||
|
assert.closeTo(seg.y1, 10, 1e-6)
|
||||||
|
assert.closeTo(seg.x2, 0, 1e-6)
|
||||||
|
assert.closeTo(seg.y2, 20, 1e-6)
|
||||||
|
assert.closeTo(seg.x, 30, 1e-6)
|
||||||
|
assert.closeTo(seg.y, 30, 1e-6)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test convertPath handles relative arcs', function () {
|
||||||
|
unitsInit({
|
||||||
|
getRoundDigits () { return 5 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 a10,20 30 0 1 40,50')
|
||||||
|
|
||||||
|
const abs = pathModule.convertPath(path)
|
||||||
|
assert.ok(abs.includes('A10,20 30 0 1 40,50'))
|
||||||
|
|
||||||
|
const rel = pathModule.convertPath(path, true)
|
||||||
|
assert.ok(rel.includes('a10,20 30 0 1 40,50'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test recalcRotatedPath with no current path', function () {
|
||||||
|
const [mockPathContext] = getMockContexts()
|
||||||
|
pathModule.init(mockPathContext)
|
||||||
|
// path is null initially after init
|
||||||
|
pathModule.recalcRotatedPath()
|
||||||
|
// Should not throw
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test recalcRotatedPath with path without rotation', function () {
|
||||||
|
const svg = document.createElementNS(NS.SVG, 'svg')
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 L10,10')
|
||||||
|
svg.append(path)
|
||||||
|
|
||||||
|
const [mockPathContext, mockUtilitiesContext] = getMockContexts(svg)
|
||||||
|
pathModule.init(mockPathContext)
|
||||||
|
utilities.init(mockUtilitiesContext)
|
||||||
|
const pathObj = new Path(path)
|
||||||
|
pathObj.oldbbox = utilities.getBBox(path)
|
||||||
|
|
||||||
|
pathModule.recalcRotatedPath()
|
||||||
|
// Should not throw, and path should remain unchanged
|
||||||
|
assert.equal(path.getAttribute('d'), 'M0,0 L10,10')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test recalcRotatedPath with path without oldbbox', function () {
|
||||||
|
const svg = document.createElementNS(NS.SVG, 'svg')
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 L10,10')
|
||||||
|
path.setAttribute('transform', 'rotate(45 0 0)')
|
||||||
|
svg.append(path)
|
||||||
|
|
||||||
|
const [mockPathContext] = getMockContexts(svg)
|
||||||
|
pathModule.init(mockPathContext)
|
||||||
|
const pathObj = new Path(path)
|
||||||
|
pathObj.oldbbox = null
|
||||||
|
|
||||||
|
pathModule.recalcRotatedPath()
|
||||||
|
// Should not throw
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test Segment class with various pathSegTypes', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 H10 V10 Z')
|
||||||
|
|
||||||
|
const seg1 = new Segment(0, path.pathSegList.getItem(0))
|
||||||
|
assert.equal(seg1.index, 0)
|
||||||
|
|
||||||
|
const seg2 = new Segment(1, path.pathSegList.getItem(1))
|
||||||
|
assert.equal(seg2.type, 12) // PATHSEG_LINETO_HORIZONTAL_ABS
|
||||||
|
|
||||||
|
const seg3 = new Segment(2, path.pathSegList.getItem(2))
|
||||||
|
assert.equal(seg3.type, 14) // PATHSEG_LINETO_VERTICAL_ABS
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test convertPath with smooth cubic curve', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 S10,10 20,20')
|
||||||
|
|
||||||
|
const result = pathModule.convertPath(path, false)
|
||||||
|
assert.ok(result.includes('S'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test convertPath with quadratic curve', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 Q10,10 20,20')
|
||||||
|
|
||||||
|
const result = pathModule.convertPath(path, false)
|
||||||
|
assert.ok(result.includes('Q'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test Path.update with no pathSegList', function () {
|
||||||
|
const svg = document.createElementNS(NS.SVG, 'svg')
|
||||||
|
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
const [mockPathContext, mockUtilitiesContext] = getMockContexts(svg)
|
||||||
|
utilities.init(mockUtilitiesContext)
|
||||||
|
pathModule.init(mockPathContext)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pathObj = new Path(rect)
|
||||||
|
pathObj.update()
|
||||||
|
} catch (e) {
|
||||||
|
// Expected for non-path elements
|
||||||
|
assert.ok(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test convertPath with smooth quadratic curve', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 Q10,10 20,0 T40,0')
|
||||||
|
|
||||||
|
const result = pathModule.convertPath(path, false)
|
||||||
|
assert.ok(result.includes('T'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test convertPath with mixed absolute and relative commands', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 L10,10 l5,5 L20,20')
|
||||||
|
|
||||||
|
const abs = pathModule.convertPath(path, false)
|
||||||
|
assert.ok(abs.includes('L'))
|
||||||
|
|
||||||
|
const rel = pathModule.convertPath(path, true)
|
||||||
|
assert.ok(rel.includes('l'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test convertPath with horizontal and vertical lines', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 H10 V10 h5 v5')
|
||||||
|
|
||||||
|
const result = pathModule.convertPath(path)
|
||||||
|
assert.ok(result.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test Segment with arc command', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 A10,10 0 0 1 20,20')
|
||||||
|
|
||||||
|
const seg = new Segment(1, path.pathSegList.getItem(1))
|
||||||
|
assert.equal(seg.type, 10) // PATHSEG_ARC_ABS
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test convertPath with quadratic bezier', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 Q10,10 20,0')
|
||||||
|
|
||||||
|
const result = pathModule.convertPath(path)
|
||||||
|
assert.ok(result.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test convertPath with smooth quadratic', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 Q10,10 20,0 T30,0')
|
||||||
|
|
||||||
|
const result = pathModule.convertPath(path)
|
||||||
|
assert.ok(result.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test convertPath with arc sweep flags', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 A10,10 0 1 0 20,20')
|
||||||
|
|
||||||
|
const result = pathModule.convertPath(path)
|
||||||
|
assert.ok(result.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test convertPath with relative arc', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M10,10 a5,5 0 0 1 10,10')
|
||||||
|
|
||||||
|
const result = pathModule.convertPath(path)
|
||||||
|
assert.ok(result.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test convertPath with close path', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 L10,0 L10,10 Z')
|
||||||
|
|
||||||
|
const result = pathModule.convertPath(path)
|
||||||
|
assert.ok(result.includes('Z') || result.includes('z'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test convertPath with mixed case commands', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 L10,10 l5,5 C20,20 25,25 30,20')
|
||||||
|
|
||||||
|
const result = pathModule.convertPath(path)
|
||||||
|
assert.ok(result.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test Segment getItem', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 L10,10 L20,20')
|
||||||
|
|
||||||
|
const seg = new Segment(1, path.pathSegList.getItem(1))
|
||||||
|
assert.ok(seg.type)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test convertPath with relative smooth cubic', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0,0 C10,10 20,10 30,0 s10,10 20,0')
|
||||||
|
|
||||||
|
const result = pathModule.convertPath(path)
|
||||||
|
assert.ok(result.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test convertPath with negative coordinates', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M-10,-10 L-20,-20 L-30,-15')
|
||||||
|
|
||||||
|
const result = pathModule.convertPath(path)
|
||||||
|
assert.ok(result.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test convertPath with decimal coordinates', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M0.5,0.5 L10.25,10.75')
|
||||||
|
|
||||||
|
const result = pathModule.convertPath(path)
|
||||||
|
assert.ok(result.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test Segment with move command', function () {
|
||||||
|
const path = document.createElementNS(NS.SVG, 'path')
|
||||||
|
path.setAttribute('d', 'M10,10 L20,20')
|
||||||
|
|
||||||
|
const seg = new Segment(0, path.pathSegList.getItem(0))
|
||||||
|
assert.equal(seg.type, 2) // PATHSEG_MOVETO_ABS
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,172 @@
|
|||||||
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||||
import * as sanitize from '../../packages/svgcanvas/core/sanitize.js'
|
import * as sanitize from '../../packages/svgcanvas/core/sanitize.js'
|
||||||
|
import * as utilities from '../../packages/svgcanvas/core/utilities.js'
|
||||||
|
|
||||||
describe('sanitize', function () {
|
describe('sanitize', function () {
|
||||||
const svg = document.createElementNS(NS.SVG, 'svg')
|
/** @type {HTMLDivElement} */
|
||||||
|
let container
|
||||||
|
/** @type {SVGSVGElement} */
|
||||||
|
let svg
|
||||||
|
let originalWarn
|
||||||
|
|
||||||
it('Test sanitizeSvg() strips ws from style attr', function () {
|
const createSvgElement = (name) => document.createElementNS(NS.SVG, name)
|
||||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
|
||||||
rect.setAttribute('style', 'stroke: blue ;\t\tstroke-width :\t\t40;')
|
beforeEach(() => {
|
||||||
|
originalWarn = console.warn
|
||||||
|
console.warn = () => {}
|
||||||
|
container = document.createElement('div')
|
||||||
|
svg = /** @type {SVGSVGElement} */ (createSvgElement('svg'))
|
||||||
|
container.append(svg)
|
||||||
|
document.body.append(container)
|
||||||
|
|
||||||
|
utilities.init({
|
||||||
|
getSvgRoot: () => svg
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
container.remove()
|
||||||
|
console.warn = originalWarn
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizeSvg() strips ws from style attr', function () {
|
||||||
|
const rect = createSvgElement('rect')
|
||||||
|
rect.setAttribute('style', 'stroke: blue ;\t\tstroke-width :\t\t40; vector-effect: non-scaling-stroke;')
|
||||||
// sanitizeSvg() requires the node to have a parent and a document.
|
// sanitizeSvg() requires the node to have a parent and a document.
|
||||||
svg.append(rect)
|
svg.append(rect)
|
||||||
sanitize.sanitizeSvg(rect)
|
sanitize.sanitizeSvg(rect)
|
||||||
|
|
||||||
assert.equal(rect.getAttribute('stroke'), 'blue')
|
assert.equal(rect.getAttribute('stroke'), 'blue')
|
||||||
assert.equal(rect.getAttribute('stroke-width'), '40')
|
assert.equal(rect.getAttribute('stroke-width'), '40')
|
||||||
|
assert.equal(rect.hasAttribute('style'), false)
|
||||||
|
assert.equal(rect.hasAttribute('vector-effect'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizeSvg() removes disallowed attributes but keeps data-*', function () {
|
||||||
|
const rect = createSvgElement('rect')
|
||||||
|
rect.setAttribute('onclick', 'alert(1)')
|
||||||
|
rect.setAttribute('data-note', 'safe')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
sanitize.sanitizeSvg(rect)
|
||||||
|
|
||||||
|
assert.equal(rect.hasAttribute('onclick'), false)
|
||||||
|
assert.equal(rect.getAttribute('data-note'), 'safe')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizeSvg() mirrors xlink:href to href', function () {
|
||||||
|
const image = createSvgElement('image')
|
||||||
|
image.setAttributeNS(NS.XLINK, 'xlink:href', 'http://example.com/test.png')
|
||||||
|
svg.append(image)
|
||||||
|
|
||||||
|
sanitize.sanitizeSvg(image)
|
||||||
|
|
||||||
|
assert.equal(image.getAttribute('href'), 'http://example.com/test.png')
|
||||||
|
assert.equal(image.hasAttributeNS(NS.XLINK, 'href'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizeSvg() drops non-local hrefs on local-only elements', function () {
|
||||||
|
const gradient = createSvgElement('linearGradient')
|
||||||
|
gradient.setAttribute('href', 'http://example.com/grad')
|
||||||
|
svg.append(gradient)
|
||||||
|
|
||||||
|
sanitize.sanitizeSvg(gradient)
|
||||||
|
|
||||||
|
assert.equal(gradient.hasAttribute('href'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizeSvg() removes <use> without href', function () {
|
||||||
|
const use = createSvgElement('use')
|
||||||
|
svg.append(use)
|
||||||
|
|
||||||
|
sanitize.sanitizeSvg(use)
|
||||||
|
|
||||||
|
assert.equal(use.parentNode, null)
|
||||||
|
assert.equal(svg.querySelector('use'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizeSvg() keeps <use> with a local href', function () {
|
||||||
|
const symbol = createSvgElement('symbol')
|
||||||
|
symbol.id = 'icon'
|
||||||
|
symbol.setAttribute('viewBox', '0 0 200 100')
|
||||||
|
svg.append(symbol)
|
||||||
|
|
||||||
|
const use = createSvgElement('use')
|
||||||
|
use.setAttribute('href', '#icon')
|
||||||
|
svg.append(use)
|
||||||
|
|
||||||
|
sanitize.sanitizeSvg(use)
|
||||||
|
|
||||||
|
assert.equal(use.parentNode, svg)
|
||||||
|
assert.equal(use.getAttribute('href'), '#icon')
|
||||||
|
assert.equal(use.hasAttribute('width'), false)
|
||||||
|
assert.equal(use.hasAttribute('height'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizeSvg() removes non-local url() paint references', function () {
|
||||||
|
const rect = createSvgElement('rect')
|
||||||
|
rect.setAttribute('fill', 'url(http://example.com/pat)')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
sanitize.sanitizeSvg(rect)
|
||||||
|
|
||||||
|
assert.equal(rect.hasAttribute('fill'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizeSvg() trims and removes text nodes', function () {
|
||||||
|
const text = createSvgElement('text')
|
||||||
|
text.append(document.createTextNode(' Hello '), document.createTextNode(' '))
|
||||||
|
svg.append(text)
|
||||||
|
|
||||||
|
sanitize.sanitizeSvg(text)
|
||||||
|
|
||||||
|
assert.equal(text.textContent, 'Hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizeSvg() removes unsupported elements but keeps children', function () {
|
||||||
|
const unknown = createSvgElement('foo')
|
||||||
|
const rect = createSvgElement('rect')
|
||||||
|
unknown.append(rect)
|
||||||
|
svg.append(unknown)
|
||||||
|
|
||||||
|
sanitize.sanitizeSvg(unknown)
|
||||||
|
|
||||||
|
assert.equal(svg.querySelector('foo'), null)
|
||||||
|
assert.equal(rect.parentNode, svg)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizeSvg() handles element with id attribute', function () {
|
||||||
|
const rect = createSvgElement('rect')
|
||||||
|
rect.setAttribute('id', 'myRect')
|
||||||
|
rect.setAttribute('x', '10')
|
||||||
|
rect.setAttribute('y', '20')
|
||||||
|
svg.append(rect)
|
||||||
|
|
||||||
|
sanitize.sanitizeSvg(rect)
|
||||||
|
|
||||||
|
assert.equal(rect.getAttribute('id'), 'myRect')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizeSvg() handles comment nodes', function () {
|
||||||
|
const g = createSvgElement('g')
|
||||||
|
const comment = document.createComment('This is a comment')
|
||||||
|
g.append(comment)
|
||||||
|
svg.append(g)
|
||||||
|
|
||||||
|
sanitize.sanitizeSvg(g)
|
||||||
|
assert.ok(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizeSvg() handles nested groups', function () {
|
||||||
|
const g1 = createSvgElement('g')
|
||||||
|
const g2 = createSvgElement('g')
|
||||||
|
const rect = createSvgElement('rect')
|
||||||
|
g2.append(rect)
|
||||||
|
g1.append(g2)
|
||||||
|
svg.append(g1)
|
||||||
|
|
||||||
|
sanitize.sanitizeSvg(g1)
|
||||||
|
|
||||||
|
assert.ok(svg.querySelector('rect'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
479
tests/unit/select-module.test.js
Normal file
479
tests/unit/select-module.test.js
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { init as selectInit, getSelectorManager, Selector, SelectorManager } from '../../packages/svgcanvas/core/select.js'
|
||||||
|
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||||
|
|
||||||
|
describe('Select Module', () => {
|
||||||
|
let svgRoot
|
||||||
|
let svgContent
|
||||||
|
let svgCanvas
|
||||||
|
let rectElement
|
||||||
|
let circleElement
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create mock SVG elements
|
||||||
|
svgRoot = document.createElementNS(NS.SVG, 'svg')
|
||||||
|
svgRoot.setAttribute('width', '640')
|
||||||
|
svgRoot.setAttribute('height', '480')
|
||||||
|
document.body.append(svgRoot)
|
||||||
|
|
||||||
|
svgContent = document.createElementNS(NS.SVG, 'g')
|
||||||
|
svgContent.setAttribute('id', 'svgcontent')
|
||||||
|
svgRoot.append(svgContent)
|
||||||
|
|
||||||
|
rectElement = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
rectElement.setAttribute('id', 'rect1')
|
||||||
|
rectElement.setAttribute('x', '10')
|
||||||
|
rectElement.setAttribute('y', '10')
|
||||||
|
rectElement.setAttribute('width', '100')
|
||||||
|
rectElement.setAttribute('height', '50')
|
||||||
|
svgContent.append(rectElement)
|
||||||
|
|
||||||
|
circleElement = document.createElementNS(NS.SVG, 'circle')
|
||||||
|
circleElement.setAttribute('id', 'circle1')
|
||||||
|
circleElement.setAttribute('cx', '200')
|
||||||
|
circleElement.setAttribute('cy', '200')
|
||||||
|
circleElement.setAttribute('r', '50')
|
||||||
|
svgContent.append(circleElement)
|
||||||
|
|
||||||
|
// Mock data storage
|
||||||
|
const mockDataStorage = {
|
||||||
|
_storage: new Map(),
|
||||||
|
put: function (element, key, value) {
|
||||||
|
if (!this._storage.has(element)) {
|
||||||
|
this._storage.set(element, new Map())
|
||||||
|
}
|
||||||
|
this._storage.get(element).set(key, value)
|
||||||
|
},
|
||||||
|
get: function (element, key) {
|
||||||
|
return this._storage.has(element) ? this._storage.get(element).get(key) : undefined
|
||||||
|
},
|
||||||
|
has: function (element, key) {
|
||||||
|
return this._storage.has(element) && this._storage.get(element).has(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock svgCanvas
|
||||||
|
svgCanvas = {
|
||||||
|
getSvgRoot: () => svgRoot,
|
||||||
|
getSvgContent: () => svgContent,
|
||||||
|
getZoom: () => 1,
|
||||||
|
getDataStorage: () => mockDataStorage,
|
||||||
|
curConfig: {
|
||||||
|
imgPath: 'images',
|
||||||
|
dimensions: [640, 480]
|
||||||
|
},
|
||||||
|
createSVGElement: vi.fn((config) => {
|
||||||
|
const elem = document.createElementNS(NS.SVG, config.element)
|
||||||
|
if (config.attr) {
|
||||||
|
Object.entries(config.attr).forEach(([key, value]) => {
|
||||||
|
elem.setAttribute(key, String(value))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return elem
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize select module
|
||||||
|
selectInit(svgCanvas)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.textContent = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Module initialization', () => {
|
||||||
|
it('should initialize and return SelectorManager singleton', () => {
|
||||||
|
const manager = getSelectorManager()
|
||||||
|
expect(manager).toBeDefined()
|
||||||
|
expect(manager).toBeInstanceOf(SelectorManager)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return the same SelectorManager instance', () => {
|
||||||
|
const manager1 = getSelectorManager()
|
||||||
|
const manager2 = getSelectorManager()
|
||||||
|
expect(manager1).toBe(manager2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not expose private selectorManager field', () => {
|
||||||
|
const manager = getSelectorManager()
|
||||||
|
expect(manager.selectorManager).toBeUndefined()
|
||||||
|
expect(manager.selectorManager_).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SelectorManager class', () => {
|
||||||
|
let manager
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = getSelectorManager()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have initialized all required properties', () => {
|
||||||
|
expect(manager.selectorParentGroup).toBeDefined()
|
||||||
|
expect(manager.rubberBandBox).toBeNull()
|
||||||
|
expect(manager.selectors).toEqual([])
|
||||||
|
expect(manager.selectorMap).toEqual({})
|
||||||
|
expect(manager.selectorGrips).toBeDefined()
|
||||||
|
expect(manager.selectorGripsGroup).toBeDefined()
|
||||||
|
expect(manager.rotateGripConnector).toBeDefined()
|
||||||
|
expect(manager.rotateGrip).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have all 8 selector grips', () => {
|
||||||
|
const directions = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
|
||||||
|
directions.forEach(dir => {
|
||||||
|
expect(manager.selectorGrips[dir]).toBeDefined()
|
||||||
|
expect(manager.selectorGrips[dir].tagName).toBe('circle')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create selectorParentGroup in DOM', () => {
|
||||||
|
const parentGroup = svgRoot.querySelector('#selectorParentGroup')
|
||||||
|
expect(parentGroup).toBeDefined()
|
||||||
|
expect(parentGroup).toBe(manager.selectorParentGroup)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('requestSelector', () => {
|
||||||
|
it('should create a new selector for an element', () => {
|
||||||
|
const selector = manager.requestSelector(rectElement)
|
||||||
|
expect(selector).toBeInstanceOf(Selector)
|
||||||
|
expect(selector.selectedElement).toBe(rectElement)
|
||||||
|
expect(selector.locked).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return existing selector for same element', () => {
|
||||||
|
const selector1 = manager.requestSelector(rectElement)
|
||||||
|
const selector2 = manager.requestSelector(rectElement)
|
||||||
|
expect(selector1).toBe(selector2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reuse unlocked selectors', () => {
|
||||||
|
const selector1 = manager.requestSelector(rectElement)
|
||||||
|
manager.releaseSelector(rectElement)
|
||||||
|
const selector2 = manager.requestSelector(circleElement)
|
||||||
|
expect(selector1).toBe(selector2)
|
||||||
|
expect(selector2.selectedElement).toBe(circleElement)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create multiple selectors when needed', () => {
|
||||||
|
const selector1 = manager.requestSelector(rectElement)
|
||||||
|
const selector2 = manager.requestSelector(circleElement)
|
||||||
|
expect(selector1).not.toBe(selector2)
|
||||||
|
expect(manager.selectors.length).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null for null element', () => {
|
||||||
|
const selector = manager.requestSelector(null)
|
||||||
|
expect(selector).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add selector to selectorMap', () => {
|
||||||
|
const selector = manager.requestSelector(rectElement)
|
||||||
|
expect(manager.selectorMap[rectElement.id]).toBe(selector)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('releaseSelector', () => {
|
||||||
|
it('should unlock selector', () => {
|
||||||
|
const selector = manager.requestSelector(rectElement)
|
||||||
|
expect(selector.locked).toBe(true)
|
||||||
|
manager.releaseSelector(rectElement)
|
||||||
|
expect(selector.locked).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove selector from selectorMap', () => {
|
||||||
|
manager.requestSelector(rectElement)
|
||||||
|
expect(manager.selectorMap[rectElement.id]).toBeDefined()
|
||||||
|
manager.releaseSelector(rectElement)
|
||||||
|
expect(manager.selectorMap[rectElement.id]).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear selectedElement', () => {
|
||||||
|
const selector = manager.requestSelector(rectElement)
|
||||||
|
manager.releaseSelector(rectElement)
|
||||||
|
expect(selector.selectedElement).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide selector group', () => {
|
||||||
|
const selector = manager.requestSelector(rectElement)
|
||||||
|
manager.releaseSelector(rectElement)
|
||||||
|
expect(selector.selectorGroup.getAttribute('display')).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle null element gracefully', () => {
|
||||||
|
expect(() => manager.releaseSelector(null)).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getRubberBandBox', () => {
|
||||||
|
it('should create rubber band box on first call', () => {
|
||||||
|
const rubberBand = manager.getRubberBandBox()
|
||||||
|
expect(rubberBand).toBeDefined()
|
||||||
|
expect(rubberBand.tagName).toBe('rect')
|
||||||
|
expect(rubberBand.id).toBe('selectorRubberBand')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return same rubber band box on subsequent calls', () => {
|
||||||
|
const rubberBand1 = manager.getRubberBandBox()
|
||||||
|
const rubberBand2 = manager.getRubberBandBox()
|
||||||
|
expect(rubberBand1).toBe(rubberBand2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have correct initial display state', () => {
|
||||||
|
const rubberBand = manager.getRubberBandBox()
|
||||||
|
expect(rubberBand.getAttribute('display')).toBe('none')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('initGroup', () => {
|
||||||
|
it('should reset selectors and selectorMap', () => {
|
||||||
|
manager.requestSelector(rectElement)
|
||||||
|
manager.initGroup()
|
||||||
|
expect(manager.selectors).toEqual([])
|
||||||
|
expect(manager.selectorMap).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should recreate selectorParentGroup', () => {
|
||||||
|
const oldGroup = manager.selectorParentGroup
|
||||||
|
manager.initGroup()
|
||||||
|
const newGroup = manager.selectorParentGroup
|
||||||
|
expect(newGroup).not.toBe(oldGroup)
|
||||||
|
expect(newGroup.id).toBe('selectorParentGroup')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create canvasBackground if not exists', () => {
|
||||||
|
// Remove any existing background
|
||||||
|
const existing = document.getElementById('canvasBackground')
|
||||||
|
if (existing) existing.remove()
|
||||||
|
|
||||||
|
manager.initGroup()
|
||||||
|
const background = document.getElementById('canvasBackground')
|
||||||
|
expect(background).toBeDefined()
|
||||||
|
expect(background.tagName).toBe('svg')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Selector class', () => {
|
||||||
|
let manager
|
||||||
|
let selector
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = getSelectorManager()
|
||||||
|
selector = manager.requestSelector(rectElement)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have correct initial properties', () => {
|
||||||
|
expect(selector.id).toBeDefined()
|
||||||
|
expect(selector.selectedElement).toBe(rectElement)
|
||||||
|
expect(selector.locked).toBe(true)
|
||||||
|
expect(selector.selectorGroup).toBeDefined()
|
||||||
|
expect(selector.selectorRect).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have all grip coordinates initialized', () => {
|
||||||
|
const expectedGrips = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
|
||||||
|
expectedGrips.forEach(grip => {
|
||||||
|
expect(selector.gripCoords[grip]).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should update selectedElement', () => {
|
||||||
|
selector.reset(circleElement)
|
||||||
|
expect(selector.selectedElement).toBe(circleElement)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should lock the selector', () => {
|
||||||
|
selector.locked = false
|
||||||
|
selector.reset(circleElement)
|
||||||
|
expect(selector.locked).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show selectorGroup', () => {
|
||||||
|
selector.selectorGroup.setAttribute('display', 'none')
|
||||||
|
selector.reset(circleElement)
|
||||||
|
expect(selector.selectorGroup.getAttribute('display')).toBe('inline')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resize', () => {
|
||||||
|
it('should update selectorRect d attribute', () => {
|
||||||
|
selector.resize()
|
||||||
|
const d = selector.selectorRect.getAttribute('d')
|
||||||
|
expect(d).toBeTruthy()
|
||||||
|
expect(d).toMatch(/^M/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update grip coordinates', () => {
|
||||||
|
selector.resize()
|
||||||
|
expect(selector.gripCoords.nw).toBeDefined()
|
||||||
|
expect(Array.isArray(selector.gripCoords.nw)).toBe(true)
|
||||||
|
expect(selector.gripCoords.nw.length).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use provided bbox when given', () => {
|
||||||
|
const customBbox = { x: 50, y: 50, width: 200, height: 100 }
|
||||||
|
selector.resize(customBbox)
|
||||||
|
const d = selector.selectorRect.getAttribute('d')
|
||||||
|
expect(d).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('showGrips', () => {
|
||||||
|
it('should show grips when true', () => {
|
||||||
|
selector.showGrips(true)
|
||||||
|
expect(selector.hasGrips).toBe(true)
|
||||||
|
expect(manager.selectorGripsGroup.getAttribute('display')).toBe('inline')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide grips when false', () => {
|
||||||
|
selector.showGrips(true)
|
||||||
|
selector.showGrips(false)
|
||||||
|
expect(selector.hasGrips).toBe(false)
|
||||||
|
expect(manager.selectorGripsGroup.getAttribute('display')).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should append gripsGroup to selectorGroup when showing', () => {
|
||||||
|
selector.showGrips(true)
|
||||||
|
expect(selector.selectorGroup.contains(manager.selectorGripsGroup)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateGripCursors (static)', () => {
|
||||||
|
it('should update cursor styles for rotated elements', () => {
|
||||||
|
Selector.updateGripCursors(45)
|
||||||
|
const updatedCursor = manager.selectorGrips.nw.getAttribute('style')
|
||||||
|
// After 45-degree rotation, cursors should shift
|
||||||
|
expect(updatedCursor).toBeTruthy()
|
||||||
|
expect(updatedCursor).toMatch(/cursor:/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle negative angles', () => {
|
||||||
|
expect(() => Selector.updateGripCursors(-45)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle zero angle', () => {
|
||||||
|
Selector.updateGripCursors(0)
|
||||||
|
expect(manager.selectorGrips.nw.getAttribute('style')).toMatch(/nw-resize/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle 360-degree rotation', () => {
|
||||||
|
Selector.updateGripCursors(360)
|
||||||
|
expect(manager.selectorGrips.nw.getAttribute('style')).toMatch(/nw-resize/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Integration scenarios', () => {
|
||||||
|
let manager
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = getSelectorManager()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle multiple element selection workflow', () => {
|
||||||
|
const selector1 = manager.requestSelector(rectElement)
|
||||||
|
const selector2 = manager.requestSelector(circleElement)
|
||||||
|
|
||||||
|
expect(selector1.selectedElement).toBe(rectElement)
|
||||||
|
expect(selector2.selectedElement).toBe(circleElement)
|
||||||
|
expect(manager.selectors.length).toBe(2)
|
||||||
|
|
||||||
|
selector1.showGrips(true)
|
||||||
|
expect(selector1.hasGrips).toBe(true)
|
||||||
|
|
||||||
|
manager.releaseSelector(rectElement)
|
||||||
|
expect(selector1.locked).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle selector reuse efficiently', () => {
|
||||||
|
// Create and release multiple selectors
|
||||||
|
const s1 = manager.requestSelector(rectElement)
|
||||||
|
manager.releaseSelector(rectElement)
|
||||||
|
|
||||||
|
const s2 = manager.requestSelector(circleElement)
|
||||||
|
manager.releaseSelector(circleElement)
|
||||||
|
|
||||||
|
const s3 = manager.requestSelector(rectElement)
|
||||||
|
|
||||||
|
// Should reuse the same selector object
|
||||||
|
expect(s1).toBe(s2)
|
||||||
|
expect(s2).toBe(s3)
|
||||||
|
expect(manager.selectors.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle element with transforms', () => {
|
||||||
|
rectElement.setAttribute('transform', 'rotate(45 60 35)')
|
||||||
|
const selector = manager.requestSelector(rectElement)
|
||||||
|
|
||||||
|
expect(() => selector.resize()).not.toThrow()
|
||||||
|
expect(selector.selectorRect.getAttribute('d')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle group elements', () => {
|
||||||
|
const group = document.createElementNS(NS.SVG, 'g')
|
||||||
|
group.setAttribute('id', 'testgroup')
|
||||||
|
group.append(rectElement.cloneNode())
|
||||||
|
svgContent.append(group)
|
||||||
|
|
||||||
|
const selector = manager.requestSelector(group)
|
||||||
|
expect(() => selector.resize()).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle rubber band box for multi-select', () => {
|
||||||
|
const rubberBand = manager.getRubberBandBox()
|
||||||
|
|
||||||
|
rubberBand.setAttribute('x', '10')
|
||||||
|
rubberBand.setAttribute('y', '10')
|
||||||
|
rubberBand.setAttribute('width', '100')
|
||||||
|
rubberBand.setAttribute('height', '100')
|
||||||
|
rubberBand.setAttribute('display', 'inline')
|
||||||
|
|
||||||
|
expect(rubberBand.getAttribute('display')).toBe('inline')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
let manager
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = getSelectorManager()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle elements with zero dimensions', () => {
|
||||||
|
const zeroRect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
zeroRect.setAttribute('id', 'zerorect')
|
||||||
|
zeroRect.setAttribute('width', '0')
|
||||||
|
zeroRect.setAttribute('height', '0')
|
||||||
|
svgContent.append(zeroRect)
|
||||||
|
|
||||||
|
const selector = manager.requestSelector(zeroRect)
|
||||||
|
expect(() => selector.resize()).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle elements without id', () => {
|
||||||
|
const noIdRect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
svgContent.append(noIdRect)
|
||||||
|
|
||||||
|
const selector = manager.requestSelector(noIdRect)
|
||||||
|
expect(selector).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle requesting same element twice without release', () => {
|
||||||
|
const selector1 = manager.requestSelector(rectElement)
|
||||||
|
const selector2 = manager.requestSelector(rectElement)
|
||||||
|
|
||||||
|
expect(selector1).toBe(selector2)
|
||||||
|
expect(selector1.locked).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Private field encapsulation', () => {
|
||||||
|
it('should not expose SelectModule private field', () => {
|
||||||
|
const manager = getSelectorManager()
|
||||||
|
expect(manager.selectorManager).toBeUndefined()
|
||||||
|
expect(manager['#selectorManager']).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
176
tests/unit/selected-elem.test.js
Normal file
176
tests/unit/selected-elem.test.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import SvgCanvas from '../../packages/svgcanvas/svgcanvas.js'
|
||||||
|
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||||
|
|
||||||
|
describe('selected-elem', () => {
|
||||||
|
let svgCanvas
|
||||||
|
|
||||||
|
const createSvgCanvas = () => {
|
||||||
|
document.body.textContent = ''
|
||||||
|
const svgEditor = document.createElement('div')
|
||||||
|
svgEditor.id = 'svg_editor'
|
||||||
|
const svgcanvas = document.createElement('div')
|
||||||
|
svgcanvas.style.visibility = 'hidden'
|
||||||
|
svgcanvas.id = 'svgcanvas'
|
||||||
|
const workarea = document.createElement('div')
|
||||||
|
workarea.id = 'workarea'
|
||||||
|
workarea.append(svgcanvas)
|
||||||
|
const toolsLeft = document.createElement('div')
|
||||||
|
toolsLeft.id = 'tools_left'
|
||||||
|
svgEditor.append(workarea, toolsLeft)
|
||||||
|
document.body.append(svgEditor)
|
||||||
|
|
||||||
|
svgCanvas = new SvgCanvas(document.getElementById('svgcanvas'), {
|
||||||
|
canvas_expansion: 3,
|
||||||
|
dimensions: [640, 480],
|
||||||
|
initFill: {
|
||||||
|
color: 'FF0000',
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
initStroke: {
|
||||||
|
width: 5,
|
||||||
|
color: '000000',
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
initOpacity: 1,
|
||||||
|
imgPath: '../editor/images',
|
||||||
|
langPath: 'locale/',
|
||||||
|
extPath: 'extensions/',
|
||||||
|
extensions: [],
|
||||||
|
initTool: 'select',
|
||||||
|
wireframe: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
createSvgCanvas()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.textContent = ''
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copies selection without requiring context menu DOM', () => {
|
||||||
|
const rect = svgCanvas.addSVGElementsFromJson({
|
||||||
|
element: 'rect',
|
||||||
|
attr: {
|
||||||
|
id: 'rect-copy',
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 30,
|
||||||
|
height: 40
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
svgCanvas.selectOnly([rect], true)
|
||||||
|
|
||||||
|
expect(() => svgCanvas.copySelectedElements()).not.toThrow()
|
||||||
|
|
||||||
|
const raw = sessionStorage.getItem(svgCanvas.getClipboardID())
|
||||||
|
expect(raw).toBeTruthy()
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
expect(parsed).toHaveLength(1)
|
||||||
|
expect(parsed[0].element).toBe('rect')
|
||||||
|
expect(parsed[0].attr.id).toBe('rect-copy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves element to bottom even with whitespace/title/defs nodes', () => {
|
||||||
|
const rect1 = svgCanvas.addSVGElementsFromJson({
|
||||||
|
element: 'rect',
|
||||||
|
attr: {
|
||||||
|
id: 'rect-bottom-1',
|
||||||
|
x: 10,
|
||||||
|
y: 10,
|
||||||
|
width: 10,
|
||||||
|
height: 10
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const rect2 = svgCanvas.addSVGElementsFromJson({
|
||||||
|
element: 'rect',
|
||||||
|
attr: {
|
||||||
|
id: 'rect-bottom-2',
|
||||||
|
x: 30,
|
||||||
|
y: 10,
|
||||||
|
width: 10,
|
||||||
|
height: 10
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const parent = svgCanvas.addSVGElementsFromJson({
|
||||||
|
element: 'g',
|
||||||
|
attr: { id: 'move-bottom-container' }
|
||||||
|
})
|
||||||
|
parent.append(rect1, rect2)
|
||||||
|
parent.insertBefore(document.createTextNode('\n'), parent.firstChild)
|
||||||
|
const title = document.createElementNS(NS.SVG, 'title')
|
||||||
|
title.textContent = 'Layer'
|
||||||
|
parent.insertBefore(title, rect1)
|
||||||
|
const defs = document.createElementNS(NS.SVG, 'defs')
|
||||||
|
parent.insertBefore(defs, rect1)
|
||||||
|
|
||||||
|
svgCanvas.selectOnly([rect2], true)
|
||||||
|
const undoSize = svgCanvas.undoMgr.getUndoStackSize()
|
||||||
|
|
||||||
|
expect(() => svgCanvas.moveToBottomSelectedElement()).not.toThrow()
|
||||||
|
expect(svgCanvas.undoMgr.getUndoStackSize()).toBe(undoSize + 1)
|
||||||
|
|
||||||
|
const order = Array.from(parent.childNodes)
|
||||||
|
.filter((n) => n.nodeType === 1)
|
||||||
|
.map((n) => (n.tagName === 'title' || n.tagName === 'defs') ? n.tagName : n.id)
|
||||||
|
|
||||||
|
expect(order).toEqual(['title', 'defs', 'rect-bottom-2', 'rect-bottom-1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ungroups a <use> when it is the first element child', () => {
|
||||||
|
const defs = svgCanvas.getSvgContent().querySelector('defs') ||
|
||||||
|
svgCanvas.getSvgContent().appendChild(document.createElementNS(NS.SVG, 'defs'))
|
||||||
|
|
||||||
|
const symbol = document.createElementNS(NS.SVG, 'symbol')
|
||||||
|
symbol.id = 'symbol-test'
|
||||||
|
const symRect = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
symRect.setAttribute('x', '10')
|
||||||
|
symRect.setAttribute('y', '20')
|
||||||
|
symRect.setAttribute('width', '30')
|
||||||
|
symRect.setAttribute('height', '40')
|
||||||
|
symbol.append(symRect)
|
||||||
|
defs.append(symbol)
|
||||||
|
|
||||||
|
const container = svgCanvas.addSVGElementsFromJson({
|
||||||
|
element: 'g',
|
||||||
|
attr: { id: 'use-container' }
|
||||||
|
})
|
||||||
|
const use = svgCanvas.addSVGElementsFromJson({
|
||||||
|
element: 'use',
|
||||||
|
attr: { id: 'use-test', href: '#symbol-test' }
|
||||||
|
})
|
||||||
|
container.append(use)
|
||||||
|
svgCanvas.setUseData(use)
|
||||||
|
svgCanvas.selectOnly([use], true)
|
||||||
|
|
||||||
|
expect(() => svgCanvas.ungroupSelectedElement()).not.toThrow()
|
||||||
|
|
||||||
|
expect(container.querySelector('use')).toBeNull()
|
||||||
|
const group = container.firstElementChild
|
||||||
|
expect(group).toBeTruthy()
|
||||||
|
expect(group.tagName).toBe('g')
|
||||||
|
expect(group.querySelector('rect')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not crash ungrouping a <use> without href', () => {
|
||||||
|
const use = svgCanvas.addSVGElementsFromJson({
|
||||||
|
element: 'use',
|
||||||
|
attr: { id: 'use-no-href' }
|
||||||
|
})
|
||||||
|
svgCanvas.selectOnly([use], true)
|
||||||
|
|
||||||
|
const originalWarn = console.warn
|
||||||
|
console.warn = () => {}
|
||||||
|
try {
|
||||||
|
expect(() => svgCanvas.ungroupSelectedElement()).not.toThrow()
|
||||||
|
} finally {
|
||||||
|
console.warn = originalWarn
|
||||||
|
}
|
||||||
|
expect(svgCanvas.getSvgContent().querySelector('#use-no-href')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable max-len, no-console */
|
/* eslint-disable max-len, no-console */
|
||||||
import SvgCanvas from '../../packages/svgcanvas'
|
import SvgCanvas from '../../packages/svgcanvas/svgcanvas.js'
|
||||||
|
|
||||||
describe('Basic Module', function () {
|
describe('Basic Module', function () {
|
||||||
// helper functions
|
// helper functions
|
||||||
@@ -110,6 +110,27 @@ describe('Basic Module', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('Import Module', function () {
|
describe('Import Module', function () {
|
||||||
|
it('Test setSvgString handles empty SVG', function () {
|
||||||
|
const ok = svgCanvas.setSvgString(
|
||||||
|
'<svg xmlns="' + svgns + '"></svg>'
|
||||||
|
)
|
||||||
|
assert.equal(ok, true, 'Expected setSvgString to succeed')
|
||||||
|
|
||||||
|
const svgContent = document.getElementById('svgcontent')
|
||||||
|
const w = Number(svgContent.getAttribute('width'))
|
||||||
|
const h = Number(svgContent.getAttribute('height'))
|
||||||
|
assert.equal(
|
||||||
|
Number.isFinite(w) && w > 0,
|
||||||
|
true,
|
||||||
|
'Width is a positive number (got ' + svgContent.getAttribute('width') + ')'
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
Number.isFinite(h) && h > 0,
|
||||||
|
true,
|
||||||
|
'Height is a positive number (got ' + svgContent.getAttribute('height') + ')'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('Test import use', function () {
|
it('Test import use', function () {
|
||||||
svgCanvas.setSvgString(
|
svgCanvas.setSvgString(
|
||||||
"<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='400' x='300'>" +
|
"<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='400' x='300'>" +
|
||||||
@@ -262,5 +283,20 @@ describe('Basic Module', function () {
|
|||||||
assert.notEqual(rects.item(0).getAttribute('stroke'), 'url(#svg_2)', 'Rectangle stroke value not remapped')
|
assert.notEqual(rects.item(0).getAttribute('stroke'), 'url(#svg_2)', 'Rectangle stroke value not remapped')
|
||||||
assert.notEqual(uses.item(0).getAttributeNS(xlinkns, 'href'), '#svg_3')
|
assert.notEqual(uses.item(0).getAttributeNS(xlinkns, 'href'), '#svg_3')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Test importing SVG without width/height/viewBox', function () {
|
||||||
|
const imported = svgCanvas.importSvgString(
|
||||||
|
'<svg xmlns="' + svgns + '">' +
|
||||||
|
'<rect width="20" height="20" fill="blue"/>' +
|
||||||
|
'</svg>'
|
||||||
|
)
|
||||||
|
assert.equal((imported && imported.nodeName), 'use', 'Imported as a <use> element')
|
||||||
|
const t = imported.getAttribute('transform') || ''
|
||||||
|
assert.equal(
|
||||||
|
t.includes('Infinity') || t.includes('NaN'),
|
||||||
|
false,
|
||||||
|
'Transform is finite (got ' + t + ')'
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
506
tests/unit/text-actions.test.js
Normal file
506
tests/unit/text-actions.test.js
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
import 'pathseg'
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { init as textActionsInit, textActionsMethod } from '../../packages/svgcanvas/core/text-actions.js'
|
||||||
|
import { init as utilitiesInit } from '../../packages/svgcanvas/core/utilities.js'
|
||||||
|
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||||
|
|
||||||
|
describe('TextActions', () => {
|
||||||
|
let svgCanvas
|
||||||
|
let svgRoot
|
||||||
|
let textElement
|
||||||
|
let inputElement
|
||||||
|
let mockSelectorManager
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create mock SVG elements
|
||||||
|
svgRoot = document.createElementNS(NS.SVG, 'svg')
|
||||||
|
svgRoot.setAttribute('width', '640')
|
||||||
|
svgRoot.setAttribute('height', '480')
|
||||||
|
document.body.append(svgRoot)
|
||||||
|
|
||||||
|
textElement = document.createElementNS(NS.SVG, 'text')
|
||||||
|
textElement.setAttribute('x', '100')
|
||||||
|
textElement.setAttribute('y', '100')
|
||||||
|
textElement.setAttribute('id', 'text1')
|
||||||
|
textElement.textContent = 'Test'
|
||||||
|
svgRoot.append(textElement)
|
||||||
|
|
||||||
|
// Mock text measurement methods
|
||||||
|
textElement.getStartPositionOfChar = vi.fn((i) => ({ x: 100 + i * 10, y: 100 }))
|
||||||
|
textElement.getEndPositionOfChar = vi.fn((i) => ({ x: 100 + (i + 1) * 10, y: 100 }))
|
||||||
|
textElement.getCharNumAtPosition = vi.fn(() => 0)
|
||||||
|
textElement.getBBox = vi.fn(() => ({
|
||||||
|
x: 100,
|
||||||
|
y: 90,
|
||||||
|
width: 40,
|
||||||
|
height: 20
|
||||||
|
}))
|
||||||
|
|
||||||
|
inputElement = document.createElement('input')
|
||||||
|
inputElement.type = 'text'
|
||||||
|
document.body.append(inputElement)
|
||||||
|
|
||||||
|
// Create mock selector group
|
||||||
|
const selectorParentGroup = document.createElementNS(NS.SVG, 'g')
|
||||||
|
selectorParentGroup.id = 'selectorParentGroup'
|
||||||
|
svgRoot.append(selectorParentGroup)
|
||||||
|
|
||||||
|
// Mock selector manager
|
||||||
|
mockSelectorManager = {
|
||||||
|
requestSelector: vi.fn(() => ({
|
||||||
|
showGrips: vi.fn()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock svgCanvas
|
||||||
|
svgCanvas = {
|
||||||
|
getSvgRoot: () => svgRoot,
|
||||||
|
getZoom: () => 1,
|
||||||
|
setCurrentMode: vi.fn(),
|
||||||
|
clearSelection: vi.fn(),
|
||||||
|
addToSelection: vi.fn(),
|
||||||
|
deleteSelectedElements: vi.fn(),
|
||||||
|
call: vi.fn(),
|
||||||
|
getSelectedElements: () => [textElement],
|
||||||
|
getCurrentMode: () => 'select',
|
||||||
|
selectorManager: mockSelectorManager,
|
||||||
|
getrootSctm: () => svgRoot.getScreenCTM?.() || { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
|
||||||
|
$click: vi.fn(),
|
||||||
|
contentW: 640,
|
||||||
|
textActions: textActionsMethod
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize utilities and text-actions modules
|
||||||
|
utilitiesInit(svgCanvas)
|
||||||
|
textActionsInit(svgCanvas)
|
||||||
|
textActionsMethod.setInputElem(inputElement)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.textContent = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Class instantiation', () => {
|
||||||
|
it('should export textActionsMethod as singleton instance', () => {
|
||||||
|
expect(textActionsMethod).toBeDefined()
|
||||||
|
expect(typeof textActionsMethod.select).toBe('function')
|
||||||
|
expect(typeof textActionsMethod.start).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have all public methods', () => {
|
||||||
|
const publicMethods = [
|
||||||
|
'select',
|
||||||
|
'start',
|
||||||
|
'mouseDown',
|
||||||
|
'mouseMove',
|
||||||
|
'mouseUp',
|
||||||
|
'setCursor',
|
||||||
|
'toEditMode',
|
||||||
|
'toSelectMode',
|
||||||
|
'setInputElem',
|
||||||
|
'clear',
|
||||||
|
'init'
|
||||||
|
]
|
||||||
|
|
||||||
|
publicMethods.forEach(method => {
|
||||||
|
expect(typeof textActionsMethod[method]).toBe('function')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setInputElem', () => {
|
||||||
|
it('should set the input element', () => {
|
||||||
|
const newInput = document.createElement('input')
|
||||||
|
textActionsMethod.setInputElem(newInput)
|
||||||
|
// Method should not throw and should be callable
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('select', () => {
|
||||||
|
it('should set current text element and enter edit mode', () => {
|
||||||
|
textActionsMethod.select(textElement, 100, 100)
|
||||||
|
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('textedit')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('start', () => {
|
||||||
|
it('should start editing a text element', () => {
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('textedit')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('init', () => {
|
||||||
|
it('should initialize text editing for current element', () => {
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.init()
|
||||||
|
|
||||||
|
// Verify text measurement methods were called
|
||||||
|
expect(textElement.getStartPositionOfChar).toHaveBeenCalled()
|
||||||
|
expect(textElement.getEndPositionOfChar).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty text content', () => {
|
||||||
|
const emptyText = document.createElementNS(NS.SVG, 'text')
|
||||||
|
emptyText.textContent = ''
|
||||||
|
emptyText.getStartPositionOfChar = vi.fn(() => ({ x: 100, y: 100 }))
|
||||||
|
emptyText.getEndPositionOfChar = vi.fn(() => ({ x: 100, y: 100 }))
|
||||||
|
emptyText.getBBox = vi.fn(() => ({ x: 100, y: 90, width: 0, height: 20 }))
|
||||||
|
emptyText.removeEventListener = vi.fn()
|
||||||
|
emptyText.addEventListener = vi.fn()
|
||||||
|
svgRoot.append(emptyText)
|
||||||
|
|
||||||
|
textActionsMethod.start(emptyText)
|
||||||
|
textActionsMethod.init()
|
||||||
|
|
||||||
|
expect(true).toBe(true) // Should not throw
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return early if no current text', () => {
|
||||||
|
textActionsMethod.init()
|
||||||
|
// Should not throw when called without a current text element
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('toEditMode', () => {
|
||||||
|
it('should switch to text edit mode', () => {
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
|
||||||
|
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('textedit')
|
||||||
|
expect(mockSelectorManager.requestSelector).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should accept x, y coordinates for cursor positioning', () => {
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.toEditMode(100, 100)
|
||||||
|
|
||||||
|
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('textedit')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('toSelectMode', () => {
|
||||||
|
it('should switch to select mode', () => {
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.toSelectMode(false)
|
||||||
|
|
||||||
|
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should select element when selectElem is true', () => {
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.toSelectMode(true)
|
||||||
|
|
||||||
|
expect(svgCanvas.clearSelection).toHaveBeenCalled()
|
||||||
|
expect(svgCanvas.call).toHaveBeenCalled()
|
||||||
|
expect(svgCanvas.addToSelection).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should delete empty text elements', () => {
|
||||||
|
const emptyText = document.createElementNS(NS.SVG, 'text')
|
||||||
|
emptyText.textContent = ''
|
||||||
|
emptyText.getBBox = vi.fn(() => ({ x: 100, y: 90, width: 0, height: 20 }))
|
||||||
|
emptyText.removeEventListener = vi.fn()
|
||||||
|
emptyText.addEventListener = vi.fn()
|
||||||
|
emptyText.style = {}
|
||||||
|
svgRoot.append(emptyText)
|
||||||
|
|
||||||
|
textActionsMethod.start(emptyText)
|
||||||
|
textActionsMethod.toSelectMode(false)
|
||||||
|
|
||||||
|
expect(svgCanvas.deleteSelectedElements).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clear', () => {
|
||||||
|
it('should exit text edit mode if currently in it', () => {
|
||||||
|
svgCanvas.getCurrentMode = () => 'textedit'
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.clear()
|
||||||
|
|
||||||
|
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should do nothing if not in text edit mode', () => {
|
||||||
|
svgCanvas.getCurrentMode = () => 'select'
|
||||||
|
const callCount = svgCanvas.setCurrentMode.mock.calls.length
|
||||||
|
textActionsMethod.clear()
|
||||||
|
|
||||||
|
expect(svgCanvas.setCurrentMode.mock.calls.length).toBe(callCount)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mouseDown', () => {
|
||||||
|
it('should handle mouse down event', () => {
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
|
||||||
|
const mockEvent = { pageX: 100, pageY: 100 }
|
||||||
|
textActionsMethod.mouseDown(mockEvent, textElement, 100, 100)
|
||||||
|
|
||||||
|
// Should set focus (via private method)
|
||||||
|
expect(true).toBe(true) // Method executed without error
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mouseMove', () => {
|
||||||
|
it('should handle mouse move event', () => {
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.mouseMove(110, 100)
|
||||||
|
|
||||||
|
// Method should execute without error
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mouseUp', () => {
|
||||||
|
it('should handle mouse up event', () => {
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
|
||||||
|
const mockEvent = { target: textElement, pageX: 100, pageY: 100 }
|
||||||
|
textActionsMethod.mouseUp(mockEvent, 100, 100)
|
||||||
|
|
||||||
|
// Method should execute without error
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should exit text mode if clicked outside text element', () => {
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
|
||||||
|
const otherElement = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
const mockEvent = { target: otherElement, pageX: 200, pageY: 200 }
|
||||||
|
|
||||||
|
textActionsMethod.mouseDown(mockEvent, textElement, 200, 200)
|
||||||
|
textActionsMethod.mouseUp(mockEvent, 200, 200)
|
||||||
|
|
||||||
|
// Should have called toSelectMode
|
||||||
|
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setCursor', () => {
|
||||||
|
it('should set cursor position', () => {
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.init()
|
||||||
|
textActionsMethod.setCursor(0)
|
||||||
|
|
||||||
|
// Method should execute without error
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should accept undefined index', () => {
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.init()
|
||||||
|
textActionsMethod.setCursor(undefined)
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Private methods encapsulation', () => {
|
||||||
|
it('should not expose private methods', () => {
|
||||||
|
const privateMethodNames = [
|
||||||
|
'#setCursor',
|
||||||
|
'#setSelection',
|
||||||
|
'#getIndexFromPoint',
|
||||||
|
'#setCursorFromPoint',
|
||||||
|
'#setEndSelectionFromPoint',
|
||||||
|
'#screenToPt',
|
||||||
|
'#ptToScreen',
|
||||||
|
'#selectAll',
|
||||||
|
'#selectWord'
|
||||||
|
]
|
||||||
|
|
||||||
|
privateMethodNames.forEach(method => {
|
||||||
|
expect(textActionsMethod[method]).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not expose private fields', () => {
|
||||||
|
const privateFieldNames = [
|
||||||
|
'#curtext',
|
||||||
|
'#textinput',
|
||||||
|
'#cursor',
|
||||||
|
'#selblock',
|
||||||
|
'#blinker',
|
||||||
|
'#chardata',
|
||||||
|
'#textbb',
|
||||||
|
'#matrix',
|
||||||
|
'#lastX',
|
||||||
|
'#lastY',
|
||||||
|
'#allowDbl'
|
||||||
|
]
|
||||||
|
|
||||||
|
privateFieldNames.forEach(field => {
|
||||||
|
expect(textActionsMethod[field]).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Integration scenarios', () => {
|
||||||
|
it('should handle complete edit workflow', () => {
|
||||||
|
// Start editing
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('textedit')
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
textActionsMethod.init()
|
||||||
|
|
||||||
|
// Simulate mouse interaction
|
||||||
|
textActionsMethod.mouseDown({ pageX: 100, pageY: 100 }, textElement, 100, 100)
|
||||||
|
textActionsMethod.mouseMove(110, 100)
|
||||||
|
textActionsMethod.mouseUp({ target: textElement, pageX: 110, pageY: 100 }, 110, 100)
|
||||||
|
|
||||||
|
// Exit edit mode
|
||||||
|
textActionsMethod.toSelectMode(true)
|
||||||
|
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle text with transform attribute', () => {
|
||||||
|
textElement.setAttribute('transform', 'rotate(45 100 100)')
|
||||||
|
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.init()
|
||||||
|
|
||||||
|
// Should handle transformed text without error
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty text element', () => {
|
||||||
|
textElement.textContent = ''
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.init()
|
||||||
|
textActionsMethod.toSelectMode(true)
|
||||||
|
expect(svgCanvas.deleteSelectedElements).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle text element without parent', () => {
|
||||||
|
const orphanText = document.createElementNS(NS.SVG, 'text')
|
||||||
|
orphanText.textContent = 'Orphan'
|
||||||
|
orphanText.getStartPositionOfChar = vi.fn((i) => ({ x: 100 + i * 10, y: 100 }))
|
||||||
|
orphanText.getEndPositionOfChar = vi.fn((i) => ({ x: 100 + (i + 1) * 10, y: 100 }))
|
||||||
|
orphanText.getBBox = vi.fn(() => ({ x: 100, y: 90, width: 60, height: 20 }))
|
||||||
|
|
||||||
|
textActionsMethod.start(orphanText)
|
||||||
|
textActionsMethod.init()
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle setCursor with undefined index', () => {
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.init()
|
||||||
|
textActionsMethod.setCursor(undefined)
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle setCursor with empty input', () => {
|
||||||
|
inputElement.value = ''
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.init()
|
||||||
|
textActionsMethod.setCursor()
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle text with no transform', () => {
|
||||||
|
textElement.removeAttribute('transform')
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.init()
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle getIndexFromPoint with single character', () => {
|
||||||
|
textElement.textContent = 'A'
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.init()
|
||||||
|
textActionsMethod.mouseDown({ pageX: 100, pageY: 100 }, textElement, 100, 100)
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle getIndexFromPoint outside text range', () => {
|
||||||
|
textElement.getCharNumAtPosition = vi.fn(() => -1)
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.init()
|
||||||
|
textActionsMethod.mouseDown({ pageX: 50, pageY: 100 }, textElement, 50, 100)
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle getIndexFromPoint at end of text', () => {
|
||||||
|
textElement.getCharNumAtPosition = vi.fn(() => 100)
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.init()
|
||||||
|
textActionsMethod.mouseDown({ pageX: 200, pageY: 100 }, textElement, 200, 100)
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle mouseUp clicking outside text', () => {
|
||||||
|
const outsideElement = document.createElementNS(NS.SVG, 'rect')
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.init()
|
||||||
|
textActionsMethod.mouseDown({ pageX: 100, pageY: 100 }, textElement, 100, 100)
|
||||||
|
textActionsMethod.mouseUp({ target: outsideElement, pageX: 101, pageY: 101 }, 101, 101)
|
||||||
|
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle toEditMode with no arguments', () => {
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.toEditMode()
|
||||||
|
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('textedit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle toSelectMode without selectElem', () => {
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.init()
|
||||||
|
textActionsMethod.toSelectMode(false)
|
||||||
|
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle clear when not in textedit mode', () => {
|
||||||
|
const originalGetMode = svgCanvas.getCurrentMode
|
||||||
|
svgCanvas.getCurrentMode = vi.fn(() => 'select')
|
||||||
|
textActionsMethod.clear()
|
||||||
|
svgCanvas.getCurrentMode = originalGetMode
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle init with no current text', () => {
|
||||||
|
textActionsMethod.init()
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle mouseMove during selection', () => {
|
||||||
|
textActionsMethod.start(textElement)
|
||||||
|
textActionsMethod.init()
|
||||||
|
textActionsMethod.mouseDown({ pageX: 100, pageY: 100 }, textElement, 100, 100)
|
||||||
|
textActionsMethod.mouseMove(120, 100)
|
||||||
|
textActionsMethod.mouseMove(130, 100)
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle mouseMove without shift key', () => {
|
||||||
|
const evt = { shiftKey: false, clientX: 100, clientY: 100 }
|
||||||
|
textActionsMethod.mouseMove(10, 20, evt)
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle mouseDown with different mouse button', () => {
|
||||||
|
const evt = { button: 2 }
|
||||||
|
textActionsMethod.mouseDown(evt, null, 10, 20)
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle mouseUp with valid cursor position', () => {
|
||||||
|
const elem = document.createElementNS(NS.SVG, 'text')
|
||||||
|
elem.textContent = 'test'
|
||||||
|
const evt = { target: elem }
|
||||||
|
textActionsMethod.mouseUp(evt, elem, 10, 20)
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle toSelectMode with valid element', () => {
|
||||||
|
const elem = document.createElementNS(NS.SVG, 'text')
|
||||||
|
textActionsMethod.toSelectMode(elem)
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -88,4 +88,22 @@ describe('units', function () {
|
|||||||
assert.equal(units.convertUnit(42), 1.1113)
|
assert.equal(units.convertUnit(42), 1.1113)
|
||||||
assert.equal(units.convertUnit(42, 'px'), 42)
|
assert.equal(units.convertUnit(42, 'px'), 42)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Test svgedit.units.convertUnit() with mm', function () {
|
||||||
|
assert.equal(units.convertUnit(42, 'mm'), 11.1125)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test svgedit.units.convertUnit() with in', function () {
|
||||||
|
assert.equal(units.convertUnit(96, 'in'), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test svgedit.units.convertUnit() with pt', function () {
|
||||||
|
const result = units.convertUnit(72, 'pt')
|
||||||
|
assert.ok(result > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test svgedit.units.convertUnit() with pc', function () {
|
||||||
|
const result = units.convertUnit(96, 'pc')
|
||||||
|
assert.ok(result > 0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -372,4 +372,40 @@ describe('utilities', function () {
|
|||||||
assert.equal(mockCount.addToSelection, 0)
|
assert.equal(mockCount.addToSelection, 0)
|
||||||
assert.equal(mockCount.addCommandToHistory, 0)
|
assert.equal(mockCount.addCommandToHistory, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Test isNullish with null', function () {
|
||||||
|
const { isNullish } = utilities
|
||||||
|
const result = isNullish(null)
|
||||||
|
assert.ok(result === true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test isNullish with undefined', function () {
|
||||||
|
const { isNullish } = utilities
|
||||||
|
const result = isNullish(undefined)
|
||||||
|
assert.ok(result === true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test isNullish with value', function () {
|
||||||
|
const { isNullish } = utilities
|
||||||
|
const result = isNullish('test')
|
||||||
|
assert.ok(result === false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test isNullish with zero', function () {
|
||||||
|
const { isNullish } = utilities
|
||||||
|
const result = isNullish(0)
|
||||||
|
assert.ok(result === false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test isNullish with empty string', function () {
|
||||||
|
const { isNullish } = utilities
|
||||||
|
const result = isNullish('')
|
||||||
|
assert.ok(result === false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Test isNullish with boolean false', function () {
|
||||||
|
const { isNullish } = utilities
|
||||||
|
const result = isNullish(false)
|
||||||
|
assert.ok(result === false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ export default defineConfig({
|
|||||||
'packages/svgcanvas/core/coords.js',
|
'packages/svgcanvas/core/coords.js',
|
||||||
'packages/svgcanvas/core/recalculate.js',
|
'packages/svgcanvas/core/recalculate.js',
|
||||||
'packages/svgcanvas/core/utilities.js',
|
'packages/svgcanvas/core/utilities.js',
|
||||||
|
'packages/svgcanvas/core/layer.js',
|
||||||
|
'packages/svgcanvas/core/sanitize.js',
|
||||||
'packages/svgcanvas/common/util.js',
|
'packages/svgcanvas/common/util.js',
|
||||||
'packages/svgcanvas/core/touch.js'
|
'packages/svgcanvas/core/touch.js'
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user