fix(text): handle Hangul IME composition (#319)
Co-authored-by: 05Park <im0505@kakao.com>
This commit is contained in:
parent
0224b2ef61
commit
ee2a7472ee
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () =>
|
||||
|
|
|
|||
147
tests/e2e/text/hangul-editing.spec.ts
Normal file
147
tests/e2e/text/hangul-editing.spec.ts
Normal 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()
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue