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 @@