Skip to content
Merged
101 changes: 50 additions & 51 deletions frontend/src/features/authoring/AuthoringToolPage.jsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) {
Comment thread
rgin216 marked this conversation as resolved.
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
Comment thread
rgin216 marked this conversation as resolved.
? 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();
Comment thread
rgin216 marked this conversation as resolved.
});

historyEvents.addEventListener("update", listener);
return () => historyEvents.removeEventListener("update", listener);
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/features/authoring/canvas/Overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ function Overlay() {

const isMarqueeing =
mode.includes("marquee") && hasMarqueeMoved(mutationBounds);
const hasSelection = selected.length > 0 && !isMarqueeing;
const hasSelection =
Comment thread
rgin216 marked this conversation as resolved.
selected.length > 0 &&
selected.every((id) => components[id]) &&
!isMarqueeing;
const type = hasSelection
? selected.length > 1
? "box"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
18 changes: 14 additions & 4 deletions frontend/src/features/authoring/handlers/pointer/pointer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let verts = translate(bounds.verts, subtract(position, offset));

if (e.altKey) {
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (components.length === 1) return components[0].bounds;

Comment thread
rgin216 marked this conversation as resolved.
return { verts: computeBounds(components), rotation: 0 };
}

Expand Down
Loading