add flip buttons

also update svgedit.css
This commit is contained in:
JFH
2025-12-07 18:26:28 +01:00
parent 370ba56ff0
commit 7ca39b6471
9 changed files with 310 additions and 14 deletions

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 100 100"
version="1.1"
id="svg4"
sodipodi:docname="flip_horizontal.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs8" />
<sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="8.27"
inkscape:cx="43.168077"
inkscape:cy="50.120919"
inkscape:window-width="1920"
inkscape:window-height="1008"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<path
stroke-width="4"
stroke="#f9bc01"
fill="none"
d="M 49.940133,0.25937021 V 100.25936 Z m -10,30.00000079 v 40.000006 l -3.27677,-5.76184 -16.723226,-34.238166 z"
id="path2"
sodipodi:nodetypes="cccccccc" />
<path
stroke-width="4"
stroke="#f9bc01"
fill="none"
d="M 61.490429,31.147345 V 71.147347 L 67.079935,61.143717 81.49043,31.147345 Z"
id="path2-3"
sodipodi:nodetypes="ccccc"
style="stroke:#f9dab8;stroke-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 100 100"
version="1.1"
id="svg4"
sodipodi:docname="flip_vertical.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs8" />
<sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="8.27"
inkscape:cx="43.168077"
inkscape:cy="50.120919"
inkscape:window-width="1920"
inkscape:window-height="1008"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<path
stroke-width="4"
stroke="#f9bc01"
fill="none"
d="M 0.10978222,43.451827 H 100.10978 Z m 29.99999978,10 H 70.10978 L 64.347943,56.7286 30.109782,73.451827 Z"
id="path2"
sodipodi:nodetypes="cccccccc" />
<path
stroke-width="4"
stroke="#f9bc01"
fill="none"
d="m 30.216722,33.403103 h 40 L 60.213097,27.813597 30.216722,13.403103 Z"
id="path2-3"
sodipodi:nodetypes="ccccc"
style="stroke:#f9dab8;stroke-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

@@ -28,6 +28,9 @@
<se-button id="tool_topath" title="tools.to_path" src="to_path.svg"></se-button>
<se-button id="tool_reorient" title="tools.reorient_path" src="reorient.svg"></se-button>
<se-button id="tool_make_link" title="tools.make_link" src="globe_link.svg"></se-button>
<div class="tool_sep"></div>
<se-button id="tool_flip_h" title="tools.flip_horizontal" src="flip_horizontal.svg"></se-button>
<se-button id="tool_flip_v" title="tools.flip_vertical" src="flip_vertical.svg"></se-button>
</div>
<div class="selected_panel">
<div class="tool_sep"></div>
@@ -219,4 +222,4 @@
<se-button id="tool_add_subpath" title="tools.add_subpath" src="tool_add_subpath.svg"></se-button>
</div> <!-- path_node_panel -->
<div id="cur_context_panel"></div>
</div>
</div>

View File

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

View File

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

View File

@@ -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\)/)
})
})