From 3be01dfdeb95f579c9b4ebac1d72fd469cfc04ce Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 6 Apr 2026 15:59:57 +0300 Subject: [PATCH] Include styleRuns in text edit undo styleRuns were updated on the graph during editing (via syncText) but commitTextEdit only captured text changes. Now both text and styleRuns are snapshotted at startTextEditing and restored on undo. --- packages/core/src/editor/text.ts | 20 ++++++++--- tests/engine/text-edit-undo.test.ts | 55 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) 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) + }) })