Skip to content
118 changes: 59 additions & 59 deletions coverage/coverage-summary.json

Large diffs are not rendered by default.

150 changes: 54 additions & 96 deletions packages/svgcanvas/core/event.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import {
convertAttrs
} from './units.js'
import {
transformPoint, hasMatrixTransform, getMatrix, snapToAngle, getTransformList, transformListToTransform
transformPoint, hasMatrixTransform, getMatrix, snapToAngle, getTransformList
} from './math.js'
import { withStartTransform } from './recalculate.js'
import * as draw from './draw.js'
import * as pathModule from './path.js'
import * as hstry from './history.js'
Expand Down Expand Up @@ -79,45 +80,45 @@ const getBsplinePoint = (t) => {
}
}

// update the dummy transform in our transform list
// to be a translate. We need to check if there was a transformation
// to avoid loosing it
const beginDragTransform = (svgRoot, selectedElements) => {
svgCanvas.dragStartTransforms = new Map()
for (const selectedElement of selectedElements) {
if (!selectedElement) { continue }
svgCanvas.dragStartTransforms.set(
selectedElement,
selectedElement.getAttribute('transform') || ''
)
const tlist = getTransformList(selectedElement)
if (!tlist) { continue }
const xform = svgRoot.createSVGTransform()
xform.setTranslate(0, 0)
if (tlist.numberOfItems) {
tlist.insertItemBefore(xform, 0)
} else {
tlist.appendItem(xform)
}
}
svgCanvas.hasDragStartTransform = true
}

// Update only the temporary drag transform inserted at index 0.
const updateTransformList = (svgRoot, element, dx, dy) => {
const xform = svgRoot.createSVGTransform()
xform.setTranslate(dx, dy)
const tlist = getTransformList(element)
if (!tlist) { return }
if (tlist.numberOfItems) {
const firstItem = tlist.getItem(0)
if (firstItem.type === SVGTransform.SVG_TRANSFORM_TRANSLATE) {
if (typeof tlist.replaceItem === 'function') {
tlist.replaceItem(xform, 0)
} else {
tlist.removeItem(0)
tlist.insertItemBefore(xform, 0)
}
} else {
tlist.appendItem(xform)
}
}

// Consolidate an element's current transform list into a single matrix,
// returning an undo command that restores `oldTransform`. Used for groups
// (whose transform must stay on the group rather than be flattened into
// their children) and as a fallback when recalculateDimensions() doesn't
// recognize the transform-list shape a drag interaction left behind.
const consolidateTransform = (svgRoot, elem, tlist, oldTransform) => {
const consolidatedMatrix = transformListToTransform(tlist).matrix

while (tlist.numberOfItems > 0) {
tlist.removeItem(0)
}

const newTransform = svgRoot.createSVGTransform()
newTransform.setMatrix(consolidatedMatrix)
tlist.appendItem(newTransform)

return new ChangeElementCommand(elem, { transform: oldTransform })
}

/**
*
* @param {MouseEvent} evt
Expand Down Expand Up @@ -167,31 +168,6 @@ const mouseMoveEvent = (evt) => {
let tlist
switch (svgCanvas.getCurrentMode()) {
case 'select': {
// Insert dummy transform on first mouse move (drag start), not on click.
// This avoids creating multiple transforms that trigger unwanted flattening.
if (!svgCanvas.hasDragStartTransform && selectedElements.length > 0) {
// Store original transforms BEFORE adding the drag transform (for undo)
svgCanvas.dragStartTransforms = new Map()
for (const selectedElement of selectedElements) {
if (!selectedElement) { continue }
// Capture the transform attribute before we modify it
svgCanvas.dragStartTransforms.set(selectedElement, selectedElement.getAttribute('transform') || '')
const slist = getTransformList(selectedElement)
if (!slist) { continue }
// The dummy must already be a translate (not the default identity
// matrix) so updateTransformList's `firstItem.type === 2` check
// recognizes and replaces it in place on the very first mousemove,
// instead of leaving it behind as a stray extra transform item.
const dummy = svgRoot.createSVGTransform()
dummy.setTranslate(0, 0)
if (slist.numberOfItems) {
slist.insertItemBefore(dummy, 0)
} else {
slist.appendItem(dummy)
}
}
svgCanvas.hasDragStartTransform = true
}
// we temporarily use a translate on the element(s) being dragged
// this transform is removed upon mousing up and the element is
// relocated to the new location
Expand All @@ -203,13 +179,16 @@ const mouseMoveEvent = (evt) => {
dy = snapToGrid(dy)
}

// Enable moving selection only if mouse has been moved at least 4 px in any direction
// Enable moving selection only if the pointer moved more than 4 px.
// This prevents objects from being accidentally moved when (initially) selected
const deltaThreshold = 4
const deltaThresholdReached = Math.abs(dx) > deltaThreshold || Math.abs(dy) > deltaThreshold
moveSelectionThresholdReached = moveSelectionThresholdReached || deltaThresholdReached

if (moveSelectionThresholdReached) {
if (!svgCanvas.hasDragStartTransform) {
beginDragTransform(svgRoot, selectedElements)
}
selectedElements.forEach((el) => {
if (el) {
updateTransformList(svgRoot, el, dx, dy)
Expand Down Expand Up @@ -630,6 +609,7 @@ const mouseOutEvent = (evt) => {
*/
const mouseUpEvent = (evt) => {
evt.preventDefault()
const didMoveSelection = moveSelectionThresholdReached || Boolean(svgCanvas.dragStartTransforms)
moveSelectionThresholdReached = false
if (evt.button === 2) { return }
if (!svgCanvas.getStarted()) { return }
Expand Down Expand Up @@ -658,7 +638,8 @@ const mouseUpEvent = (evt) => {
const useUnit = false // (svgCanvas.getCurConfig().baseUnit !== 'px');
svgCanvas.setStarted(false)
let t
switch (svgCanvas.getCurrentMode()) {
const mouseUpMode = svgCanvas.getCurrentMode()
switch (mouseUpMode) {
// intentionally fall-through to select here
case 'resize':
case 'multiselect':
Expand Down Expand Up @@ -697,12 +678,17 @@ const mouseUpEvent = (evt) => {
svgCanvas.selectorManager.requestSelector(selected).showGrips(true)
}
// if it was being dragged/resized
if (realX !== svgCanvas.getRStartX() || realY !== svgCanvas.getRStartY()) {
const hasPointerDelta = realX !== svgCanvas.getRStartX() || realY !== svgCanvas.getRStartY()
const shouldCommitTransform = mouseUpMode === 'select'
? didMoveSelection
: hasPointerDelta
if (shouldCommitTransform) {
// Only recalculate dimensions after actual dragging/resizing to avoid
// unwanted transform flattening on simple clicks

// Create a single batch command for all moved elements
const batchCmd = new BatchCommand('position')
const changedElements = []

selectedElements.forEach((elem) => {
if (!elem) return
Expand All @@ -719,45 +705,31 @@ const mouseUpEvent = (evt) => {
? svgCanvas.dragStartTransforms.get(elem)
: (svgCanvas.getStartTransform() || '')

// Check if the first transform is a translate (the drag transform we added)
const firstTransform = tlist.getItem(0)
const hasDragTranslate = firstTransform.type === SVGTransform.SVG_TRANSFORM_TRANSLATE

// recalculateDimensions() returns null for groups (their transform must stay
// on the group itself), so consolidate those directly into one matrix. For
// every other element type, recalculateDimensions() already understands the
// translate/scale/translate shape produced by a resize (and plain translate
// from a move), and flattening into it keeps the shape's real geometry
// attributes (x/y/width/height/rx/ry/points/d/etc.) in sync - which is what
// drives the coordinate/size panel inputs and keeps stroke width from being
// visually distorted by a leftover scale matrix.
const isGroup = elem.tagName === 'g' || elem.tagName === 'a'

if (isGroup && hasDragTranslate) {
batchCmd.addSubCommand(consolidateTransform(svgCanvas.getSvgRoot(), elem, tlist, oldTransform))
return
}

const cmd = svgCanvas.recalculateDimensions(elem)
// recalculateDimensions uses startTransform when building its undo command.
// Set it per element so multi-selection preserves every original transform.
const cmd = withStartTransform(
svgCanvas,
oldTransform,
() => svgCanvas.recalculateDimensions(elem)
)
if (cmd) {
batchCmd.addSubCommand(cmd)
} else if (tlist.numberOfItems > 1 && hasDragTranslate) {
// recalculateDimensions() didn't recognize this transform-list shape;
// fall back to consolidating into one matrix so the transform isn't lost.
batchCmd.addSubCommand(consolidateTransform(svgCanvas.getSvgRoot(), elem, tlist, oldTransform))
changedElements.push(elem)
} else {
// recalculateDimensions returned null and there's nothing left to consolidate
// Check if the transform actually changed and record it manually
const newTransform = elem.getAttribute('transform') || ''
if (newTransform !== oldTransform) {
batchCmd.addSubCommand(new ChangeElementCommand(elem, { transform: oldTransform }))
changedElements.push(elem)
}
}
})

if (!batchCmd.isEmpty()) {
svgCanvas.addCommandToHistory(batchCmd)
}
if (changedElements.length) {
svgCanvas.call('changed', changedElements)
}

// Clear the stored transforms AND reset the flag together
svgCanvas.dragStartTransforms = null
Expand Down Expand Up @@ -798,6 +770,9 @@ const mouseUpEvent = (evt) => {
}
}
}
if (mouseUpMode === 'multiselect' && selectedElements.some(Boolean)) {
svgCanvas.call('selected', selectedElements)
}
return
case 'zoom': {
svgCanvas.getRubberBox()?.setAttribute('display', 'none')
Expand Down Expand Up @@ -1174,23 +1149,6 @@ const mouseDownEvent = (evt) => {
svgCanvas.setStartTransform(mouseTarget.getAttribute('transform'))

const tlist = getTransformList(mouseTarget)

// Consolidate transforms for non-group elements to simplify dragging
// For elements with multiple transforms (e.g., after ungrouping), consolidate them
// into a single matrix so the dummy translate can be properly applied during drag
if (tlist?.numberOfItems > 1 && mouseTarget.tagName !== 'g' && mouseTarget.tagName !== 'a') {
// Compute the consolidated matrix from all transforms
const consolidatedMatrix = transformListToTransform(tlist).matrix

// Clear the transform list and add a single matrix transform
while (tlist.numberOfItems > 0) {
tlist.removeItem(0)
}

const newTransform = svgCanvas.getSvgRoot().createSVGTransform()
newTransform.setMatrix(consolidatedMatrix)
tlist.appendItem(newTransform)
}
switch (svgCanvas.getCurrentMode()) {
case 'select':
svgCanvas.setStarted(true)
Expand Down
76 changes: 75 additions & 1 deletion packages/svgcanvas/core/math.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { NS } from './namespaces.js'
import { warn } from '../common/logger.js'

// Constants
const NEAR_ZERO = 1e-10
export const NEAR_ZERO = 1e-10

// Create a throwaway SVG element for matrix operations
const svg = document.createElementNS(NS.SVG, 'svg')
Expand Down Expand Up @@ -73,6 +73,80 @@ export const transformPoint = (x, y, m) => ({
y: m.b * x + m.d * y + m.f
})

/**
* Normalizes a rotation angle to the (-180, 180] range.
* @function normalizeRotationAngle
* @param {number|string} angle - The angle in degrees
* @returns {number} The normalized angle, or zero for invalid input
*/
export const normalizeRotationAngle = (angle) => {
let normalized = Number.parseFloat(angle)
if (!Number.isFinite(normalized)) return 0
normalized %= 360
if (normalized > 180) normalized -= 360
if (normalized <= -180) normalized += 360
return Math.abs(normalized) < NEAR_ZERO ? 0 : normalized
}

/**
* Gets the center encoded by a typed SVG rotation transform.
* The transform matrix must come from `setRotate()` and therefore contain
* only rotation and the translation induced by its center.
* @function getRotationCenterFromRotateTransform
* @param {SVGTransform} transform - A typed SVG rotation transform
* @returns {XYObject} The rotation center
*/
export const getRotationCenterFromRotateTransform = (transform) => {
if (transform.type !== SVGTransform.SVG_TRANSFORM_ROTATE) {
throw new TypeError('Expected an SVG rotation transform')
}

if (Number.isFinite(transform.cx) && Number.isFinite(transform.cy)) {
return { x: transform.cx, y: transform.cy }
}

const angle = transform.angle * Math.PI / 180
const cos = Math.cos(angle)
const sin = Math.sin(angle)
const det = (1 - cos) * (1 - cos) + sin * sin

if (Math.abs(det) < NEAR_ZERO) {
return { x: 0, y: 0 }
}

const { e, f } = transform.matrix
return {
x: ((1 - cos) * e - sin * f) / det,
y: (sin * e + (1 - cos) * f) / det
}
}

/**
* Gets the combined angle and first encoded center from a transform list.
* @function getRotationTransformSummary
* @param {SVGTransformList} tlist - The transform list
* @returns {{rotationAngle: number, rotationCenter: XYObject|null}} Rotation details
*/
export const getRotationTransformSummary = (tlist) => {
let rotationAngle = 0
let rotationCenter = null

if (tlist) {
for (let i = 0; i < tlist.numberOfItems; i++) {
const transform = tlist.getItem(i)
if (transform.type === SVGTransform.SVG_TRANSFORM_ROTATE) {
rotationAngle += transform.angle
rotationCenter ||= getRotationCenterFromRotateTransform(transform)
}
}
}

return {
rotationAngle: normalizeRotationAngle(rotationAngle),
rotationCenter
}
}

/**
* Gets the transform list (baseVal) from an element if it exists.
* @function getTransformList
Expand Down
Loading
Loading