diff --git a/frontend/src/features/authoring/AuthoringToolPage.jsx b/frontend/src/features/authoring/AuthoringToolPage.jsx index ee37f64b5..08c5467d8 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,59 @@ 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); - } + 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..51fa5d187 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) { @@ -185,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") { @@ -304,9 +311,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 }; }