diff --git a/packages/core/src/editor/text.ts b/packages/core/src/editor/text.ts index bf22605e9..59fc55c3e 100644 --- a/packages/core/src/editor/text.ts +++ b/packages/core/src/editor/text.ts @@ -1,7 +1,9 @@ +import type { SceneNode } from '../scene-graph' import type { EditorContext } from './types' export function createTextActions(ctx: EditorContext) { let textBeforeEdit: string | null = null + let styleRunsBeforeEdit: SceneNode['styleRuns'] | null = null function startTextEditing(nodeId: string) { const te = ctx.getTextEditor() @@ -9,6 +11,7 @@ export function createTextActions(ctx: EditorContext) { const node = ctx.graph.getNode(nodeId) if (!node) return textBeforeEdit = node.text + styleRunsBeforeEdit = structuredClone(node.styleRuns) ctx.state.editingTextId = nodeId if (te) { te.setRenderer(ctx.getRenderer()) @@ -22,28 +25,37 @@ export function createTextActions(ctx: EditorContext) { if (!te?.isActive) { ctx.state.editingTextId = null textBeforeEdit = null + styleRunsBeforeEdit = null return } const result = te.stop() if (!result) { ctx.state.editingTextId = null textBeforeEdit = null + styleRunsBeforeEdit = null ctx.requestRender() return } const prevText = textBeforeEdit ?? '' + const prevRuns = styleRunsBeforeEdit ?? [] const newText = result.text - ctx.graph.updateNode(result.nodeId, { text: newText }) + const node = ctx.graph.getNode(result.nodeId) + const newRuns = node ? structuredClone(node.styleRuns) : [] + ctx.graph.updateNode(result.nodeId, { text: newText, styleRuns: newRuns }) ctx.state.editingTextId = null textBeforeEdit = null - if (prevText !== newText) { + styleRunsBeforeEdit = null + + const textChanged = prevText !== newText + const runsChanged = JSON.stringify(prevRuns) !== JSON.stringify(newRuns) + if (textChanged || runsChanged) { ctx.undo.push({ label: 'Edit text', forward: () => { - ctx.graph.updateNode(result.nodeId, { text: newText }) + ctx.graph.updateNode(result.nodeId, { text: newText, styleRuns: newRuns }) }, inverse: () => { - ctx.graph.updateNode(result.nodeId, { text: prevText }) + ctx.graph.updateNode(result.nodeId, { text: prevText, styleRuns: prevRuns }) } }) } diff --git a/tests/engine/text-edit-undo.test.ts b/tests/engine/text-edit-undo.test.ts index 4d19e8652..784f75ee4 100644 --- a/tests/engine/text-edit-undo.test.ts +++ b/tests/engine/text-edit-undo.test.ts @@ -3,6 +3,7 @@ import { describe, test, expect } from 'bun:test' import { SceneGraph, TextEditor, UndoManager } from '@open-pencil/core' import { createTextActions } from '@open-pencil/core/editor' +import type { StyleRun } from '@open-pencil/core' import type { EditorContext, EditorState } from '@open-pencil/core/editor' function setup() { @@ -117,4 +118,58 @@ describe('text edit undo', () => { undo.undo() expect(graph.getNode(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: textEditor.state!.text, styleRuns: newRuns }) + + actions.commitTextEdit() + + expect(graph.getNode(textNode.id)!.styleRuns).toEqual(newRuns) + expect(undo.canUndo).toBe(true) + + undo.undo() + expect(graph.getNode(textNode.id)!.text).toBe('Hello') + expect(graph.getNode(textNode.id)!.styleRuns).toEqual([boldRun]) + + undo.redo() + expect(graph.getNode(textNode.id)!.text).toBe('Hello World') + expect(graph.getNode(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(graph.getNode(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) + }) })