From 7ca39b6471526679ac0c8e3e971df0a788d9c027 Mon Sep 17 00:00:00 2001 From: JFH <20402845+jfhenon@users.noreply.github.com> Date: Sun, 7 Dec 2025 18:26:28 +0100 Subject: [PATCH] add flip buttons also update svgedit.css --- coverage/coverage-summary.json | 8 +- packages/svgcanvas/core/selected-elem.js | 75 +++++++++++++++++ src/editor/images/flip_horizontal.svg | 48 +++++++++++ src/editor/images/flip_vertical.svg | 48 +++++++++++ src/editor/locale/lang.en.js | 2 + src/editor/panels/TopPanel.html | 5 +- src/editor/panels/TopPanel.js | 22 +++++ src/editor/svgedit.css | 14 ++-- tests/unit/flip-selected.test.js | 102 +++++++++++++++++++++++ 9 files changed, 310 insertions(+), 14 deletions(-) create mode 100644 src/editor/images/flip_horizontal.svg create mode 100644 src/editor/images/flip_vertical.svg create mode 100644 tests/unit/flip-selected.test.js diff --git a/coverage/coverage-summary.json b/coverage/coverage-summary.json index d47e24a8..a6e1a28a 100644 --- a/coverage/coverage-summary.json +++ b/coverage/coverage-summary.json @@ -1,13 +1,13 @@ -{"total": {"lines":{"total":1903,"covered":1123,"skipped":0,"pct":59.01},"statements":{"total":2952,"covered":1625,"skipped":0,"pct":55.04},"functions":{"total":219,"covered":123,"skipped":0,"pct":56.16},"branches":{"total":853,"covered":417,"skipped":0,"pct":48.88},"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":136,"skipped":0,"pct":70.1},"branches":{"total":98,"covered":59,"skipped":0,"pct":60.2}} +{"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}} +,"/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/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/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/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/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/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/recalculate.js": {"lines":{"total":241,"covered":86,"skipped":0,"pct":35.68},"functions":{"total":5,"covered":4,"skipped":0,"pct":80},"statements":{"total":338,"covered":114,"skipped":0,"pct":33.72},"branches":{"total":140,"covered":50,"skipped":0,"pct":35.71}} +,"/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/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/utilities.js": {"lines":{"total":669,"covered":438,"skipped":0,"pct":65.47},"functions":{"total":78,"covered":54,"skipped":0,"pct":69.23},"statements":{"total":991,"covered":630,"skipped":0,"pct":63.57},"branches":{"total":312,"covered":163,"skipped":0,"pct":52.24}} +,"/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/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/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/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}} diff --git a/packages/svgcanvas/core/selected-elem.js b/packages/svgcanvas/core/selected-elem.js index 60fc705a..28df95b9 100644 --- a/packages/svgcanvas/core/selected-elem.js +++ b/packages/svgcanvas/core/selected-elem.js @@ -63,6 +63,7 @@ export const init = canvas => { svgCanvas.updateCanvas = updateCanvas // Updates the editor canvas width/height/position after a zoom has occurred. svgCanvas.cycleElement = cycleElement // Select the next/previous element within the current layer. svgCanvas.deleteSelectedElements = deleteSelectedElements // Removes all selected elements from the DOM and adds the change to the history + svgCanvas.flipSelectedElements = flipSelectedElements // Flips selected elements horizontally or vertically } /** @@ -621,6 +622,80 @@ const deleteSelectedElements = () => { svgCanvas.clearSelection() } +/** + * Flips selected elements horizontally or vertically by transforming actual coordinates. + * @function module:selected-elem.SvgCanvas#flipSelectedElements + * @param {number} scaleX - Scale factor for X axis (-1 for horizontal flip, 1 for no flip) + * @param {number} scaleY - Scale factor for Y axis (1 for no flip, -1 for vertical flip) + * @fires module:selected-elem.SvgCanvas#event:changed + * @returns {void} + */ +const flipSelectedElements = (scaleX, scaleY) => { + const selectedElements = svgCanvas.getSelectedElements() + const batchCmd = new BatchCommand('Flip Elements') + const svgRoot = svgCanvas.getSvgRoot() + + selectedElements.forEach(selected => { + if (!selected) return + + const bbox = getStrokedBBoxDefaultVisible([selected]) + if (!bbox) return + + const cx = bbox.x + bbox.width / 2 + const cy = bbox.y + bbox.height / 2 + const existingTransform = selected.getAttribute('transform') || '' + + const flipMatrix = svgRoot + .createSVGMatrix() + .translate(cx, cy) + .scaleNonUniform(scaleX, scaleY) + .translate(-cx, -cy) + + const tlist = getTransformList(selected) + const combinedMatrix = matrixMultiply( + transformListToTransform(tlist).matrix, + flipMatrix + ) + + const flipTransform = svgRoot.createSVGTransform() + flipTransform.setMatrix(combinedMatrix) + + tlist.clear() + tlist.appendItem(flipTransform) + + const prevStartTransform = svgCanvas.getStartTransform + ? svgCanvas.getStartTransform() + : null + if (svgCanvas.setStartTransform) { + svgCanvas.setStartTransform(existingTransform) + } + + const cmd = recalculateDimensions(selected) + + if (svgCanvas.setStartTransform) { + svgCanvas.setStartTransform(prevStartTransform) + } + + if (cmd) { + batchCmd.addSubCommand(cmd) + } else if ((selected.getAttribute('transform') || '') !== existingTransform) { + batchCmd.addSubCommand( + new ChangeElementCommand(selected, { transform: existingTransform }) + ) + } + + svgCanvas + .gettingSelectorManager() + .requestSelector(selected) + .resize() + }) + + if (!batchCmd.isEmpty()) { + svgCanvas.addCommandToHistory(batchCmd) + svgCanvas.call('changed', selectedElements.filter(Boolean)) + } +} + /** * Remembers the current selected elements on the clipboard. * @function module:selected-elem.SvgCanvas#copySelectedElements diff --git a/src/editor/images/flip_horizontal.svg b/src/editor/images/flip_horizontal.svg new file mode 100644 index 00000000..9c1a7726 --- /dev/null +++ b/src/editor/images/flip_horizontal.svg @@ -0,0 +1,48 @@ + + + + + + + diff --git a/src/editor/images/flip_vertical.svg b/src/editor/images/flip_vertical.svg new file mode 100644 index 00000000..11b73b3d --- /dev/null +++ b/src/editor/images/flip_vertical.svg @@ -0,0 +1,48 @@ + + + + + + + diff --git a/src/editor/locale/lang.en.js b/src/editor/locale/lang.en.js index 78b72434..f9ad21e8 100644 --- a/src/editor/locale/lang.en.js +++ b/src/editor/locale/lang.en.js @@ -168,6 +168,8 @@ export default { set_link_url: 'Set link URL (leave empty to remove)', to_path: 'Convert to Path', reorient_path: 'Reorient path', + flip_horizontal: 'Flip Horizontally', + flip_vertical: 'Flip Vertically', ungroup: 'Ungroup Elements', docprops: 'Document Properties', editor_homepage: 'SVG-Edit Home Page', diff --git a/src/editor/panels/TopPanel.html b/src/editor/panels/TopPanel.html index 9cdd976a..481bb665 100644 --- a/src/editor/panels/TopPanel.html +++ b/src/editor/panels/TopPanel.html @@ -28,6 +28,9 @@ +
+ +
@@ -219,4 +222,4 @@
- \ No newline at end of file + diff --git a/src/editor/panels/TopPanel.js b/src/editor/panels/TopPanel.js index c27d9eb7..fdafe5a0 100644 --- a/src/editor/panels/TopPanel.js +++ b/src/editor/panels/TopPanel.js @@ -667,6 +667,26 @@ class TopPanel { } } + /** + * Flip selected element(s) horizontally. + * @returns {void} + */ + clickFlipHorizontal () { + if (this.editor.selectedElement || this.multiselected) { + this.editor.svgCanvas.flipSelectedElements(-1, 1) + } + } + + /** + * Flip selected element(s) vertically. + * @returns {void} + */ + clickFlipVertical () { + if (this.editor.selectedElement || this.multiselected) { + this.editor.svgCanvas.flipSelectedElements(1, -1) + } + } + /** * * @returns {void} Resolves to `undefined` @@ -962,6 +982,8 @@ class TopPanel { $click($id('tool_make_link'), this.makeHyperlink.bind(this)) $click($id('tool_make_link_multi'), this.makeHyperlink.bind(this)) $click($id('tool_reorient'), this.reorientPath.bind(this)) + $click($id('tool_flip_h'), this.clickFlipHorizontal.bind(this)) + $click($id('tool_flip_v'), this.clickFlipVertical.bind(this)) $click($id('tool_group_elements'), this.clickGroup.bind(this)) $id('tool_position').addEventListener('change', evt => this.clickAlignEle.bind(this)(evt) diff --git a/src/editor/svgedit.css b/src/editor/svgedit.css index db727d3b..c83b0062 100644 --- a/src/editor/svgedit.css +++ b/src/editor/svgedit.css @@ -10,6 +10,7 @@ --input-color: #B2B2B2; --orange-color: #f9bc01; --global-se-spin-input-width: 82px; + --top-toolbar-min-height: 80px; } .svg_editor * { @@ -18,7 +19,7 @@ .svg_editor { display: grid; - grid-template-rows: auto 15px 1fr 40px; + grid-template-rows: minmax(var(--top-toolbar-min-height), auto) 15px 1fr 40px; grid-template-columns: 40px 15px 50px 1fr 15px; grid-template-areas: "main main main top top" @@ -41,13 +42,6 @@ font-weight: bold; } -/* on smaller screen, allow 2 lines for the toolbar */ -@media screen and (max-width:1250px) { - .svg_editor { - grid-template-rows: minmax(80px, auto) 15px 1fr 40px; - } -} - /* class to open the right panel */ .svg_editor.open { grid-template-columns: 34px 15px 50px 1fr 220px; @@ -311,7 +305,9 @@ hr { flex-direction: row; flex-wrap: wrap; align-items: flex-start; + align-content: flex-start; background-color: var(--main-bg-color); + min-height: var(--top-toolbar-min-height); } #tools_top>* { @@ -628,4 +624,4 @@ ul li.current { .dropdown li.tool_button { width: 24px; -} \ No newline at end of file +} diff --git a/tests/unit/flip-selected.test.js b/tests/unit/flip-selected.test.js new file mode 100644 index 00000000..d32f872d --- /dev/null +++ b/tests/unit/flip-selected.test.js @@ -0,0 +1,102 @@ +import SvgCanvas from '../../packages/svgcanvas/svgcanvas.js' + +describe('flipSelectedElements', () => { + 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('flips a simple line horizontally and records history', () => { + const line = svgCanvas.addSVGElementsFromJson({ + element: 'line', + attr: { + id: 'line-basic', + x1: 10, + y1: 20, + x2: 30, + y2: 20, + stroke: '#000' + } + }) + + svgCanvas.selectOnly([line], true) + const undoSize = svgCanvas.undoMgr.getUndoStackSize() + + svgCanvas.flipSelectedElements(-1, 1) + + expect(Number(line.getAttribute('x1'))).toBe(30) + expect(Number(line.getAttribute('x2'))).toBe(10) + expect(line.hasAttribute('transform')).toBe(false) + expect(svgCanvas.undoMgr.getUndoStackSize()).toBe(undoSize + 1) + }) + + it('flips around the visual center when a transform exists and can be undone', () => { + const line = svgCanvas.addSVGElementsFromJson({ + element: 'line', + attr: { + id: 'line-transformed', + x1: 10, + y1: 0, + x2: 30, + y2: 0, + stroke: '#000', + transform: 'translate(100,0)' + } + }) + + svgCanvas.selectOnly([line], true) + svgCanvas.flipSelectedElements(-1, 1) + + expect(Number(line.getAttribute('x1'))).toBe(130) + expect(Number(line.getAttribute('x2'))).toBe(110) + expect(line.hasAttribute('transform')).toBe(false) + + svgCanvas.undoMgr.undo() + + expect(Number(line.getAttribute('x1'))).toBe(10) + expect(Number(line.getAttribute('x2'))).toBe(30) + expect(line.getAttribute('transform')).toMatch(/translate\(100[ ,]0\)/) + }) +})