From ee2a7472ee5325bbda77d80b40379fd4c73ee4a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=98=81=ED=98=B8?= Date: Sat, 6 Jun 2026 13:21:56 +0900 Subject: [PATCH] fix(text): handle Hangul IME composition (#319) Co-authored-by: 05Park --- packages/core/src/editor/text.ts | 57 ++++--- packages/vue/src/canvas/text-edit/editing.ts | 130 +++++++++++++++- packages/vue/src/canvas/text-edit/use.ts | 28 +++- tests/e2e/text/hangul-editing.spec.ts | 147 +++++++++++++++++++ tests/engine/text/edit-undo.test.ts | 41 ++++++ 5 files changed, 372 insertions(+), 31 deletions(-) create mode 100644 tests/e2e/text/hangul-editing.spec.ts diff --git a/packages/core/src/editor/text.ts b/packages/core/src/editor/text.ts index 175941e85..93811bed6 100644 --- a/packages/core/src/editor/text.ts +++ b/packages/core/src/editor/text.ts @@ -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 } diff --git a/packages/vue/src/canvas/text-edit/editing.ts b/packages/vue/src/canvas/text-edit/editing.ts index 4ad7cb73b..58e709be0 100644 --- a/packages/vue/src/canvas/text-edit/editing.ts +++ b/packages/vue/src/canvas/text-edit/editing.ts @@ -32,6 +32,9 @@ type TextCompositionOptions = { textareaRef: ShallowRef 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 = { 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 + } } diff --git a/packages/vue/src/canvas/text-edit/use.ts b/packages/vue/src/canvas/text-edit/use.ts index 59dc9c49d..c5b25ed00 100644 --- a/packages/vue/src/canvas/text-edit/use.ts +++ b/packages/vue/src/canvas/text-edit/use.ts @@ -19,7 +19,14 @@ import { focusTextAreaOnCanvasPointerDown, useTextEditingSession } from './texta export function useTextEdit(canvasRef: Ref, store: Editor) { const textareaRef = shallowRef(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, 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, 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', () => diff --git a/tests/e2e/text/hangul-editing.spec.ts b/tests/e2e/text/hangul-editing.spec.ts new file mode 100644 index 000000000..cd44c63c4 --- /dev/null +++ b/tests/e2e/text/hangul-editing.spec.ts @@ -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('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() +}) diff --git a/tests/engine/text/edit-undo.test.ts b/tests/engine/text/edit-undo.test.ts index 384ab5492..43cb8128e 100644 --- a/tests/engine/text/edit-undo.test.ts +++ b/tests/engine/text/edit-undo.test.ts @@ -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 + 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()