Skip to content

Improve SVG transform handling and numeric toolbar input behavior - #1099

Merged
jfhenon merged 8 commits into
SVG-Edit:masterfrom
veselin-kutsarov:dev
Jul 20, 2026
Merged

Improve SVG transform handling and numeric toolbar input behavior#1099
jfhenon merged 8 commits into
SVG-Edit:masterfrom
veselin-kutsarov:dev

Conversation

@veselin-kutsarov

@veselin-kutsarov veselin-kutsarov commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

PR description

Summary

This PR improves SVG transform handling across editing workflows and makes numeric toolbar inputs safer and more predictable.

Transform handling

  • Normalize transforms after drag, resize, rotation, programmatic movement, and ungrouping.
  • Recalculate rotation centers after geometry changes to prevent elements from jumping when rotation starts.
  • Bake representable transforms into shape geometry while preserving residual matrices required for skew/shear.
  • Keep groups, text, and <use> elements in a normalized rotate(...) matrix(...) form when necessary.
  • Normalize transforms pushed to child elements during ungrouping without changing text font-size.
  • Avoid creating identity transforms on clicks or zero-delta pointer movements.
  • Preserve existing leading translations when dragging imported groups.
  • Correct transformed bounding-box calculations used by area selection.
  • Emit final changed and selected events so toolbar values and multi-selection actions remain synchronized.
  • Avoid rewriting rotation transforms when the requested angle has not changed.

Numeric spin inputs

  • Select the current value when an input receives focus.
  • Keep manually typed values local until Enter or a valid blur.
  • Restore the previous value on Escape or invalid input.
  • Preserve immediate updates from increment/decrement buttons.

Verification

  • Full unit suite: 45 test files, 605 tests passed.
  • Playwright end-to-end suite: 81 tests passed.
  • Lint passed.
  • Production build passed.
  • Added focused coverage for transforms, rotation centers, ungrouping,
    selection events, transformed bounding boxes, undo history, drag thresholds,
    and spin-input behavior.

Checklist

  • Added Playwright UI tests.
  • Ran npm test; lint, unit tests, coverage collection, production build,
    and Playwright tests passed.
  • Added user documentation. Not applicable for this PR because it changes
    internal editing behavior and is documented through focused unit and
    Playwright regression tests.

Summary by Sourcery

Improve SVG transform normalization across editing workflows and make numeric spin inputs commit changes more safely and predictably.

New Features:

  • Normalize and preserve group, text, and use element transforms into stable rotate-plus-matrix forms while baking affine transforms into geometry where possible.
  • Introduce rotation math utilities for normalizing angles and extracting rotation centers to support consistent transform handling.
  • Add controlled commit/revert behavior to the SESpinInput component so manual edits are only applied on explicit confirmation.

Bug Fixes:

  • Prevent creation of stray identity drag transforms or unnecessary matrices during small pointer movements and clicks.
  • Ensure recalculateDimensions returns concrete undoable commands for groups, text, use elements, and shapes instead of silently bailing out.
  • Fix transformed bounding-box calculations so pure translations and identity transforms are handled correctly for selection and clipping.
  • Preserve text font-size and referenced positioning when normalizing transforms during ungrouping and recalc flows.
  • Ensure drag, move, resize, and multiselect mouse-up flows emit final changed/selected events only when there were actual geometry or transform updates.

Enhancements:

  • Refine recalculateDimensions to bake transforms into geometry when safe, preserve off-axis residual matrices, and recenter rotation transforms after drag/resize or programmatic moves.
  • Improve group, text, and use transform handling so rotation centers and visual placement remain stable across drag, resize, flip, ungroup, and toolbar rotation operations.
  • Normalize pushed-down transforms during ungroup so children receive well-structured rotate-plus-matrix transform lists suitable for further editing.
  • Update toolbar rotation handling to avoid no-op transform rewrites, maintain visual rotation centers, and capture baked-geometry changes as single undoable steps.
  • Harden Playwright/NYC E2E tooling and coverage seeding for cross-platform execution and cleaner coverage output.

Tests:

  • Add extensive unit tests for recalculateDimensions covering rotated/scaled shapes, groups, text, use, clip-paths, and off-axis residual transforms.
  • Add unit tests for event drag thresholds, transform normalization, and final changed/selected event emission after selection interactions.
  • Introduce tests for programmatic group movement and ungrouping to verify transform normalization and text font-size preservation.
  • Add math utility tests for rotation angle normalization, rotation center recovery, and rotation transform summarization.
  • Add unit and E2E tests validating toolbar rotation behavior, including undo/redo of baked geometry changes.
  • Add focused unit tests for the SESpinInput component to cover manual entry, commit/revert behavior, and interaction with spin buttons.
  • Extend bounding-box tests to cover transformed and translated elements and verify visual bounds preservation during recalc.

@sourcery-ai

sourcery-ai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Reviewer's Guide

Refactors SVG transform normalization across recalculate, event handling, selection, and grouping flows so transforms are baked into geometry when safe, retained as normalized rotate+matrix when necessary, and properly undoable, while hardening drag/move events and the SESpinInput numeric component for more predictable user interactions and e2e tooling for cross‑platform coverage.

Sequence diagram for drag/move mouse interaction and transform normalization

sequenceDiagram
  actor User
  participant Browser
  participant event_js as event.js
  participant svgCanvas
  participant recalc_js as recalculate.js
  participant history_js as history.js

  User->>Browser: drag selection
  Browser->>event_js: mouseMoveEvent(evt)
  event_js->>event_js: beginDragTransform(svgRoot, selectedElements)
  event_js->>event_js: updateTransformList(svgRoot, element, dx, dy)

  User->>Browser: mouse up
  Browser->>event_js: mouseUpEvent(evt)
  event_js->>event_js: compute shouldCommitTransform
  alt shouldCommitTransform
    loop for each elem in selectedElements
      event_js->>svgCanvas: getStartTransform()
      event_js->>recalc_js: withStartTransform(svgCanvas, oldTransform, recalculateDimensions)
      recalc_js->>recalc_js: recalculateDimensions(elem)
      alt cmd returned
        event_js->>history_js: addSubCommand(cmd) on BatchCommand
      else transform attribute changed
        event_js->>history_js: new ChangeElementCommand(elem, { transform: oldTransform })
      end
    end
    event_js->>history_js: addCommandToHistory(BatchCommand)
    event_js->>svgCanvas: call('changed', changedElements)
  end
  event_js->>svgCanvas: clear dragStartTransforms
Loading

Sequence diagram for toolbar rotation via setRotationAngle

sequenceDiagram
  actor User
  participant Toolbar
  participant selection_js as selection.js
  participant svgCanvas
  participant recalc_js as recalculate.js
  participant history_js as history.js

  User->>Toolbar: change rotation input
  Toolbar->>selection_js: setRotationAngle(val, preventUndo=false)
  selection_js->>selection_js: normalizeRotationAngle(val)
  selection_js->>svgCanvas: getSelectedElements()
  selection_js->>selection_js: getRotationTransformSummary(tlist)
  alt angle unchanged or no bbox
    selection_js-->>Toolbar: return
  else
    selection_js->>selection_js: rewrite tlist to rotate+matrix
    selection_js->>selection_js: oldValues = getTransformGeometryValues(elem)
    selection_js->>recalc_js: recalculateDimensions(elem)
    selection_js->>selection_js: hasTransformGeometryChange(elem, oldValues)
    alt geometry changed
      selection_js->>history_js: addCommandToHistory(new ChangeElementCommand(elem, oldValues, 'transform'))
    end
    selection_js->>svgCanvas: call('changed', selectedElements)
  end
Loading

Sequence diagram for SESpinInput manual entry and commit/revert behavior

sequenceDiagram
  actor User
  participant SESpinInput
  participant ElixBox as elix-number-spin-box
  participant Application

  User->>SESpinInput: element added to DOM
  SESpinInput->>SESpinInput: connectedCallback()
  SESpinInput->>SESpinInput: queueMicrotask(#connectInputEvents)
  SESpinInput->>ElixBox: #connectInputEvents() attaches listeners

  User->>ElixBox: focus input
  ElixBox-->>SESpinInput: focus event
  SESpinInput->>SESpinInput: committedValue = String(value), input.select()

  User->>ElixBox: type value
  ElixBox-->>SESpinInput: input event
  SESpinInput->>SESpinInput: manualEdit = true

  alt User presses Enter
    ElixBox-->>SESpinInput: keydown Enter
    SESpinInput->>SESpinInput: #commitValue(input.value)
    alt #isValidValue(value)
      SESpinInput->>SESpinInput: value setter
      SESpinInput->>SESpinInput: committedValue = String(value), manualEdit = false
      SESpinInput->>SESpinInput: #dispatchChange()
      SESpinInput-->>Application: CustomEvent change
    else invalid
      SESpinInput->>SESpinInput: #revertValue()
    end
  else User presses Escape
    ElixBox-->>SESpinInput: keydown Escape
    SESpinInput->>SESpinInput: #revertValue()
  else User blurs input
    ElixBox-->>SESpinInput: blur event
    alt manualEdit
      SESpinInput->>SESpinInput: #commitValue(input.value)
    end
  end

  User->>ElixBox: click spin button
  ElixBox-->>SESpinInput: mousedown upButton/downButton
  SESpinInput->>SESpinInput: manualEdit = false
  ElixBox-->>SESpinInput: change event
  SESpinInput->>SESpinInput: value = e.target.value, committedValue = String(value)
  SESpinInput->>SESpinInput: #dispatchChange()
  SESpinInput-->>Application: CustomEvent change
Loading

File-Level Changes

Change Details Files
Normalize and bake element transforms in recalculateDimensions while preserving off-axis residual matrices and stable rotation centers.
  • Introduce helpers in math.js (NEAR_ZERO, normalizeRotationAngle, rotation center and summary helpers) and expose them to recalculate/selection.
  • Add geometryLimitedElements, off-axis detection, and helpers in recalculate.js to either bake transforms into attributes or normalize a retained rotate(+matrix) transform list.
  • Change recalculateDimensions to return concrete BatchCommand/ChangeElementCommand instances for groups, text, tspan, and use; avoid no-op commands by comparing pre/post transform signatures.
  • Ensure rotation centers are recomputed from transformed bounding boxes so drag/resize and re-normalization do not visually move elements.
  • Adjust behavior for text/tspan/use to keep transforms as normalized rotate+matrix without touching font-size, and treat pure matrix-only group transforms as already normalized.
packages/svgcanvas/core/math.js
packages/svgcanvas/core/recalculate.js
tests/unit/recalculate.test.js
tests/e2e/unit/svgcore-recalculate.spec.js
tests/e2e/unit/svgcore-recalculate-extra.spec.js
tests/unit/math.test.js
Stabilize drag/move, selection, and grouping flows so transforms aren’t flattened accidentally and change/selected events fire only when geometry really changes.
  • Refactor event.js to insert a dedicated drag translate at index 0 only after crossing a movement threshold, track dragStartTransforms, and on mouseup call recalculateDimensions per element via withStartTransform, emitting a single BatchCommand and a final changed event only when needed.
  • Ensure move/resize and multiselect mouseup paths respect pointer thresholds, avoid creating identity transforms on click, and emit a final selected event for multiselect.
  • Add withStartTransform helper in recalculate.js and use it from event.js and selected-elem.js so undo commands capture the correct pre-drag transform per element.
  • Change moveSelectedElements and flipSelectedElements in selected-elem.js to compose transforms in the correct order, call recalculateDimensions via withStartTransform, and normalize pushed group-child transforms during ungrouping (including for text and use) without altering text font-size.
  • Update getBBoxWithTransform in utilities.js to treat pure-translate and identity correctly, applying matrix-based bbox expansion only when a real transform is present.
packages/svgcanvas/core/event.js
packages/svgcanvas/core/recalculate.js
packages/svgcanvas/core/selected-elem.js
packages/svgcanvas/core/utilities.js
packages/svgcanvas/core/selection.js
tests/unit/event.test.js
tests/unit/selected-elem.test.js
tests/unit/utilities-bbox.test.js
tests/e2e/group-transforms.spec.js
tests/e2e/unit/svgcore-recalculate-extra.spec.js
Rework toolbar rotation handling to preserve visual centers, avoid unnecessary rewrites, and bundle geometry baking into a single undoable step.
  • Update selection.setRotationAngle to normalize requested angles, compute the visual rotation center from the current transform matrix, decompose existing rotation+matrix, and rebuild the transform list as rotate(+matrix) while preserving visual center.
  • Avoid rewriting transforms when the requested angle equals the current normalized rotation angle, and use recalculateDimensions plus a ChangeElementCommand over transform+geometry attributes to capture transformations as a single undo step.
  • Add coverage ensuring compound rotations keep the visual center, idempotent angle updates don’t touch transforms, and undo/redo restores baked geometry/transform state.
packages/svgcanvas/core/selection.js
tests/unit/rotation-toolbar.test.js
Make SESpinInput numeric behavior safer by distinguishing manual edits from spin button changes and committing only validated values.
  • Add internal committedValue/manualEdit state, validation helpers, and change dispatching to SESpinInput; keep values local during text editing and commit only on Enter or valid blur, reverting on Escape or invalid input.
  • Wire focus/input/keydown/blur/mousedown listeners inside the Elix spin box shadow root via a microtask in connectedCallback so handlers survive internal input replacement, and keep spin button changes immediate via the underlying component’s change event.
  • Update attribute reflection and value setter to keep committedValue in sync when not in a manual edit.
src/editor/components/seSpinInput.js
tests/unit/se-spin-input.test.js
Harden Playwright/NYC e2e tooling and transform-related tests for cross-platform reliability and more precise expectations.
  • Update run-e2e.mjs to wrap npm/npx invocation in a cross-platform runner, use fs.rm instead of shell-based rimraf, and generate NYC reports via npx.
  • Adjust existing e2e/unit tests to assert new transform normalization behavior (e.g., text/use/group transforms, baked rectangles, clip-path stability, and translation-aware bounding boxes).
  • Extend recalculate, math, event, selection, utilities, and group-transform tests to cover new behaviors like off-axis matrix preservation, rotation-center recomputation, drag thresholds, normalized ungrouping, and bbox translation handling.
scripts/run-e2e.mjs
tests/e2e/group-transforms.spec.js
tests/e2e/unit/svgcore-recalculate.spec.js
tests/e2e/unit/svgcore-recalculate-extra.spec.js
tests/unit/recalculate.test.js
tests/unit/event.test.js
tests/unit/utilities-bbox.test.js
tests/unit/math.test.js
tests/unit/selected-elem.test.js
tests/unit/rotation-toolbar.test.js
tests/unit/se-spin-input.test.js

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@veselin-kutsarov
veselin-kutsarov marked this pull request as ready for review July 20, 2026 11:45
@veselin-kutsarov veselin-kutsarov changed the title Dev Improves SVG transform handling across editing workflows and makes numeric toolbar inputs safer and more predictable. Jul 20, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • There’s duplicated rotation-center/angle logic across modules (e.g. getRotationCenterFromTransform in recalculate.js and getTransformRotationCenter/normalizeRotationValue in selected-elem.js); consider centralizing this math in a shared helper to keep behavior consistent and easier to maintain.
  • The NEAR_ZERO tolerance constant is redefined in multiple files (recalculate.js, selection.js, selected-elem.js); extracting a single shared numeric-tolerance utility would reduce drift and make it easier to tune precision in one place.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- There’s duplicated rotation-center/angle logic across modules (e.g. `getRotationCenterFromTransform` in `recalculate.js` and `getTransformRotationCenter`/`normalizeRotationValue` in `selected-elem.js`); consider centralizing this math in a shared helper to keep behavior consistent and easier to maintain.
- The `NEAR_ZERO` tolerance constant is redefined in multiple files (`recalculate.js`, `selection.js`, `selected-elem.js`); extracting a single shared numeric-tolerance utility would reduce drift and make it easier to tune precision in one place.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@veselin-kutsarov
veselin-kutsarov marked this pull request as draft July 20, 2026 14:15
@veselin-kutsarov
veselin-kutsarov marked this pull request as ready for review July 20, 2026 14:15

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • In setRotationAngle, you call svgCanvas.recalculateDimensions(elem) but ignore the returned command and only add a ChangeElementCommand for tracked attributes; consider capturing the batch command from recalculateDimensions and adding/merging it into history so all geometry/transform changes from rotation normalization are undoable together.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `setRotationAngle`, you call `svgCanvas.recalculateDimensions(elem)` but ignore the returned command and only add a `ChangeElementCommand` for tracked attributes; consider capturing the batch command from `recalculateDimensions` and adding/merging it into history so all geometry/transform changes from rotation normalization are undoable together.

## Individual Comments

### Comment 1
<location path="src/editor/components/seSpinInput.js" line_range="115-120" />
<code_context>
+    this.value = this.committedValue
+  }
+
+  #connectInputEvents () {
+    if (!this.isConnected || this.inputEventsConnected) return
+
+    const input = this.#getTextInput()
+    if (!input) {
+      setTimeout(() => this.#connectInputEvents(), 0)
+      return
+    }
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Avoid unbounded recursive timeouts when wiring up the internal input element.

`#connectInputEvents` keeps rescheduling itself with `setTimeout` until `#getTextInput()` succeeds. If the internal template changes or the input never appears, this becomes an infinite retry loop for the component’s lifetime.

Add a guard to bound these retries (e.g., max retry count) or switch to a more reliable readiness signal (e.g., `MutationObserver` on the shadow root or a lifecycle event from the Elix control) so the component doesn’t spin indefinitely if its internals change.

Suggested implementation:

```javascript
  #connectInputEvents () {
    if (!this.isConnected || this.inputEventsConnected) return

    const input = this.#getTextInput()
    if (!input) {
      // Bound retries to avoid unbounded recursive timeouts if the internal
      // input element never appears.
      this._inputEventsRetryCount = (this._inputEventsRetryCount ?? 0) + 1

      const maxRetries = 20
      if (this._inputEventsRetryCount > maxRetries) {
        return
      }

      setTimeout(() => this.#connectInputEvents(), 0)
      return
    }

    // Reset retry counter once the input has been successfully resolved.
    this._inputEventsRetryCount = 0

```

To fully integrate this change with the rest of the component, you should also:
1. Initialize `this._inputEventsRetryCount` in the constructor or `connectedCallback` (e.g., set it to `0`).
2. Optionally reset `this._inputEventsRetryCount` in `disconnectedCallback` if the component supports being disconnected and reconnected.
These ensure the retry counter starts from a known state and doesn't leak across lifecycles.
</issue_to_address>

### Comment 2
<location path="packages/svgcanvas/core/math.js" line_range="97-106" />
<code_context>
+ * @param {SVGTransform} transform - The rotation transform
+ * @returns {XYObject} The rotation center
+ */
+export const getRotationCenterFromTransform = (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
+  }
+}
</code_context>
<issue_to_address>
**suggestion:** Clarify or constrain `getRotationCenterFromTransform` assumptions about the underlying matrix.

This logic only yields a correct center if the matrix encodes a pure rotation + translation. If scale or shear are present, the computed center will be wrong.

Since this helper is exported and fairly general, consider either enforcing this invariant (e.g. assert/guard that the matrix is approximately orthonormal) or making the constraint explicit via documentation/renaming so it’s only used with transforms known to come from `setRotate`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/editor/components/seSpinInput.js Outdated
Comment thread packages/svgcanvas/core/math.js Outdated
@veselin-kutsarov
veselin-kutsarov marked this pull request as draft July 20, 2026 15:03
@veselin-kutsarov
veselin-kutsarov marked this pull request as ready for review July 20, 2026 15:03

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • In selection.setRotationAngle, you call svgCanvas.recalculateDimensions(elem) but ignore its returned BatchCommand and instead add a separate ChangeElementCommand to history; this likely leaves geometry changes from recalc untracked/undoable, so consider wiring the recalc command into history (or skipping recalc entirely and relying solely on a single undo command) to keep rotation updates fully undoable and consistent.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `selection.setRotationAngle`, you call `svgCanvas.recalculateDimensions(elem)` but ignore its returned `BatchCommand` and instead add a separate `ChangeElementCommand` to history; this likely leaves geometry changes from recalc untracked/undoable, so consider wiring the recalc command into history (or skipping recalc entirely and relying solely on a single undo command) to keep rotation updates fully undoable and consistent.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@veselin-kutsarov
veselin-kutsarov marked this pull request as draft July 20, 2026 16:21
@veselin-kutsarov
veselin-kutsarov marked this pull request as ready for review July 20, 2026 16:21

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@jfhenon

jfhenon commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the detailed PR — the transform-normalization work and the spin-input rewrite are both solid, and the added unit/e2e coverage is appreciated. A few things worth clarifying/addressing before merge:

1. Drag threshold: 4px → 1px — please confirm intent

In event.js (deltaThreshold), the threshold was lowered from 4 to 1, with the comment changed from "moved at least 4 px" to "a deliberate pointer movement." This isn't mentioned in the PR description, which only talks about avoiding stray identity transforms on zero-delta movement/clicks — a 1px threshold is a much bigger behavioral change than that goal requires.

Was lowering the threshold to 1px intentional, or should it have stayed at 4px (or somewhere in between) while only the "don't insert a drag transform until the threshold is reached" logic changed? At 1px, trackpad jitter or a slightly unsteady click is enough to register as a drag — exactly the accidental-move problem the original 4px threshold was meant to prevent. If this was just to avoid an off-by-one in a test, it'd be better to keep the threshold at 4 and fix the test instead.

2. Make the shape/path baking branch consistent about no-op detection

The group branch and the text/tspan/use branch of recalculateDimensions both snapshot transformListSignature(tlist) before normalizing, normalize, and only build a ChangeElementCommand / return a batch command if the signature actually changed — otherwise they return null (no history entry).

The generic shape/path branch doesn't do this: bakeTransformsIntoAttributes returns true whenever tlist was non-empty going in, and the caller unconditionally pushes a ChangeElementCommand whenever that's true — even if baking happens to leave the element in an equivalent state.

Suggestion: capture a "did anything actually change" snapshot (transform signature plus the geometry attrs baking can touch — x/y/width/height/d, etc.) before calling bakeTransformsIntoAttributes, and only push the undo command if something differs afterward, matching what the group and text/use branches already do. This avoids polluting the undo stack with no-op entries for plain shapes.

3. Consolidate the getStartTransform/setStartTransform guard-and-restore pattern

This shape appears three times (once in event.js's mouseUpEvent, twice in selected-elem.js's moveSelectedElements/normalizePushedGroupChild/flipSelectedElements):

const hasStartTransformApi = typeof svgCanvas.getStartTransform === 'function' &&
  typeof svgCanvas.setStartTransform === 'function'
const saved = hasStartTransformApi ? svgCanvas.getStartTransform() : null
if (hasStartTransformApi) svgCanvas.setStartTransform(oldTransform)
try {
  cmd = svgCanvas.recalculateDimensions(elem)
} finally {
  if (hasStartTransformApi) svgCanvas.setStartTransform(saved)
}

The typeof ... === 'function' guard only exists to tolerate test doubles for svgCanvas that don't implement getStartTransform/setStartTransform — the real canvas always has them.

Two suggestions:

  • Extract the save/set/call/restore sequence into one shared helper, e.g.:
    export const withStartTransform = (transform, fn) => {
      const saved = svgCanvas.getStartTransform()
      svgCanvas.setStartTransform(transform)
      try {
        return fn()
      } finally {
        svgCanvas.setStartTransform(saved)
      }
    }
    and call withStartTransform(oldTransform, () => svgCanvas.recalculateDimensions(elem)) at all three sites.
  • Drop the typeof ... === 'function' runtime guards, and instead update the test mocks/stubs to implement getStartTransform/setStartTransform (even as trivial no-ops). Production code shouldn't need to feature-detect its own internal API to accommodate incomplete test doubles.

- restore the 4px selection drag threshold
- avoid no-op history commands during transform baking
- centralize temporary start-transform handling
@veselin-kutsarov
veselin-kutsarov marked this pull request as draft July 20, 2026 20:22
@veselin-kutsarov
veselin-kutsarov marked this pull request as ready for review July 20, 2026 20:25

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • The transform normalization helpers (normalizeRetainedTransformList, bakeTransformsIntoAttributes, transformListSignature, elementTransformStateSignature) are quite intertwined; consider extracting them into a dedicated module or clearly documenting their intended invariants to make future reasoning about transform and undo behavior easier.
  • The SESpinInput implementation and tests rely on Elix internals like shadowRoot structure and element ids (input, upButton, downButton); if Elix changes, this will be fragile—consider encapsulating these lookups behind a minimal adapter or asserting their presence once with a clear failure mode.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The transform normalization helpers (`normalizeRetainedTransformList`, `bakeTransformsIntoAttributes`, `transformListSignature`, `elementTransformStateSignature`) are quite intertwined; consider extracting them into a dedicated module or clearly documenting their intended invariants to make future reasoning about transform and undo behavior easier.
- The SESpinInput implementation and tests rely on Elix internals like `shadowRoot` structure and element ids (`input`, `upButton`, `downButton`); if Elix changes, this will be fragile—consider encapsulating these lookups behind a minimal adapter or asserting their presence once with a clear failure mode.

## Individual Comments

### Comment 1
<location path="src/editor/components/seSpinInput.js" line_range="84-86" />
<code_context>
+    const number = Number(normalized)
+    if (!Number.isFinite(number)) return false
+
+    const min = this.getAttribute('min')
+    const max = this.getAttribute('max')
+    return (min === null || number >= Number(min)) &&
+      (max === null || number <= Number(max))
+  }
</code_context>
<issue_to_address>
**suggestion:** Min/max parsing is brittle; non-numeric attributes can incorrectly invalidate user input.

In `#isValidValue`, `min`/`max` are converted with `Number()` but never checked for `NaN`. If a consumer sets a non-numeric `min`/`max` (e.g. `""`, `"abc"`, localized formats), `Number(min)`/`Number(max)` become `NaN`, all comparisons with `number` are false, and valid input is rejected. Please guard against this by only enforcing bounds when the parsed values are finite numbers, e.g.:

```js
const minAttr = this.getAttribute('min')
const min = minAttr != null ? Number(minAttr) : null
const hasMin = Number.isFinite(min)
// same for max
return (!hasMin || number >= min) && (!hasMax || number <= max)
```

Suggested implementation:

```javascript
  #isValidValue (value) {
    const normalized = String(value).trim()
    if (normalized === '') return false

    const number = Number(normalized)
    if (!Number.isFinite(number)) return false

    const minAttr = this.getAttribute('min')
    const maxAttr = this.getAttribute('max')

    const min = minAttr != null ? Number(minAttr) : null
    const max = maxAttr != null ? Number(maxAttr) : null

    const hasMin = Number.isFinite(min)
    const hasMax = Number.isFinite(max)

    return (!hasMin || number >= min) &&
      (!hasMax || number <= max)

```

The snippet you shared appears to contain a duplicate `#isValidValue` definition (one without min/max handling, then one with it). In the actual file, ensure there is only a single `#isValidValue` method: remove or update any earlier duplicate so that this consolidated version is the only one present.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +84 to +86
const min = this.getAttribute('min')
const max = this.getAttribute('max')
return (min === null || number >= Number(min)) &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Min/max parsing is brittle; non-numeric attributes can incorrectly invalidate user input.

In #isValidValue, min/max are converted with Number() but never checked for NaN. If a consumer sets a non-numeric min/max (e.g. "", "abc", localized formats), Number(min)/Number(max) become NaN, all comparisons with number are false, and valid input is rejected. Please guard against this by only enforcing bounds when the parsed values are finite numbers, e.g.:

const minAttr = this.getAttribute('min')
const min = minAttr != null ? Number(minAttr) : null
const hasMin = Number.isFinite(min)
// same for max
return (!hasMin || number >= min) && (!hasMax || number <= max)

Suggested implementation:

  #isValidValue (value) {
    const normalized = String(value).trim()
    if (normalized === '') return false

    const number = Number(normalized)
    if (!Number.isFinite(number)) return false

    const minAttr = this.getAttribute('min')
    const maxAttr = this.getAttribute('max')

    const min = minAttr != null ? Number(minAttr) : null
    const max = maxAttr != null ? Number(maxAttr) : null

    const hasMin = Number.isFinite(min)
    const hasMax = Number.isFinite(max)

    return (!hasMin || number >= min) &&
      (!hasMax || number <= max)

The snippet you shared appears to contain a duplicate #isValidValue definition (one without min/max handling, then one with it). In the actual file, ensure there is only a single #isValidValue method: remove or update any earlier duplicate so that this consolidated version is the only one present.

@veselin-kutsarov
veselin-kutsarov marked this pull request as draft July 20, 2026 20:33
@veselin-kutsarov
veselin-kutsarov marked this pull request as ready for review July 20, 2026 20:33
@veselin-kutsarov veselin-kutsarov changed the title Improves SVG transform handling across editing workflows and makes numeric toolbar inputs safer and more predictable. Improve SVG transform handling and numeric toolbar input behavior Jul 20, 2026
@jfhenon
jfhenon merged commit 4673693 into SVG-Edit:master Jul 20, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants