* fix release script * fix svgcanvas edge cases * Update path-actions.js * add modern js * update deps * Update CHANGES.md
507 lines
16 KiB
JavaScript
507 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)
|
|
|
|
textElement = document.createElementNS(NS.SVG, 'text')
|
|
textElement.setAttribute('x', '100')
|
|
textElement.setAttribute('y', '100')
|
|
textElement.setAttribute('id', 'text1')
|
|
textElement.textContent = 'Test'
|
|
svgRoot.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,
|
|
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)
|
|
})
|
|
})
|
|
})
|