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:
JFH
2026-01-10 20:57:06 -03:00
committed by GitHub
parent 9dd1349599
commit 97386d20b5
76 changed files with 11654 additions and 2416 deletions

View 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)
}
})
})

View File

@@ -126,4 +126,165 @@ test.describe('Regression issues', () => {
})
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')
})
})