Compare commits
3 Commits
0be0c3916c
...
f6fbc8d9ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6fbc8d9ff | ||
|
|
7c6085e7b9 | ||
|
|
173dd2607a |
@@ -655,14 +655,31 @@ const setStrokeAttrMethod = (attr, val) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getSelectedTextElements = () => {
|
||||
return svgCanvas.getSelectedElements().filter(el => el?.tagName === 'text')
|
||||
}
|
||||
|
||||
const getChangedTextElements = (textElements, attr, newValue) => {
|
||||
const normalizedValue = String(newValue)
|
||||
return textElements.filter((elem) => {
|
||||
const oldValue = attr === '#text' ? elem.textContent : elem.getAttribute(attr)
|
||||
return (oldValue || '') !== normalizedValue
|
||||
})
|
||||
}
|
||||
|
||||
const notifyTextChange = (textElements) => {
|
||||
if (textElements.length > 0) {
|
||||
svgCanvas.call('changed', textElements)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all selected text elements are in bold.
|
||||
* @function module:svgcanvas.SvgCanvas#getBold
|
||||
* @returns {boolean} `true` if all selected elements are bold, `false` otherwise.
|
||||
*/
|
||||
const getBoldMethod = () => {
|
||||
const selectedElements = svgCanvas.getSelectedElements()
|
||||
const textElements = selectedElements.filter(el => el?.tagName === 'text')
|
||||
const textElements = getSelectedTextElements()
|
||||
return textElements.every(el => el.getAttribute('font-weight') === 'bold')
|
||||
}
|
||||
|
||||
@@ -673,12 +690,16 @@ const getBoldMethod = () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const setBoldMethod = (b) => {
|
||||
const selectedElements = svgCanvas.getSelectedElements()
|
||||
const textElements = selectedElements.filter(el => el?.tagName === 'text')
|
||||
svgCanvas.changeSelectedAttribute('font-weight', b ? 'bold' : 'normal', textElements)
|
||||
const textElements = getSelectedTextElements()
|
||||
const value = b ? 'bold' : 'normal'
|
||||
const changedTextElements = getChangedTextElements(textElements, 'font-weight', value)
|
||||
if (changedTextElements.length > 0) {
|
||||
svgCanvas.changeSelectedAttribute('font-weight', value, changedTextElements)
|
||||
}
|
||||
if (!textElements.some(el => el.textContent)) {
|
||||
svgCanvas.textActions.setCursor()
|
||||
}
|
||||
notifyTextChange(changedTextElements)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -686,8 +707,7 @@ const setBoldMethod = (b) => {
|
||||
* @returns {boolean} Indicates whether or not elements have the text decoration value
|
||||
*/
|
||||
const hasTextDecorationMethod = (value) => {
|
||||
const selectedElements = svgCanvas.getSelectedElements()
|
||||
const textElements = selectedElements.filter(el => el?.tagName === 'text')
|
||||
const textElements = getSelectedTextElements()
|
||||
return textElements.every(el => (el.getAttribute('text-decoration') || '').includes(value))
|
||||
}
|
||||
|
||||
@@ -698,8 +718,7 @@ const hasTextDecorationMethod = (value) => {
|
||||
*/
|
||||
const addTextDecorationMethod = (value) => {
|
||||
const { ChangeElementCommand, BatchCommand } = svgCanvas.history
|
||||
const selectedElements = svgCanvas.getSelectedElements()
|
||||
const textElements = selectedElements.filter(el => el?.tagName === 'text')
|
||||
const textElements = getSelectedTextElements()
|
||||
|
||||
const batchCmd = new BatchCommand()
|
||||
textElements.forEach(elem => {
|
||||
@@ -726,8 +745,7 @@ const addTextDecorationMethod = (value) => {
|
||||
*/
|
||||
const removeTextDecorationMethod = (value) => {
|
||||
const { ChangeElementCommand, BatchCommand } = svgCanvas.history
|
||||
const selectedElements = svgCanvas.getSelectedElements()
|
||||
const textElements = selectedElements.filter(el => el?.tagName === 'text')
|
||||
const textElements = getSelectedTextElements()
|
||||
|
||||
const batchCmd = new BatchCommand()
|
||||
textElements.forEach(elem => {
|
||||
@@ -750,8 +768,7 @@ const removeTextDecorationMethod = (value) => {
|
||||
* @returns {boolean} `true` if all selected elements are in italics, `false` otherwise.
|
||||
*/
|
||||
const getItalicMethod = () => {
|
||||
const selectedElements = svgCanvas.getSelectedElements()
|
||||
const textElements = selectedElements.filter(el => el?.tagName === 'text')
|
||||
const textElements = getSelectedTextElements()
|
||||
return textElements.every(el => el.getAttribute('font-style') === 'italic')
|
||||
}
|
||||
|
||||
@@ -762,12 +779,16 @@ const getItalicMethod = () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const setItalicMethod = (i) => {
|
||||
const selectedElements = svgCanvas.getSelectedElements()
|
||||
const textElements = selectedElements.filter(el => el?.tagName === 'text')
|
||||
svgCanvas.changeSelectedAttribute('font-style', i ? 'italic' : 'normal', textElements)
|
||||
const textElements = getSelectedTextElements()
|
||||
const value = i ? 'italic' : 'normal'
|
||||
const changedTextElements = getChangedTextElements(textElements, 'font-style', value)
|
||||
if (changedTextElements.length > 0) {
|
||||
svgCanvas.changeSelectedAttribute('font-style', value, changedTextElements)
|
||||
}
|
||||
if (!textElements.some(el => el.textContent)) {
|
||||
svgCanvas.textActions.setCursor()
|
||||
}
|
||||
notifyTextChange(changedTextElements)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -776,9 +797,12 @@ const setItalicMethod = (i) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const setTextAnchorMethod = (value) => {
|
||||
const selectedElements = svgCanvas.getSelectedElements()
|
||||
const textElements = selectedElements.filter(el => el?.tagName === 'text')
|
||||
svgCanvas.changeSelectedAttribute('text-anchor', value, textElements)
|
||||
const textElements = getSelectedTextElements()
|
||||
const changedTextElements = getChangedTextElements(textElements, 'text-anchor', value)
|
||||
if (changedTextElements.length > 0) {
|
||||
svgCanvas.changeSelectedAttribute('text-anchor', value, changedTextElements)
|
||||
}
|
||||
notifyTextChange(changedTextElements)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -787,12 +811,15 @@ const setTextAnchorMethod = (value) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const setLetterSpacingMethod = (value) => {
|
||||
const selectedElements = svgCanvas.getSelectedElements()
|
||||
const textElements = selectedElements.filter(el => el?.tagName === 'text')
|
||||
svgCanvas.changeSelectedAttribute('letter-spacing', value, textElements)
|
||||
const textElements = getSelectedTextElements()
|
||||
const changedTextElements = getChangedTextElements(textElements, 'letter-spacing', value)
|
||||
if (changedTextElements.length > 0) {
|
||||
svgCanvas.changeSelectedAttribute('letter-spacing', value, changedTextElements)
|
||||
}
|
||||
if (!textElements.some(el => el.textContent)) {
|
||||
svgCanvas.textActions.setCursor()
|
||||
}
|
||||
notifyTextChange(changedTextElements)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -801,12 +828,15 @@ const setLetterSpacingMethod = (value) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const setWordSpacingMethod = (value) => {
|
||||
const selectedElements = svgCanvas.getSelectedElements()
|
||||
const textElements = selectedElements.filter(el => el?.tagName === 'text')
|
||||
svgCanvas.changeSelectedAttribute('word-spacing', value, textElements)
|
||||
const textElements = getSelectedTextElements()
|
||||
const changedTextElements = getChangedTextElements(textElements, 'word-spacing', value)
|
||||
if (changedTextElements.length > 0) {
|
||||
svgCanvas.changeSelectedAttribute('word-spacing', value, changedTextElements)
|
||||
}
|
||||
if (!textElements.some(el => el.textContent)) {
|
||||
svgCanvas.textActions.setCursor()
|
||||
}
|
||||
notifyTextChange(changedTextElements)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -815,12 +845,15 @@ const setWordSpacingMethod = (value) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const setTextLengthMethod = (value) => {
|
||||
const selectedElements = svgCanvas.getSelectedElements()
|
||||
const textElements = selectedElements.filter(el => el?.tagName === 'text')
|
||||
svgCanvas.changeSelectedAttribute('textLength', value, textElements)
|
||||
const textElements = getSelectedTextElements()
|
||||
const changedTextElements = getChangedTextElements(textElements, 'textLength', value)
|
||||
if (changedTextElements.length > 0) {
|
||||
svgCanvas.changeSelectedAttribute('textLength', value, changedTextElements)
|
||||
}
|
||||
if (!textElements.some(el => el.textContent)) {
|
||||
svgCanvas.textActions.setCursor()
|
||||
}
|
||||
notifyTextChange(changedTextElements)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -829,12 +862,15 @@ const setTextLengthMethod = (value) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const setLengthAdjustMethod = (value) => {
|
||||
const selectedElements = svgCanvas.getSelectedElements()
|
||||
const textElements = selectedElements.filter(el => el?.tagName === 'text')
|
||||
svgCanvas.changeSelectedAttribute('lengthAdjust', value, textElements)
|
||||
const textElements = getSelectedTextElements()
|
||||
const changedTextElements = getChangedTextElements(textElements, 'lengthAdjust', value)
|
||||
if (changedTextElements.length > 0) {
|
||||
svgCanvas.changeSelectedAttribute('lengthAdjust', value, changedTextElements)
|
||||
}
|
||||
if (!textElements.some(el => el.textContent)) {
|
||||
svgCanvas.textActions.setCursor()
|
||||
}
|
||||
notifyTextChange(changedTextElements)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -852,13 +888,16 @@ const getFontFamilyMethod = () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const setFontFamilyMethod = (val) => {
|
||||
const selectedElements = svgCanvas.getSelectedElements()
|
||||
const textElements = selectedElements.filter(el => el?.tagName === 'text')
|
||||
const textElements = getSelectedTextElements()
|
||||
const changedTextElements = getChangedTextElements(textElements, 'font-family', val)
|
||||
svgCanvas.setCurText('font_family', val)
|
||||
svgCanvas.changeSelectedAttribute('font-family', val, textElements)
|
||||
if (changedTextElements.length > 0) {
|
||||
svgCanvas.changeSelectedAttribute('font-family', val, changedTextElements)
|
||||
}
|
||||
if (!textElements.some(el => el.textContent)) {
|
||||
svgCanvas.textActions.setCursor()
|
||||
}
|
||||
notifyTextChange(changedTextElements)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -868,8 +907,13 @@ const setFontFamilyMethod = (val) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const setFontColorMethod = (val) => {
|
||||
const textElements = getSelectedTextElements()
|
||||
const changedTextElements = getChangedTextElements(textElements, 'fill', val)
|
||||
svgCanvas.setCurText('fill', val)
|
||||
svgCanvas.changeSelectedAttribute('fill', val)
|
||||
if (changedTextElements.length > 0) {
|
||||
svgCanvas.changeSelectedAttribute('fill', val, changedTextElements)
|
||||
}
|
||||
notifyTextChange(changedTextElements)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -895,12 +939,16 @@ const getFontSizeMethod = () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const setFontSizeMethod = (val) => {
|
||||
const selectedElements = svgCanvas.getSelectedElements()
|
||||
const textElements = getSelectedTextElements()
|
||||
const changedTextElements = getChangedTextElements(textElements, 'font-size', val)
|
||||
svgCanvas.setCurText('font_size', val)
|
||||
svgCanvas.changeSelectedAttribute('font-size', val)
|
||||
if (!selectedElements[0]?.textContent) {
|
||||
if (changedTextElements.length > 0) {
|
||||
svgCanvas.changeSelectedAttribute('font-size', val, changedTextElements)
|
||||
}
|
||||
if (!textElements.some(el => el.textContent)) {
|
||||
svgCanvas.textActions.setCursor()
|
||||
}
|
||||
notifyTextChange(changedTextElements)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,61 @@
|
||||
|
||||
import { NS } from './namespaces.js'
|
||||
import { getHref, setHref, getRotationAngle, getBBox } from './utilities.js'
|
||||
import { getTransformList, transformListToTransform, transformPoint } from './math.js'
|
||||
|
||||
// Attributes that affect an element's bounding box. Only these require
|
||||
// recalculating the rotation center when changed.
|
||||
export const BBOX_AFFECTING_ATTRS = new Set([
|
||||
'x', 'y', 'x1', 'y1', 'x2', 'y2',
|
||||
'cx', 'cy', 'r', 'rx', 'ry',
|
||||
'width', 'height', 'd', 'points'
|
||||
])
|
||||
|
||||
/**
|
||||
* Relocate rotation center after a bbox-affecting attribute change.
|
||||
* Uses the transform list API to update only the rotation entry,
|
||||
* preserving compound transforms (translate, scale, etc.).
|
||||
* @param {Element} elem - SVG element
|
||||
* @param {string[]} changedAttrs - attribute names that were changed
|
||||
*/
|
||||
function relocateRotationCenter (elem, changedAttrs) {
|
||||
const hasBboxChange = changedAttrs.some(attr => BBOX_AFFECTING_ATTRS.has(attr))
|
||||
if (!hasBboxChange) return
|
||||
|
||||
const angle = getRotationAngle(elem)
|
||||
if (!angle) return
|
||||
|
||||
const tlist = getTransformList(elem)
|
||||
let n = tlist.numberOfItems
|
||||
while (n--) {
|
||||
const xform = tlist.getItem(n)
|
||||
if (xform.type === 4) { // SVG_TRANSFORM_ROTATE
|
||||
// Compute bbox BEFORE removing the rotation so we can bail out
|
||||
// safely if getBBox returns nothing (avoids losing the rotation).
|
||||
const box = getBBox(elem)
|
||||
if (!box) return
|
||||
|
||||
tlist.removeItem(n)
|
||||
|
||||
// Transform bbox center through only post-rotation transforms.
|
||||
// After removeItem(n), what was at n+1 is now at n.
|
||||
let centerMatrix
|
||||
if (n < tlist.numberOfItems) {
|
||||
centerMatrix = transformListToTransform(tlist, n, tlist.numberOfItems - 1).matrix
|
||||
} else {
|
||||
centerMatrix = elem.ownerSVGElement.createSVGMatrix() // identity
|
||||
}
|
||||
const center = transformPoint(
|
||||
box.x + box.width / 2, box.y + box.height / 2, centerMatrix
|
||||
)
|
||||
|
||||
const newrot = elem.ownerSVGElement.createSVGTransform()
|
||||
newrot.setRotate(angle, center.x, center.y)
|
||||
tlist.insertItemBefore(newrot, n)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group: Undo/Redo history management.
|
||||
@@ -344,17 +399,7 @@ export class ChangeElementCommand extends Command {
|
||||
|
||||
// relocate rotational transform, if necessary
|
||||
if (!bChangedTransform) {
|
||||
const angle = getRotationAngle(this.elem)
|
||||
if (angle) {
|
||||
const bbox = getBBox(this.elem)
|
||||
if (!bbox) return
|
||||
const cx = bbox.x + bbox.width / 2
|
||||
const cy = bbox.y + bbox.height / 2
|
||||
const rotate = ['rotate(', angle, ' ', cx, ',', cy, ')'].join('')
|
||||
if (rotate !== this.elem.getAttribute('transform')) {
|
||||
this.elem.setAttribute('transform', rotate)
|
||||
}
|
||||
}
|
||||
relocateRotationCenter(this.elem, Object.keys(this.newValues))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -388,17 +433,7 @@ export class ChangeElementCommand extends Command {
|
||||
})
|
||||
// relocate rotational transform, if necessary
|
||||
if (!bChangedTransform) {
|
||||
const angle = getRotationAngle(this.elem)
|
||||
if (angle) {
|
||||
const bbox = getBBox(this.elem)
|
||||
if (!bbox) return
|
||||
const cx = bbox.x + bbox.width / 2
|
||||
const cy = bbox.y + bbox.height / 2
|
||||
const rotate = ['rotate(', angle, ' ', cx, ',', cy, ')'].join('')
|
||||
if (rotate !== this.elem.getAttribute('transform')) {
|
||||
this.elem.setAttribute('transform', rotate)
|
||||
}
|
||||
}
|
||||
relocateRotationCenter(this.elem, Object.keys(this.oldValues))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import * as draw from './draw.js'
|
||||
import * as hstry from './history.js'
|
||||
import { BBOX_AFFECTING_ATTRS } from './history.js'
|
||||
import {
|
||||
getRotationAngle, getBBox as utilsGetBBox, setHref, getStrokedBBoxDefaultVisible
|
||||
} from './utilities.js'
|
||||
@@ -239,26 +240,39 @@ export const changeSelectedAttributeNoUndoMethod = (attr, newValue, elems) => {
|
||||
svgCanvas.selectorManager.requestSelector(elem).resize()
|
||||
}, 0)
|
||||
}
|
||||
// if this element was rotated, and we changed the position of this element
|
||||
// we need to update the rotational transform attribute
|
||||
// Only recalculate rotation center for attributes that change element geometry.
|
||||
// Non-geometric attributes (stroke-width, fill, opacity, etc.) don't affect
|
||||
// the bbox center, so the rotation is already correct and must not be touched.
|
||||
// BBOX_AFFECTING_ATTRS is imported from history.js to keep the list in one place.
|
||||
const angle = getRotationAngle(elem)
|
||||
if (angle !== 0 && attr !== 'transform') {
|
||||
if (angle !== 0 && attr !== 'transform' && BBOX_AFFECTING_ATTRS.has(attr)) {
|
||||
const tlist = getTransformList(elem)
|
||||
let n = tlist.numberOfItems
|
||||
while (n--) {
|
||||
const xform = tlist.getItem(n)
|
||||
if (xform.type === 4) {
|
||||
// remove old rotate
|
||||
// Compute bbox BEFORE removing the rotation so we can bail out
|
||||
// safely if getBBox returns nothing (avoids losing the rotation).
|
||||
const box = utilsGetBBox(elem)
|
||||
if (!box) break
|
||||
|
||||
tlist.removeItem(n)
|
||||
|
||||
const box = utilsGetBBox(elem)
|
||||
// Transform bbox center through only the transforms that come
|
||||
// AFTER the rotation in the list (not the pre-rotation transforms).
|
||||
// After removeItem(n), what was at n+1 is now at n.
|
||||
let centerMatrix
|
||||
if (n < tlist.numberOfItems) {
|
||||
centerMatrix = transformListToTransform(tlist, n, tlist.numberOfItems - 1).matrix
|
||||
} else {
|
||||
centerMatrix = svgCanvas.getSvgRoot().createSVGMatrix() // identity
|
||||
}
|
||||
const center = transformPoint(
|
||||
box.x + box.width / 2, box.y + box.height / 2, transformListToTransform(tlist).matrix
|
||||
box.x + box.width / 2, box.y + box.height / 2, centerMatrix
|
||||
)
|
||||
const cx = center.x
|
||||
const cy = center.y
|
||||
|
||||
const newrot = svgCanvas.getSvgRoot().createSVGTransform()
|
||||
newrot.setRotate(angle, cx, cy)
|
||||
newrot.setRotate(angle, center.x, center.y)
|
||||
tlist.insertItemBefore(newrot, n)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -201,7 +201,9 @@ class SvgCanvas {
|
||||
this.curConfig.initFill.color,
|
||||
fill_paint: null,
|
||||
fill_opacity: this.curConfig.initFill.opacity,
|
||||
stroke: `#${this.curConfig.initStroke.color}`,
|
||||
stroke:
|
||||
(this.curConfig.initStroke.color === 'none' ? '' : '#') +
|
||||
this.curConfig.initStroke.color,
|
||||
stroke_paint: null,
|
||||
stroke_opacity: this.curConfig.initStroke.opacity,
|
||||
stroke_width: this.curConfig.initStroke.width,
|
||||
|
||||
@@ -447,7 +447,10 @@ export default {
|
||||
attr: {
|
||||
id: 'conn_' + svgCanvas.getNextId(),
|
||||
points: `${x},${y} ${x},${y} ${startX},${startY}`,
|
||||
stroke: `#${initStroke.color}`,
|
||||
stroke:
|
||||
initStroke.color === 'none'
|
||||
? 'none'
|
||||
: `#${initStroke.color}`,
|
||||
'stroke-width':
|
||||
!startElem.stroke_width || startElem.stroke_width === 0
|
||||
? initStroke.width
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, afterEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } 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'
|
||||
@@ -35,7 +35,8 @@ describe('elem-get-set', () => {
|
||||
clear () {}
|
||||
},
|
||||
runExtensions () {},
|
||||
call () {},
|
||||
call: vi.fn(),
|
||||
changeSelectedAttribute: vi.fn(),
|
||||
getDOMDocument () { return document },
|
||||
getSvgContent () { return svgContent },
|
||||
getSelectedElements () { return this.selectedElements || [] },
|
||||
@@ -51,6 +52,10 @@ describe('elem-get-set', () => {
|
||||
},
|
||||
addCommandToHistory (cmd) {
|
||||
historyStack.push(cmd)
|
||||
},
|
||||
setCurText () {},
|
||||
textActions: {
|
||||
setCursor () {}
|
||||
}
|
||||
}
|
||||
svgContent.setAttribute('width', '100')
|
||||
@@ -232,4 +237,43 @@ describe('elem-get-set', () => {
|
||||
expect(localCanvas.contentW).toBe(200)
|
||||
expect(localCanvas.contentH).toBe(150)
|
||||
})
|
||||
|
||||
it('setBold() emits changed only for modified text elements', () => {
|
||||
const text = createSvgElement('text')
|
||||
text.textContent = 'Hello'
|
||||
const rect = createSvgElement('rect')
|
||||
svgContent.append(text, rect)
|
||||
canvas.selectedElements = [text, rect]
|
||||
|
||||
canvas.setBold(true)
|
||||
|
||||
expect(canvas.changeSelectedAttribute).toHaveBeenCalledWith('font-weight', 'bold', [text])
|
||||
expect(canvas.call).toHaveBeenCalledWith('changed', [text])
|
||||
})
|
||||
|
||||
it('setBold() skips changed event for no-op text updates', () => {
|
||||
const text = createSvgElement('text')
|
||||
text.textContent = 'Hello'
|
||||
text.setAttribute('font-weight', 'bold')
|
||||
svgContent.append(text)
|
||||
canvas.selectedElements = [text]
|
||||
|
||||
canvas.setBold(true)
|
||||
|
||||
expect(canvas.changeSelectedAttribute).not.toHaveBeenCalled()
|
||||
expect(canvas.call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('setFontColor() ignores non-text selections when emitting changed', () => {
|
||||
const text = createSvgElement('text')
|
||||
text.textContent = 'Hello'
|
||||
const rect = createSvgElement('rect')
|
||||
svgContent.append(text, rect)
|
||||
canvas.selectedElements = [text, rect]
|
||||
|
||||
canvas.setFontColor('#f00')
|
||||
|
||||
expect(canvas.changeSelectedAttribute).toHaveBeenCalledWith('fill', '#f00', [text])
|
||||
expect(canvas.call).toHaveBeenCalledWith('changed', [text])
|
||||
})
|
||||
})
|
||||
|
||||
300
tests/unit/rotation-recalc.test.js
Normal file
300
tests/unit/rotation-recalc.test.js
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Tests for rotation recalculation when changing attributes on rotated elements.
|
||||
*
|
||||
* These tests verify two bugs that were fixed:
|
||||
*
|
||||
* Bug 1: Changing non-geometric attributes (stroke-width, fill, opacity, etc.)
|
||||
* on a rotated element would trigger an unnecessary rotation center recalculation,
|
||||
* corrupting compound transforms.
|
||||
*
|
||||
* Bug 2: The rotation center was computed through ALL remaining transforms
|
||||
* (including pre-rotation ones like translate), causing the translate to leak
|
||||
* into the center calculation and produce an incorrect rotation.
|
||||
*
|
||||
* Bug 3 (history.js only): The undo/redo code replaced the ENTIRE transform
|
||||
* attribute with just rotate(...), destroying any other transforms in the list.
|
||||
*/
|
||||
|
||||
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
|
||||
import * as utilities from '../../packages/svgcanvas/core/utilities.js'
|
||||
import * as history from '../../packages/svgcanvas/core/history.js'
|
||||
import { getTransformList } from '../../packages/svgcanvas/core/math.js'
|
||||
|
||||
describe('Rotation recalculation on attribute change', function () {
|
||||
/**
|
||||
* Helper: create an SVG <rect> with the given attributes inside an <svg>.
|
||||
*/
|
||||
function createSvgRect (attrs = {}) {
|
||||
const svg = document.createElementNS(NS.SVG, 'svg')
|
||||
document.body.appendChild(svg)
|
||||
const rect = document.createElementNS(NS.SVG, 'rect')
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
rect.setAttribute(k, v)
|
||||
}
|
||||
svg.appendChild(rect)
|
||||
return rect
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: read back the transform list entries as simple objects for assertions.
|
||||
*/
|
||||
function readTransforms (elem) {
|
||||
const tlist = getTransformList(elem)
|
||||
const result = []
|
||||
for (let i = 0; i < tlist.numberOfItems; i++) {
|
||||
const t = tlist.getItem(i)
|
||||
result.push({ type: t.type, matrix: { ...t.matrix } })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.textContent = ''
|
||||
// Reset mock to default (no rotation)
|
||||
utilities.mock({
|
||||
getHref () { return '#foo' },
|
||||
setHref () { /* empty fn */ },
|
||||
getRotationAngle () { return 0 }
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChangeElementCommand with rotated elements', function () {
|
||||
it('non-geometric attribute change preserves simple rotation', function () {
|
||||
const rect = createSvgRect({
|
||||
x: '0',
|
||||
y: '0',
|
||||
width: '100',
|
||||
height: '100',
|
||||
transform: 'rotate(30, 50, 50)',
|
||||
'stroke-width': '1'
|
||||
})
|
||||
|
||||
// Mock getRotationAngle to return 30 (matching our transform)
|
||||
utilities.mock({
|
||||
getHref () { return '' },
|
||||
setHref () { /* empty fn */ },
|
||||
getRotationAngle () { return 30 }
|
||||
})
|
||||
|
||||
const transformsBefore = readTransforms(rect)
|
||||
|
||||
// Simulate changing stroke-width from 1 to 5
|
||||
rect.setAttribute('stroke-width', '5')
|
||||
const change = new history.ChangeElementCommand(rect, { 'stroke-width': '1' })
|
||||
|
||||
// Apply (redo) — should NOT touch the transform
|
||||
change.apply()
|
||||
const transformsAfterApply = readTransforms(rect)
|
||||
assert.equal(transformsAfterApply.length, transformsBefore.length,
|
||||
'apply: transform list length unchanged')
|
||||
assert.equal(transformsAfterApply[0].type, 4,
|
||||
'apply: rotation transform preserved')
|
||||
|
||||
// Unapply (undo) — should NOT touch the transform
|
||||
change.unapply()
|
||||
const transformsAfterUnapply = readTransforms(rect)
|
||||
assert.equal(transformsAfterUnapply.length, transformsBefore.length,
|
||||
'unapply: transform list length unchanged')
|
||||
assert.equal(transformsAfterUnapply[0].type, 4,
|
||||
'unapply: rotation transform preserved')
|
||||
})
|
||||
|
||||
it('non-geometric attribute change preserves compound transforms', function () {
|
||||
const rect = createSvgRect({
|
||||
x: '0',
|
||||
y: '0',
|
||||
width: '100',
|
||||
height: '100',
|
||||
transform: 'translate(100, 50) rotate(30)',
|
||||
'stroke-width': '2'
|
||||
})
|
||||
|
||||
utilities.mock({
|
||||
getHref () { return '' },
|
||||
setHref () { /* empty fn */ },
|
||||
getRotationAngle () { return 30 }
|
||||
})
|
||||
|
||||
const tlistBefore = getTransformList(rect)
|
||||
assert.equal(tlistBefore.numberOfItems, 2,
|
||||
'setup: two transforms (translate + rotate)')
|
||||
assert.equal(tlistBefore.getItem(0).type, 2,
|
||||
'setup: first transform is translate')
|
||||
assert.equal(tlistBefore.getItem(1).type, 4,
|
||||
'setup: second transform is rotate')
|
||||
|
||||
// Capture the translate matrix before
|
||||
const translateMatrix = { ...tlistBefore.getItem(0).matrix }
|
||||
|
||||
// Simulate changing stroke-width from 2 to 5
|
||||
rect.setAttribute('stroke-width', '5')
|
||||
const change = new history.ChangeElementCommand(rect, { 'stroke-width': '2' })
|
||||
|
||||
// Apply (redo) — must preserve both translate and rotate
|
||||
change.apply()
|
||||
const tlistAfter = getTransformList(rect)
|
||||
assert.equal(tlistAfter.numberOfItems, 2,
|
||||
'apply: still two transforms')
|
||||
assert.equal(tlistAfter.getItem(0).type, 2,
|
||||
'apply: first is still translate')
|
||||
assert.equal(tlistAfter.getItem(1).type, 4,
|
||||
'apply: second is still rotate')
|
||||
assert.closeTo(tlistAfter.getItem(0).matrix.e, translateMatrix.e, 0.01,
|
||||
'apply: translate tx preserved')
|
||||
assert.closeTo(tlistAfter.getItem(0).matrix.f, translateMatrix.f, 0.01,
|
||||
'apply: translate ty preserved')
|
||||
|
||||
// Unapply (undo) — must also preserve both transforms
|
||||
change.unapply()
|
||||
assert.equal(tlistAfter.numberOfItems, 2,
|
||||
'unapply: still two transforms')
|
||||
assert.equal(tlistAfter.getItem(0).type, 2,
|
||||
'unapply: first is still translate')
|
||||
assert.equal(tlistAfter.getItem(1).type, 4,
|
||||
'unapply: second is still rotate')
|
||||
})
|
||||
|
||||
it('geometric attribute change updates rotation center correctly', function () {
|
||||
// Element with simple rotation — changing x should update the rotation center
|
||||
const rect = createSvgRect({
|
||||
x: '0',
|
||||
y: '0',
|
||||
width: '100',
|
||||
height: '100',
|
||||
transform: 'rotate(45, 50, 50)'
|
||||
})
|
||||
|
||||
utilities.mock({
|
||||
getHref () { return '' },
|
||||
setHref () { /* empty fn */ },
|
||||
getRotationAngle () { return 45 }
|
||||
})
|
||||
|
||||
// Change x from 0 to 20 (new bbox center at 70, 50)
|
||||
rect.setAttribute('x', '20')
|
||||
const change = new history.ChangeElementCommand(rect, { x: '0' })
|
||||
|
||||
// Apply should update the rotation center to (70, 50)
|
||||
change.apply()
|
||||
const tlist = getTransformList(rect)
|
||||
assert.equal(tlist.numberOfItems, 1, 'still one transform')
|
||||
assert.equal(tlist.getItem(0).type, 4, 'still a rotation')
|
||||
// The rotation center should reflect the new bbox center
|
||||
assert.closeTo(tlist.getItem(0).cx, 70, 0.01,
|
||||
'rotation cx updated to new bbox center')
|
||||
assert.closeTo(tlist.getItem(0).cy, 50, 0.01,
|
||||
'rotation cy unchanged')
|
||||
})
|
||||
|
||||
it('geometric attribute change on compound transform uses only post-rotation transforms for center', function () {
|
||||
// Element with translate(100, 50) rotate(30)
|
||||
// When x changes, the rotation center should be computed from the
|
||||
// bbox center WITHOUT the pre-rotation translate leaking in.
|
||||
const rect = createSvgRect({
|
||||
x: '0',
|
||||
y: '0',
|
||||
width: '100',
|
||||
height: '100',
|
||||
transform: 'translate(100, 50) rotate(30)'
|
||||
})
|
||||
|
||||
utilities.mock({
|
||||
getHref () { return '' },
|
||||
setHref () { /* empty fn */ },
|
||||
getRotationAngle () { return 30 }
|
||||
})
|
||||
|
||||
const tlistBefore = getTransformList(rect)
|
||||
assert.equal(tlistBefore.numberOfItems, 2, 'setup: two transforms')
|
||||
|
||||
// Change x from 0 to 20
|
||||
rect.setAttribute('x', '20')
|
||||
const change = new history.ChangeElementCommand(rect, { x: '0' })
|
||||
|
||||
change.apply()
|
||||
const tlist = getTransformList(rect)
|
||||
|
||||
// Should still have 2 transforms: translate + rotate
|
||||
assert.equal(tlist.numberOfItems, 2,
|
||||
'compound transform preserved (2 entries)')
|
||||
assert.equal(tlist.getItem(0).type, 2,
|
||||
'first is still translate')
|
||||
assert.equal(tlist.getItem(1).type, 4,
|
||||
'second is still rotate')
|
||||
|
||||
// The translate should be unchanged
|
||||
assert.closeTo(tlist.getItem(0).matrix.e, 100, 0.01,
|
||||
'translate tx unchanged')
|
||||
assert.closeTo(tlist.getItem(0).matrix.f, 50, 0.01,
|
||||
'translate ty unchanged')
|
||||
|
||||
// The rotation center should be (70, 50) — the new bbox center —
|
||||
// NOT (170, 100) which is what you'd get if the translate leaked in.
|
||||
assert.closeTo(tlist.getItem(1).cx, 70, 0.01,
|
||||
'rotation cx = new bbox center, not offset by translate')
|
||||
assert.closeTo(tlist.getItem(1).cy, 50, 0.01,
|
||||
'rotation cy = new bbox center, not offset by translate')
|
||||
})
|
||||
|
||||
it('fill change does not trigger rotation recalculation', function () {
|
||||
const rect = createSvgRect({
|
||||
x: '0',
|
||||
y: '0',
|
||||
width: '100',
|
||||
height: '100',
|
||||
transform: 'rotate(45, 50, 50)',
|
||||
fill: 'red'
|
||||
})
|
||||
|
||||
utilities.mock({
|
||||
getHref () { return '' },
|
||||
setHref () { /* empty fn */ },
|
||||
getRotationAngle () { return 45 }
|
||||
})
|
||||
|
||||
const tlistBefore = getTransformList(rect)
|
||||
const cxBefore = tlistBefore.getItem(0).cx
|
||||
const cyBefore = tlistBefore.getItem(0).cy
|
||||
|
||||
rect.setAttribute('fill', 'blue')
|
||||
const change = new history.ChangeElementCommand(rect, { fill: 'red' })
|
||||
|
||||
change.apply()
|
||||
const tlistAfter = getTransformList(rect)
|
||||
assert.equal(tlistAfter.getItem(0).cx, cxBefore,
|
||||
'rotation cx unchanged after fill change')
|
||||
assert.equal(tlistAfter.getItem(0).cy, cyBefore,
|
||||
'rotation cy unchanged after fill change')
|
||||
})
|
||||
|
||||
it('opacity change does not trigger rotation recalculation', function () {
|
||||
const rect = createSvgRect({
|
||||
x: '0',
|
||||
y: '0',
|
||||
width: '100',
|
||||
height: '100',
|
||||
transform: 'translate(50, 25) rotate(60)',
|
||||
opacity: '1'
|
||||
})
|
||||
|
||||
utilities.mock({
|
||||
getHref () { return '' },
|
||||
setHref () { /* empty fn */ },
|
||||
getRotationAngle () { return 60 }
|
||||
})
|
||||
|
||||
const tlistBefore = getTransformList(rect)
|
||||
assert.equal(tlistBefore.numberOfItems, 2, 'setup: two transforms')
|
||||
|
||||
rect.setAttribute('opacity', '0.5')
|
||||
const change = new history.ChangeElementCommand(rect, { opacity: '1' })
|
||||
|
||||
change.apply()
|
||||
const tlist = getTransformList(rect)
|
||||
assert.equal(tlist.numberOfItems, 2,
|
||||
'opacity change preserves compound transform count')
|
||||
assert.equal(tlist.getItem(0).type, 2, 'translate preserved')
|
||||
assert.equal(tlist.getItem(1).type, 4, 'rotate preserved')
|
||||
})
|
||||
})
|
||||
})
|
||||
15
tests/visual/compound-transform-bug.svg
Normal file
15
tests/visual/compound-transform-bug.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="400" viewBox="0 0 600 400">
|
||||
<!-- Group with stroke. Changing stroke-width in SVG-Edit corrupts
|
||||
the compound transforms on the child elements. -->
|
||||
<g id="fan" stroke="black" stroke-width="2" fill="none">
|
||||
<!-- Each rect uses translate + rotate: a compound transform.
|
||||
The bug causes the translate to leak into the rotation center
|
||||
recalculation, destroying the layout. -->
|
||||
<rect width="80" height="20" rx="4" fill="#e74c3c" transform="translate(200, 200) rotate(0)"/>
|
||||
<rect width="80" height="20" rx="4" fill="#e67e22" transform="translate(200, 200) rotate(30)"/>
|
||||
<rect width="80" height="20" rx="4" fill="#f1c40f" transform="translate(200, 200) rotate(60)"/>
|
||||
<rect width="80" height="20" rx="4" fill="#2ecc71" transform="translate(200, 200) rotate(90)"/>
|
||||
<rect width="80" height="20" rx="4" fill="#3498db" transform="translate(200, 200) rotate(120)"/>
|
||||
<rect width="80" height="20" rx="4" fill="#9b59b6" transform="translate(200, 200) rotate(150)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
295
tests/visual/rotation-recalc-demo.html
Normal file
295
tests/visual/rotation-recalc-demo.html
Normal file
@@ -0,0 +1,295 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>SVG-Edit Bug: Rotation Recalculation Corrupts Compound Transforms</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
padding: 32px 40px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: #e6edf3;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #8b949e;
|
||||
margin-bottom: 28px;
|
||||
max-width: 800px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 24px 0 14px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
.bug1-title { color: #f0883e; }
|
||||
.bug2-title { color: #bc8cff; }
|
||||
.panels {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.panel {
|
||||
flex: 1;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
.panel h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.panel .desc {
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.badge-original { background: #1f6feb33; color: #58a6ff; border: 1px solid #1f6feb; }
|
||||
.badge-bug { background: #f8514933; color: #ff7b72; border: 1px solid #f85149; }
|
||||
.badge-fixed { background: #23883433; color: #3fb950; border: 1px solid #238834; }
|
||||
svg {
|
||||
display: block;
|
||||
background: #0d1117;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #21262d;
|
||||
}
|
||||
.caption {
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
margin-top: 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.code-block {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: #c9d1d9;
|
||||
margin: 10px 0;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.code-comment { color: #8b949e; }
|
||||
.code-bad { color: #ff7b72; }
|
||||
.code-good { color: #3fb950; }
|
||||
.arrow-label {
|
||||
font-size: 28px;
|
||||
align-self: center;
|
||||
color: #484f58;
|
||||
flex-shrink: 0;
|
||||
width: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Bug: Rotation Recalculation Corrupts Compound Transforms</h1>
|
||||
<p class="subtitle">
|
||||
SVG-Edit recalculates rotation centers when element attributes change. Two bugs cause this
|
||||
recalculation to corrupt elements with compound transforms (e.g. word art characters on a curve).
|
||||
</p>
|
||||
|
||||
<!-- ======================= BUG 1: history.js ======================= -->
|
||||
<div class="section-title bug1-title">Bug 1: Non-geometric attribute change destroys compound transforms (history.js)</div>
|
||||
|
||||
<p class="desc" style="margin-bottom: 14px; font-size: 12px; line-height: 1.5;">
|
||||
When undoing/redoing a <code>stroke-width</code> change, the old code replaces the
|
||||
<b>entire</b> transform attribute with just <code>rotate(...)</code>, destroying any
|
||||
<code>translate()</code> or <code>scale()</code> transforms. Characters positioned via
|
||||
compound transforms collapse to the SVG origin.
|
||||
</p>
|
||||
|
||||
<div class="panels">
|
||||
<!-- Original -->
|
||||
<div class="panel">
|
||||
<h3><span class="badge badge-original">Original</span> Characters on curve</h3>
|
||||
<p class="desc">Each character has <code>translate(x,y) rotate(angle)</code></p>
|
||||
<svg width="540" height="200" viewBox="0 0 540 200">
|
||||
<!-- Curve guide -->
|
||||
<path d="M 60,155 Q 270,75 480,155" fill="none" stroke="#1f6feb33" stroke-width="1" stroke-dasharray="4 3"/>
|
||||
<!-- Characters with compound transforms: translate + rotate -->
|
||||
<text transform="translate(80, 155) rotate(-15)" font-size="64" font-family="Georgia, serif" fill="#58a6ff" stroke="#1f6feb" stroke-width="2" paint-order="stroke" text-anchor="middle" dominant-baseline="alphabetic">S</text>
|
||||
<text transform="translate(170, 132) rotate(-8)" font-size="64" font-family="Georgia, serif" fill="#58a6ff" stroke="#1f6feb" stroke-width="2" paint-order="stroke" text-anchor="middle" dominant-baseline="alphabetic">T</text>
|
||||
<text transform="translate(260, 118) rotate(0)" font-size="64" font-family="Georgia, serif" fill="#58a6ff" stroke="#1f6feb" stroke-width="2" paint-order="stroke" text-anchor="middle" dominant-baseline="alphabetic">O</text>
|
||||
<text transform="translate(350, 132) rotate(8)" font-size="64" font-family="Georgia, serif" fill="#58a6ff" stroke="#1f6feb" stroke-width="2" paint-order="stroke" text-anchor="middle" dominant-baseline="alphabetic">R</text>
|
||||
<text transform="translate(450, 155) rotate(15)" font-size="64" font-family="Georgia, serif" fill="#58a6ff" stroke="#1f6feb" stroke-width="2" paint-order="stroke" text-anchor="middle" dominant-baseline="alphabetic">Y</text>
|
||||
</svg>
|
||||
<p class="caption">stroke-width: 2</p>
|
||||
</div>
|
||||
|
||||
<div class="arrow-label">→</div>
|
||||
|
||||
<!-- Bug: old code -->
|
||||
<div class="panel" style="border-color: #f85149;">
|
||||
<h3><span class="badge badge-bug">Bug</span> After undo of stroke-width change</h3>
|
||||
<p class="desc">Old code replaces transform with just <code>rotate()</code> — translate destroyed</p>
|
||||
<svg width="540" height="200" viewBox="0 0 540 200">
|
||||
<path d="M 60,155 Q 270,75 480,155" fill="none" stroke="#1f6feb33" stroke-width="1" stroke-dasharray="4 3"/>
|
||||
<!--
|
||||
Old history.js code does:
|
||||
const bbox = getBBox(elem) // e.g. {x:-15, y:-50, width:35, height:55}
|
||||
const cx = bbox.x + bbox.width / 2 // ~2.5
|
||||
const cy = bbox.y + bbox.height / 2 // ~-22.5
|
||||
elem.setAttribute('transform', `rotate(${angle}, ${cx}, ${cy})`)
|
||||
This REPLACES the entire transform, destroying the translate.
|
||||
All characters collapse to near the origin.
|
||||
-->
|
||||
<text transform="rotate(-15, 0, -20)" font-size="64" font-family="Georgia, serif" fill="#ff7b72" stroke="#f85149" stroke-width="2" paint-order="stroke" text-anchor="middle" dominant-baseline="alphabetic" opacity="0.8">S</text>
|
||||
<text transform="rotate(-8, 0, -20)" font-size="64" font-family="Georgia, serif" fill="#ff7b72" stroke="#f85149" stroke-width="2" paint-order="stroke" text-anchor="middle" dominant-baseline="alphabetic" opacity="0.8">T</text>
|
||||
<text transform="rotate(0, 0, -20)" font-size="64" font-family="Georgia, serif" fill="#ff7b72" stroke="#f85149" stroke-width="2" paint-order="stroke" text-anchor="middle" dominant-baseline="alphabetic" opacity="0.8">O</text>
|
||||
<text transform="rotate(8, 0, -20)" font-size="64" font-family="Georgia, serif" fill="#ff7b72" stroke="#f85149" stroke-width="2" paint-order="stroke" text-anchor="middle" dominant-baseline="alphabetic" opacity="0.8">R</text>
|
||||
<text transform="rotate(15, 0, -20)" font-size="64" font-family="Georgia, serif" fill="#ff7b72" stroke="#f85149" stroke-width="2" paint-order="stroke" text-anchor="middle" dominant-baseline="alphabetic" opacity="0.8">Y</text>
|
||||
<!-- Ghost showing where they should be -->
|
||||
<text transform="translate(80, 155) rotate(-15)" font-size="64" font-family="Georgia, serif" fill="none" stroke="#484f58" stroke-width="1" stroke-dasharray="3 2" text-anchor="middle" dominant-baseline="alphabetic">S</text>
|
||||
<text transform="translate(170, 132) rotate(-8)" font-size="64" font-family="Georgia, serif" fill="none" stroke="#484f58" stroke-width="1" stroke-dasharray="3 2" text-anchor="middle" dominant-baseline="alphabetic">T</text>
|
||||
<text transform="translate(260, 118) rotate(0)" font-size="64" font-family="Georgia, serif" fill="none" stroke="#484f58" stroke-width="1" stroke-dasharray="3 2" text-anchor="middle" dominant-baseline="alphabetic">O</text>
|
||||
<text transform="translate(350, 132) rotate(8)" font-size="64" font-family="Georgia, serif" fill="none" stroke="#484f58" stroke-width="1" stroke-dasharray="3 2" text-anchor="middle" dominant-baseline="alphabetic">R</text>
|
||||
<text transform="translate(450, 155) rotate(15)" font-size="64" font-family="Georgia, serif" fill="none" stroke="#484f58" stroke-width="1" stroke-dasharray="3 2" text-anchor="middle" dominant-baseline="alphabetic">Y</text>
|
||||
</svg>
|
||||
<p class="caption" style="color: #ff7b72;">Characters collapse to origin — translate() destroyed</p>
|
||||
</div>
|
||||
|
||||
<div class="arrow-label" style="visibility: hidden;">→</div>
|
||||
|
||||
<!-- Fixed -->
|
||||
<div class="panel" style="border-color: #238834;">
|
||||
<h3><span class="badge badge-fixed">Fixed</span> After undo of stroke-width change</h3>
|
||||
<p class="desc">New code skips recalculation for non-geometric attrs — transform untouched</p>
|
||||
<svg width="540" height="200" viewBox="0 0 540 200">
|
||||
<path d="M 60,155 Q 270,75 480,155" fill="none" stroke="#1f6feb33" stroke-width="1" stroke-dasharray="4 3"/>
|
||||
<text transform="translate(80, 155) rotate(-15)" font-size="64" font-family="Georgia, serif" fill="#3fb950" stroke="#238834" stroke-width="2" paint-order="stroke" text-anchor="middle" dominant-baseline="alphabetic">S</text>
|
||||
<text transform="translate(170, 132) rotate(-8)" font-size="64" font-family="Georgia, serif" fill="#3fb950" stroke="#238834" stroke-width="2" paint-order="stroke" text-anchor="middle" dominant-baseline="alphabetic">T</text>
|
||||
<text transform="translate(260, 118) rotate(0)" font-size="64" font-family="Georgia, serif" fill="#3fb950" stroke="#238834" stroke-width="2" paint-order="stroke" text-anchor="middle" dominant-baseline="alphabetic">O</text>
|
||||
<text transform="translate(350, 132) rotate(8)" font-size="64" font-family="Georgia, serif" fill="#3fb950" stroke="#238834" stroke-width="2" paint-order="stroke" text-anchor="middle" dominant-baseline="alphabetic">R</text>
|
||||
<text transform="translate(450, 155) rotate(15)" font-size="64" font-family="Georgia, serif" fill="#3fb950" stroke="#238834" stroke-width="2" paint-order="stroke" text-anchor="middle" dominant-baseline="alphabetic">Y</text>
|
||||
</svg>
|
||||
<p class="caption" style="color: #3fb950;">Characters unchanged — non-geometric attrs skip recalculation</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-block"><span class="code-comment">// history.js — old code (apply/unapply):</span>
|
||||
<span class="code-bad">const rotate = `rotate(${angle}, ${cx}, ${cy})`
|
||||
this.elem.setAttribute('transform', rotate) // replaces ENTIRE transform!</span>
|
||||
|
||||
<span class="code-comment">// history.js — new code:</span>
|
||||
<span class="code-good">if (!BBOX_AFFECTING_ATTRS.has(attr)) return // skip for stroke-width, fill, etc.
|
||||
// When needed, update only the rotation entry via transform list API</span></div>
|
||||
|
||||
|
||||
<!-- ======================= BUG 2: undo.js ======================= -->
|
||||
<div class="section-title bug2-title">Bug 2: Pre-rotation translate leaks into rotation center (undo.js)</div>
|
||||
|
||||
<p class="desc" style="margin-bottom: 14px; font-size: 12px; line-height: 1.5;">
|
||||
When a geometric attribute (e.g. <code>width</code>) changes on an element with
|
||||
<code>translate(tx,ty) rotate(angle)</code>, the rotation center should be computed
|
||||
from the bbox center alone. The old code transforms the center through <b>all</b> remaining
|
||||
transforms (including the pre-rotation translate), shifting the rotation center by (tx, ty).
|
||||
</p>
|
||||
|
||||
<div class="panels">
|
||||
<!-- Original -->
|
||||
<div class="panel">
|
||||
<h3><span class="badge badge-original">Original</span> Rect with translate + rotate</h3>
|
||||
<p class="desc"><code>translate(200,60) rotate(25, 50, 40)</code> on 100×80 rect</p>
|
||||
<svg width="540" height="200" viewBox="0 0 540 200">
|
||||
<!-- Grid reference -->
|
||||
<line x1="200" y1="0" x2="200" y2="200" stroke="#21262d" stroke-width="0.5"/>
|
||||
<line x1="0" y1="60" x2="540" y2="60" stroke="#21262d" stroke-width="0.5"/>
|
||||
<!-- Crosshair at translate target -->
|
||||
<circle cx="200" cy="60" r="3" fill="none" stroke="#484f58" stroke-width="1"/>
|
||||
<text x="208" y="55" font-size="10" fill="#484f58" font-family="system-ui">translate(200, 60)</text>
|
||||
<!-- The rect -->
|
||||
<g transform="translate(200, 60)">
|
||||
<rect transform="rotate(25, 50, 40)" x="0" y="0" width="100" height="80" fill="#1f6feb33" stroke="#58a6ff" stroke-width="2" rx="3"/>
|
||||
<!-- Center marker -->
|
||||
<circle transform="rotate(25, 50, 40)" cx="50" cy="40" r="4" fill="#58a6ff"/>
|
||||
<text transform="rotate(25, 50, 40)" x="60" y="38" font-size="10" fill="#58a6ff" font-family="system-ui">center (50, 40)</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="arrow-label">→</div>
|
||||
|
||||
<!-- Bug: old code -->
|
||||
<div class="panel" style="border-color: #f85149;">
|
||||
<h3><span class="badge badge-bug">Bug</span> After width change: center shifted</h3>
|
||||
<p class="desc">Old code: center through all transforms → <code>rotate(25, 260, 100)</code></p>
|
||||
<svg width="540" height="200" viewBox="0 0 540 200">
|
||||
<line x1="200" y1="0" x2="200" y2="200" stroke="#21262d" stroke-width="0.5"/>
|
||||
<line x1="0" y1="60" x2="540" y2="60" stroke="#21262d" stroke-width="0.5"/>
|
||||
<circle cx="200" cy="60" r="3" fill="none" stroke="#484f58" stroke-width="1"/>
|
||||
<!-- Ghost of correct position -->
|
||||
<g transform="translate(200, 60)">
|
||||
<rect transform="rotate(25, 60, 40)" x="0" y="0" width="120" height="80" fill="none" stroke="#484f58" stroke-width="1" stroke-dasharray="3 2" rx="3"/>
|
||||
</g>
|
||||
<!-- Bug: translate(200,60) leaks into center → rotate(25, 60+200, 40+60) = rotate(25, 260, 100) -->
|
||||
<g transform="translate(200, 60)">
|
||||
<rect transform="rotate(25, 260, 100)" x="0" y="0" width="120" height="80" fill="#f8514933" stroke="#ff7b72" stroke-width="2" rx="3"/>
|
||||
<!-- Wrong center marker -->
|
||||
<circle cx="260" cy="100" r="4" fill="#ff7b72"/>
|
||||
<text x="270" y="98" font-size="10" fill="#ff7b72" font-family="system-ui">wrong! (260, 100)</text>
|
||||
</g>
|
||||
</svg>
|
||||
<p class="caption" style="color: #ff7b72;">translate(200,60) leaks into rotation center calculation</p>
|
||||
</div>
|
||||
|
||||
<div class="arrow-label" style="visibility: hidden;">→</div>
|
||||
|
||||
<!-- Fixed -->
|
||||
<div class="panel" style="border-color: #238834;">
|
||||
<h3><span class="badge badge-fixed">Fixed</span> After width change: center correct</h3>
|
||||
<p class="desc">New code: center through post-rotation transforms only → <code>rotate(25, 60, 40)</code></p>
|
||||
<svg width="540" height="200" viewBox="0 0 540 200">
|
||||
<line x1="200" y1="0" x2="200" y2="200" stroke="#21262d" stroke-width="0.5"/>
|
||||
<line x1="0" y1="60" x2="540" y2="60" stroke="#21262d" stroke-width="0.5"/>
|
||||
<circle cx="200" cy="60" r="3" fill="none" stroke="#484f58" stroke-width="1"/>
|
||||
<text x="208" y="55" font-size="10" fill="#484f58" font-family="system-ui">translate(200, 60)</text>
|
||||
<g transform="translate(200, 60)">
|
||||
<rect transform="rotate(25, 60, 40)" x="0" y="0" width="120" height="80" fill="#23883433" stroke="#3fb950" stroke-width="2" rx="3"/>
|
||||
<!-- Correct center marker -->
|
||||
<circle transform="rotate(25, 60, 40)" cx="60" cy="40" r="4" fill="#3fb950"/>
|
||||
<text transform="rotate(25, 60, 40)" x="70" y="38" font-size="10" fill="#3fb950" font-family="system-ui">correct (60, 40)</text>
|
||||
</g>
|
||||
</svg>
|
||||
<p class="caption" style="color: #3fb950;">Center computed from bbox only, translate excluded</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-block"><span class="code-comment">// undo.js — old code (center through ALL remaining transforms):</span>
|
||||
<span class="code-bad">center = transformPoint(bcx, bcy, transformListToTransform(tlist).matrix)
|
||||
// For [translate(200,60), rotate(25)]: includes translate → center = (bcx+200, bcy+60)</span>
|
||||
|
||||
<span class="code-comment">// undo.js — new code (center through only POST-rotation transforms):</span>
|
||||
<span class="code-good">centerMatrix = transformListToTransform(tlist, n, tlist.numberOfItems - 1).matrix
|
||||
// For [translate(200,60)]: post-rotation = identity → center = (bcx, bcy)</span></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
BIN
tests/visual/rotation-recalc-demo.png
Normal file
BIN
tests/visual/rotation-recalc-demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
21
tests/visual/screenshot.mjs
Normal file
21
tests/visual/screenshot.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Take screenshots of the rotation recalculation bug demo.
|
||||
* Usage: node tests/visual/screenshot.mjs
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const htmlPath = resolve(__dirname, 'rotation-recalc-demo.html')
|
||||
const outputPath = resolve(__dirname, 'rotation-recalc-demo.png')
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1800, height: 980 } })
|
||||
await page.goto(`file://${htmlPath}`)
|
||||
await page.waitForTimeout(500) // let fonts render
|
||||
await page.screenshot({ path: outputPath, fullPage: true })
|
||||
await browser.close()
|
||||
|
||||
console.log(`Screenshot saved to: ${outputPath}`)
|
||||
Reference in New Issue
Block a user