Skip to content

fix(text-actions): Calculate accumulated transform matrix for text inside groups#1082

Merged
jfhenon merged 1 commit into
SVG-Edit:masterfrom
frontalnh:fix/text-edit-group-transform
Feb 4, 2026
Merged

fix(text-actions): Calculate accumulated transform matrix for text inside groups#1082
jfhenon merged 1 commit into
SVG-Edit:masterfrom
frontalnh:fix/text-edit-group-transform

Conversation

@frontalnh
Copy link
Copy Markdown
Contributor

@frontalnh frontalnh commented Feb 4, 2026

Summary

When editing text inside a group element, the text cursor was appearing in the wrong position because only the text element's own transform was being considered, ignoring transforms from parent groups.

The Problem

In text-actions.js, the init() method was calculating the transform matrix like this:

const xform = this.#curtext.getAttribute('transform')
this.#matrix = xform ? getMatrix(this.#curtext) : null

This only considers the text element's own transform attribute. When a text element is inside a <g> group with a transform (e.g., translate, matrix, etc.), that parent transform was ignored, causing the cursor to appear at the wrong position.

The Fix

Added a new #getAccumulatedMatrix() method that:

  1. Traverses up the DOM tree from the text element to the SVG content element
  2. Collects all transform matrices from the element and its ancestors
  3. Multiplies them together in the correct order (parent-first)

This ensures that when editing text inside transformed groups, the cursor position correctly accounts for all ancestor transforms.

Changes

  • packages/svgcanvas/core/text-actions.js: Added #getAccumulatedMatrix() method and updated init() to use it
  • tests/unit/text-actions.test.js: Updated mock to include getSvgContent() method

Test plan

  • npm run lint passes
  • npm run test passes (548 tests)
  • Manual testing: Create a text element, group it with another element, move the group, then double-click to edit the text - cursor should appear at the correct position

🤖 Generated with Claude Code

Summary by Sourcery

Fix text editing to respect accumulated transforms from parent SVG groups so the text cursor appears at the correct position.

Bug Fixes:

  • Correct text cursor positioning by using the accumulated transformation matrix from the text element and all ancestor groups.

Tests:

  • Extend text-actions unit tests to use an svg content container and mock getSvgContent on svgCanvas.

…side groups

When editing text inside a group element, the text cursor was appearing
in the wrong position because only the text element's own transform was
being considered, ignoring transforms from parent groups.

This change:
- Adds a new #getAccumulatedMatrix() method that traverses up the DOM tree
  from the text element to the SVG content element, collecting and multiplying
  all transform matrices along the way
- Updates the init() method to use this accumulated matrix instead of just
  the text element's transform
- Updates test mock to include getSvgContent() method

Fixes the issue where editing text inside a transformed group would show
the cursor at the wrong position.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Feb 4, 2026

Reviewer's Guide

This PR fixes incorrect text cursor positioning when editing text inside transformed SVG groups by computing an accumulated transform matrix from the text element up through its ancestor groups, and updates tests to reflect the presence of an SVG content container and the new svgCanvas API usage.

Sequence diagram for accumulated matrix calculation during text init

sequenceDiagram
  actor User
  participant Editor
  participant TextActions
  participant SvgCanvas
  participant SVGElement as CurTextElement
  participant DOM as AncestorNodes
  participant TransformUtils
  participant GeometryUtils

  User->>Editor: Double-click text to edit
  Editor->>TextActions: init(index)
  TextActions->>CurTextElement: read textContent
  TextActions->>GeometryUtils: utilsGetBBox(CurTextElement)
  GeometryUtils-->>TextActions: textbb

  TextActions->>TextActions: #getAccumulatedMatrix(CurTextElement)
  TextActions->>SvgCanvas: getSvgContent()
  SvgCanvas-->>TextActions: svgContent

  loop Traverse ancestors up to svgContent
    TextActions->>AncestorNodes: current = current.parentNode
    TextActions->>TransformUtils: getTransformList(current)
    TransformUtils-->>TextActions: tlist
    alt tlist has items
      TextActions->>TransformUtils: transformListToTransform(tlist)
      TransformUtils-->>TextActions: transform
      TextActions->>TextActions: matrices.unshift(transform.matrix)
    end
  end

  alt no matrices
    TextActions-->>TextActions: #matrix = null
  else one or more matrices
    alt one matrix
      TextActions-->>TextActions: #matrix = matrices[0]
    else multiple matrices
      TextActions->>TransformUtils: matrixMultiply(m1, m2, ...)
      TransformUtils-->>TextActions: accumulatedMatrix
      TextActions-->>TextActions: #matrix = accumulatedMatrix
    end
  end

  TextActions-->>Editor: init complete with correct #matrix
Loading

Class diagram for updated TextActions transform handling

classDiagram

class TextActions {
  - Element #curtext
  - SVGMatrix #matrix
  - DOMRect #textbb
  - Array #chardata
  - Number #lastX
  - Number #lastY
  - Boolean #allowDbl
  + init(index)
  - SVGMatrix #getAccumulatedMatrix(elem)
}

class SvgCanvas {
  + getSvgContent() Element
}

class TransformUtils {
  + getTransformList(elem) SVGTransformList
  + transformListToTransform(tlist) SVGTransform
  + matrixMultiply(m1, m2, m3) SVGMatrix
}

class GeometryUtils {
  + utilsGetBBox(elem) DOMRect
}

TextActions --> SvgCanvas : uses getSvgContent
TextActions --> TransformUtils : uses getTransformList
TextActions --> TransformUtils : uses transformListToTransform
TextActions --> TransformUtils : uses matrixMultiply
TextActions --> GeometryUtils : uses utilsGetBBox
Loading

File-Level Changes

Change Details Files
Compute an accumulated transform matrix for text elements using ancestor group transforms instead of only the element's own transform.
  • Introduce a private #getAccumulatedMatrix(elem) helper that walks up from the given element to the svg content element, collecting transform lists on each ancestor.
  • Convert each non-empty transform list into an SVGMatrix via transformListToTransform(tlist).matrix and build an ordered array of matrices.
  • Return null when no transforms exist, return the single matrix when only one is found, or multiply all matrices with matrixMultiply(...matrices) to produce the accumulated transform.
  • Update init() to always compute this.#textbb via utilsGetBBox(this.#curtext) and set this.#matrix using #getAccumulatedMatrix(this.#curtext) instead of reading the element's transform attribute and calling getMatrix().
packages/svgcanvas/core/text-actions.js
Adjust unit tests to model svg content structure and mock new svgCanvas API used by the accumulated transform logic.
  • Create a child element under the root svg and append the text element to svgContent instead of directly to svgRoot so DOM structure matches production.
  • Extend the svgCanvas mock to include getSvgContent() returning svgContent, in addition to existing getSvgRoot() and other methods, so tests can exercise #getAccumulatedMatrix().
tests/unit/text-actions.test.js

Possibly linked issues

  • #[Edition] Text Edition Bug: PR fixes transform handling for text in groups, which likely causes the mispositioned text during editing described in issue

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

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

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:

  • In #getAccumulatedMatrix, matrixMultiply(...matrices) assumes the helper accepts a variable number of matrices; if it only supports two operands today, consider reducing explicitly (e.g. matrices.reduce(matrixMultiply)) to avoid subtle runtime errors.
  • The traversal in #getAccumulatedMatrix stops before svgContent, which means any transform on svgcontent itself is ignored; double-check whether svgcontent transforms (e.g. pan/zoom) should also be included and, if so, adjust the loop condition accordingly.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `#getAccumulatedMatrix`, `matrixMultiply(...matrices)` assumes the helper accepts a variable number of matrices; if it only supports two operands today, consider reducing explicitly (e.g. `matrices.reduce(matrixMultiply)`) to avoid subtle runtime errors.
- The traversal in `#getAccumulatedMatrix` stops before `svgContent`, which means any transform on `svgcontent` itself is ignored; double-check whether `svgcontent` transforms (e.g. pan/zoom) should also be included and, if so, adjust the loop condition accordingly.

## Individual Comments

### Comment 1
<location> `tests/unit/text-actions.test.js:21-30` </location>
<code_context>
     svgRoot.setAttribute('height', '480')
     document.body.append(svgRoot)

+    // Create svgContent element (container for SVG content)
+    const svgContent = document.createElementNS(NS.SVG, 'svg')
+    svgContent.id = 'svgcontent'
+    svgRoot.append(svgContent)
+
     textElement = document.createElementNS(NS.SVG, 'text')
     textElement.setAttribute('x', '100')
     textElement.setAttribute('y', '100')
     textElement.setAttribute('id', 'text1')
     textElement.textContent = 'Test'
-    svgRoot.append(textElement)
+    svgContent.append(textElement)

     // Mock text measurement methods
</code_context>

<issue_to_address>
**suggestion (testing):** Add tests that explicitly cover text inside transformed parent groups to validate the new accumulated matrix behavior.

The new logic focuses on handling transforms from parent `<g>` elements via `#getAccumulatedMatrix`, but the current tests only change the DOM structure and mocks. Please add a test that nests `textElement` in a transformed `<g>` (e.g., `translate(50, 20)`), invokes the cursor-positioning API used in existing tests, and asserts that the cursor position incorporates both the text’s coordinates and the group transform. This will verify the accumulated matrix is exercised and guard against regressions for text editing inside transformed groups.

Suggested implementation:

```javascript
    // Mock text measurement methods
    textElement.getStartPositionOfChar = vi.fn((i) => ({ x: 100 + i * 10, y: 100 }))

    // NOTE: Additional tests below should verify cursor positioning for text
    // inside transformed parent groups (e.g., <g transform="translate(...)">)
    // to exercise getAccumulatedMatrix behavior.

    // Mock svgCanvas

```

` so you can place it alongside the existing cursor‑positioning tests.

Here are the file operations:

<file_operations>
<file_operation operation="edit" file_path="tests/unit/text-actions.test.js">
<<<<<<< SEARCH
    // Mock text measurement methods
    textElement.getStartPositionOfChar = vi.fn((i) => ({ x: 100 + i * 10, y: 100 }))
    // Mock svgCanvas
=======
    // Mock text measurement methods
    textElement.getStartPositionOfChar = vi.fn((i) => ({ x: 100 + i * 10, y: 100 }))

    // NOTE: Additional tests below should verify cursor positioning for text
    // inside transformed parent groups (e.g., <g transform="translate(...)">)
    // to exercise getAccumulatedMatrix behavior.

    // Mock svgCanvas
>>>>>>> REPLACE
</file_operation>
</file_operations>

<additional_changes>
Please add a new test case in `tests/unit/text-actions.test.js` near the existing cursor‑positioning tests (i.e., wherever the current tests are calling into the text cursor API from `textActions`).

Assuming this file already has something like:

- `import * as textActions from '.../text-actions'`
- A `describe('textActions', ...)` block
- A `beforeEach` where the snippet you provided lives
- One or more tests that call the cursor‑positioning API (for example `textActions.setCursor(...)` and read back the cursor position from `textActions` or from the DOM)

you can add the following test body alongside them:

```js
it('positions the text cursor correctly inside a transformed parent group', () => {
  // Wrap the existing textElement in a transformed <g> to exercise getAccumulatedMatrix
  const group = document.createElementNS(NS.SVG, 'g')
  group.setAttribute('transform', 'translate(50, 20)')
  svgContent.append(group)
  group.append(textElement) // moves textElement under the transformed group

  // Choose a character index that is already covered by getStartPositionOfChar
  const charIndex = 2

  // Existing cursor-positioning API used in this test suite:
  // Adjust these calls to match whatever API the other cursor tests use.
  textActions.setCursor(textElement, charIndex)

  // Read back the cursor position using the same mechanism as the existing tests.
  // For example, if existing tests do something like:
  //   const cursor = textActions.getCursor()
  //   expect(cursor.x).toBe(...) and expect(cursor.y).toBe(...)
  const cursor = textActions.getCursor() // adapt to existing API

  // Base character position (from our mock):
  //   x = 100 + charIndex * 10, y = 100
  // Group transform: translate(50, 20)
  // Expected cursor position: (100 + charIndex * 10 + 50, 100 + 20)
  expect(cursor.x).toBe(100 + charIndex * 10 + 50)
  expect(cursor.y).toBe(100 + 20)
})
```

Key points to adapt to your actual code:

1. **Cursor API names**:  
   Replace `textActions.setCursor` and `textActions.getCursor` with the real functions used in the existing tests for placing and reading the caret position (for example, they might be named `setTextCursor`, `getTextCursor`, `setCursorAtChar`, etc.).

2. **Cursor position retrieval**:  
   If existing tests assert cursor position by inspecting DOM elements instead of a direct API (e.g., querying a `line` or `rect` that represents the caret), adjust the `cursor` retrieval and assertions accordingly. For example:

   ```js
   const caretLine = svgContent.querySelector('#text_cursor')
   expect(Number(caretLine.getAttribute('x1'))).toBe(100 + charIndex * 10 + 50)
   expect(Number(caretLine.getAttribute('y1'))).toBe(100 + 20)
   ```

3. **Matrix behavior**:  
   The important part is that the expectation **must differ from the raw `getStartPositionOfChar` coordinates** by exactly the group’s transform. That ensures the test truly exercises the new accumulated matrix behavior (`getAccumulatedMatrix`) rather than just reusing the base text coordinates.

By inserting this `it(...)` test near the other cursor‑positioning tests, you’ll have explicit coverage for text inside transformed parent `<g>` elements and guard against regressions in the accumulated-transform logic.
</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 +21 to 30
// Create svgContent element (container for SVG content)
const svgContent = document.createElementNS(NS.SVG, 'svg')
svgContent.id = 'svgcontent'
svgRoot.append(svgContent)

textElement = document.createElementNS(NS.SVG, 'text')
textElement.setAttribute('x', '100')
textElement.setAttribute('y', '100')
textElement.setAttribute('id', 'text1')
textElement.textContent = 'Test'
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 (testing): Add tests that explicitly cover text inside transformed parent groups to validate the new accumulated matrix behavior.

The new logic focuses on handling transforms from parent <g> elements via #getAccumulatedMatrix, but the current tests only change the DOM structure and mocks. Please add a test that nests textElement in a transformed <g> (e.g., translate(50, 20)), invokes the cursor-positioning API used in existing tests, and asserts that the cursor position incorporates both the text’s coordinates and the group transform. This will verify the accumulated matrix is exercised and guard against regressions for text editing inside transformed groups.

Suggested implementation:

    // Mock text measurement methods
    textElement.getStartPositionOfChar = vi.fn((i) => ({ x: 100 + i * 10, y: 100 }))

    // NOTE: Additional tests below should verify cursor positioning for text
    // inside transformed parent groups (e.g., <g transform="translate(...)">)
    // to exercise getAccumulatedMatrix behavior.

    // Mock svgCanvas

` so you can place it alongside the existing cursor‑positioning tests.

Here are the file operations:

<file_operations>
<file_operation operation="edit" file_path="tests/unit/text-actions.test.js">
<<<<<<< SEARCH
// Mock text measurement methods
textElement.getStartPositionOfChar = vi.fn((i) => ({ x: 100 + i * 10, y: 100 }))
// Mock svgCanvas

// Mock text measurement methods
textElement.getStartPositionOfChar = vi.fn((i) => ({ x: 100 + i * 10, y: 100 }))

// NOTE: Additional tests below should verify cursor positioning for text
// inside transformed parent groups (e.g., <g transform="translate(...)">)
// to exercise getAccumulatedMatrix behavior.

// Mock svgCanvas

REPLACE
</file_operation>
</file_operations>

<additional_changes>
Please add a new test case in tests/unit/text-actions.test.js near the existing cursor‑positioning tests (i.e., wherever the current tests are calling into the text cursor API from textActions).

Assuming this file already has something like:

  • import * as textActions from '.../text-actions'
  • A describe('textActions', ...) block
  • A beforeEach where the snippet you provided lives
  • One or more tests that call the cursor‑positioning API (for example textActions.setCursor(...) and read back the cursor position from textActions or from the DOM)

you can add the following test body alongside them:

it('positions the text cursor correctly inside a transformed parent group', () => {
  // Wrap the existing textElement in a transformed <g> to exercise getAccumulatedMatrix
  const group = document.createElementNS(NS.SVG, 'g')
  group.setAttribute('transform', 'translate(50, 20)')
  svgContent.append(group)
  group.append(textElement) // moves textElement under the transformed group

  // Choose a character index that is already covered by getStartPositionOfChar
  const charIndex = 2

  // Existing cursor-positioning API used in this test suite:
  // Adjust these calls to match whatever API the other cursor tests use.
  textActions.setCursor(textElement, charIndex)

  // Read back the cursor position using the same mechanism as the existing tests.
  // For example, if existing tests do something like:
  //   const cursor = textActions.getCursor()
  //   expect(cursor.x).toBe(...) and expect(cursor.y).toBe(...)
  const cursor = textActions.getCursor() // adapt to existing API

  // Base character position (from our mock):
  //   x = 100 + charIndex * 10, y = 100
  // Group transform: translate(50, 20)
  // Expected cursor position: (100 + charIndex * 10 + 50, 100 + 20)
  expect(cursor.x).toBe(100 + charIndex * 10 + 50)
  expect(cursor.y).toBe(100 + 20)
})

Key points to adapt to your actual code:

  1. Cursor API names:
    Replace textActions.setCursor and textActions.getCursor with the real functions used in the existing tests for placing and reading the caret position (for example, they might be named setTextCursor, getTextCursor, setCursorAtChar, etc.).

  2. Cursor position retrieval:
    If existing tests assert cursor position by inspecting DOM elements instead of a direct API (e.g., querying a line or rect that represents the caret), adjust the cursor retrieval and assertions accordingly. For example:

    const caretLine = svgContent.querySelector('#text_cursor')
    expect(Number(caretLine.getAttribute('x1'))).toBe(100 + charIndex * 10 + 50)
    expect(Number(caretLine.getAttribute('y1'))).toBe(100 + 20)
  3. Matrix behavior:
    The important part is that the expectation must differ from the raw getStartPositionOfChar coordinates by exactly the group’s transform. That ensures the test truly exercises the new accumulated matrix behavior (getAccumulatedMatrix) rather than just reusing the base text coordinates.

By inserting this it(...) test near the other cursor‑positioning tests, you’ll have explicit coverage for text inside transformed parent <g> elements and guard against regressions in the accumulated-transform logic.

@jfhenon jfhenon merged commit 0be0c39 into SVG-Edit:master Feb 4, 2026
8 checks passed
@jfhenon
Copy link
Copy Markdown
Collaborator

jfhenon commented Feb 4, 2026

👌

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