Files
svgedit/tests/unit/text-actions.test.js
Namhoon Lee 0be0c3916c
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
check each push / build (push) Has been cancelled
fix(text-actions): Calculate accumulated transform matrix for text inside groups (#1082)
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>
2026-02-04 14:57:35 +01:00

513 lines
16 KiB
JavaScript

import 'pathseg'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { init as textActionsInit, textActionsMethod } from '../../packages/svgcanvas/core/text-actions.js'
import { init as utilitiesInit } from '../../packages/svgcanvas/core/utilities.js'
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
describe('TextActions', () => {
let svgCanvas
let svgRoot
let textElement
let inputElement
let mockSelectorManager
beforeEach(() => {
// Create mock SVG elements
svgRoot = document.createElementNS(NS.SVG, 'svg')
svgRoot.setAttribute('width', '640')
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'
svgContent.append(textElement)
// Mock text measurement methods
textElement.getStartPositionOfChar = vi.fn((i) => ({ x: 100 + i * 10, y: 100 }))
textElement.getEndPositionOfChar = vi.fn((i) => ({ x: 100 + (i + 1) * 10, y: 100 }))
textElement.getCharNumAtPosition = vi.fn(() => 0)
textElement.getBBox = vi.fn(() => ({
x: 100,
y: 90,
width: 40,
height: 20
}))
inputElement = document.createElement('input')
inputElement.type = 'text'
document.body.append(inputElement)
// Create mock selector group
const selectorParentGroup = document.createElementNS(NS.SVG, 'g')
selectorParentGroup.id = 'selectorParentGroup'
svgRoot.append(selectorParentGroup)
// Mock selector manager
mockSelectorManager = {
requestSelector: vi.fn(() => ({
showGrips: vi.fn()
}))
}
// Mock svgCanvas
svgCanvas = {
getSvgRoot: () => svgRoot,
getSvgContent: () => svgContent,
getZoom: () => 1,
setCurrentMode: vi.fn(),
clearSelection: vi.fn(),
addToSelection: vi.fn(),
deleteSelectedElements: vi.fn(),
call: vi.fn(),
getSelectedElements: () => [textElement],
getCurrentMode: () => 'select',
selectorManager: mockSelectorManager,
getrootSctm: () => svgRoot.getScreenCTM?.() || { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
$click: vi.fn(),
contentW: 640,
textActions: textActionsMethod
}
// Initialize utilities and text-actions modules
utilitiesInit(svgCanvas)
textActionsInit(svgCanvas)
textActionsMethod.setInputElem(inputElement)
})
afterEach(() => {
document.body.textContent = ''
})
describe('Class instantiation', () => {
it('should export textActionsMethod as singleton instance', () => {
expect(textActionsMethod).toBeDefined()
expect(typeof textActionsMethod.select).toBe('function')
expect(typeof textActionsMethod.start).toBe('function')
})
it('should have all public methods', () => {
const publicMethods = [
'select',
'start',
'mouseDown',
'mouseMove',
'mouseUp',
'setCursor',
'toEditMode',
'toSelectMode',
'setInputElem',
'clear',
'init'
]
publicMethods.forEach(method => {
expect(typeof textActionsMethod[method]).toBe('function')
})
})
})
describe('setInputElem', () => {
it('should set the input element', () => {
const newInput = document.createElement('input')
textActionsMethod.setInputElem(newInput)
// Method should not throw and should be callable
expect(true).toBe(true)
})
})
describe('select', () => {
it('should set current text element and enter edit mode', () => {
textActionsMethod.select(textElement, 100, 100)
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('textedit')
})
})
describe('start', () => {
it('should start editing a text element', () => {
textActionsMethod.start(textElement)
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('textedit')
})
})
describe('init', () => {
it('should initialize text editing for current element', () => {
textActionsMethod.start(textElement)
textActionsMethod.init()
// Verify text measurement methods were called
expect(textElement.getStartPositionOfChar).toHaveBeenCalled()
expect(textElement.getEndPositionOfChar).toHaveBeenCalled()
})
it('should handle empty text content', () => {
const emptyText = document.createElementNS(NS.SVG, 'text')
emptyText.textContent = ''
emptyText.getStartPositionOfChar = vi.fn(() => ({ x: 100, y: 100 }))
emptyText.getEndPositionOfChar = vi.fn(() => ({ x: 100, y: 100 }))
emptyText.getBBox = vi.fn(() => ({ x: 100, y: 90, width: 0, height: 20 }))
emptyText.removeEventListener = vi.fn()
emptyText.addEventListener = vi.fn()
svgRoot.append(emptyText)
textActionsMethod.start(emptyText)
textActionsMethod.init()
expect(true).toBe(true) // Should not throw
})
it('should return early if no current text', () => {
textActionsMethod.init()
// Should not throw when called without a current text element
expect(true).toBe(true)
})
})
describe('toEditMode', () => {
it('should switch to text edit mode', () => {
textActionsMethod.start(textElement)
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('textedit')
expect(mockSelectorManager.requestSelector).toHaveBeenCalled()
})
it('should accept x, y coordinates for cursor positioning', () => {
textActionsMethod.start(textElement)
textActionsMethod.toEditMode(100, 100)
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('textedit')
})
})
describe('toSelectMode', () => {
it('should switch to select mode', () => {
textActionsMethod.start(textElement)
textActionsMethod.toSelectMode(false)
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
})
it('should select element when selectElem is true', () => {
textActionsMethod.start(textElement)
textActionsMethod.toSelectMode(true)
expect(svgCanvas.clearSelection).toHaveBeenCalled()
expect(svgCanvas.call).toHaveBeenCalled()
expect(svgCanvas.addToSelection).toHaveBeenCalled()
})
it('should delete empty text elements', () => {
const emptyText = document.createElementNS(NS.SVG, 'text')
emptyText.textContent = ''
emptyText.getBBox = vi.fn(() => ({ x: 100, y: 90, width: 0, height: 20 }))
emptyText.removeEventListener = vi.fn()
emptyText.addEventListener = vi.fn()
emptyText.style = {}
svgRoot.append(emptyText)
textActionsMethod.start(emptyText)
textActionsMethod.toSelectMode(false)
expect(svgCanvas.deleteSelectedElements).toHaveBeenCalled()
})
})
describe('clear', () => {
it('should exit text edit mode if currently in it', () => {
svgCanvas.getCurrentMode = () => 'textedit'
textActionsMethod.start(textElement)
textActionsMethod.clear()
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
})
it('should do nothing if not in text edit mode', () => {
svgCanvas.getCurrentMode = () => 'select'
const callCount = svgCanvas.setCurrentMode.mock.calls.length
textActionsMethod.clear()
expect(svgCanvas.setCurrentMode.mock.calls.length).toBe(callCount)
})
})
describe('mouseDown', () => {
it('should handle mouse down event', () => {
textActionsMethod.start(textElement)
const mockEvent = { pageX: 100, pageY: 100 }
textActionsMethod.mouseDown(mockEvent, textElement, 100, 100)
// Should set focus (via private method)
expect(true).toBe(true) // Method executed without error
})
})
describe('mouseMove', () => {
it('should handle mouse move event', () => {
textActionsMethod.start(textElement)
textActionsMethod.mouseMove(110, 100)
// Method should execute without error
expect(true).toBe(true)
})
})
describe('mouseUp', () => {
it('should handle mouse up event', () => {
textActionsMethod.start(textElement)
const mockEvent = { target: textElement, pageX: 100, pageY: 100 }
textActionsMethod.mouseUp(mockEvent, 100, 100)
// Method should execute without error
expect(true).toBe(true)
})
it('should exit text mode if clicked outside text element', () => {
textActionsMethod.start(textElement)
const otherElement = document.createElementNS(NS.SVG, 'rect')
const mockEvent = { target: otherElement, pageX: 200, pageY: 200 }
textActionsMethod.mouseDown(mockEvent, textElement, 200, 200)
textActionsMethod.mouseUp(mockEvent, 200, 200)
// Should have called toSelectMode
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
})
})
describe('setCursor', () => {
it('should set cursor position', () => {
textActionsMethod.start(textElement)
textActionsMethod.init()
textActionsMethod.setCursor(0)
// Method should execute without error
expect(true).toBe(true)
})
it('should accept undefined index', () => {
textActionsMethod.start(textElement)
textActionsMethod.init()
textActionsMethod.setCursor(undefined)
// Should not throw
expect(true).toBe(true)
})
})
describe('Private methods encapsulation', () => {
it('should not expose private methods', () => {
const privateMethodNames = [
'#setCursor',
'#setSelection',
'#getIndexFromPoint',
'#setCursorFromPoint',
'#setEndSelectionFromPoint',
'#screenToPt',
'#ptToScreen',
'#selectAll',
'#selectWord'
]
privateMethodNames.forEach(method => {
expect(textActionsMethod[method]).toBeUndefined()
})
})
it('should not expose private fields', () => {
const privateFieldNames = [
'#curtext',
'#textinput',
'#cursor',
'#selblock',
'#blinker',
'#chardata',
'#textbb',
'#matrix',
'#lastX',
'#lastY',
'#allowDbl'
]
privateFieldNames.forEach(field => {
expect(textActionsMethod[field]).toBeUndefined()
})
})
})
describe('Integration scenarios', () => {
it('should handle complete edit workflow', () => {
// Start editing
textActionsMethod.start(textElement)
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('textedit')
// Initialize
textActionsMethod.init()
// Simulate mouse interaction
textActionsMethod.mouseDown({ pageX: 100, pageY: 100 }, textElement, 100, 100)
textActionsMethod.mouseMove(110, 100)
textActionsMethod.mouseUp({ target: textElement, pageX: 110, pageY: 100 }, 110, 100)
// Exit edit mode
textActionsMethod.toSelectMode(true)
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
})
it('should handle text with transform attribute', () => {
textElement.setAttribute('transform', 'rotate(45 100 100)')
textActionsMethod.start(textElement)
textActionsMethod.init()
// Should handle transformed text without error
expect(true).toBe(true)
})
it('should handle empty text element', () => {
textElement.textContent = ''
textActionsMethod.start(textElement)
textActionsMethod.init()
textActionsMethod.toSelectMode(true)
expect(svgCanvas.deleteSelectedElements).toHaveBeenCalled()
})
it('should handle text element without parent', () => {
const orphanText = document.createElementNS(NS.SVG, 'text')
orphanText.textContent = 'Orphan'
orphanText.getStartPositionOfChar = vi.fn((i) => ({ x: 100 + i * 10, y: 100 }))
orphanText.getEndPositionOfChar = vi.fn((i) => ({ x: 100 + (i + 1) * 10, y: 100 }))
orphanText.getBBox = vi.fn(() => ({ x: 100, y: 90, width: 60, height: 20 }))
textActionsMethod.start(orphanText)
textActionsMethod.init()
expect(true).toBe(true)
})
it('should handle setCursor with undefined index', () => {
textActionsMethod.start(textElement)
textActionsMethod.init()
textActionsMethod.setCursor(undefined)
expect(true).toBe(true)
})
it('should handle setCursor with empty input', () => {
inputElement.value = ''
textActionsMethod.start(textElement)
textActionsMethod.init()
textActionsMethod.setCursor()
expect(true).toBe(true)
})
it('should handle text with no transform', () => {
textElement.removeAttribute('transform')
textActionsMethod.start(textElement)
textActionsMethod.init()
expect(true).toBe(true)
})
it('should handle getIndexFromPoint with single character', () => {
textElement.textContent = 'A'
textActionsMethod.start(textElement)
textActionsMethod.init()
textActionsMethod.mouseDown({ pageX: 100, pageY: 100 }, textElement, 100, 100)
expect(true).toBe(true)
})
it('should handle getIndexFromPoint outside text range', () => {
textElement.getCharNumAtPosition = vi.fn(() => -1)
textActionsMethod.start(textElement)
textActionsMethod.init()
textActionsMethod.mouseDown({ pageX: 50, pageY: 100 }, textElement, 50, 100)
expect(true).toBe(true)
})
it('should handle getIndexFromPoint at end of text', () => {
textElement.getCharNumAtPosition = vi.fn(() => 100)
textActionsMethod.start(textElement)
textActionsMethod.init()
textActionsMethod.mouseDown({ pageX: 200, pageY: 100 }, textElement, 200, 100)
expect(true).toBe(true)
})
it('should handle mouseUp clicking outside text', () => {
const outsideElement = document.createElementNS(NS.SVG, 'rect')
textActionsMethod.start(textElement)
textActionsMethod.init()
textActionsMethod.mouseDown({ pageX: 100, pageY: 100 }, textElement, 100, 100)
textActionsMethod.mouseUp({ target: outsideElement, pageX: 101, pageY: 101 }, 101, 101)
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
})
it('should handle toEditMode with no arguments', () => {
textActionsMethod.start(textElement)
textActionsMethod.toEditMode()
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('textedit')
})
it('should handle toSelectMode without selectElem', () => {
textActionsMethod.start(textElement)
textActionsMethod.init()
textActionsMethod.toSelectMode(false)
expect(svgCanvas.setCurrentMode).toHaveBeenCalledWith('select')
})
it('should handle clear when not in textedit mode', () => {
const originalGetMode = svgCanvas.getCurrentMode
svgCanvas.getCurrentMode = vi.fn(() => 'select')
textActionsMethod.clear()
svgCanvas.getCurrentMode = originalGetMode
expect(true).toBe(true)
})
it('should handle init with no current text', () => {
textActionsMethod.init()
expect(true).toBe(true)
})
it('should handle mouseMove during selection', () => {
textActionsMethod.start(textElement)
textActionsMethod.init()
textActionsMethod.mouseDown({ pageX: 100, pageY: 100 }, textElement, 100, 100)
textActionsMethod.mouseMove(120, 100)
textActionsMethod.mouseMove(130, 100)
expect(true).toBe(true)
})
it('should handle mouseMove without shift key', () => {
const evt = { shiftKey: false, clientX: 100, clientY: 100 }
textActionsMethod.mouseMove(10, 20, evt)
expect(true).toBe(true)
})
it('should handle mouseDown with different mouse button', () => {
const evt = { button: 2 }
textActionsMethod.mouseDown(evt, null, 10, 20)
expect(true).toBe(true)
})
it('should handle mouseUp with valid cursor position', () => {
const elem = document.createElementNS(NS.SVG, 'text')
elem.textContent = 'test'
const evt = { target: elem }
textActionsMethod.mouseUp(evt, elem, 10, 20)
expect(true).toBe(true)
})
it('should handle toSelectMode with valid element', () => {
const elem = document.createElementNS(NS.SVG, 'text')
textActionsMethod.toSelectMode(elem)
expect(true).toBe(true)
})
})
})