fix(text): handle Hangul IME composition (#319)

Co-authored-by: 05Park <im0505@kakao.com>
This commit is contained in:
박영호 2026-06-06 13:21:56 +09:00 committed by GitHub
parent 0224b2ef61
commit ee2a7472ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 372 additions and 31 deletions

View file

@ -31,20 +31,33 @@ export function createTextActions(ctx: EditorContext) {
activeSession = null
return
}
const paragraph = te.state?.paragraph ?? null
const result = te.stop()
if (!result) {
const textState = te.state
if (!textState) {
te.stop()
ctx.state.editingTextId = null
activeSession = null
ctx.requestRender()
return
}
const result = { nodeId: textState.nodeId, text: textState.text }
const before = activeSession?.before ?? { text: '', styleRuns: [], size: {} }
const node = ctx.graph.getNode(result.nodeId)
const after = snapshotTextNode(node, result.text)
after.text = result.text
const sizeChanges = before.text !== after.text ? resizeTextNodeForEdit(node, paragraph) : {}
const sizeChanges =
before.text !== after.text ? resizeTextNodeForEdit(node, textState.paragraph) : {}
if (Object.keys(sizeChanges).length > 0) after.size = sizeChanges
const changed = textSnapshotChanged(before, after)
te.stop()
if (!changed) {
ctx.state.editingTextId = null
activeSession = null
ctx.requestRender()
return
}
ctx.graph.updateNode(result.nodeId, {
text: after.text,
styleRuns: after.styleRuns,
@ -53,25 +66,23 @@ export function createTextActions(ctx: EditorContext) {
ctx.state.editingTextId = null
activeSession = null
if (textSnapshotChanged(before, after)) {
ctx.undo.push({
label: 'Edit text',
forward: () => {
ctx.graph.updateNode(result.nodeId, {
text: after.text,
styleRuns: after.styleRuns,
...after.size
})
},
inverse: () => {
ctx.graph.updateNode(result.nodeId, {
text: before.text,
styleRuns: before.styleRuns,
...before.size
})
}
})
}
ctx.undo.push({
label: 'Edit text',
forward: () => {
ctx.graph.updateNode(result.nodeId, {
text: after.text,
styleRuns: after.styleRuns,
...after.size
})
},
inverse: () => {
ctx.graph.updateNode(result.nodeId, {
text: before.text,
styleRuns: before.styleRuns,
...before.size
})
}
})
}
return { startTextEditing, commitTextEdit }

View file

@ -32,6 +32,9 @@ type TextCompositionOptions = {
textareaRef: ShallowRef<HTMLTextAreaElement | null>
getEditingNode: () => SceneNode | null
insertText: (text: string, node: SceneNode) => void
replaceComposedText: (text: string, node: SceneNode) => void
restoreComposition: (node: SceneNode) => void
finishComposition: () => void
resetBlink: () => void
}
@ -39,29 +42,73 @@ export function createTextCompositionHandlers({
textareaRef,
getEditingNode,
insertText,
replaceComposedText,
restoreComposition,
finishComposition,
resetBlink
}: TextCompositionOptions) {
let isComposing = false
let skipCommittedInput: { text: string; until: number } | null = null
function onCompositionStart() {
isComposing = true
skipCommittedInput = null
}
function updateComposition(text: string) {
const node = getEditingNode()
if (!node) return
replaceComposedText(text, node)
resetBlink()
}
function onCompositionUpdate(e: CompositionEvent) {
if (!isComposing) return
updateComposition(e.data)
}
function onCompositionEnd(e: CompositionEvent) {
const finalText = textareaRef.value?.value || e.data || ''
isComposing = false
if (!e.data) return
const node = getEditingNode()
if (!node) return
insertText(e.data, node)
if (!node) {
finishComposition()
return
}
if (finalText) {
replaceComposedText(finalText, node)
finishComposition()
skipCommittedInput = { text: finalText, until: Date.now() + 250 }
} else {
restoreComposition(node)
finishComposition()
}
if (textareaRef.value) textareaRef.value.value = ''
resetBlink()
}
function onInput() {
const el = textareaRef.value
if (isComposing || !el) return
if (!el) return
if (isComposing) {
updateComposition(el.value)
return
}
const text = el.value
if (!text) return
if (
skipCommittedInput &&
text === skipCommittedInput.text &&
Date.now() <= skipCommittedInput.until
) {
el.value = ''
skipCommittedInput = null
return
}
skipCommittedInput = null
el.value = ''
const node = getEditingNode()
@ -72,11 +119,14 @@ export function createTextCompositionHandlers({
function resetComposition() {
isComposing = false
skipCommittedInput = null
finishComposition()
}
return {
isComposing: () => isComposing,
onCompositionStart,
onCompositionUpdate,
onCompositionEnd,
onInput,
resetComposition
@ -94,6 +144,8 @@ export function createTextEditActions(store: Editor) {
const changes: Partial<SceneNode> = { text }
if (runs !== undefined) changes.styleRuns = runs
store.graph.updateNode(nodeId, changes)
const updated = store.graph.getNode(nodeId)
if (updated) store.textEditor?.rebuildParagraph(updated)
store.requestRender()
}
@ -112,6 +164,67 @@ export function createTextEditActions(store: Editor) {
syncText(node.id, editor.state?.text ?? '', runs)
}
type CompositionDraft = {
baseText: string
baseRuns: SceneNode['styleRuns']
cursor: number
end: number
selectionAnchor: number | null
start: number
}
let compositionDraft: CompositionDraft | null = null
function ensureCompositionDraft(node: SceneNode): CompositionDraft | null {
const editor = store.textEditor
const state = editor?.state
if (!editor || !state) return null
if (compositionDraft) return compositionDraft
const range = editor.getSelectionRange()
const start = range?.[0] ?? state.cursor
const end = range?.[1] ?? state.cursor
compositionDraft = {
baseText: state.text,
baseRuns: node.styleRuns,
cursor: state.cursor,
end,
selectionAnchor: state.selectionAnchor,
start
}
return compositionDraft
}
function replaceComposedText(text: string, node: SceneNode) {
const editor = store.textEditor
const state = editor?.state
const draft = ensureCompositionDraft(node)
if (!editor || !state || !draft) return
state.text = draft.baseText.slice(0, draft.start) + text + draft.baseText.slice(draft.end)
state.cursor = draft.start + text.length
state.selectionAnchor = null
let runs = adjustRunsForDelete(draft.baseRuns, draft.start, draft.end - draft.start)
runs = adjustRunsForInsert(runs, draft.start, text.length)
syncText(node.id, state.text, runs)
}
function restoreComposition(node: SceneNode) {
const editor = store.textEditor
const state = editor?.state
if (!state || !compositionDraft) return
state.text = compositionDraft.baseText
state.cursor = compositionDraft.cursor
state.selectionAnchor = compositionDraft.selectionAnchor
syncText(node.id, compositionDraft.baseText, compositionDraft.baseRuns)
compositionDraft = null
}
function finishComposition() {
compositionDraft = null
}
function deleteText(node: SceneNode, forward: boolean) {
const editor = store.textEditor
if (!editor) return
@ -132,5 +245,12 @@ export function createTextEditActions(store: Editor) {
syncText(node.id, editor.state?.text ?? '', runs)
}
return { getEditingNode, insertText, deleteText }
return {
getEditingNode,
insertText,
replaceComposedText,
restoreComposition,
finishComposition,
deleteText
}
}

View file

@ -19,7 +19,14 @@ import { focusTextAreaOnCanvasPointerDown, useTextEditingSession } from './texta
export function useTextEdit(canvasRef: Ref<HTMLCanvasElement | null>, store: Editor) {
const textareaRef = shallowRef<HTMLTextAreaElement | null>(null)
const { resetBlink, stopBlink } = createCaretBlink(store)
const { getEditingNode, insertText, deleteText } = createTextEditActions(store)
const {
getEditingNode,
insertText,
replaceComposedText,
restoreComposition,
finishComposition,
deleteText
} = createTextEditActions(store)
const { toggleBold, toggleItalic, toggleUnderline } = createTextFormattingActions(store)
const { handleCopy, handleCut, handlePaste } = createTextClipboardActions({
@ -28,8 +35,22 @@ export function useTextEdit(canvasRef: Ref<HTMLCanvasElement | null>, store: Edi
deleteText,
resetBlink
})
const { isComposing, onCompositionStart, onCompositionEnd, onInput, resetComposition } =
createTextCompositionHandlers({ textareaRef, getEditingNode, insertText, resetBlink })
const {
isComposing,
onCompositionStart,
onCompositionUpdate,
onCompositionEnd,
onInput,
resetComposition
} = createTextCompositionHandlers({
textareaRef,
getEditingNode,
insertText,
replaceComposedText,
restoreComposition,
finishComposition,
resetBlink
})
const onKeyDown = createTextKeyDownHandler({
store,
@ -49,6 +70,7 @@ export function useTextEdit(canvasRef: Ref<HTMLCanvasElement | null>, store: Edi
useEventListener(textareaRef, 'input', onInput)
useEventListener(textareaRef, 'compositionstart', onCompositionStart)
useEventListener(textareaRef, 'compositionupdate', onCompositionUpdate)
useEventListener(textareaRef, 'compositionend', onCompositionEnd)
useEventListener(textareaRef, 'keydown', onKeyDown)
useEventListener(canvasRef, 'mousedown', () =>

View file

@ -0,0 +1,147 @@
import { test, expect, type Page } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
async function stubGoogleFonts(page: Page) {
await page.addInitScript(() => {
const originalFetch = window.fetch.bind(window)
window.fetch = async (input, init) => {
let url: string
if (typeof input === 'string') url = input
else if (input instanceof URL) url = input.href
else url = input.url
if (url.startsWith('https://www.googleapis.com/webfonts/v1/webfonts')) {
return new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
}
return originalFetch(input, init)
}
})
}
async function startEmptyTextEdit(page: Page) {
return await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = store.createShape('TEXT', 120, 120, 280, 36)
store.graph.updateNode(id, {
text: '',
fontSize: 32,
fontFamily: 'Inter',
textAutoResize: 'HEIGHT',
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }]
})
store.select([id])
store.startTextEditing(id)
store.requestRender()
return id
})
}
test('Hangul text input commits without CanvasKit paragraph errors', async ({ page }) => {
const canvas = new CanvasHelper(page)
await stubGoogleFonts(page)
await page.goto('http://localhost:1420/?test&no-chrome&no-rulers')
await canvas.waitForInit()
const id = await startEmptyTextEdit(page)
await page.locator('textarea[aria-hidden="true"]').fill('환경설정')
await canvas.waitForRender()
await canvas.click(20, 20)
await canvas.waitForRender()
const result = await page.evaluate((nodeId) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const node = store.graph.getNode(nodeId)
if (!node || node.type !== 'TEXT') return null
return {
editingTextId: store.state.editingTextId,
height: node.height,
text: node.text
}
}, id)
expect(result).toMatchObject({
editingTextId: null,
text: '환경설정'
})
expect(result?.height).toBeGreaterThan(0)
canvas.assertNoErrors()
})
test('Hangul composition updates are visible before IME commit', async ({ page }) => {
const canvas = new CanvasHelper(page)
await stubGoogleFonts(page)
await page.goto('http://localhost:1420/?test&no-chrome&no-rulers')
await canvas.waitForInit()
const id = await startEmptyTextEdit(page)
const composing = await page.evaluate((nodeId) => {
const store = window.openPencil?.getStore?.()
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[aria-hidden="true"]')
if (!store || !textarea) throw new Error('Text edit session was not initialized')
const readText = () => {
const node = store.graph.getNode(nodeId)
return node?.type === 'TEXT' ? node.text : null
}
const update = (text: string) => {
textarea.value = text
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: text }))
textarea.dispatchEvent(
new InputEvent('input', {
data: text,
inputType: 'insertCompositionText',
isComposing: true
})
)
return readText()
}
textarea.dispatchEvent(new CompositionEvent('compositionstart'))
const steps = [update('ㅎ'), update('하'), update('한')]
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한' }))
const afterEnd = readText()
textarea.value = '한'
textarea.dispatchEvent(new InputEvent('input', { data: '한', inputType: 'insertText' }))
const afterTrailingInput = readText()
return {
afterEnd,
afterTrailingInput,
editingTextId: store.state.editingTextId,
steps
}
}, id)
expect(composing).toEqual({
afterEnd: '한',
afterTrailingInput: '한',
editingTextId: id,
steps: ['ㅎ', '하', '한']
})
await canvas.click(20, 20)
const committed = await page.evaluate((nodeId) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return {
editingTextId: store.state.editingTextId,
text: store.graph.getNode(nodeId)?.text
}
}, id)
expect(committed).toEqual({
editingTextId: null,
text: '한'
})
canvas.assertNoErrors()
})

View file

@ -61,6 +61,23 @@ function paragraphWithHeight(height: number) {
}
}
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()
@ -104,6 +121,19 @@ describe('text edit undo', () => {
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()
@ -113,6 +143,17 @@ describe('text edit undo', () => {
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()