openpencil/tests/engine/text/edit-undo.test.ts
박영호 ee2a7472ee
fix(text): handle Hangul IME composition (#319)
Co-authored-by: 05Park <im0505@kakao.com>
2026-06-06 07:21:56 +03:00

264 lines
8 KiB
TypeScript

import { describe, test, expect } from 'bun:test'
import type { CanvasKit } from 'canvaskit-wasm'
import { SceneGraph, TextEditor, UndoManager } from '@open-pencil/core'
import type { StyleRun } from '@open-pencil/core'
import { createTextActions } from '@open-pencil/core/editor'
import type { EditorContext, EditorState } from '@open-pencil/core/editor'
import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert'
function setup() {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id
const undo = new UndoManager()
const textEditor = new TextEditor({} as CanvasKit)
const state = {
editingTextId: null,
currentPageId: pageId,
renderVersion: 0,
sceneVersion: 0
} as EditorState
const ctx: EditorContext = {
graph,
undo,
state,
requestRender: () => {
state.renderVersion++
state.sceneVersion++
},
requestRepaint: () => {
state.renderVersion++
},
getTextEditor: () => textEditor,
getRenderer: () => null,
runLayoutForNode: () => undefined,
getCk: () => null,
loadFont: async () => undefined,
getViewportSize: () => ({ width: 800, height: 600 }),
subscribeToGraph: () => undefined
}
const textNode = graph.createNode('TEXT', pageId, {
name: 'Label',
text: 'Hello',
width: 100,
height: 20
})
const actions = createTextActions(ctx)
return { graph, undo, textEditor, state, textNode, actions }
}
function paragraphWithHeight(height: number) {
return {
getHeight: () => height,
getLongestLine: () => 0,
delete: () => undefined
}
}
function deletableParagraphWithHeight(height: number) {
let deleted = false
return {
getHeight: () => {
if (deleted) throw new Error('paragraph was deleted')
return height
},
getLongestLine: () => {
if (deleted) throw new Error('paragraph was deleted')
return 0
},
delete: () => {
deleted = true
}
}
}
describe('text edit undo', () => {
test('commitTextEdit pushes undo entry when text changed', () => {
const { graph, undo, textEditor, textNode, actions } = setup()
actions.startTextEditing(textNode.id)
expect(textEditor.isActive).toBe(true)
textEditor.insert(' World', textNode)
graph.updateNode(textNode.id, {
text: expectDefined(textEditor.state, 'text editor state').text
})
actions.commitTextEdit()
expect(undo.canUndo).toBe(true)
expect(undo.undoLabel).toBe('Edit text')
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello World')
undo.undo()
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello')
undo.redo()
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello World')
})
test('commitTextEdit preserves auto-height text bounds', () => {
const { graph, undo, textEditor, textNode, actions } = setup()
graph.updateNode(textNode.id, { textAutoResize: 'HEIGHT', height: 18 })
actions.startTextEditing(textNode.id)
const state = expectDefined(textEditor.state, 'text editor state')
state.paragraph = paragraphWithHeight(42) as NonNullable<typeof state.paragraph>
textEditor.insert(' World', getNodeOrThrow(graph, textNode.id))
actions.commitTextEdit()
expect(getNodeOrThrow(graph, textNode.id).height).toBe(42)
undo.undo()
expect(getNodeOrThrow(graph, textNode.id).height).toBe(18)
undo.redo()
expect(getNodeOrThrow(graph, textNode.id).height).toBe(42)
})
test('commitTextEdit measures auto-size before deleting the edit paragraph', () => {
const { graph, textEditor, textNode, actions } = setup()
graph.updateNode(textNode.id, { textAutoResize: 'HEIGHT', height: 18 })
actions.startTextEditing(textNode.id)
const state = expectDefined(textEditor.state, 'text editor state')
state.paragraph = deletableParagraphWithHeight(42) as NonNullable<typeof state.paragraph>
textEditor.insert(' World', getNodeOrThrow(graph, textNode.id))
expect(() => actions.commitTextEdit()).not.toThrow()
expect(getNodeOrThrow(graph, textNode.id).height).toBe(42)
})
test('commitTextEdit does not push undo when text unchanged', () => {
const { undo, actions, textNode } = setup()
actions.startTextEditing(textNode.id)
actions.commitTextEdit()
expect(undo.canUndo).toBe(false)
})
test('commitTextEdit preserves Figma derived glyphs when text unchanged', () => {
const { graph, actions, textNode } = setup()
const glyphs = [{ commandsBlob: new Uint8Array([0]), x: 0, y: 10, fontSize: 14 }]
graph.updateNode(textNode.id, { figmaDerivedTextGlyphs: glyphs })
actions.startTextEditing(textNode.id)
actions.commitTextEdit()
expect(getNodeOrThrow(graph, textNode.id).figmaDerivedTextGlyphs).toBe(glyphs)
})
test('undo restores original text even when graph was synced mid-edit', () => {
const { graph, undo, textEditor, textNode, actions } = setup()
actions.startTextEditing(textNode.id)
textEditor.insert(' Beautiful', textNode)
graph.updateNode(textNode.id, {
text: expectDefined(textEditor.state, 'text editor state').text
})
textEditor.insert(' World', textNode)
graph.updateNode(textNode.id, {
text: expectDefined(textEditor.state, 'text editor state').text
})
actions.commitTextEdit()
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello Beautiful World')
expect(undo.canUndo).toBe(true)
undo.undo()
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello')
})
test('sequential edits create separate undo entries', () => {
const { graph, undo, textEditor, textNode, actions } = setup()
actions.startTextEditing(textNode.id)
textEditor.insert('!', textNode)
graph.updateNode(textNode.id, {
text: expectDefined(textEditor.state, 'text editor state').text
})
actions.commitTextEdit()
actions.startTextEditing(textNode.id)
textEditor.insert('!', textNode)
graph.updateNode(textNode.id, {
text: expectDefined(textEditor.state, 'text editor state').text
})
actions.commitTextEdit()
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello!!')
undo.undo()
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello!')
undo.undo()
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello')
})
test('undo restores styleRuns when they changed during editing', () => {
const { graph, undo, textEditor, textNode, actions } = setup()
const boldRun: StyleRun = { start: 0, length: 5, style: { fontWeight: 700 } }
graph.updateNode(textNode.id, { styleRuns: [boldRun] })
actions.startTextEditing(textNode.id)
textEditor.insert(' World', textNode)
const newRuns: StyleRun[] = [
{ start: 0, length: 5, style: { fontWeight: 700 } },
{ start: 5, length: 6, style: { fontWeight: 400 } }
]
graph.updateNode(textNode.id, {
text: expectDefined(textEditor.state, 'text editor state').text,
styleRuns: newRuns
})
actions.commitTextEdit()
expect(getNodeOrThrow(graph, textNode.id).styleRuns).toEqual(newRuns)
expect(undo.canUndo).toBe(true)
undo.undo()
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello')
expect(getNodeOrThrow(graph, textNode.id).styleRuns).toEqual([boldRun])
undo.redo()
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello World')
expect(getNodeOrThrow(graph, textNode.id).styleRuns).toEqual(newRuns)
})
test('undo entry is pushed when only styleRuns changed', () => {
const { graph, undo, textNode, actions } = setup()
actions.startTextEditing(textNode.id)
const newRuns: StyleRun[] = [{ start: 0, length: 5, style: { fontWeight: 700 } }]
graph.updateNode(textNode.id, { styleRuns: newRuns })
actions.commitTextEdit()
expect(undo.canUndo).toBe(true)
undo.undo()
expect(getNodeOrThrow(graph, textNode.id).styleRuns).toEqual([])
})
test('no undo entry when neither text nor styleRuns changed', () => {
const { undo, actions, textNode } = setup()
actions.startTextEditing(textNode.id)
actions.commitTextEdit()
expect(undo.canUndo).toBe(false)
})
})