From 3f63dc1b883476b2a25fe717124cde2f5b28cf61 Mon Sep 17 00:00:00 2001 From: rgin216 <100395589+rgin216@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:23:25 +1200 Subject: [PATCH 1/7] VPS-219: batch undo restoration and guard stale selection handles --- .../features/authoring/AuthoringToolPage.jsx | 103 +++++++++--------- .../src/features/authoring/canvas/Overlay.tsx | 5 +- .../canvas/handles/RotationHandle.tsx | 1 + .../authoring/handlers/pointer/pointer.ts | 7 +- 4 files changed, 62 insertions(+), 54 deletions(-) diff --git a/frontend/src/features/authoring/AuthoringToolPage.jsx b/frontend/src/features/authoring/AuthoringToolPage.jsx index ee37f64b5..15207860d 100644 --- a/frontend/src/features/authoring/AuthoringToolPage.jsx +++ b/frontend/src/features/authoring/AuthoringToolPage.jsx @@ -1,4 +1,5 @@ import { useContext, useEffect, useRef, useState } from "react"; +import { unstable_batchedUpdates } from "react-dom"; import { useParams } from "react-router-dom"; import SceneContext from "context/SceneContext"; import CanvasSideBar from "./CanvasSideBar/CanvasSideBar"; @@ -76,61 +77,61 @@ export default function AuthoringToolPage() { } }, 2500); - const listener = async ({ operation, record }) => { - if (operation === "undo" || operation === "redo") { - const editorState = useEditorStore.getState(); - - // React 17 doesn't batch these updates outside of an event handler, - // so the text cursor/selection must be cleared before the document - // is replaced -- otherwise a render can happen in between with the - // restored (possibly shorter) document and the stale selection, - // reading past the end of the document - editorState.setSelection({ start: null, end: null }); - editorState.setVisualSelection({ start: null, end: null }); - setSelected([]); - - const batch = record; - const targetSceneId = batch[0]?.sceneId; - if (targetSceneId && targetSceneId !== sceneId) { - switchScene(getScene(), targetSceneId); - } + // History events also originate from native keyboard listeners. React 17 + // must finish restoring the scene and selection before rendering either. + const listener = ({ operation, record }) => + unstable_batchedUpdates(() => { + if (operation === "undo" || operation === "redo") { + const editorState = useEditorStore.getState(); + + // Discard text positions from the old document before restoring a + // possibly shorter one. Restore the relevant edit selection below. + editorState.setSelection({ start: null, end: null }); + editorState.setVisualSelection({ start: null, end: null }); + setSelected([]); + + const batch = record; + const targetSceneId = batch[0]?.sceneId; + if (targetSceneId && targetSceneId !== sceneId) { + switchScene(getScene(), targetSceneId); + } - const restoredIds = []; - batch.forEach((item) => { - const state = operation === "undo" ? item.before : item.after; - if (state?.type === "textbox" && state.document && properties) - syncPropertyChips(state.document, properties); - replaceComponent(item.id, state); - if (state !== null) restoredIds.push(item.id); - }); - setSelected(restoredIds); - - // jump straight to the exact text that was undone/redone, the way - // undo/redo works in any text editor, by diffing the before/after - // documents rather than relying on wherever the cursor used to be - // -- only meaningful when the batch touches a single component - if (batch.length === 1) { - const [item] = batch; - const state = operation === "undo" ? item.before : item.after; - const beforeBlocks = item.before?.document?.blocks; - const afterBlocks = item.after?.document?.blocks; - const targetBlocks = state?.document?.blocks; - const diff = - beforeBlocks?.length && afterBlocks?.length - ? findEditDiff(beforeBlocks, afterBlocks) - : null; - if (diff && targetBlocks?.length) { - const selection = diffToSelection(targetBlocks, diff); - editorState.setMode(["text"]); - editorState.setSelection(selection); - syncVisualCursor(); + const restoredIds = []; + batch.forEach((item) => { + const state = operation === "undo" ? item.before : item.after; + if (state?.type === "textbox" && state.document && properties) + syncPropertyChips(state.document, properties); + replaceComponent(item.id, state); + if (state !== null) restoredIds.push(item.id); + }); + setSelected(restoredIds); + + // jump straight to the exact text that was undone/redone, the way + // undo/redo works in any text editor, by diffing the before/after + // documents rather than relying on wherever the cursor used to be + // -- only meaningful when the batch touches a single component + if (batch.length === 1) { + const [item] = batch; + const state = operation === "undo" ? item.before : item.after; + const beforeBlocks = item.before?.document?.blocks; + const afterBlocks = item.after?.document?.blocks; + const targetBlocks = state?.document?.blocks; + const diff = + beforeBlocks?.length && afterBlocks?.length + ? findEditDiff(beforeBlocks, afterBlocks) + : null; + if (diff && targetBlocks?.length) { + const selection = diffToSelection(targetBlocks, diff); + editorState.setMode(["text"]); + editorState.setSelection(selection); + syncVisualCursor(); + } } } - } - setSaving(true); - debounced(); - }; + setSaving(true); + debounced(); + }); historyEvents.addEventListener("update", listener); return () => historyEvents.removeEventListener("update", listener); diff --git a/frontend/src/features/authoring/canvas/Overlay.tsx b/frontend/src/features/authoring/canvas/Overlay.tsx index e8509d964..5175e55e7 100644 --- a/frontend/src/features/authoring/canvas/Overlay.tsx +++ b/frontend/src/features/authoring/canvas/Overlay.tsx @@ -55,7 +55,10 @@ function Overlay() { const isMarqueeing = mode.includes("marquee") && hasMarqueeMoved(mutationBounds); - const hasSelection = selected.length > 0 && !isMarqueeing; + const hasSelection = + selected.length > 0 && + selected.every((id) => components[id]) && + !isMarqueeing; const type = hasSelection ? selected.length > 1 ? "box" diff --git a/frontend/src/features/authoring/canvas/handles/RotationHandle.tsx b/frontend/src/features/authoring/canvas/handles/RotationHandle.tsx index 7d9345166..5c588dcec 100644 --- a/frontend/src/features/authoring/canvas/handles/RotationHandle.tsx +++ b/frontend/src/features/authoring/canvas/handles/RotationHandle.tsx @@ -7,6 +7,7 @@ const RotationHandle = () => { const mode = useEditorStore((s) => s.mode); const bounds = getSelectedComponentBounds(); + if (!bounds) return null; const center = getBoxCenter(bounds.verts); const y = Math.min(bounds.verts[0].y, bounds.verts[1].y); diff --git a/frontend/src/features/authoring/handlers/pointer/pointer.ts b/frontend/src/features/authoring/handlers/pointer/pointer.ts index 092c837fc..fe02eac53 100644 --- a/frontend/src/features/authoring/handlers/pointer/pointer.ts +++ b/frontend/src/features/authoring/handlers/pointer/pointer.ts @@ -304,9 +304,12 @@ export function getSelectedComponentBounds() { const scene = useVisualScene.getState(); - if (selected.length === 1) return scene.components[selected[0]].bounds; - const components = selected.map((id) => scene.components[id]); + // A store update can render a handle before its selection has caught up. + // Hide the handles until every selected component is available again. + if (components.some((component) => !component)) return null; + if (components.length === 1) return components[0].bounds; + return { verts: computeBounds(components), rotation: 0 }; } From 211e6bd3de68aaf456f5b9413a5264a02603d9c2 Mon Sep 17 00:00:00 2001 From: rgin216 <100395589+rgin216@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:23:39 +1200 Subject: [PATCH 2/7] VPS-219: cover multi-object deletion undo and selection render regressions --- frontend/package.json | 2 +- .../features/authoring/scene/history.test.js | 116 ++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/authoring/scene/history.test.js diff --git a/frontend/package.json b/frontend/package.json index 9bfa1a0ed..ae422fc40 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "vite build", - "test": "node --test src/components/Properties/propertyTypes.test.js", + "test": "node --test src/components/Properties/propertyTypes.test.js src/features/authoring/scene/history.test.js", "lint": "eslint src", "lint:fix": "eslint --fix src", "prettier": "prettier --check src", diff --git a/frontend/src/features/authoring/scene/history.test.js b/frontend/src/features/authoring/scene/history.test.js new file mode 100644 index 000000000..cfb0c47db --- /dev/null +++ b/frontend/src/features/authoring/scene/history.test.js @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { after, test } from "node:test"; +import { createServer } from "vite"; +import React from "react"; +import { renderToString } from "react-dom/server.js"; + +// The scene model exposes a debug window reference; text layout creates a canvas. +globalThis.window = {}; +globalThis.document = { createElement: () => ({ getContext: () => ({}) }) }; +const server = await createServer({ + configFile: false, + server: { middlewareMode: true }, + esbuild: { jsx: "automatic" }, + optimizeDeps: { noDiscovery: true, include: [] }, +}); +after(() => server.close()); + +const load = (path) => server.ssrLoadModule(`/src/features/authoring/${path}`); +const { default: editor } = await load("stores/editor.ts"); +const { default: visual } = await load("stores/visual.ts"); +const { default: Overlay } = await load("canvas/Overlay.tsx"); +const { default: RotationHandle } = await load( + "canvas/handles/RotationHandle.tsx" +); +const { setScene, getScene } = await load("scene/scene.ts"); +const { remove, replaceComponent } = await load( + "scene/operations/modifiers.ts" +); +const { clearHistory, historyEvents, undo, redo } = + await load("scene/history.ts"); + +function setup() { + clearHistory(); + editor.getState().clear(); + const components = Object.fromEntries( + ["box", "ellipse", "line"].map((type, i) => [ + type, + { + id: type, + type, + zIndex: i, + bounds: { + verts: [ + { x: i * 100, y: 0 }, + { x: i * 100 + 50, y: 50 }, + ], + rotation: 0, + }, + }, + ]) + ); + setScene({ _id: "scene", components: structuredClone(components) }); + visual.getState().setComponents(structuredClone(components)); + return components; +} + +test("overlay tolerates selected IDs whose visual components have been deleted", () => { + for (const ids of [["box"], ["box", "ellipse"]]) { + setup(); + editor.getState().setSelected(ids); + visual.getState().setComponents({}); + assert.doesNotThrow(() => renderToString(React.createElement(Overlay))); + } +}); + +test("rotation handle tolerates selection being cleared before it unmounts", () => { + setup(); + assert.equal(renderToString(React.createElement(RotationHandle)), ""); +}); + +test("selection handles wait for the whole deleted group to be restored", () => { + const original = setup(); + editor.getState().setSelected(["box", "ellipse"]); + visual.getState().setComponents({ box: original.box }); + const partial = renderToString(React.createElement(Overlay)); + assert.doesNotMatch(partial, /data-handle/); + visual.getState().setComponents(original); + const restored = renderToString(React.createElement(Overlay)); + assert.match(restored, /data-handle/); +}); + +test("multi-object deletion survives repeated undo/redo with intact snapshots", () => { + const original = setup(); + const selected = ["box", "ellipse"]; + editor.getState().setSelected(selected); + editor.getState().setSelected([]); + remove(selected); + assert.deepEqual(Object.keys(getScene().components), ["line"]); + + const apply = ({ operation, record }) => { + if (operation === "do") return; + editor.getState().setSelected([]); + const restored = []; + for (const item of record) { + const state = operation === "undo" ? item.before : item.after; + replaceComponent(item.id, state); + if (state) restored.push(item.id); + } + editor.getState().setSelected(restored); + }; + historyEvents.addEventListener("update", apply); + try { + for (let cycle = 0; cycle < 3; cycle++) { + undo(); + assert.deepEqual(getScene().components, original); + assert.deepEqual(editor.getState().selected, selected); + assert.doesNotThrow(() => renderToString(React.createElement(Overlay))); + getScene().components.box.bounds.verts[0].x = 999; + redo(); + assert.deepEqual(Object.keys(getScene().components), ["line"]); + assert.deepEqual(editor.getState().selected, []); + } + } finally { + historyEvents.removeEventListener("update", apply); + } +}); From f3c617a1da26e1e4fc48207009c0629cfcebad1f Mon Sep 17 00:00:00 2001 From: rgin216 <100395589+rgin216@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:13:54 +1200 Subject: [PATCH 3/7] VPS-219: test undo through the shared production history listener --- .../features/authoring/AuthoringToolPage.jsx | 68 +++--------------- .../features/authoring/scene/history.test.js | 30 ++++---- .../authoring/scene/historyListener.js | 72 +++++++++++++++++++ 3 files changed, 97 insertions(+), 73 deletions(-) create mode 100644 frontend/src/features/authoring/scene/historyListener.js diff --git a/frontend/src/features/authoring/AuthoringToolPage.jsx b/frontend/src/features/authoring/AuthoringToolPage.jsx index 15207860d..02b385ee3 100644 --- a/frontend/src/features/authoring/AuthoringToolPage.jsx +++ b/frontend/src/features/authoring/AuthoringToolPage.jsx @@ -1,5 +1,4 @@ import { useContext, useEffect, useRef, useState } from "react"; -import { unstable_batchedUpdates } from "react-dom"; import { useParams } from "react-router-dom"; import SceneContext from "context/SceneContext"; import CanvasSideBar from "./CanvasSideBar/CanvasSideBar"; @@ -11,9 +10,8 @@ import useVisualScene from "./stores/visual"; import { copy, cut, paste } from "./handlers/keyboard/clipboard"; import useEditorStore from "./stores/editor"; import { useHistory } from "react-router-dom"; -import { replace, replaceComponent } from "./scene/operations/modifiers"; -import { diffToSelection, findEditDiff } from "./scene/operations/text"; -import { syncVisualCursor } from "./text/cursor"; +import { replace } from "./scene/operations/modifiers"; +import { createHistoryListener } from "./scene/historyListener"; import { syncPropertyChips } from "./text/property"; import { buildVisualComponent } from "./pipeline"; import { @@ -77,61 +75,13 @@ export default function AuthoringToolPage() { } }, 2500); - // History events also originate from native keyboard listeners. React 17 - // must finish restoring the scene and selection before rendering either. - const listener = ({ operation, record }) => - unstable_batchedUpdates(() => { - if (operation === "undo" || operation === "redo") { - const editorState = useEditorStore.getState(); - - // Discard text positions from the old document before restoring a - // possibly shorter one. Restore the relevant edit selection below. - editorState.setSelection({ start: null, end: null }); - editorState.setVisualSelection({ start: null, end: null }); - setSelected([]); - - const batch = record; - const targetSceneId = batch[0]?.sceneId; - if (targetSceneId && targetSceneId !== sceneId) { - switchScene(getScene(), targetSceneId); - } - - const restoredIds = []; - batch.forEach((item) => { - const state = operation === "undo" ? item.before : item.after; - if (state?.type === "textbox" && state.document && properties) - syncPropertyChips(state.document, properties); - replaceComponent(item.id, state); - if (state !== null) restoredIds.push(item.id); - }); - setSelected(restoredIds); - - // jump straight to the exact text that was undone/redone, the way - // undo/redo works in any text editor, by diffing the before/after - // documents rather than relying on wherever the cursor used to be - // -- only meaningful when the batch touches a single component - if (batch.length === 1) { - const [item] = batch; - const state = operation === "undo" ? item.before : item.after; - const beforeBlocks = item.before?.document?.blocks; - const afterBlocks = item.after?.document?.blocks; - const targetBlocks = state?.document?.blocks; - const diff = - beforeBlocks?.length && afterBlocks?.length - ? findEditDiff(beforeBlocks, afterBlocks) - : null; - if (diff && targetBlocks?.length) { - const selection = diffToSelection(targetBlocks, diff); - editorState.setMode(["text"]); - editorState.setSelection(selection); - syncVisualCursor(); - } - } - } - - setSaving(true); - debounced(); - }); + const listener = createHistoryListener({ + sceneId, + switchScene, + properties, + setSaving, + debounced, + }); historyEvents.addEventListener("update", listener); return () => historyEvents.removeEventListener("update", listener); diff --git a/frontend/src/features/authoring/scene/history.test.js b/frontend/src/features/authoring/scene/history.test.js index cfb0c47db..f5d1fe379 100644 --- a/frontend/src/features/authoring/scene/history.test.js +++ b/frontend/src/features/authoring/scene/history.test.js @@ -3,6 +3,8 @@ import { after, test } from "node:test"; import { createServer } from "vite"; import React from "react"; import { renderToString } from "react-dom/server.js"; +// Initialize React's scheduler in Node before installing the scene's window stub. +import "react-dom"; // The scene model exposes a debug window reference; text layout creates a canvas. globalThis.window = {}; @@ -23,9 +25,8 @@ const { default: RotationHandle } = await load( "canvas/handles/RotationHandle.tsx" ); const { setScene, getScene } = await load("scene/scene.ts"); -const { remove, replaceComponent } = await load( - "scene/operations/modifiers.ts" -); +const { remove } = await load("scene/operations/modifiers.ts"); +const { createHistoryListener } = await load("scene/historyListener.js"); const { clearHistory, historyEvents, undo, redo } = await load("scene/history.ts"); @@ -87,17 +88,16 @@ test("multi-object deletion survives repeated undo/redo with intact snapshots", remove(selected); assert.deepEqual(Object.keys(getScene().components), ["line"]); - const apply = ({ operation, record }) => { - if (operation === "do") return; - editor.getState().setSelected([]); - const restored = []; - for (const item of record) { - const state = operation === "undo" ? item.before : item.after; - replaceComponent(item.id, state); - if (state) restored.push(item.id); - } - editor.getState().setSelected(restored); - }; + let savesScheduled = 0; + const savingStates = []; + const apply = createHistoryListener({ + sceneId: "scene", + switchScene: () => + assert.fail("Undo within the active scene must not switch scenes"), + properties: [], + setSaving: (saving) => savingStates.push(saving), + debounced: () => savesScheduled++, + }); historyEvents.addEventListener("update", apply); try { for (let cycle = 0; cycle < 3; cycle++) { @@ -110,6 +110,8 @@ test("multi-object deletion survives repeated undo/redo with intact snapshots", assert.deepEqual(Object.keys(getScene().components), ["line"]); assert.deepEqual(editor.getState().selected, []); } + assert.equal(savesScheduled, 6); + assert.deepEqual(savingStates, Array(6).fill(true)); } finally { historyEvents.removeEventListener("update", apply); } diff --git a/frontend/src/features/authoring/scene/historyListener.js b/frontend/src/features/authoring/scene/historyListener.js new file mode 100644 index 000000000..34fea2b09 --- /dev/null +++ b/frontend/src/features/authoring/scene/historyListener.js @@ -0,0 +1,72 @@ +import { unstable_batchedUpdates } from "react-dom"; +import useEditorStore from "../stores/editor"; +import { replaceComponent } from "./operations/modifiers"; +import { diffToSelection, findEditDiff } from "./operations/text"; +import { syncVisualCursor } from "../text/cursor"; +import { syncPropertyChips } from "../text/property"; +import { getScene } from "./scene"; + +export function createHistoryListener({ + sceneId, + switchScene, + properties, + setSaving, + debounced, +}) { + const { setSelected } = useEditorStore.getState(); + // History events also originate from native keyboard listeners. React 17 + // must finish restoring the scene and selection before rendering either. + return ({ operation, record }) => + unstable_batchedUpdates(() => { + if (operation === "undo" || operation === "redo") { + const editorState = useEditorStore.getState(); + + // Discard text positions from the old document before restoring a + // possibly shorter one. Restore the relevant edit selection below. + editorState.setSelection({ start: null, end: null }); + editorState.setVisualSelection({ start: null, end: null }); + setSelected([]); + + const batch = record; + const targetSceneId = batch[0]?.sceneId; + if (targetSceneId && targetSceneId !== sceneId) { + switchScene(getScene(), targetSceneId); + } + + const restoredIds = []; + batch.forEach((item) => { + const state = operation === "undo" ? item.before : item.after; + if (state?.type === "textbox" && state.document && properties) + syncPropertyChips(state.document, properties); + replaceComponent(item.id, state); + if (state !== null) restoredIds.push(item.id); + }); + setSelected(restoredIds); + + // jump straight to the exact text that was undone/redone, the way + // undo/redo works in any text editor, by diffing the before/after + // documents rather than relying on wherever the cursor used to be + // -- only meaningful when the batch touches a single component + if (batch.length === 1) { + const [item] = batch; + const state = operation === "undo" ? item.before : item.after; + const beforeBlocks = item.before?.document?.blocks; + const afterBlocks = item.after?.document?.blocks; + const targetBlocks = state?.document?.blocks; + const diff = + beforeBlocks?.length && afterBlocks?.length + ? findEditDiff(beforeBlocks, afterBlocks) + : null; + if (diff && targetBlocks?.length) { + const selection = diffToSelection(targetBlocks, diff); + editorState.setMode(["text"]); + editorState.setSelection(selection); + syncVisualCursor(); + } + } + } + + setSaving(true); + debounced(); + }); +} From 15bb9eb47a2394266d6dd14bbff3052e155d8b1d Mon Sep 17 00:00:00 2001 From: rgin216 <100395589+rgin216@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:17:20 +1200 Subject: [PATCH 4/7] VPS-219: remove added tests and test-only listener extraction --- frontend/package.json | 2 +- .../features/authoring/AuthoringToolPage.jsx | 68 ++++++++-- .../features/authoring/scene/history.test.js | 118 ------------------ .../authoring/scene/historyListener.js | 72 ----------- 4 files changed, 60 insertions(+), 200 deletions(-) delete mode 100644 frontend/src/features/authoring/scene/history.test.js delete mode 100644 frontend/src/features/authoring/scene/historyListener.js diff --git a/frontend/package.json b/frontend/package.json index ae422fc40..9bfa1a0ed 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "vite build", - "test": "node --test src/components/Properties/propertyTypes.test.js src/features/authoring/scene/history.test.js", + "test": "node --test src/components/Properties/propertyTypes.test.js", "lint": "eslint src", "lint:fix": "eslint --fix src", "prettier": "prettier --check src", diff --git a/frontend/src/features/authoring/AuthoringToolPage.jsx b/frontend/src/features/authoring/AuthoringToolPage.jsx index 02b385ee3..15207860d 100644 --- a/frontend/src/features/authoring/AuthoringToolPage.jsx +++ b/frontend/src/features/authoring/AuthoringToolPage.jsx @@ -1,4 +1,5 @@ import { useContext, useEffect, useRef, useState } from "react"; +import { unstable_batchedUpdates } from "react-dom"; import { useParams } from "react-router-dom"; import SceneContext from "context/SceneContext"; import CanvasSideBar from "./CanvasSideBar/CanvasSideBar"; @@ -10,8 +11,9 @@ import useVisualScene from "./stores/visual"; import { copy, cut, paste } from "./handlers/keyboard/clipboard"; import useEditorStore from "./stores/editor"; import { useHistory } from "react-router-dom"; -import { replace } from "./scene/operations/modifiers"; -import { createHistoryListener } from "./scene/historyListener"; +import { replace, replaceComponent } from "./scene/operations/modifiers"; +import { diffToSelection, findEditDiff } from "./scene/operations/text"; +import { syncVisualCursor } from "./text/cursor"; import { syncPropertyChips } from "./text/property"; import { buildVisualComponent } from "./pipeline"; import { @@ -75,13 +77,61 @@ export default function AuthoringToolPage() { } }, 2500); - const listener = createHistoryListener({ - sceneId, - switchScene, - properties, - setSaving, - debounced, - }); + // History events also originate from native keyboard listeners. React 17 + // must finish restoring the scene and selection before rendering either. + const listener = ({ operation, record }) => + unstable_batchedUpdates(() => { + if (operation === "undo" || operation === "redo") { + const editorState = useEditorStore.getState(); + + // Discard text positions from the old document before restoring a + // possibly shorter one. Restore the relevant edit selection below. + editorState.setSelection({ start: null, end: null }); + editorState.setVisualSelection({ start: null, end: null }); + setSelected([]); + + const batch = record; + const targetSceneId = batch[0]?.sceneId; + if (targetSceneId && targetSceneId !== sceneId) { + switchScene(getScene(), targetSceneId); + } + + const restoredIds = []; + batch.forEach((item) => { + const state = operation === "undo" ? item.before : item.after; + if (state?.type === "textbox" && state.document && properties) + syncPropertyChips(state.document, properties); + replaceComponent(item.id, state); + if (state !== null) restoredIds.push(item.id); + }); + setSelected(restoredIds); + + // jump straight to the exact text that was undone/redone, the way + // undo/redo works in any text editor, by diffing the before/after + // documents rather than relying on wherever the cursor used to be + // -- only meaningful when the batch touches a single component + if (batch.length === 1) { + const [item] = batch; + const state = operation === "undo" ? item.before : item.after; + const beforeBlocks = item.before?.document?.blocks; + const afterBlocks = item.after?.document?.blocks; + const targetBlocks = state?.document?.blocks; + const diff = + beforeBlocks?.length && afterBlocks?.length + ? findEditDiff(beforeBlocks, afterBlocks) + : null; + if (diff && targetBlocks?.length) { + const selection = diffToSelection(targetBlocks, diff); + editorState.setMode(["text"]); + editorState.setSelection(selection); + syncVisualCursor(); + } + } + } + + setSaving(true); + debounced(); + }); historyEvents.addEventListener("update", listener); return () => historyEvents.removeEventListener("update", listener); diff --git a/frontend/src/features/authoring/scene/history.test.js b/frontend/src/features/authoring/scene/history.test.js deleted file mode 100644 index f5d1fe379..000000000 --- a/frontend/src/features/authoring/scene/history.test.js +++ /dev/null @@ -1,118 +0,0 @@ -import assert from "node:assert/strict"; -import { after, test } from "node:test"; -import { createServer } from "vite"; -import React from "react"; -import { renderToString } from "react-dom/server.js"; -// Initialize React's scheduler in Node before installing the scene's window stub. -import "react-dom"; - -// The scene model exposes a debug window reference; text layout creates a canvas. -globalThis.window = {}; -globalThis.document = { createElement: () => ({ getContext: () => ({}) }) }; -const server = await createServer({ - configFile: false, - server: { middlewareMode: true }, - esbuild: { jsx: "automatic" }, - optimizeDeps: { noDiscovery: true, include: [] }, -}); -after(() => server.close()); - -const load = (path) => server.ssrLoadModule(`/src/features/authoring/${path}`); -const { default: editor } = await load("stores/editor.ts"); -const { default: visual } = await load("stores/visual.ts"); -const { default: Overlay } = await load("canvas/Overlay.tsx"); -const { default: RotationHandle } = await load( - "canvas/handles/RotationHandle.tsx" -); -const { setScene, getScene } = await load("scene/scene.ts"); -const { remove } = await load("scene/operations/modifiers.ts"); -const { createHistoryListener } = await load("scene/historyListener.js"); -const { clearHistory, historyEvents, undo, redo } = - await load("scene/history.ts"); - -function setup() { - clearHistory(); - editor.getState().clear(); - const components = Object.fromEntries( - ["box", "ellipse", "line"].map((type, i) => [ - type, - { - id: type, - type, - zIndex: i, - bounds: { - verts: [ - { x: i * 100, y: 0 }, - { x: i * 100 + 50, y: 50 }, - ], - rotation: 0, - }, - }, - ]) - ); - setScene({ _id: "scene", components: structuredClone(components) }); - visual.getState().setComponents(structuredClone(components)); - return components; -} - -test("overlay tolerates selected IDs whose visual components have been deleted", () => { - for (const ids of [["box"], ["box", "ellipse"]]) { - setup(); - editor.getState().setSelected(ids); - visual.getState().setComponents({}); - assert.doesNotThrow(() => renderToString(React.createElement(Overlay))); - } -}); - -test("rotation handle tolerates selection being cleared before it unmounts", () => { - setup(); - assert.equal(renderToString(React.createElement(RotationHandle)), ""); -}); - -test("selection handles wait for the whole deleted group to be restored", () => { - const original = setup(); - editor.getState().setSelected(["box", "ellipse"]); - visual.getState().setComponents({ box: original.box }); - const partial = renderToString(React.createElement(Overlay)); - assert.doesNotMatch(partial, /data-handle/); - visual.getState().setComponents(original); - const restored = renderToString(React.createElement(Overlay)); - assert.match(restored, /data-handle/); -}); - -test("multi-object deletion survives repeated undo/redo with intact snapshots", () => { - const original = setup(); - const selected = ["box", "ellipse"]; - editor.getState().setSelected(selected); - editor.getState().setSelected([]); - remove(selected); - assert.deepEqual(Object.keys(getScene().components), ["line"]); - - let savesScheduled = 0; - const savingStates = []; - const apply = createHistoryListener({ - sceneId: "scene", - switchScene: () => - assert.fail("Undo within the active scene must not switch scenes"), - properties: [], - setSaving: (saving) => savingStates.push(saving), - debounced: () => savesScheduled++, - }); - historyEvents.addEventListener("update", apply); - try { - for (let cycle = 0; cycle < 3; cycle++) { - undo(); - assert.deepEqual(getScene().components, original); - assert.deepEqual(editor.getState().selected, selected); - assert.doesNotThrow(() => renderToString(React.createElement(Overlay))); - getScene().components.box.bounds.verts[0].x = 999; - redo(); - assert.deepEqual(Object.keys(getScene().components), ["line"]); - assert.deepEqual(editor.getState().selected, []); - } - assert.equal(savesScheduled, 6); - assert.deepEqual(savingStates, Array(6).fill(true)); - } finally { - historyEvents.removeEventListener("update", apply); - } -}); diff --git a/frontend/src/features/authoring/scene/historyListener.js b/frontend/src/features/authoring/scene/historyListener.js deleted file mode 100644 index 34fea2b09..000000000 --- a/frontend/src/features/authoring/scene/historyListener.js +++ /dev/null @@ -1,72 +0,0 @@ -import { unstable_batchedUpdates } from "react-dom"; -import useEditorStore from "../stores/editor"; -import { replaceComponent } from "./operations/modifiers"; -import { diffToSelection, findEditDiff } from "./operations/text"; -import { syncVisualCursor } from "../text/cursor"; -import { syncPropertyChips } from "../text/property"; -import { getScene } from "./scene"; - -export function createHistoryListener({ - sceneId, - switchScene, - properties, - setSaving, - debounced, -}) { - const { setSelected } = useEditorStore.getState(); - // History events also originate from native keyboard listeners. React 17 - // must finish restoring the scene and selection before rendering either. - return ({ operation, record }) => - unstable_batchedUpdates(() => { - if (operation === "undo" || operation === "redo") { - const editorState = useEditorStore.getState(); - - // Discard text positions from the old document before restoring a - // possibly shorter one. Restore the relevant edit selection below. - editorState.setSelection({ start: null, end: null }); - editorState.setVisualSelection({ start: null, end: null }); - setSelected([]); - - const batch = record; - const targetSceneId = batch[0]?.sceneId; - if (targetSceneId && targetSceneId !== sceneId) { - switchScene(getScene(), targetSceneId); - } - - const restoredIds = []; - batch.forEach((item) => { - const state = operation === "undo" ? item.before : item.after; - if (state?.type === "textbox" && state.document && properties) - syncPropertyChips(state.document, properties); - replaceComponent(item.id, state); - if (state !== null) restoredIds.push(item.id); - }); - setSelected(restoredIds); - - // jump straight to the exact text that was undone/redone, the way - // undo/redo works in any text editor, by diffing the before/after - // documents rather than relying on wherever the cursor used to be - // -- only meaningful when the batch touches a single component - if (batch.length === 1) { - const [item] = batch; - const state = operation === "undo" ? item.before : item.after; - const beforeBlocks = item.before?.document?.blocks; - const afterBlocks = item.after?.document?.blocks; - const targetBlocks = state?.document?.blocks; - const diff = - beforeBlocks?.length && afterBlocks?.length - ? findEditDiff(beforeBlocks, afterBlocks) - : null; - if (diff && targetBlocks?.length) { - const selection = diffToSelection(targetBlocks, diff); - editorState.setMode(["text"]); - editorState.setSelection(selection); - syncVisualCursor(); - } - } - } - - setSaving(true); - debounced(); - }); -} From a5f1535c68e809b2af532cea0cffab1df6b8ebc7 Mon Sep 17 00:00:00 2001 From: rgin216 <100395589+rgin216@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:55:01 +1300 Subject: [PATCH 5/7] VPS-219: guard component dragging against missing bounds --- frontend/src/features/authoring/handlers/pointer/pointer.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/authoring/handlers/pointer/pointer.ts b/frontend/src/features/authoring/handlers/pointer/pointer.ts index fe02eac53..44152cb90 100644 --- a/frontend/src/features/authoring/handlers/pointer/pointer.ts +++ b/frontend/src/features/authoring/handlers/pointer/pointer.ts @@ -154,7 +154,8 @@ function handleComponentDrag(e: React.MouseEvent, position: Vec2) { useEditorStore.getState(); if (!selected?.length) return; - const bounds = getSelectedComponentBounds()!; + const bounds = getSelectedComponentBounds(); + if (!bounds) return; let verts = translate(bounds.verts, subtract(position, offset)); if (e.altKey) { From b27818ba58ea215d9824e69dbd8f974cb5b1d2a2 Mon Sep 17 00:00:00 2001 From: rgin216 <100395589+rgin216@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:06:45 +1300 Subject: [PATCH 6/7] Pushing a useless commit to restart vercel deployment --- frontend/src/features/authoring/AuthoringToolPage.jsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/features/authoring/AuthoringToolPage.jsx b/frontend/src/features/authoring/AuthoringToolPage.jsx index 15207860d..08c5467d8 100644 --- a/frontend/src/features/authoring/AuthoringToolPage.jsx +++ b/frontend/src/features/authoring/AuthoringToolPage.jsx @@ -77,8 +77,6 @@ export default function AuthoringToolPage() { } }, 2500); - // History events also originate from native keyboard listeners. React 17 - // must finish restoring the scene and selection before rendering either. const listener = ({ operation, record }) => unstable_batchedUpdates(() => { if (operation === "undo" || operation === "redo") { From ca92453fa9bae15df95c67df505f2507bc025765 Mon Sep 17 00:00:00 2001 From: rgin216 <100395589+rgin216@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:18:11 +1300 Subject: [PATCH 7/7] VPS-219: handle missing bounds when ending mutations --- .../src/features/authoring/handlers/pointer/pointer.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/authoring/handlers/pointer/pointer.ts b/frontend/src/features/authoring/handlers/pointer/pointer.ts index 44152cb90..51fa5d187 100644 --- a/frontend/src/features/authoring/handlers/pointer/pointer.ts +++ b/frontend/src/features/authoring/handlers/pointer/pointer.ts @@ -186,7 +186,13 @@ function handleMutationEnd() { if (selected.length == 1) { modifyComponentBounds(selected, mutationBounds); } else { - const { verts: prevVerts } = getSelectedComponentBounds()!; + const bounds = getSelectedComponentBounds(); + if (!bounds) { + setMode(["normal"]); + setActiveGuides([]); + return; + } + const { verts: prevVerts } = bounds; const { verts } = mutationBounds; if (mode.includes("resize") && getHandleType() === "rotation") {