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.
330 lines
9.9 KiB
TypeScript
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)
|
|
})
|
|
})
|