openpencil/tests/engine/text/editor.test.ts
Danila Poyarkov 08c13dabbd
fix: show the caret in new, empty text (#932)
CanvasKit lays out no line for an empty paragraph, so the text editor found no caret until the first character was typed. A one-space line now gives an empty text's caret its height, and its alignment places it.
2026-10-06 12:33:49 +00:00

330 lines
9.9 KiB
TypeScript

import { describe, test, expect } from 'bun:test'
import type { CanvasKit, Paragraph, RectWithDirection } from 'canvaskit-wasm'
import { TextEditor, type SceneNode } from '@open-pencil/core'
import { createDefaultNode } from '@open-pencil/scene-graph/node-defaults'
import { expectDefined } from '#tests/helpers/assert'
import { asDouble } from '#tests/helpers/doubles'
const mockCk = asDouble<CanvasKit>({
RectHeightStyle: { Max: 0 },
RectWidthStyle: { Tight: 0 }
})
function createEditor(text = 'Hello World') {
const editor = new TextEditor(mockCk)
const node = { id: 'test-node', text } as SceneNode
editor.start(node)
return { editor, node }
}
function editorState(editor: TextEditor) {
return expectDefined(editor.state, 'text editor state')
}
function createParagraphEditor(textAlignVertical: SceneNode['textAlignVertical']) {
const hitTestYs: number[] = []
const rects = [asDouble<RectWithDirection>({ rect: [4, 2, 14, 12] })]
const lineMetrics = {
endExcludingWhitespaces: 5,
height: 10,
left: 3,
startIndex: 0
}
const paragraph = asDouble<Paragraph>({
delete: () => undefined,
getGlyphPositionAtCoordinate: (_x: number, y: number) => {
hitTestYs.push(y)
return { pos: Math.round(y) }
},
getHeight: () => 20,
getLineMetrics: () => [lineMetrics],
getLineMetricsAt: () => lineMetrics,
getLineNumberAt: () => 0,
getRectsForRange: () => rects
})
const renderer = asDouble<Parameters<TextEditor['setRenderer']>[0]>({
buildParagraph: () => paragraph,
fontGeneration: 1
})
const editor = new TextEditor(mockCk)
editor.setRenderer(renderer)
const node = createDefaultNode(() => 'aligned-text', 'TEXT', {
height: 100,
text: 'Hello',
textAlignVertical
})
editor.start(node)
return { editor, hitTestYs }
}
describe('TextEditor', () => {
test('start initializes state', () => {
const { editor } = createEditor()
expect(editor.isActive).toBe(true)
expect(editor.nodeId).toBe('test-node')
expect(editor.state?.text).toBe('Hello World')
expect(editor.state?.cursor).toBe(11)
})
test('stop returns result and clears state', () => {
const { editor } = createEditor()
const result = editor.stop()
expect(result?.nodeId).toBe('test-node')
expect(result?.text).toBe('Hello World')
expect(editor.isActive).toBe(false)
expect(editor.nodeId).toBeNull()
})
test('stop on inactive returns null', () => {
const editor = new TextEditor(mockCk)
expect(editor.stop()).toBeNull()
})
test('insert at end', () => {
const { editor, node } = createEditor()
editor.insert('!', node)
expect(editor.state?.text).toBe('Hello World!')
expect(editor.state?.cursor).toBe(12)
})
test('insert at cursor position', () => {
const { editor, node } = createEditor()
editorState(editor).cursor = 5
editor.insert(' Beautiful', node)
expect(editor.state?.text).toBe('Hello Beautiful World')
})
test('insert replaces selection', () => {
const { editor, node } = createEditor()
editorState(editor).selectionAnchor = 0
editorState(editor).cursor = 5
editor.insert('Goodbye', node)
expect(editor.state?.text).toBe('Goodbye World')
expect(editor.state?.cursor).toBe(7)
expect(editor.state?.selectionAnchor).toBeNull()
})
test('backspace deletes char before cursor', () => {
const { editor, node } = createEditor()
editor.backspace(node)
expect(editor.state?.text).toBe('Hello Worl')
expect(editor.state?.cursor).toBe(10)
})
test('backspace at start does nothing', () => {
const { editor, node } = createEditor()
editorState(editor).cursor = 0
editor.backspace(node)
expect(editor.state?.text).toBe('Hello World')
})
test('backspace deletes selection', () => {
const { editor, node } = createEditor()
editorState(editor).selectionAnchor = 6
editorState(editor).cursor = 11
editor.backspace(node)
expect(editor.state?.text).toBe('Hello ')
expect(editor.state?.cursor).toBe(6)
})
test('delete removes char after cursor', () => {
const { editor, node } = createEditor()
editorState(editor).cursor = 0
editor.delete(node)
expect(editor.state?.text).toBe('ello World')
expect(editor.state?.cursor).toBe(0)
})
test('delete at end does nothing', () => {
const { editor, node } = createEditor()
editor.delete(node)
expect(editor.state?.text).toBe('Hello World')
})
test('delete removes selection', () => {
const { editor, node } = createEditor()
editorState(editor).selectionAnchor = 0
editorState(editor).cursor = 5
editor.delete(node)
expect(editor.state?.text).toBe(' World')
})
test('offsets hit testing, caret, and selection for vertical alignment', () => {
for (const [alignment, offset] of [
['TOP', 0],
['CENTER', 40],
['BOTTOM', 80]
] as const) {
const { editor, hitTestYs } = createParagraphEditor(alignment)
editorState(editor).selectionAnchor = 0
editorState(editor).cursor = 5
expect(editor.getSelectionRects()).toEqual([{ x: 4, y: 2 + offset, width: 10, height: 10 }])
editorState(editor).selectionAnchor = null
expect(editor.getCaretRect()).toEqual({ x: 14, y0: 2 + offset, y1: 12 + offset })
editor.setCursorAt(4, offset + 3)
expect(hitTestYs.at(-1)).toBe(3)
expect(editorState(editor).cursor).toBe(3)
editor.selectWordAt(4, offset + 2)
expect(hitTestYs.at(-1)).toBe(2)
expect(editor.getSelectionRange()).toEqual([0, 5])
editor.selectLineAt(4, offset + 4)
expect(hitTestYs.at(-1)).toBe(4)
expect(editor.getSelectionRange()).toEqual([0, 5])
editorState(editor).selectionAnchor = null
editorState(editor).cursor = 1
editor.moveUp()
expect(hitTestYs.at(-1)).toBe(-3)
editorState(editor).cursor = 1
editor.moveDown()
expect(hitTestYs.at(-1)).toBe(17)
}
})
test('places the caret of empty text, which CanvasKit lays out no line for', () => {
const empty = asDouble<Paragraph>({
delete: () => undefined,
getHeight: () => 0,
getLineMetrics: () => []
})
const space = asDouble<Paragraph>({
delete: () => undefined,
getHeight: () => 17,
getLineMetrics: () => [{ height: 17, left: 0 }]
})
const editor = new TextEditor(mockCk)
editor.setRenderer(
asDouble<Parameters<TextEditor['setRenderer']>[0]>({
buildParagraph: (node: SceneNode) => (node.text === '' ? empty : space),
fontGeneration: 1
})
)
editor.start(
createDefaultNode(() => 'new-text', 'TEXT', {
text: '',
width: 120,
height: 57,
textAlignHorizontal: 'CENTER',
textAlignVertical: 'BOTTOM'
})
)
expect(editor.getCaretRect()).toEqual({ x: 60, y0: 40, y1: 57 })
})
test('offsets the empty-text caret for vertical alignment', () => {
const { editor } = createParagraphEditor('BOTTOM')
editorState(editor).text = ''
editorState(editor).cursor = 0
expect(editor.getCaretRect()).toEqual({ x: 3, y0: 80, y1: 90 })
})
test('moveLeft', () => {
const { editor } = createEditor()
editor.moveLeft()
expect(editor.state?.cursor).toBe(10)
})
test('moveRight at end stays', () => {
const { editor } = createEditor()
editor.moveRight()
expect(editor.state?.cursor).toBe(11)
})
test('moveLeft collapses selection to start', () => {
const { editor } = createEditor()
editorState(editor).selectionAnchor = 3
editorState(editor).cursor = 8
editor.moveLeft()
expect(editor.state?.cursor).toBe(3)
expect(editor.state?.selectionAnchor).toBeNull()
})
test('moveRight collapses selection to end', () => {
const { editor } = createEditor()
editorState(editor).selectionAnchor = 3
editorState(editor).cursor = 8
editor.moveRight()
expect(editor.state?.cursor).toBe(8)
expect(editor.state?.selectionAnchor).toBeNull()
})
test('selectAll', () => {
const { editor } = createEditor()
editor.selectAll()
expect(editor.state?.selectionAnchor).toBe(0)
expect(editor.state?.cursor).toBe(11)
})
test('hasSelection', () => {
const { editor } = createEditor()
expect(editor.hasSelection()).toBe(false)
editorState(editor).selectionAnchor = 0
expect(editor.hasSelection()).toBe(true)
})
test('hasSelection false when anchor equals cursor', () => {
const { editor } = createEditor()
editorState(editor).selectionAnchor = editorState(editor).cursor
expect(editor.hasSelection()).toBe(false)
})
test('getSelectionRange', () => {
const { editor } = createEditor()
expect(editor.getSelectionRange()).toBeNull()
editorState(editor).selectionAnchor = 8
editorState(editor).cursor = 3
expect(editor.getSelectionRange()).toEqual([3, 8])
})
test('getSelectedText', () => {
const { editor } = createEditor()
editorState(editor).selectionAnchor = 0
editorState(editor).cursor = 5
expect(editor.getSelectedText()).toBe('Hello')
})
test('selectWord', () => {
const { editor } = createEditor()
editor.selectWord(2)
expect(editor.state?.selectionAnchor).toBe(0)
expect(editor.state?.cursor).toBe(5)
})
test('selectWord on second word', () => {
const { editor } = createEditor()
editor.selectWord(8)
expect(editor.state?.selectionAnchor).toBe(6)
expect(editor.state?.cursor).toBe(11)
})
test('moveWordLeft', () => {
const { editor } = createEditor()
editor.moveWordLeft()
expect(editor.state?.cursor).toBe(6)
})
test('moveWordRight from start', () => {
const { editor } = createEditor()
editorState(editor).cursor = 0
editor.moveWordRight()
expect(editor.state?.cursor).toBe(6)
})
test('moveLeft with extend creates selection', () => {
const { editor } = createEditor()
editorState(editor).cursor = 5
editor.moveLeft(true)
expect(editor.state?.selectionAnchor).toBe(5)
expect(editor.state?.cursor).toBe(4)
})
})