From ffc35d08117ec9f72fc312ecc25a4833a874bca9 Mon Sep 17 00:00:00 2001 From: K1mmyn Date: Thu, 21 May 2026 16:59:43 +1200 Subject: [PATCH 01/14] feat multiselect --- .../ListContainer/ImageListContainer.jsx | 2 +- .../features/authoring/AuthoringToolPage.jsx | 19 +---- .../authoring/handlers/keyboard/text.ts | 1 - .../authoring/handlers/pointer/pointer.ts | 82 ++++++++++++++++--- .../src/features/authoring/scene/history.ts | 71 ++++++++++++---- .../authoring/scene/operations/component.ts | 55 ++++++++----- .../authoring/scene/operations/modifiers.ts | 52 ++++++++---- .../src/features/authoring/scene/scene.ts | 4 +- .../src/features/authoring/stores/editor.ts | 21 ++--- frontend/src/features/authoring/text/Text.tsx | 2 - .../src/features/authoring/text/cursor.ts | 22 ++++- .../authoring/topbar/ShapeSection.tsx | 19 ++++- .../src/features/authoring/topbar/Topbar.tsx | 8 +- 13 files changed, 252 insertions(+), 106 deletions(-) diff --git a/frontend/src/components/ListContainer/ImageListContainer.jsx b/frontend/src/components/ListContainer/ImageListContainer.jsx index e103aaaa..e2f1b65e 100644 --- a/frontend/src/components/ListContainer/ImageListContainer.jsx +++ b/frontend/src/components/ListContainer/ImageListContainer.jsx @@ -31,7 +31,7 @@ export default function ImageListContainer({ opacity: "0.5", cursor: "pointer", }, - backgroundImage: `url("${item.url}")`, + backgroundImage: `url(${item.url})`, backgroundSize: "cover", backgroundPosition: "center", boxSizing: "border-box", diff --git a/frontend/src/features/authoring/AuthoringToolPage.jsx b/frontend/src/features/authoring/AuthoringToolPage.jsx index 025afca6..b1d7bec3 100644 --- a/frontend/src/features/authoring/AuthoringToolPage.jsx +++ b/frontend/src/features/authoring/AuthoringToolPage.jsx @@ -47,22 +47,10 @@ export default function AuthoringToolPage() { return () => clearInterval(autosave); }, [sceneId]); - // if the active scene was deleted, switch to the first available scene - useEffect(() => { - if (!sceneId || !scenes) return; - if (!scenes.find((s) => s._id === sceneId)) { - const next = scenes[0]; - if (next) replace(next); - } - }, [scenes]); - useEffect(() => { const activeScene = localStorage.getItem(`${scenarioId}:activeScene`); - const found = activeScene - ? scenes.find((s) => s._id === activeScene) - : null; - const target = found ?? scenes[0]; - if (target) replace(target); + if (activeScene) replace(scenes.find((s) => s._id === activeScene)); + else replace(scenes[0]); useEditorStore.getState().clear(); @@ -76,8 +64,7 @@ export default function AuthoringToolPage() { }, []); function playScenario() { - const startScene = sceneId ? `?startScene=${sceneId}` : ""; - window.open(`/play/${scenarioId}${startScene}`, "_blank"); + window.open(`/play/${scenarioId}`, "_blank"); } function goToGroups() { diff --git a/frontend/src/features/authoring/handlers/keyboard/text.ts b/frontend/src/features/authoring/handlers/keyboard/text.ts index 3af194d1..a8924de5 100644 --- a/frontend/src/features/authoring/handlers/keyboard/text.ts +++ b/frontend/src/features/authoring/handlers/keyboard/text.ts @@ -24,7 +24,6 @@ export function handleTextMode(e: KeyboardEvent) { if (!selected) return; if ((e.metaKey || e.ctrlKey) && e.key == "a") { - e.preventDefault(); handleSelectAll(selected); } else if (e.key.startsWith("Arrow") || ["Home", "End"].includes(e.key)) { handleNavigation(e, selected); diff --git a/frontend/src/features/authoring/handlers/pointer/pointer.ts b/frontend/src/features/authoring/handlers/pointer/pointer.ts index 3a397fb7..f1c41ade 100644 --- a/frontend/src/features/authoring/handlers/pointer/pointer.ts +++ b/frontend/src/features/authoring/handlers/pointer/pointer.ts @@ -1,5 +1,6 @@ import { render } from "../../../../components/ContextMenu/portal"; import { modifyComponentBounds } from "../../scene/operations/component"; +import { getComponent } from "../../scene/scene"; import useEditorStore from "../../stores/editor"; import useVisualScene from "../../stores/visual"; import { @@ -70,14 +71,14 @@ export function handleMouseUpGlobal() { function handleCanvasClick() { const { setSelected, setMode } = useEditorStore.getState(); - setSelected(null); + setSelected([]); setMode(["normal"]); } // component handlers function handleComponentClick(e: React.MouseEvent, position: Vec2) { - const { setSelected, setOffset, setMode, setMutationBounds } = + const { selected, setSelected, setOffset, setMode, setMutationBounds } = useEditorStore.getState(); const scene = useVisualScene.getState().components; @@ -85,7 +86,9 @@ function handleComponentClick(e: React.MouseEvent, position: Vec2) { const id = target.dataset.id as string; setOffset(position); - setSelected(id); + if (!selected.includes(id)) { + setSelected([...selected, id]); + } const component = scene[target.dataset.id as string]; setMutationBounds({ ...component.bounds }); @@ -93,21 +96,80 @@ function handleComponentClick(e: React.MouseEvent, position: Vec2) { setMode(["normal"]); } +function findMaxSelectedMinXY() { + const { selected } = useEditorStore.getState(); + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + const components = useVisualScene.getState().components; + + selected.forEach((id: string) => { + const component = components[id]; + if (!component || !component.bounds || !component.bounds.verts) return; + + component.bounds.verts.forEach((obj) => { + minX = Math.min(minX, obj.x); + minY = Math.min(minY, obj.y); + maxX = Math.max(maxX, obj.x); + maxY = Math.max(maxY, obj.y); + }); + }); + + return [minX, minY, maxX, maxY]; +} + function handleComponentDrag(_: React.MouseEvent, position: Vec2) { const { selected, setMutationBounds, offset, setMode } = useEditorStore.getState(); - if (!selected) return; - const component = useVisualScene.getState().components[selected]; + if (!selected || selected.length === 0) return; - const verts = translate(component.bounds.verts, subtract(position, offset)); - setMutationBounds((prev) => ({ ...prev, verts })); + const [minX, minY, maxX, maxY] = findMaxSelectedMinXY(); + + const initialGroupVerts = [ + { x: minX, y: minY }, + { x: maxX, y: maxY }, + ]; + + // 2. Translate the entire group box by the mouse movement offset + const delta = subtract(position, offset); + const verts = translate(initialGroupVerts, delta); + + setMutationBounds({ verts, rotation: 0 }); setMode(["mutation"]); } function handleMutationEnd() { const { selected, mutationBounds, setMode } = useEditorStore.getState(); - modifyComponentBounds(selected!, mutationBounds); + + if (selected && selected.length > 0 && mutationBounds?.verts) { + const [minX, minY, maxX, maxY] = findMaxSelectedMinXY(); + + const newVerts = mutationBounds.verts; + const deltaVec = { + x: newVerts[0].x - minX, + y: newVerts[0].y - minY, + }; + + modifyComponentBounds(selected, (currentBounds) => { + const updatedBounds = { + ...currentBounds, + verts: translate(currentBounds.verts, deltaVec), + }; + + if (typeof currentBounds.x === "number") { + updatedBounds.x = currentBounds.x + deltaVec.x; + } + if (typeof currentBounds.y === "number") { + updatedBounds.y = currentBounds.y + deltaVec.y; + } + // + return updatedBounds; + }); + } + setMode(["normal"]); } @@ -131,7 +193,7 @@ function handleDocumentClick(e: React.MouseEvent, position: Vec2) { doc.blocks ); - setSelected(target.dataset.id as string); + setSelected([target.dataset.id as string]); setMode(["text"]); const component = scene[target.dataset.id as string]; @@ -144,7 +206,7 @@ function handleDocumentClick(e: React.MouseEvent, position: Vec2) { function handleTextSelection(_: React.MouseEvent, position: Vec2) { const { selected, setVisualSelection } = useEditorStore.getState(); - const { document: doc } = useVisualScene.getState().components[selected!]; + const { document: doc } = useVisualScene.getState().components[selected[0]]; const cursor = parseHit( getRelativePosition(position, doc.bounds), doc.blocks diff --git a/frontend/src/features/authoring/scene/history.ts b/frontend/src/features/authoring/scene/history.ts index f8397517..828079ec 100644 --- a/frontend/src/features/authoring/scene/history.ts +++ b/frontend/src/features/authoring/scene/history.ts @@ -21,8 +21,13 @@ interface HistoryObject { state: Component | null; } -let undoStack: HistoryObject[] = []; -let redoStack: HistoryObject[] = []; +export interface ChangeRecord { + id: string; + prevState: Component | null; +} + +let undoStack: HistoryObject[][] = []; +let redoStack: HistoryObject[][] = []; let scenes: SceneRef[] = []; let scenarioId: string | null = null; let saveScene: ((patch: Record) => Promise) | null = null; @@ -41,30 +46,62 @@ export function init( saveScene = _saveScene; } -export function updateHistory(id: string, prevState: Component | null) { - if (fastIsEqual(prevState, getComponent(id))) return; +export function updateHistory(incomingChanges: ChangeRecord[]) { const sceneId = getSceneId(); - undoStack.push({ sceneId, id, state: prevState }); + let validChanges: HistoryObject[] = []; + + incomingChanges.forEach(({ id, prevState }) => { + const currentState = getComponent(id); + + if (!fastIsEqual(prevState, currentState)) { + validChanges.push({ sceneId, id, state: prevState }); + } + }); + + if (validChanges.length === 0) return; + + undoStack.push(validChanges); if (undoStack.length > 100) undoStack.shift(); + redoStack = []; } export function undo() { - const prev = undoStack.pop(); - if (!prev) return; - switchToScene(prev.sceneId); - const current = structuredClone(getComponent(prev.id)); - restoreComponent(prev.id, prev.state); - redoStack.push({ sceneId: prev.sceneId, id: prev.id, state: current }); + const batch = undoStack.pop(); + if (!batch || batch.length === 0) return; + + switchToScene(batch[0].sceneId); + + let redoChanges: HistoryObject[] = []; + + batch.forEach((p) => { + const current = getComponent(p.id); + const stateToSave = current ? structuredClone(current) : null; + + restoreComponent(p.id, p.state); + redoChanges.push({ sceneId: p.sceneId, id: p.id, state: stateToSave }); + }); + + redoStack.push(redoChanges); } export function redo() { - const entry = redoStack.pop(); - if (!entry) return; - switchToScene(entry.sceneId); - const current = structuredClone(getComponent(entry.id)); - restoreComponent(entry.id, entry.state); - undoStack.push({ sceneId: entry.sceneId, id: entry.id, state: current }); + const batch = redoStack.pop(); + if (!batch || batch.length === 0) return; + + switchToScene(batch[0].sceneId); + + let undoChanges: HistoryObject[] = []; + + batch.forEach((e) => { + const current = getComponent(e.id); + const stateToSave = current ? structuredClone(current) : null; + + restoreComponent(e.id, e.state); + undoChanges.push({ sceneId: e.sceneId, id: e.id, state: stateToSave }); + }); + + undoStack.push(undoChanges); } function switchToScene(targetSceneId: string) { diff --git a/frontend/src/features/authoring/scene/operations/component.ts b/frontend/src/features/authoring/scene/operations/component.ts index eb5c2492..c29d4893 100644 --- a/frontend/src/features/authoring/scene/operations/component.ts +++ b/frontend/src/features/authoring/scene/operations/component.ts @@ -141,23 +141,30 @@ export function createComponentFromBounds( } export const modifyComponentProp = modify( - (id: string, prop: string, val: any) => { - const component = getComponent(id); - if (!component) return; - - const [object, key] = getObject(prop, component); - if (typeof val === "function") object[key] = val(object[key]); - else if (val !== null && typeof val === "object" && !Array.isArray(val)) - object[key] = merge(object[key], val); - else object[key] = val; + (ids: string[], prop: string, val: any) => { + ids.forEach((id) => { + const component = getComponent(id); + if (!component) return; + + const [object, key] = getObject(prop, component); + if (typeof val === "function") object[key] = val(object[key]); + else if (val !== null && typeof val === "object" && !Array.isArray(val)) + object[key] = merge(object[key], val); + else object[key] = val; + }); } ); -export function modifyComponentBounds(id: string, bounds: Partial) { - modifyComponentProp(id, "bounds", bounds); +export function modifyComponentBounds( + ids: string[], + bounds: Partial | ((prev: Bounds) => Bounds) +) { + modifyComponentProp(ids, "bounds", bounds); } -export function bringForward(id: string) { +export function bringForward(ids: string[]) { + if (ids.length > 1) return; + const id = ids[0]; const currentZIndex = getComponentProp(id, "zIndex") as number; const components = Object.values(getScene().components) as Component[]; @@ -178,11 +185,13 @@ export function bringForward(id: string) { const aboveZIndex = targetComponent.zIndex; // Swap Zindexs - modifyComponentProp(id, "zIndex", aboveZIndex); - modifyComponentProp(targetComponent.id, "zIndex", currentZIndex); + modifyComponentProp([id], "zIndex", aboveZIndex); + modifyComponentProp([targetComponent.id], "zIndex", currentZIndex); } -export function sendBackward(id: string) { +export function sendBackward(ids: string[]) { + if (ids.length > 1) return; + const id = ids[0]; const currentZIndex = getComponentProp(id, "zIndex") as number; const components = Object.values(getScene().components) as Component[]; @@ -198,26 +207,30 @@ export function sendBackward(id: string) { if (!targetComponent) return; const belowZIndex = targetComponent.zIndex; - modifyComponentProp(id, "zIndex", belowZIndex); - modifyComponentProp(targetComponent.id, "zIndex", currentZIndex); + modifyComponentProp([id], "zIndex", belowZIndex); + modifyComponentProp([targetComponent.id], "zIndex", currentZIndex); } -export function bringToFront(id: string) { +export function bringToFront(ids: string[]) { + if (ids.length > 1) return; + const id = ids[0]; const components = Object.values(getScene().components) as Component[]; const max = components.reduce( (p, c) => (c.zIndex >= p ? c.zIndex : p), -Infinity ); if (max == -Infinity) return; - modifyComponentProp(id, "zIndex", max + 1); + modifyComponentProp([id], "zIndex", max + 1); } -export function sendToBack(id: string) { +export function sendToBack(ids: string[]) { + if (ids.length > 1) return; + const id = ids[0]; const components = Object.values(getScene().components) as Component[]; const min = components.reduce( (p, c) => (c.zIndex <= p ? c.zIndex : p), Infinity ); if (min == Infinity) return; - modifyComponentProp(id, "zIndex", min - 1); + modifyComponentProp([id], "zIndex", min - 1); } diff --git a/frontend/src/features/authoring/scene/operations/modifiers.ts b/frontend/src/features/authoring/scene/operations/modifiers.ts index 6c49e0fb..5fd4fee9 100644 --- a/frontend/src/features/authoring/scene/operations/modifiers.ts +++ b/frontend/src/features/authoring/scene/operations/modifiers.ts @@ -1,7 +1,7 @@ import { v4 } from "uuid"; import { buildVisualComponent, buildVisualScene } from "../../pipeline"; import useVisualScene, { type VisualSceneState } from "../../stores/visual"; -import { updateHistory } from "../history"; +import { updateHistory, type ChangeRecord } from "../history"; import { commitSavedScene, getComponent, getScene, setScene } from "../scene"; import type { Component, Scene } from "../../types"; import { arrayToObject } from "../util"; @@ -23,38 +23,60 @@ export function modifySceneProp( } // wrapper for state mutating functions, will capture both state and operation -export function modify(fn: (...args: A) => R) { +export function modify( + fn: (...args: A) => R +) { return function (...args: A): R { - const id = args[0]; - const component = getComponent(id); + const ids = args[0]; + + const previousStates: ChangeRecord[] = ids.map((id) => { + const comp = getComponent(id); + return { + id, + prevState: comp ? structuredClone(comp) : null, + }; + }); - const prev = structuredClone(component); const output = fn(...args); - updateHistory(id, prev); + updateHistory(previousStates); - useVisualScene.getState().updateComponent(buildVisualComponent(component)); + ids.forEach((id) => { + const component = getComponent(id); + if (component) { + useVisualScene + .getState() + .updateComponent(buildVisualComponent(component)); + } + }); return output; }; } -export function remove(id: string, history = true) { - const component = getComponent(id); - const prev = structuredClone(component); - - delete getScene().components[id]; +export function remove(ids: string[], history = true) { + const previousStates: ChangeRecord[] = ids.map((id) => { + const comp = getComponent(id); + return { + id, + prevState: comp ? structuredClone(comp) : null, + }; + }); - if (history) updateHistory(id, prev); + ids.forEach((id) => { + delete getScene().components[id]; + useVisualScene.getState().deleteComponent(id); + }); - useVisualScene.getState().deleteComponent(id); + // ** IMPORTANT ** getComponents(ids[0]) is a place holder for prevState what does that do? + if (history) updateHistory(previousStates); } export function add(props: Record, history = true) { if (!props.id) props.id = v4(); getScene().components[props.id] = props; - if (history) updateHistory(props.id, null); + if (history) updateHistory([{ id: props.id, prevState: null }]); useVisualScene .getState() diff --git a/frontend/src/features/authoring/scene/scene.ts b/frontend/src/features/authoring/scene/scene.ts index 554b33c4..581eec60 100644 --- a/frontend/src/features/authoring/scene/scene.ts +++ b/frontend/src/features/authoring/scene/scene.ts @@ -23,11 +23,11 @@ export function setScene(newScene: Record) { } export function getComponent(id: string) { - return scene.components[id] ?? null; + return scene.components?.[id] ?? null; } export function getComponentProp(id: string, prop: string) { - const component = scene.components[id]; + const component = scene.components?.[id]; if (!component) return; const [object, key] = getObject(prop, component); return object[key]; diff --git a/frontend/src/features/authoring/stores/editor.ts b/frontend/src/features/authoring/stores/editor.ts index dd20fd89..cd35dee1 100644 --- a/frontend/src/features/authoring/stores/editor.ts +++ b/frontend/src/features/authoring/stores/editor.ts @@ -7,14 +7,13 @@ import { getStyleForSelection } from "../scene/operations/text"; type Mode = "normal" | "resize" | "create" | "text" | "mutation"; interface EditorState { - loading: boolean; - selected: string | null; + selected: string[]; createType: string | null; mouseDown: boolean; mutationBounds: Bounds; offset: Vec2; - setSelected: (id: string | null) => void; + setSelected: (id: string[]) => void; setCreateType: (type: string) => void; setMouseDown: (mouseDown: boolean) => void; setMutationBounds: Dynamic; @@ -26,7 +25,6 @@ interface EditorState { desiredColumn: number | null; activeStyle: BaseTextStyle | null; - setLoading: (loading: boolean) => void; setSelection: (selection: ModelSelection) => void; setVisualSelection: Dynamic; setDesiredColumn: (column: number | null) => void; @@ -54,15 +52,13 @@ function setter(set: Function, prop: K) { } const useEditorStore = create((set) => ({ - loading: false, - selected: null, + selected: [], createType: null, mouseDown: false, mutationBounds: { verts: [], rotation: 0 }, offset: { x: 0, y: 0 }, - setLoading: (value: boolean) => set({ loading: value }), - setSelected: (id) => set({ selected: id }), + setSelected: (ids) => set({ selected: ids }), setCreateType: (type: string) => set({ createType: type }), setMouseDown: (mouseDown) => set({ mouseDown }), setMutationBounds: setter(set, "mutationBounds"), @@ -75,11 +71,12 @@ const useEditorStore = create((set) => ({ setSelection: (selection) => set(({ selected }) => { - if (selected && getComponent(selected).type === "textbox") { - const activeStyle = getStyleForSelection(selected, selection); + const mainTarget = selected[0]; + if (mainTarget && getComponent(mainTarget).type === "textbox") { + const activeStyle = getStyleForSelection(mainTarget, selection); return { selection, activeStyle }; } - return { selection }; + return { selection, activeStyle: null }; }), setVisualSelection: setter(set, "visualSelection"), setActiveStyle: (style: BaseTextStyle) => set({ activeStyle: style }), @@ -93,7 +90,7 @@ const useEditorStore = create((set) => ({ clear: () => set({ - selected: null, + selected: [], selection: { start: null, end: null }, visualSelection: { start: null, end: null }, mode: ["normal"], diff --git a/frontend/src/features/authoring/text/Text.tsx b/frontend/src/features/authoring/text/Text.tsx index fcb3e479..2d224fdd 100644 --- a/frontend/src/features/authoring/text/Text.tsx +++ b/frontend/src/features/authoring/text/Text.tsx @@ -1,7 +1,6 @@ import type { VisualDocument } from "./types"; import Cursor from "./Cursor.tsx"; import Highlight from "./Highlight"; -import TextHighlight from "./TextHighlight"; import Rectangle from "../canvas/Rectangle"; import { buildStyle } from "./build"; import useEditorStore from "../stores/editor"; @@ -58,7 +57,6 @@ function Text({ doc, editable }: { doc: VisualDocument; editable?: boolean }) { return ( - {isSelected && } {buildGroups(doc)} diff --git a/frontend/src/features/authoring/text/cursor.ts b/frontend/src/features/authoring/text/cursor.ts index 7944a7d8..d15a0803 100644 --- a/frontend/src/features/authoring/text/cursor.ts +++ b/frontend/src/features/authoring/text/cursor.ts @@ -112,9 +112,18 @@ export function toVisualSelection( export function syncModelSelection() { const editorState = useEditorStore.getState(); + + // Assume only one object is selected + if ( + !editorState.selected || + editorState.selected.length !== 1 || + !editorState.visualSelection.start + ) + return; if (!editorState.selected || !editorState.visualSelection.start) return; const blocks = - useVisualScene.getState().components[editorState.selected].document.blocks; + useVisualScene.getState().components[editorState.selected[0]].document + .blocks; editorState.setSelection( toModelSelection(editorState.visualSelection, blocks) ); @@ -122,9 +131,18 @@ export function syncModelSelection() { export function syncVisualCursor() { const editorState = useEditorStore.getState(); + + // Assume only one object is selected + if ( + !editorState.selected || + editorState.selected.length !== 1 || + !editorState.visualSelection.start + ) + return; if (!editorState.selected || !editorState.selection.start) return; const blocks = - useVisualScene.getState().components[editorState.selected].document.blocks; + useVisualScene.getState().components[editorState.selected[0]].document + .blocks; editorState.setVisualSelection( toVisualSelection(editorState.selection, blocks) ); diff --git a/frontend/src/features/authoring/topbar/ShapeSection.tsx b/frontend/src/features/authoring/topbar/ShapeSection.tsx index 31752f24..38592019 100644 --- a/frontend/src/features/authoring/topbar/ShapeSection.tsx +++ b/frontend/src/features/authoring/topbar/ShapeSection.tsx @@ -1,6 +1,6 @@ import { PaintBucket, Pencil, RulerIcon } from "lucide-react"; import ChromePicker from "../wrapper/ChromePicker"; -import NumberInput from "../wrapper/NumberInput"; + import useEditorStore from "../stores/editor"; import { useEffect, useState } from "react"; import { getComponent } from "../scene/scene"; @@ -13,9 +13,20 @@ interface ShapeProps { strokeWidth: number; } -function extractProps(selected: string): ShapeProps { - const { fill, stroke, strokeWidth } = getComponent(selected); - return { fill, stroke, strokeWidth }; +const defaultProps: ShapeProps = { + fill: "#000000", + stroke: "#000000", + strokeWidth: 1, +}; + +function extractProps(selected: string[]): ShapeProps { + if (!selected || selected.length === 0) return defaultProps; + const component = getComponent(selected[0]); + return { + fill: component?.fill ?? defaultProps.fill, + stroke: component?.stroke ?? defaultProps.stroke, + strokeWidth: component?.strokeWidth ?? defaultProps.strokeWidth, + }; } const widths = [1, 2, 3, 4, 8, 12, 16, 24]; diff --git a/frontend/src/features/authoring/topbar/Topbar.tsx b/frontend/src/features/authoring/topbar/Topbar.tsx index 30dfc69a..075c6c26 100644 --- a/frontend/src/features/authoring/topbar/Topbar.tsx +++ b/frontend/src/features/authoring/topbar/Topbar.tsx @@ -32,7 +32,9 @@ function Topbar({ saving, save }: { saving: boolean; save: () => void }) { setCreateType(type); }; - const component = selected ? getComponent(selected) : null; + const hasSelection = selected && selected.length > 0; + + const component = hasSelection ? getComponent(selected[0]) : null; return ( <> @@ -84,7 +86,7 @@ function Topbar({ saving, save }: { saving: boolean; save: () => void }) { {/* shape properties */} - {component.type !== "image" && ( + {component?.type !== "image" && ( <>
@@ -92,7 +94,7 @@ function Topbar({ saving, save }: { saving: boolean; save: () => void }) { )} {/* text content styles */} - {component.type === "textbox" && ( + {component?.type === "textbox" && ( <>
From d9217fffabe3a41be11cb03e7b52c71e5d316888 Mon Sep 17 00:00:00 2001 From: K1mmyn Date: Tue, 26 May 2026 01:15:36 +1200 Subject: [PATCH 02/14] Create border for multiselect --- .../ListContainer/ImageListContainer.jsx | 2 +- .../src/features/authoring/canvas/Overlay.tsx | 65 ++++++---- .../canvas/handles/ConstrainedHandle.tsx | 10 +- .../canvas/handles/RotationHandle.tsx | 14 ++- .../authoring/handlers/keyboard/text.ts | 1 + .../authoring/handlers/pointer/pointer.ts | 115 +++++++++++------- .../src/features/authoring/scene/history.ts | 30 +++-- .../authoring/scene/operations/modifiers.ts | 5 +- 8 files changed, 151 insertions(+), 91 deletions(-) diff --git a/frontend/src/components/ListContainer/ImageListContainer.jsx b/frontend/src/components/ListContainer/ImageListContainer.jsx index e2f1b65e..e103aaaa 100644 --- a/frontend/src/components/ListContainer/ImageListContainer.jsx +++ b/frontend/src/components/ListContainer/ImageListContainer.jsx @@ -31,7 +31,7 @@ export default function ImageListContainer({ opacity: "0.5", cursor: "pointer", }, - backgroundImage: `url(${item.url})`, + backgroundImage: `url("${item.url}")`, backgroundSize: "cover", backgroundPosition: "center", boxSizing: "border-box", diff --git a/frontend/src/features/authoring/canvas/Overlay.tsx b/frontend/src/features/authoring/canvas/Overlay.tsx index 6d8cfcdb..22c2b50f 100644 --- a/frontend/src/features/authoring/canvas/Overlay.tsx +++ b/frontend/src/features/authoring/canvas/Overlay.tsx @@ -10,6 +10,7 @@ import SpeechHandles from "./handles/SpeechHandles"; import Rectangle from "./Rectangle"; import useEditorStore from "../stores/editor"; import useVisualScene from "../stores/visual"; +import { getSelectedComponentBounds } from "../handlers/pointer/pointer"; const componentMap: Record> = { speech: Speech, @@ -23,46 +24,68 @@ function resolve(type: Component["type"], bounds: Bounds) { return ; } -function Overlay() { - const selected = useEditorStore((state) => state.selected)!; - const bounds = useEditorStore((state) => state.mutationBounds); - const scene = useVisualScene((scene) => scene.components); - const mode = useEditorStore((scene) => scene.mode); - const createType = useEditorStore((scene) => scene.createType); +function ResolveHandles({ + type, + isMultiSelect, +}: { + type: string; + isMultiSelect: boolean; +}) { + if (isMultiSelect) return ; + switch (type) { + case "speech": + return ; + case "line": + return ; + default: + return ; + } +} - const component = scene[selected]; +function Overlay() { + const { selected, mode, createType, mutationBounds } = + useEditorStore.getState(); - function ResolveHandles() { - switch (component.type) { - case "speech": - return ; - case "line": - return ; - default: - return ; - } + if (!selected || selected.length === 0) { + return ( + + ); } + const components = useVisualScene.getState().components; + + const primaryComponent = components[selected[0]]; + + const bounds = getSelectedComponentBounds(); + const verts = bounds.verts; + return ( - {component && ( + {components && ( <> - + 1} + /> )} {mode.includes("mutation") && - resolve(component?.type ?? createType, bounds)} + resolve(primaryComponent?.type ?? createType, mutationBounds as Bounds)} ); } diff --git a/frontend/src/features/authoring/canvas/handles/ConstrainedHandle.tsx b/frontend/src/features/authoring/canvas/handles/ConstrainedHandle.tsx index 91c74877..b586747d 100644 --- a/frontend/src/features/authoring/canvas/handles/ConstrainedHandle.tsx +++ b/frontend/src/features/authoring/canvas/handles/ConstrainedHandle.tsx @@ -1,6 +1,6 @@ import { getBoxCenter, rotate } from "../../util"; import useEditorStore from "../../stores/editor"; -import useVisualScene from "../../stores/visual"; +import { getSelectedComponentBounds } from "../../handlers/pointer/pointer"; interface Props { x: number; @@ -8,19 +8,19 @@ interface Props { } const ResizeHandle = ({ x, y }: Props) => { - const selected = useEditorStore((state) => state.selected)!; + const selected = useEditorStore((state) => state.selected); const mode = useEditorStore((state) => state.mode); - const scene = useVisualScene((scene) => scene.components); - const bounds = scene[selected].bounds; + const bounds = getSelectedComponentBounds(); const verts = bounds.verts; + const componentRotation = bounds.rotation; let point = { x: x === 0.5 ? (verts[0].x + verts[1].x) / 2 : verts[x].x, y: y === 0.5 ? (verts[0].y + verts[1].y) / 2 : verts[y].y, }; - point = rotate(point, getBoxCenter(verts), bounds.rotation); + point = rotate(point, getBoxCenter(verts), componentRotation); return ( { - const selected = useEditorStore((state) => state.selected)!; - const scene = useVisualScene((scene) => scene.components); const mode = useEditorStore((state) => state.mode); - const bounds = scene[selected].bounds; + const bounds = getSelectedComponentBounds(); + const verts = bounds.verts; + const componentRotation = bounds.rotation; + const center = getBoxCenter(bounds.verts); - const y = Math.min(bounds.verts[0].y, bounds.verts[1].y); + const y = Math.min(verts[0].y, verts[1].y); - const initial = rotate({ x: center.x, y }, center, bounds.rotation); - const point = rotate({ x: center.x, y: y - 40 }, center, bounds.rotation); + const initial = rotate({ x: center.x, y }, center, componentRotation); + const point = rotate({ x: center.x, y: y - 40 }, center, componentRotation); return ( { - const component = components[id]; - if (!component || !component.bounds || !component.bounds.verts) return; - - component.bounds.verts.forEach((obj) => { - minX = Math.min(minX, obj.x); - minY = Math.min(minY, obj.y); - maxX = Math.max(maxX, obj.x); - maxY = Math.max(maxY, obj.y); - }); - }); - - return [minX, minY, maxX, maxY]; -} - function handleComponentDrag(_: React.MouseEvent, position: Vec2) { const { selected, setMutationBounds, offset, setMode } = useEditorStore.getState(); if (!selected || selected.length === 0) return; - const [minX, minY, maxX, maxY] = findMaxSelectedMinXY(); + const [minX, minY, maxX, maxY] = findSelectedMinMaxXY(); + // Get Box Dimensions const initialGroupVerts = [ { x: minX, y: minY }, { x: maxX, y: maxY }, ]; - // 2. Translate the entire group box by the mouse movement offset + // Translate the entire group box by the mouse movement offset const delta = subtract(position, offset); const verts = translate(initialGroupVerts, delta); - setMutationBounds({ verts, rotation: 0 }); + let componentRotation = + selected.length == 1 ? findComponentRotation(selected[0]) : 0; + + setMutationBounds({ verts, rotation: componentRotation }); setMode(["mutation"]); } function handleMutationEnd() { const { selected, mutationBounds, setMode } = useEditorStore.getState(); - if (selected && selected.length > 0 && mutationBounds?.verts) { - const [minX, minY, maxX, maxY] = findMaxSelectedMinXY(); + const [minX, minY, maxX, maxY] = findSelectedMinMaxXY(); - const newVerts = mutationBounds.verts; - const deltaVec = { - x: newVerts[0].x - minX, - y: newVerts[0].y - minY, - }; + const newVerts = mutationBounds.verts; + const deltaVec = { + x: newVerts[0].x - minX, + y: newVerts[0].y - minY, + }; + if (selected.length == 1) { + modifyComponentBounds(selected, mutationBounds); + } else { modifyComponentBounds(selected, (currentBounds) => { const updatedBounds = { ...currentBounds, verts: translate(currentBounds.verts, deltaVec), }; - if (typeof currentBounds.x === "number") { - updatedBounds.x = currentBounds.x + deltaVec.x; - } - if (typeof currentBounds.y === "number") { - updatedBounds.y = currentBounds.y + deltaVec.y; - } - // + updatedBounds.x = currentBounds.x + deltaVec.x; + updatedBounds.y = currentBounds.y + deltaVec.y; + return updatedBounds; }); } @@ -173,6 +154,52 @@ function handleMutationEnd() { setMode(["normal"]); } +// Component Helper Functions + +function findSelectedMinMaxXY() { + const { selected } = useEditorStore.getState(); + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + const components = useVisualScene.getState().components; + + selected.forEach((id: string) => { + const component = components[id]; + if (!component || !component.bounds || !component.bounds.verts) return; + + component.bounds.verts.forEach((obj) => { + minX = Math.min(minX, obj.x); + minY = Math.min(minY, obj.y); + maxX = Math.max(maxX, obj.x); + maxY = Math.max(maxY, obj.y); + }); + }); + + return [minX, minY, maxX, maxY]; +} + +function findComponentRotation(id: string) { + const components = useVisualScene.getState().components; + return components[id].bounds.rotation; +} + +export function getSelectedComponentBounds() { + const { selected } = useEditorStore.getState(); + const [minX, minY, maxX, maxY] = findSelectedMinMaxXY(); + const verts = [ + { x: minX, y: minY }, + { x: maxX, y: maxY }, + ]; + + const componentRotation = + selected.length === 1 ? findComponentRotation(selected[0]) : 0; + + const bounds = { verts, rotation: componentRotation, x: minX, y: minY }; + return bounds; +} + // document handlers function handleDocumentClick(e: React.MouseEvent, position: Vec2) { diff --git a/frontend/src/features/authoring/scene/history.ts b/frontend/src/features/authoring/scene/history.ts index 828079ec..d485da82 100644 --- a/frontend/src/features/authoring/scene/history.ts +++ b/frontend/src/features/authoring/scene/history.ts @@ -74,12 +74,16 @@ export function undo() { let redoChanges: HistoryObject[] = []; - batch.forEach((p) => { - const current = getComponent(p.id); - const stateToSave = current ? structuredClone(current) : null; - - restoreComponent(p.id, p.state); - redoChanges.push({ sceneId: p.sceneId, id: p.id, state: stateToSave }); + batch.forEach((prev) => { + const current = getComponent(prev.id); + const stateToSave = structuredClone(current); + + restoreComponent(prev.id, prev.state); + redoChanges.push({ + sceneId: prev.sceneId, + id: prev.id, + state: stateToSave, + }); }); redoStack.push(redoChanges); @@ -93,12 +97,16 @@ export function redo() { let undoChanges: HistoryObject[] = []; - batch.forEach((e) => { - const current = getComponent(e.id); - const stateToSave = current ? structuredClone(current) : null; + batch.forEach((prev) => { + const current = getComponent(prev.id); + const stateToSave = structuredClone(current); - restoreComponent(e.id, e.state); - undoChanges.push({ sceneId: e.sceneId, id: e.id, state: stateToSave }); + restoreComponent(prev.id, prev.state); + undoChanges.push({ + sceneId: prev.sceneId, + id: prev.id, + state: stateToSave, + }); }); undoStack.push(undoChanges); diff --git a/frontend/src/features/authoring/scene/operations/modifiers.ts b/frontend/src/features/authoring/scene/operations/modifiers.ts index 5fd4fee9..65462ba5 100644 --- a/frontend/src/features/authoring/scene/operations/modifiers.ts +++ b/frontend/src/features/authoring/scene/operations/modifiers.ts @@ -33,7 +33,7 @@ export function modify( const comp = getComponent(id); return { id, - prevState: comp ? structuredClone(comp) : null, + prevState: structuredClone(comp), }; }); @@ -59,7 +59,7 @@ export function remove(ids: string[], history = true) { const comp = getComponent(id); return { id, - prevState: comp ? structuredClone(comp) : null, + prevState: structuredClone(comp), }; }); @@ -68,7 +68,6 @@ export function remove(ids: string[], history = true) { useVisualScene.getState().deleteComponent(id); }); - // ** IMPORTANT ** getComponents(ids[0]) is a place holder for prevState what does that do? if (history) updateHistory(previousStates); } From 1188023462fd758104631fc6ffb24c08d977be3a Mon Sep 17 00:00:00 2001 From: K1mmyn Date: Thu, 28 May 2026 01:10:27 +1200 Subject: [PATCH 03/14] Feat Multi Select Resize Done --- .../canvas/handles/ConstrainedHandle.tsx | 3 +- .../canvas/handles/RotationHandle.tsx | 2 +- .../authoring/handlers/pointer/create.ts | 2 +- .../authoring/handlers/pointer/pointer.ts | 73 ++++++++++++++++--- .../authoring/handlers/pointer/resize.ts | 22 +++--- frontend/src/features/authoring/util.ts | 4 + 6 files changed, 81 insertions(+), 25 deletions(-) diff --git a/frontend/src/features/authoring/canvas/handles/ConstrainedHandle.tsx b/frontend/src/features/authoring/canvas/handles/ConstrainedHandle.tsx index b586747d..d77c0d53 100644 --- a/frontend/src/features/authoring/canvas/handles/ConstrainedHandle.tsx +++ b/frontend/src/features/authoring/canvas/handles/ConstrainedHandle.tsx @@ -8,8 +8,7 @@ interface Props { } const ResizeHandle = ({ x, y }: Props) => { - const selected = useEditorStore((state) => state.selected); - const mode = useEditorStore((state) => state.mode); + const { mode } = useEditorStore.getState(); const bounds = getSelectedComponentBounds(); const verts = bounds.verts; diff --git a/frontend/src/features/authoring/canvas/handles/RotationHandle.tsx b/frontend/src/features/authoring/canvas/handles/RotationHandle.tsx index 995ed311..8169602d 100644 --- a/frontend/src/features/authoring/canvas/handles/RotationHandle.tsx +++ b/frontend/src/features/authoring/canvas/handles/RotationHandle.tsx @@ -4,7 +4,7 @@ import useVisualScene from "../../stores/visual"; import { getSelectedComponentBounds } from "../../handlers/pointer/pointer"; const RotationHandle = () => { - const mode = useEditorStore((state) => state.mode); + const { mode } = useEditorStore.getState(); const bounds = getSelectedComponentBounds(); const verts = bounds.verts; diff --git a/frontend/src/features/authoring/handlers/pointer/create.ts b/frontend/src/features/authoring/handlers/pointer/create.ts index 1f7fb281..396b6882 100644 --- a/frontend/src/features/authoring/handlers/pointer/create.ts +++ b/frontend/src/features/authoring/handlers/pointer/create.ts @@ -7,7 +7,7 @@ export function handleCreateStart(_: React.MouseEvent, position: Vec2) { const { setSelected, setOffset, setMutationBounds } = useEditorStore.getState(); - setSelected(null); + setSelected([]); setOffset(position); setMutationBounds({ verts: [position, position], rotation: 0 }); } diff --git a/frontend/src/features/authoring/handlers/pointer/pointer.ts b/frontend/src/features/authoring/handlers/pointer/pointer.ts index 02c84efa..9f9b0bf3 100644 --- a/frontend/src/features/authoring/handlers/pointer/pointer.ts +++ b/frontend/src/features/authoring/handlers/pointer/pointer.ts @@ -92,7 +92,9 @@ function handleComponentClick(e: React.MouseEvent, position: Vec2) { // ! Text Selection Broken // ! Front back implementation // ! Resize - + // ! npm i --save-dev @types/uuid for another type + // ! Object creation + // ! MultiSelect rotation const component = scene[target.dataset.id as string]; setMutationBounds({ ...component.bounds }); @@ -105,7 +107,7 @@ function handleComponentDrag(_: React.MouseEvent, position: Vec2) { if (!selected || selected.length === 0) return; - const [minX, minY, maxX, maxY] = findSelectedMinMaxXY(); + const [minX, minY, maxX, maxY] = getSelectedMinMaxXY(); // Get Box Dimensions const initialGroupVerts = [ @@ -125,28 +127,40 @@ function handleComponentDrag(_: React.MouseEvent, position: Vec2) { } function handleMutationEnd() { - const { selected, mutationBounds, setMode } = useEditorStore.getState(); - - const [minX, minY, maxX, maxY] = findSelectedMinMaxXY(); + const { selected, mutationBounds, setMode, mode } = useEditorStore.getState(); const newVerts = mutationBounds.verts; + + const [minX, minY] = getSelectedMinMaxXY(); + const deltaVec = { x: newVerts[0].x - minX, y: newVerts[0].y - minY, }; + const origin = { + x: minX, + y: minY, + }; + + const scaleVec = getResizeScaleVec(newVerts); + if (selected.length == 1) { modifyComponentBounds(selected, mutationBounds); } else { modifyComponentBounds(selected, (currentBounds) => { const updatedBounds = { ...currentBounds, - verts: translate(currentBounds.verts, deltaVec), + verts: mode.includes("resize") + ? getNewResizePosition( + currentBounds.verts, + newVerts, + origin, + scaleVec + ) + : translate(currentBounds.verts, deltaVec), }; - updatedBounds.x = currentBounds.x + deltaVec.x; - updatedBounds.y = currentBounds.y + deltaVec.y; - return updatedBounds; }); } @@ -156,7 +170,42 @@ function handleMutationEnd() { // Component Helper Functions -function findSelectedMinMaxXY() { +function getResizeScaleVec(newVerts: Vec2[]) { + const [minX, minY, maxX, maxY] = getSelectedMinMaxXY(); + + const oldGroupWidth = maxX - minX; + const oldGroupHeight = maxY - minY; + + const newGroupWidth = newVerts[1].x - newVerts[0].x; + const newGroupHeight = newVerts[1].y - newVerts[0].y; + + const scaleX = oldGroupWidth === 0 ? 1 : newGroupWidth / oldGroupWidth; + const scaleY = oldGroupHeight === 0 ? 1 : newGroupHeight / oldGroupHeight; + + return { x: scaleX, y: scaleY }; +} + +function getNewResizePosition( + verts: Vec2[], + newVerts: Vec2[], + origin: Vec2, + scaleVec: Vec2 +) { + for (let i = 0; i < 2; i++) { + const vert = verts[i]; + + // Vert.var - origin.var is the distance from original top-left corner + // This is then scaled based on change in size and is mapped to new top-left corner + vert.x = newVerts[0].x + (vert.x - origin.x) * scaleVec.x; + vert.y = newVerts[0].y + (vert.y - origin.y) * scaleVec.y; + + verts[i] = vert; + } + + return verts; +} + +function getSelectedMinMaxXY() { const { selected } = useEditorStore.getState(); let minX = Infinity; let minY = Infinity; @@ -169,7 +218,7 @@ function findSelectedMinMaxXY() { const component = components[id]; if (!component || !component.bounds || !component.bounds.verts) return; - component.bounds.verts.forEach((obj) => { + component.bounds.verts.forEach((obj: Vec2) => { minX = Math.min(minX, obj.x); minY = Math.min(minY, obj.y); maxX = Math.max(maxX, obj.x); @@ -187,7 +236,7 @@ function findComponentRotation(id: string) { export function getSelectedComponentBounds() { const { selected } = useEditorStore.getState(); - const [minX, minY, maxX, maxY] = findSelectedMinMaxXY(); + const [minX, minY, maxX, maxY] = getSelectedMinMaxXY(); const verts = [ { x: minX, y: minY }, { x: maxX, y: maxY }, diff --git a/frontend/src/features/authoring/handlers/pointer/resize.ts b/frontend/src/features/authoring/handlers/pointer/resize.ts index bee4efce..3c26ad4e 100644 --- a/frontend/src/features/authoring/handlers/pointer/resize.ts +++ b/frontend/src/features/authoring/handlers/pointer/resize.ts @@ -1,5 +1,6 @@ import { getComponent, getComponentProp } from "../../scene/scene"; import useEditorStore from "../../stores/editor"; +import useVisualScene from "../../stores/visual"; import type { Bounds, Vec2 } from "../../types"; import { add, @@ -13,8 +14,10 @@ import { scale, subtract, } from "../../util"; +import { getSelectedComponentBounds } from "./pointer"; type HandleType = "size" | "rotation"; +const BOX_CENTER_VALUE = 0.5; let type: HandleType; let coords: number[]; @@ -30,10 +33,10 @@ export function handleResizeStart(e: React.MouseEvent) { } export function handleResizeDrag(e: React.MouseEvent, position: Vec2) { - const { addMode, setMutationBounds, selected } = useEditorStore.getState(); + const { addMode, setMutationBounds } = useEditorStore.getState(); addMode("mutation"); - const bounds = getComponentProp(selected!, "bounds"); + const bounds = getSelectedComponentBounds(); const newBounds: Partial = {}; @@ -67,13 +70,13 @@ function getNewTail(verts: Vec2[], newVerts: Vec2[], coords: number[]) { const inversePoint = { x: 0, y: 0 }; const newPoint = { x: 0, y: 0 }; - if (coords[0] !== 0.5) { + if (coords[0] !== BOX_CENTER_VALUE) { point.x = verts[coords[0]].x; inversePoint.x = verts[1 - coords[0]].x; newPoint.x = newVerts[coords[0]].x; } - if (coords[1] !== 0.5) { + if (coords[1] !== BOX_CENTER_VALUE) { point.y = verts[coords[1]].y; inversePoint.y = verts[1 - coords[1]].y; newPoint.y = newVerts[coords[1]].y; @@ -119,16 +122,17 @@ function updateResize( anchorCenter: boolean, fixed: boolean ) { - const { selected } = useEditorStore.getState(); - const { bounds, type } = getComponent(selected!); + const bounds = getSelectedComponentBounds(); + const center = getBoxCenter(bounds.verts); + let type = "box"; let verts = modifyVerts(bounds.verts, coords, position); if (!coords.includes(2)) { // none of these apply to the speech triangle // shift modifier - if (fixed && !coords.includes(0.5)) { + if (fixed && !coords.includes(BOX_CENTER_VALUE)) { lockAspect(bounds.verts, verts, coords); } @@ -157,7 +161,7 @@ function mirror(verts: Vec2[], center: Vec2, coords: number[]) { function modifyVerts(verts: Vec2[], coords: number[], v: Vec2) { const newVerts = verts.map((v) => ({ ...v })); - if (coords[1] !== 0.5) newVerts[coords[1]].y = v.y; - if (coords[0] !== 0.5) newVerts[coords[0]].x = v.x; + if (coords[1] !== BOX_CENTER_VALUE) newVerts[coords[1]].y = v.y; + if (coords[0] !== BOX_CENTER_VALUE) newVerts[coords[0]].x = v.x; return newVerts; } diff --git a/frontend/src/features/authoring/util.ts b/frontend/src/features/authoring/util.ts index 02c8d82d..6a524783 100644 --- a/frontend/src/features/authoring/util.ts +++ b/frontend/src/features/authoring/util.ts @@ -35,6 +35,10 @@ export function divide(v1: Vec2, v2: Vec2) { return { x: v1.x / v2.x, y: v1.y / v2.y }; } +export function scaleVecByScaleVec(v: Vec2, scale: Vec2) { + return { x: v.x * scale.x, y: v.y * scale.y }; +} + export function rotate(v: Vec2, origin: Vec2, angle: Degree) { if (!angle) return v; const relative = subtract(v, origin); From bba127ba4644c47b519eb3e0c0a809a3eeee22bc Mon Sep 17 00:00:00 2001 From: K1mmyn Date: Thu, 28 May 2026 11:28:55 +1200 Subject: [PATCH 04/14] fix copy and paste implementation --- .../authoring/handlers/keyboard/clipboard.ts | 40 ++++++++++--------- .../authoring/handlers/keyboard/keyboard.ts | 8 ++-- .../authoring/handlers/pointer/pointer.ts | 4 +- .../authoring/handlers/pointer/resize.ts | 2 - .../authoring/scene/operations/component.ts | 9 +++-- 5 files changed, 35 insertions(+), 28 deletions(-) diff --git a/frontend/src/features/authoring/handlers/keyboard/clipboard.ts b/frontend/src/features/authoring/handlers/keyboard/clipboard.ts index 3cbafdc3..20524bba 100644 --- a/frontend/src/features/authoring/handlers/keyboard/clipboard.ts +++ b/frontend/src/features/authoring/handlers/keyboard/clipboard.ts @@ -40,7 +40,7 @@ export function cut(e: ClipboardEvent) { addToClipboard(e, selected); remove(selected); - setSelected(null); + setSelected([]); } export function paste(e: ClipboardEvent) { @@ -64,40 +64,44 @@ export function paste(e: ClipboardEvent) { } syncVisualCursor(); } else { + let newSelection = []; if (app) { const obj = JSON.parse(app); if (obj.type) { - setSelected(parseComponent(obj)); + newSelection.push(parseComponent(obj)); } else { const component = structuredClone(defaults["textbox"]); component.document = structuredClone(obj); - setSelected(add(component)); + newSelection.push(add(component)); } } else if (text) { const doc = plainToDoc(text); const component = structuredClone(defaults["textbox"]); component.document = structuredClone(doc); - setSelected(add(component)); + newSelection.push(add(component)); } + setSelected(newSelection); } } -function addToClipboard(e: ClipboardEvent, selected: string) { +function addToClipboard(e: ClipboardEvent, selected: string[]) { const { mode, selection } = useEditorStore.getState(); - if (mode.includes("text")) { - if (!selection.end) return; + selected.forEach((id: string) => { + if (mode.includes("text")) { + if (!selection.end) return; - const { text, doc } = getSelectionContent(selected, selection); - e.clipboardData?.setData("text/plain", text); - e.clipboardData?.setData("application/component", JSON.stringify(doc)); - } else { - e.clipboardData?.setData( - "application/component", - stringifyComponent(selected) || "" - ); - if (getComponent(selected).type === "textbox") { - const text = getDocumentText(selected); + const { text, doc } = getSelectionContent(id, selection); e.clipboardData?.setData("text/plain", text); + e.clipboardData?.setData("application/component", JSON.stringify(doc)); + } else { + e.clipboardData?.setData( + "application/component", + stringifyComponent(id) || "" + ); + if (getComponent(id).type === "textbox") { + const text = getDocumentText(id); + e.clipboardData?.setData("text/plain", text); + } } - } + }); } diff --git a/frontend/src/features/authoring/handlers/keyboard/keyboard.ts b/frontend/src/features/authoring/handlers/keyboard/keyboard.ts index f623c0c8..c3a2802c 100644 --- a/frontend/src/features/authoring/handlers/keyboard/keyboard.ts +++ b/frontend/src/features/authoring/handlers/keyboard/keyboard.ts @@ -34,8 +34,8 @@ function handleCtrlOperations(e: KeyboardEvent) { else if (e.key === "y") redo(); else if (e.key === "d" && selected) { e.preventDefault(); - const id = duplicateComponent(selected); - setSelected(id); + const ids = duplicateComponent(selected); + setSelected(ids); } else if (e.key === "ArrowUp" && selected) { if (e.shiftKey) bringToFront(selected); else bringForward(selected); @@ -45,12 +45,12 @@ function handleCtrlOperations(e: KeyboardEvent) { } } -function handleComponentOperations(e: KeyboardEvent, selected: string) { +function handleComponentOperations(e: KeyboardEvent, selected: string[]) { const { setSelected } = useEditorStore.getState(); if (e.key === "Backspace") { remove(selected); - setSelected(null); + setSelected([]); } else if (e.key === "ArrowUp") { modifyComponentProp(selected, "bounds.verts", (prev: Vec2[]) => translate(prev, { x: 0, y: -5 }) diff --git a/frontend/src/features/authoring/handlers/pointer/pointer.ts b/frontend/src/features/authoring/handlers/pointer/pointer.ts index 9f9b0bf3..f852f837 100644 --- a/frontend/src/features/authoring/handlers/pointer/pointer.ts +++ b/frontend/src/features/authoring/handlers/pointer/pointer.ts @@ -90,11 +90,13 @@ function handleComponentClick(e: React.MouseEvent, position: Vec2) { } // ! Text Selection Broken + // ! Clipboard text selection // ! Front back implementation - // ! Resize + // * DONE Resize // ! npm i --save-dev @types/uuid for another type // ! Object creation // ! MultiSelect rotation + const component = scene[target.dataset.id as string]; setMutationBounds({ ...component.bounds }); diff --git a/frontend/src/features/authoring/handlers/pointer/resize.ts b/frontend/src/features/authoring/handlers/pointer/resize.ts index 3c26ad4e..34763b1b 100644 --- a/frontend/src/features/authoring/handlers/pointer/resize.ts +++ b/frontend/src/features/authoring/handlers/pointer/resize.ts @@ -1,6 +1,4 @@ -import { getComponent, getComponentProp } from "../../scene/scene"; import useEditorStore from "../../stores/editor"; -import useVisualScene from "../../stores/visual"; import type { Bounds, Vec2 } from "../../types"; import { add, diff --git a/frontend/src/features/authoring/scene/operations/component.ts b/frontend/src/features/authoring/scene/operations/component.ts index c29d4893..e6f9f810 100644 --- a/frontend/src/features/authoring/scene/operations/component.ts +++ b/frontend/src/features/authoring/scene/operations/component.ts @@ -121,9 +121,11 @@ export function parseComponent(component: Component) { return add(component); } -export function duplicateComponent(id: string) { - const newComponent = structuredClone(getComponent(id)); - return parseComponent(newComponent); +export function duplicateComponent(ids: string[]) { + return ids.map((id: string) => { + const newComponent = structuredClone(getComponent(id)); + return parseComponent(newComponent); + }); } export function createComponentFromBounds( @@ -147,6 +149,7 @@ export const modifyComponentProp = modify( if (!component) return; const [object, key] = getObject(prop, component); + if (typeof val === "function") object[key] = val(object[key]); else if (val !== null && typeof val === "object" && !Array.isArray(val)) object[key] = merge(object[key], val); From a5af642b49053df174dedb007aac04d1c59cb097 Mon Sep 17 00:00:00 2001 From: K1mmyn Date: Thu, 28 May 2026 11:44:33 +1200 Subject: [PATCH 05/14] fix shape creation --- frontend/src/features/authoring/handlers/pointer/create.ts | 4 +++- frontend/src/features/authoring/handlers/pointer/pointer.ts | 4 +++- frontend/src/features/authoring/topbar/Topbar.tsx | 2 -- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/features/authoring/handlers/pointer/create.ts b/frontend/src/features/authoring/handlers/pointer/create.ts index 396b6882..17f3bf9d 100644 --- a/frontend/src/features/authoring/handlers/pointer/create.ts +++ b/frontend/src/features/authoring/handlers/pointer/create.ts @@ -25,8 +25,10 @@ export function handleCreateDrag(_: React.MouseEvent, position: Vec2) { useEditorStore.getState(); const verts = [offset, position]; + if (createType === "speech") verts.push(getTailVert(verts)); setMutationBounds((prev) => ({ ...prev, verts })); + addMode("mutation"); } @@ -34,6 +36,6 @@ export function handleCreateEnd() { const { mutationBounds, setMode, setSelected, createType } = useEditorStore.getState(); const id = createComponentFromBounds(createType!, mutationBounds); - setSelected(id); + setSelected([id]); setMode(["normal"]); } diff --git a/frontend/src/features/authoring/handlers/pointer/pointer.ts b/frontend/src/features/authoring/handlers/pointer/pointer.ts index f852f837..727c847b 100644 --- a/frontend/src/features/authoring/handlers/pointer/pointer.ts +++ b/frontend/src/features/authoring/handlers/pointer/pointer.ts @@ -93,8 +93,10 @@ function handleComponentClick(e: React.MouseEvent, position: Vec2) { // ! Clipboard text selection // ! Front back implementation // * DONE Resize + // * DONE Fix copy and paste // ! npm i --save-dev @types/uuid for another type - // ! Object creation + // * DONE Object creation + // ! fix object mutationbounds visual // ! MultiSelect rotation const component = scene[target.dataset.id as string]; diff --git a/frontend/src/features/authoring/topbar/Topbar.tsx b/frontend/src/features/authoring/topbar/Topbar.tsx index 075c6c26..50c189a1 100644 --- a/frontend/src/features/authoring/topbar/Topbar.tsx +++ b/frontend/src/features/authoring/topbar/Topbar.tsx @@ -72,7 +72,6 @@ function Topbar({ saving, save }: { saving: boolean; save: () => void }) { {selected && ( <>
- {/* reorder */}
  • bringToFront(selected)}> @@ -84,7 +83,6 @@ function Topbar({ saving, save }: { saving: boolean; save: () => void }) {
  • - {/* shape properties */} {component?.type !== "image" && ( <> From 4ac5124e02a72c6a4f1b36fc1cf14afce20c4181 Mon Sep 17 00:00:00 2001 From: K1mmyn Date: Thu, 28 May 2026 23:00:43 +1200 Subject: [PATCH 06/14] fix delete W one line fix --- frontend/src/features/authoring/handlers/keyboard/keyboard.ts | 2 +- frontend/src/features/authoring/handlers/pointer/pointer.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/authoring/handlers/keyboard/keyboard.ts b/frontend/src/features/authoring/handlers/keyboard/keyboard.ts index c3a2802c..0f2361d7 100644 --- a/frontend/src/features/authoring/handlers/keyboard/keyboard.ts +++ b/frontend/src/features/authoring/handlers/keyboard/keyboard.ts @@ -49,8 +49,8 @@ function handleComponentOperations(e: KeyboardEvent, selected: string[]) { const { setSelected } = useEditorStore.getState(); if (e.key === "Backspace") { - remove(selected); setSelected([]); + remove(selected); } else if (e.key === "ArrowUp") { modifyComponentProp(selected, "bounds.verts", (prev: Vec2[]) => translate(prev, { x: 0, y: -5 }) diff --git a/frontend/src/features/authoring/handlers/pointer/pointer.ts b/frontend/src/features/authoring/handlers/pointer/pointer.ts index 727c847b..cd9f4aae 100644 --- a/frontend/src/features/authoring/handlers/pointer/pointer.ts +++ b/frontend/src/features/authoring/handlers/pointer/pointer.ts @@ -98,6 +98,8 @@ function handleComponentClick(e: React.MouseEvent, position: Vec2) { // * DONE Object creation // ! fix object mutationbounds visual // ! MultiSelect rotation + // ! fix delete + // ! change add implementation to take in string of ids const component = scene[target.dataset.id as string]; setMutationBounds({ ...component.bounds }); From 15071f410b2be625879a6ccce2ebe82b73753642 Mon Sep 17 00:00:00 2001 From: K1mmyn Date: Fri, 29 May 2026 00:41:41 +1200 Subject: [PATCH 07/14] Fix make redo undo code better --- .../authoring/handlers/keyboard/keyboard.ts | 6 +- .../authoring/handlers/pointer/create.ts | 1 - .../authoring/handlers/pointer/pointer.ts | 3 +- .../src/features/authoring/scene/history.ts | 75 ++++++++----------- .../src/features/authoring/topbar/Topbar.tsx | 6 +- 5 files changed, 40 insertions(+), 51 deletions(-) diff --git a/frontend/src/features/authoring/handlers/keyboard/keyboard.ts b/frontend/src/features/authoring/handlers/keyboard/keyboard.ts index 0f2361d7..79fbd6fe 100644 --- a/frontend/src/features/authoring/handlers/keyboard/keyboard.ts +++ b/frontend/src/features/authoring/handlers/keyboard/keyboard.ts @@ -1,4 +1,4 @@ -import { redo, undo } from "../../scene/history"; +import { handleUndoRedo } from "../../scene/history"; import { bringForward, bringToFront, @@ -30,8 +30,8 @@ function handleCtrlOperations(e: KeyboardEvent) { const { selected, setSelected } = useEditorStore.getState(); // TODO ADD e.key === "a" for all components or create new function for cmd - if (e.key === "z") undo(); - else if (e.key === "y") redo(); + if (e.key === "z") handleUndoRedo(true); + else if (e.key === "y") handleUndoRedo(false); else if (e.key === "d" && selected) { e.preventDefault(); const ids = duplicateComponent(selected); diff --git a/frontend/src/features/authoring/handlers/pointer/create.ts b/frontend/src/features/authoring/handlers/pointer/create.ts index 17f3bf9d..69a41123 100644 --- a/frontend/src/features/authoring/handlers/pointer/create.ts +++ b/frontend/src/features/authoring/handlers/pointer/create.ts @@ -28,7 +28,6 @@ export function handleCreateDrag(_: React.MouseEvent, position: Vec2) { if (createType === "speech") verts.push(getTailVert(verts)); setMutationBounds((prev) => ({ ...prev, verts })); - addMode("mutation"); } diff --git a/frontend/src/features/authoring/handlers/pointer/pointer.ts b/frontend/src/features/authoring/handlers/pointer/pointer.ts index cd9f4aae..a7fd80c9 100644 --- a/frontend/src/features/authoring/handlers/pointer/pointer.ts +++ b/frontend/src/features/authoring/handlers/pointer/pointer.ts @@ -98,7 +98,8 @@ function handleComponentClick(e: React.MouseEvent, position: Vec2) { // * DONE Object creation // ! fix object mutationbounds visual // ! MultiSelect rotation - // ! fix delete + // * DONE fix delete + // * Fix Undo Redo // ! change add implementation to take in string of ids const component = scene[target.dataset.id as string]; diff --git a/frontend/src/features/authoring/scene/history.ts b/frontend/src/features/authoring/scene/history.ts index d485da82..53812659 100644 --- a/frontend/src/features/authoring/scene/history.ts +++ b/frontend/src/features/authoring/scene/history.ts @@ -9,6 +9,7 @@ import { } from "./scene"; import useVisualScene from "../stores/visual"; import { buildVisualComponent } from "../pipeline"; +import useEditorStore from "../stores/editor"; interface SceneRef { _id: string; @@ -66,50 +67,42 @@ export function updateHistory(incomingChanges: ChangeRecord[]) { redoStack = []; } -export function undo() { - const batch = undoStack.pop(); +export function handleUndoRedo(isUndo: boolean) { + const batch = isUndo ? undoStack.pop() : redoStack.pop(); if (!batch || batch.length === 0) return; - switchToScene(batch[0].sceneId); + const sceneID = batch[0].sceneId; + const ids = batch.map((obj) => obj.id); + const isDelete = batch[0].state === null; - let redoChanges: HistoryObject[] = []; + switchToScene(sceneID); - batch.forEach((prev) => { - const current = getComponent(prev.id); - const stateToSave = structuredClone(current); - - restoreComponent(prev.id, prev.state); - redoChanges.push({ - sceneId: prev.sceneId, - id: prev.id, - state: stateToSave, - }); + const validChanges: HistoryObject[] = ids.map((id) => { + const comp = getComponent(id); + return { + sceneId: sceneID, + id, + state: structuredClone(comp), + }; }); - redoStack.push(redoChanges); -} - -export function redo() { - const batch = redoStack.pop(); - if (!batch || batch.length === 0) return; - - switchToScene(batch[0].sceneId); - - let undoChanges: HistoryObject[] = []; - - batch.forEach((prev) => { - const current = getComponent(prev.id); - const stateToSave = structuredClone(current); - - restoreComponent(prev.id, prev.state); - undoChanges.push({ - sceneId: prev.sceneId, - id: prev.id, - state: stateToSave, + if (isDelete) { + useEditorStore.getState().setSelected([]); + ids.forEach((id) => { + delete getScene().components[id]; + useVisualScene.getState().deleteComponent(id); }); - }); + } else { + batch.forEach((obj) => { + restoreComponent(obj.id, obj.state); + }); + } - undoStack.push(undoChanges); + if (isUndo) { + redoStack.push(validChanges); + } else { + undoStack.push(validChanges); + } } function switchToScene(targetSceneId: string) { @@ -121,11 +114,7 @@ function switchToScene(targetSceneId: string) { } function restoreComponent(id: string, state: Component | null) { - if (state === null) { - delete getScene().components[id]; - useVisualScene.getState().deleteComponent(id); - } else { - getScene().components[id] = structuredClone(state); - useVisualScene.getState().updateComponent(buildVisualComponent(state)); - } + if (state === null) return; + getScene().components[id] = structuredClone(state); + useVisualScene.getState().updateComponent(buildVisualComponent(state)); } diff --git a/frontend/src/features/authoring/topbar/Topbar.tsx b/frontend/src/features/authoring/topbar/Topbar.tsx index 50c189a1..6dc5a48a 100644 --- a/frontend/src/features/authoring/topbar/Topbar.tsx +++ b/frontend/src/features/authoring/topbar/Topbar.tsx @@ -9,7 +9,7 @@ import ShapeSection from "./ShapeSection"; import TextSection from "./TextSection"; import useEditorStore from "../stores/editor"; import { getComponent } from "../scene/scene"; -import { redo, undo } from "../scene/history"; +import { handleUndoRedo } from "../scene/history"; import { bringToFront, sendToBack } from "../scene/operations/component"; import { useState } from "react"; import StateVariableMenu from "../../../components/StateVariables/StateVariableMenu"; @@ -47,12 +47,12 @@ function Topbar({ saving, save }: { saving: boolean; save: () => void }) {
  • - + handleUndoRedo(true)}>
  • - + handleUndoRedo(false)}>
  • From e96180ad9bbacebec0e72e572b3b3a18cd2c1e09 Mon Sep 17 00:00:00 2001 From: K1mmyn Date: Fri, 29 May 2026 01:25:19 +1200 Subject: [PATCH 08/14] fix copy to clipboard and paste for multi-select --- .../authoring/handlers/keyboard/clipboard.ts | 94 +++++++++++++------ 1 file changed, 64 insertions(+), 30 deletions(-) diff --git a/frontend/src/features/authoring/handlers/keyboard/clipboard.ts b/frontend/src/features/authoring/handlers/keyboard/clipboard.ts index 20524bba..0ea18958 100644 --- a/frontend/src/features/authoring/handlers/keyboard/clipboard.ts +++ b/frontend/src/features/authoring/handlers/keyboard/clipboard.ts @@ -12,7 +12,7 @@ import { import { getComponent } from "../../scene/scene"; import useEditorStore from "../../stores/editor"; import { syncVisualCursor } from "../../text/cursor"; -import type { ModelDocument } from "../../types"; +import type { Component, ModelDocument } from "../../types"; function plainToDoc(text: string) { const plainBlocks = text.split("\n"); @@ -48,60 +48,94 @@ export function paste(e: ClipboardEvent) { const { mode, selected, selection, setSelected, setSelection } = useEditorStore.getState(); - const app = e.clipboardData?.getData("application/component"); - const text = e.clipboardData?.getData("text/plain"); + const components = e.clipboardData?.getData("application/component"); + const plainTexts = e.clipboardData?.getData("text/plain"); if (selected && mode.includes("text")) { - if (app) { - const obj = JSON.parse(app); - const doc = obj.type === "textbox" ? obj.document : obj; - const cursor = mergeDocs(selected, selection.start!, doc); - setSelection({ start: cursor, end: null }); - } else if (text) { - const doc = plainToDoc(text) as ModelDocument; - const cursor = mergeDocs(selected, selection.start!, doc); - setSelection({ start: cursor, end: null }); - } - syncVisualCursor(); + return; + // ! OLD + // if (components) { + // const obj = JSON.parse(components); + // const doc = obj.type === "textbox" ? obj.document : obj; + // const cursor = mergeDocs(selected, selection.start!, doc); + // setSelection({ start: cursor, end: null }); + // } else if (plainTexts) { + // const doc = plainToDoc(plainTexts) as ModelDocument; + // const cursor = mergeDocs(selected, selection.start!, doc); + // setSelection({ start: cursor, end: null }); + // } + // syncVisualCursor(); + + // * GPT answer might be correct but no way to test without text selection + // let cursor = selection.start!; + + // if (appData) { + // const parsed = JSON.parse(appData); + // // Ensure we are working with an array + // const items = Array.isArray(parsed) ? parsed : [parsed]; + + // // Merge all documents in the array sequentially + // for (const item of items) { + // const doc = item.type === "textbox" ? item.document : item; + // cursor = mergeDocs(selected, cursor, doc); + // } + // setSelection({ start: cursor, end: null }); + // } else if (textData) { + // const doc = plainToDoc(textData) as ModelDocument; + // cursor = mergeDocs(selected, cursor, doc); + // setSelection({ start: cursor, end: null }); + // } + // syncVisualCursor(); } else { - let newSelection = []; - if (app) { - const obj = JSON.parse(app); + if (!components) return; + + let newSelection: string[] = []; + const parsed = JSON.parse(components); + const items = Array.isArray(parsed) ? parsed : [parsed]; + + items.forEach((obj) => { if (obj.type) { newSelection.push(parseComponent(obj)); } else { + //! IMPORTANT I dont think this will ever run because ever copied component has a type const component = structuredClone(defaults["textbox"]); component.document = structuredClone(obj); newSelection.push(add(component)); } - } else if (text) { - const doc = plainToDoc(text); - const component = structuredClone(defaults["textbox"]); - component.document = structuredClone(doc); - newSelection.push(add(component)); - } + }); + setSelected(newSelection); } } function addToClipboard(e: ClipboardEvent, selected: string[]) { const { mode, selection } = useEditorStore.getState(); + + const plainTextChunks: string[] = []; + const components: Component[] = []; selected.forEach((id: string) => { if (mode.includes("text")) { if (!selection.end) return; const { text, doc } = getSelectionContent(id, selection); - e.clipboardData?.setData("text/plain", text); - e.clipboardData?.setData("application/component", JSON.stringify(doc)); + if (text) plainTextChunks.push(text); + if (doc) components.push(doc); } else { - e.clipboardData?.setData( - "application/component", - stringifyComponent(id) || "" - ); + components.push(getComponent(id)); if (getComponent(id).type === "textbox") { const text = getDocumentText(id); - e.clipboardData?.setData("text/plain", text); + if (text) plainTextChunks.push(text); } } }); + + if (plainTextChunks.length > 0) { + e.clipboardData?.setData("text/plain", plainTextChunks.join("\n")); + } + if (components.length > 0) { + e.clipboardData?.setData( + "application/component", + JSON.stringify(components) + ); + } } From 881a1dfb31b93bb91b2b4a3b6d4b6910b472e73d Mon Sep 17 00:00:00 2001 From: K1mmyn Date: Mon, 1 Jun 2026 14:36:24 +1200 Subject: [PATCH 09/14] fix Overlay when object is being create and front-back-forward-backwards implementation --- .../src/features/authoring/canvas/Overlay.tsx | 15 +- .../handlers/pointer/ComponentContext.tsx | 20 +-- .../authoring/handlers/pointer/context.ts | 6 +- .../authoring/handlers/pointer/create.ts | 2 +- .../authoring/handlers/pointer/pointer.ts | 9 +- .../authoring/scene/operations/component.ts | 128 +++++++++++------- 6 files changed, 98 insertions(+), 82 deletions(-) diff --git a/frontend/src/features/authoring/canvas/Overlay.tsx b/frontend/src/features/authoring/canvas/Overlay.tsx index 22c2b50f..573ff5cd 100644 --- a/frontend/src/features/authoring/canvas/Overlay.tsx +++ b/frontend/src/features/authoring/canvas/Overlay.tsx @@ -46,19 +46,10 @@ function Overlay() { const { selected, mode, createType, mutationBounds } = useEditorStore.getState(); - if (!selected || selected.length === 0) { - return ( - - ); - } - const components = useVisualScene.getState().components; - const primaryComponent = components[selected[0]]; + const primaryComponent = + selected.length === 0 ? null : components[selected[0]]; const bounds = getSelectedComponentBounds(); const verts = bounds.verts; @@ -69,7 +60,7 @@ function Overlay() { className="w-full h-full absolute pointer-events-none" viewBox={`-50 -50 ${1920 + 50 * 2} ${1080 + 50 * 2}`} > - {components && ( + {primaryComponent && ( <> { - function removeAndDeselect(id: string) { - remove(id); - useEditorStore.getState().setSelected(null); +const ComponentMenu = (ids: string[]) => { + function removeAndDeselect(selectedIds: string[]) { + remove(selectedIds); + useEditorStore.getState().setSelected([]); } return (
    • - + Duplicate
    • - + Delete
    • - + Bring Forward
    • - + Bring to Front
    • - + Send Backward
    • - + Send to Back diff --git a/frontend/src/features/authoring/handlers/pointer/context.ts b/frontend/src/features/authoring/handlers/pointer/context.ts index 8292e58f..c283acf4 100644 --- a/frontend/src/features/authoring/handlers/pointer/context.ts +++ b/frontend/src/features/authoring/handlers/pointer/context.ts @@ -1,4 +1,5 @@ import { render } from "../../../../components/ContextMenu/portal"; +import useEditorStore from "../../stores/editor"; import type { Vec2 } from "../../types"; import ComponentMenu from "./ComponentContext"; @@ -11,12 +12,11 @@ export function handleContextGlobal(e: React.MouseEvent, position: Vec2) { } function handleComponentContext(e: React.MouseEvent, _: Vec2) { - const target = e.target as HTMLElement; - const id = target.dataset.id as string; + const { selected } = useEditorStore.getState(); e.preventDefault(); render({ - menu: ComponentMenu({ id }), + menu: ComponentMenu(selected), position: { x: e.clientX, y: e.clientY }, }); } diff --git a/frontend/src/features/authoring/handlers/pointer/create.ts b/frontend/src/features/authoring/handlers/pointer/create.ts index 69a41123..6fadedd3 100644 --- a/frontend/src/features/authoring/handlers/pointer/create.ts +++ b/frontend/src/features/authoring/handlers/pointer/create.ts @@ -34,7 +34,7 @@ export function handleCreateDrag(_: React.MouseEvent, position: Vec2) { export function handleCreateEnd() { const { mutationBounds, setMode, setSelected, createType } = useEditorStore.getState(); - const id = createComponentFromBounds(createType!, mutationBounds); + const id = createComponentFromBounds(createType, mutationBounds); setSelected([id]); setMode(["normal"]); } diff --git a/frontend/src/features/authoring/handlers/pointer/pointer.ts b/frontend/src/features/authoring/handlers/pointer/pointer.ts index a7fd80c9..d0dba004 100644 --- a/frontend/src/features/authoring/handlers/pointer/pointer.ts +++ b/frontend/src/features/authoring/handlers/pointer/pointer.ts @@ -91,16 +91,17 @@ function handleComponentClick(e: React.MouseEvent, position: Vec2) { // ! Text Selection Broken // ! Clipboard text selection - // ! Front back implementation + // * DONE Front back implementation // * DONE Resize // * DONE Fix copy and paste // ! npm i --save-dev @types/uuid for another type // * DONE Object creation - // ! fix object mutationbounds visual + // * DONE fix object mutationbounds visual // ! MultiSelect rotation // * DONE fix delete - // * Fix Undo Redo - // ! change add implementation to take in string of ids + // * DONE Fix Undo Redo + // * DONE Fix Copy Paste + // ! change add implementation to take in string of ids (dont need?) const component = scene[target.dataset.id as string]; setMutationBounds({ ...component.bounds }); diff --git a/frontend/src/features/authoring/scene/operations/component.ts b/frontend/src/features/authoring/scene/operations/component.ts index e6f9f810..d23f676b 100644 --- a/frontend/src/features/authoring/scene/operations/component.ts +++ b/frontend/src/features/authoring/scene/operations/component.ts @@ -166,74 +166,98 @@ export function modifyComponentBounds( } export function bringForward(ids: string[]) { - if (ids.length > 1) return; - const id = ids[0]; - const currentZIndex = getComponentProp(id, "zIndex") as number; + if (!ids.length) return; + const components = Object.values(getScene().components) as Component[]; + const selectedIds = new Set(ids); - // 1. Find the highest zIndex that is strictly greater than the current one + const sortedComponents = components.sort((a, b) => a.zIndex - b.zIndex); - const targetComponent = components - .filter((curr) => curr.zIndex > currentZIndex) - .reduce( - (prev, curr) => { - return prev == null || prev.zIndex > curr.zIndex ? curr : prev; - }, - null as Component | null - ); + const zIndexScale = sortedComponents.map((c) => c.zIndex); - // Return if component is at the top already - if (!targetComponent) return; + for (let i = sortedComponents.length - 1; i >= 0; i--) { + if (selectedIds.has(sortedComponents[i].id)) { + if ( + i < sortedComponents.length - 1 && + !selectedIds.has(sortedComponents[i + 1].id) + ) { + const temp = sortedComponents[i]; + sortedComponents[i] = sortedComponents[i + 1]; + sortedComponents[i + 1] = temp; + } + } + } - const aboveZIndex = targetComponent.zIndex; + sortedComponents.forEach((comp, index) => { + const targetZIndex = zIndexScale[index]; - // Swap Zindexs - modifyComponentProp([id], "zIndex", aboveZIndex); - modifyComponentProp([targetComponent.id], "zIndex", currentZIndex); + if (comp.zIndex !== targetZIndex) { + modifyComponentProp([comp.id], "zIndex", targetZIndex); + } + }); } export function sendBackward(ids: string[]) { - if (ids.length > 1) return; - const id = ids[0]; - const currentZIndex = getComponentProp(id, "zIndex") as number; + if (!ids.length) return; + const components = Object.values(getScene().components) as Component[]; + const selectedIds = new Set(ids); + + const sortedComponents = components.sort((a, b) => a.zIndex - b.zIndex); + + const zIndexScale = sortedComponents.map((c) => c.zIndex); - // 1. Find the highest zIndex that is strictly less than the current one - const targetComponent = components - .filter((curr) => curr.zIndex < currentZIndex) - .reduce( - (prev, curr) => { - return prev == null || prev.zIndex < curr.zIndex ? curr : prev; - }, - null as Component | null - ); - - if (!targetComponent) return; - const belowZIndex = targetComponent.zIndex; - modifyComponentProp([id], "zIndex", belowZIndex); - modifyComponentProp([targetComponent.id], "zIndex", currentZIndex); + for (let i = 0; i < sortedComponents.length; i++) { + if (selectedIds.has(sortedComponents[i].id)) { + if (i > 0 && !selectedIds.has(sortedComponents[i - 1].id)) { + const temp = sortedComponents[i]; + sortedComponents[i] = sortedComponents[i - 1]; + sortedComponents[i - 1] = temp; + } + } + } + + sortedComponents.forEach((comp, index) => { + const targetZIndex = zIndexScale[index]; + + if (comp.zIndex !== targetZIndex) { + modifyComponentProp([comp.id], "zIndex", targetZIndex); + } + }); } +function moveComponentFrontAndBack(ids: string[], state: "front" | "back") { + if (!ids.length) return; -export function bringToFront(ids: string[]) { - if (ids.length > 1) return; - const id = ids[0]; const components = Object.values(getScene().components) as Component[]; - const max = components.reduce( - (p, c) => (c.zIndex >= p ? c.zIndex : p), - -Infinity + const selectedIds = new Set(ids); + + const sortedComponents = components.sort((a, b) => a.zIndex - b.zIndex); + const zIndexScale = sortedComponents.map((c) => c.zIndex); + const selectedComponents = sortedComponents.filter((comp) => + selectedIds.has(comp.id) + ); + const unselectedComponents = sortedComponents.filter( + (comp) => !selectedIds.has(comp.id) ); - if (max == -Infinity) return; - modifyComponentProp([id], "zIndex", max + 1); + + const newSortedComponents = + state == "front" + ? [...unselectedComponents, ...selectedComponents] + : [...selectedComponents, ...unselectedComponents]; + + newSortedComponents.forEach((comp, index) => { + const targetZIndex = zIndexScale[index]; + + if (comp.zIndex !== targetZIndex) { + modifyComponentProp([comp.id], "zIndex", targetZIndex); + } + }); +} + +export function bringToFront(ids: string[]) { + moveComponentFrontAndBack(ids, "front"); } export function sendToBack(ids: string[]) { - if (ids.length > 1) return; - const id = ids[0]; - const components = Object.values(getScene().components) as Component[]; - const min = components.reduce( - (p, c) => (c.zIndex <= p ? c.zIndex : p), - Infinity - ); - if (min == Infinity) return; - modifyComponentProp([id], "zIndex", min - 1); + moveComponentFrontAndBack(ids, "back"); } From 14e8bb9244447de5f51384d46e244ee355eab544 Mon Sep 17 00:00:00 2001 From: K1mmyn Date: Mon, 1 Jun 2026 16:32:34 +1200 Subject: [PATCH 10/14] Fix text selection --- .../authoring/handlers/keyboard/text.ts | 16 ++++----- .../authoring/handlers/pointer/pointer.ts | 3 +- .../authoring/scene/operations/text.ts | 36 +++++++++---------- .../src/features/authoring/text/Cursor.tsx | 4 +-- frontend/src/features/authoring/text/Text.tsx | 2 +- .../src/features/authoring/text/cursor.ts | 9 ++--- 6 files changed, 33 insertions(+), 37 deletions(-) diff --git a/frontend/src/features/authoring/handlers/keyboard/text.ts b/frontend/src/features/authoring/handlers/keyboard/text.ts index 3af194d1..d5ad1695 100644 --- a/frontend/src/features/authoring/handlers/keyboard/text.ts +++ b/frontend/src/features/authoring/handlers/keyboard/text.ts @@ -21,15 +21,15 @@ import type { VisualBlock, VisualSelection } from "../../text/types"; export function handleTextMode(e: KeyboardEvent) { const { selected } = useEditorStore.getState(); - if (!selected) return; + if (!selected || selected.length != 1) return; if ((e.metaKey || e.ctrlKey) && e.key == "a") { e.preventDefault(); - handleSelectAll(selected); + handleSelectAll(selected[0]); } else if (e.key.startsWith("Arrow") || ["Home", "End"].includes(e.key)) { - handleNavigation(e, selected); + handleNavigation(e, selected[0]); } else { - handleEditing(e, selected); + handleEditing(e, selected[0]); } } @@ -75,17 +75,17 @@ function handleEditing(e: KeyboardEvent, selected: string) { // insert character at cursor const newCursor = end ? insertSelection(selected, selection, e.key) - : insertChar(selected, start, e.key); + : insertChar([selected], start, e.key); setSelection({ start: newCursor, end: null }); } else if (e.key === "Backspace") { // delete character before cursor const newCursor = !end - ? deleteChar(selected, start) - : deleteSelection(selected, selection); + ? deleteChar([selected], start) + : deleteSelection([selected], selection); setSelection({ start: newCursor, end: null }); } else if (e.key === "Enter") { // create a new block at cursor - const newCursor = createBlock(selected, start); + const newCursor = createBlock([selected], start); setSelection({ start: newCursor, end }); } else if (e.key === "Escape") { // clear current selection diff --git a/frontend/src/features/authoring/handlers/pointer/pointer.ts b/frontend/src/features/authoring/handlers/pointer/pointer.ts index d0dba004..3aac29bb 100644 --- a/frontend/src/features/authoring/handlers/pointer/pointer.ts +++ b/frontend/src/features/authoring/handlers/pointer/pointer.ts @@ -101,7 +101,6 @@ function handleComponentClick(e: React.MouseEvent, position: Vec2) { // * DONE fix delete // * DONE Fix Undo Redo // * DONE Fix Copy Paste - // ! change add implementation to take in string of ids (dont need?) const component = scene[target.dataset.id as string]; setMutationBounds({ ...component.bounds }); @@ -290,10 +289,12 @@ function handleDocumentClick(e: React.MouseEvent, position: Vec2) { function handleTextSelection(_: React.MouseEvent, position: Vec2) { const { selected, setVisualSelection } = useEditorStore.getState(); + const { document: doc } = useVisualScene.getState().components[selected[0]]; const cursor = parseHit( getRelativePosition(position, doc.bounds), doc.blocks ); + setVisualSelection((prev) => ({ start: prev.start, end: cursor })); } diff --git a/frontend/src/features/authoring/scene/operations/text.ts b/frontend/src/features/authoring/scene/operations/text.ts index 1a055553..04243620 100644 --- a/frontend/src/features/authoring/scene/operations/text.ts +++ b/frontend/src/features/authoring/scene/operations/text.ts @@ -13,8 +13,8 @@ import shallow from "zustand/shallow"; import { modify } from "./modifiers"; export const insertChar = modify( - (id: string, cursor: ModelCursor, char: string) => { - const doc = getComponentProp(id, "document") as ModelDocument; + (id: string[], cursor: ModelCursor, char: string) => { + const doc = getComponentProp(id[0], "document") as ModelDocument; const diff = objectDiff( useEditorStore.getState().activeStyle!, @@ -39,16 +39,16 @@ export const insertChar = modify( else block.spans.splice(cursor.spanI, 0, { text: char, style: diff }); // block start } - return moveCursor(id, cursor, 1); + return moveCursor(id[0], cursor, 1); } ); -export const deleteChar = modify((id: string, cursor: ModelCursor) => { +export const deleteChar = modify((id: string[], cursor: ModelCursor) => { if (!cursor.blockI && !cursor.spanI && !cursor.charI) return cursor; // start of text - const newCursor = moveCursor(id, cursor, -1); + const newCursor = moveCursor(id[0], cursor, -1); - const doc = getComponentProp(id, `document`) as ModelDocument; + const doc = getComponentProp(id[0], `document`) as ModelDocument; const spans = doc.blocks[cursor.blockI].spans; if (newCursor.blockI === cursor.blockI && newCursor.spanI === cursor.spanI) { @@ -81,16 +81,16 @@ export const deleteChar = modify((id: string, cursor: ModelCursor) => { // NOTE: will cause two distinct state operations in history export function insertSelection(id: string, sel: ModelSelection, char: string) { - const cursor = deleteSelection(id, sel); - return insertChar(id, cursor, char); + const cursor = deleteSelection([id], sel); + return insertChar([id], cursor, char); } -export const deleteSelection = modify((id: string, sel: ModelSelection) => { - const doc = getComponentProp(id, `document`) as ModelDocument; +export const deleteSelection = modify((id: string[], sel: ModelSelection) => { + const doc = getComponentProp(id[0], `document`) as ModelDocument; const { blocks } = doc; const normd = normaliseSelection(sel); - const { start, end } = isolateSelection(id, normd); + const { start, end } = isolateSelection(id[0], normd); const startBlock = blocks[start.blockI]; const endBlock = blocks[end.blockI]; @@ -111,8 +111,8 @@ export const deleteSelection = modify((id: string, sel: ModelSelection) => { return normaliseDocument(doc, start); }); -export const createBlock = modify((id: string, cursor: ModelCursor) => { - const blocks = getComponentProp(id, `document.blocks`) as ModelBlock[]; +export const createBlock = modify((id: string[], cursor: ModelCursor) => { + const blocks = getComponentProp(id[0], `document.blocks`) as ModelBlock[]; const block = blocks[cursor.blockI]; splitSpan(blocks, cursor); @@ -142,11 +142,11 @@ export const createBlock = modify((id: string, cursor: ModelCursor) => { }); export const applySelectionStyle = modify( - (id: string, sel: ModelSelection, style: Partial) => { - const doc = getComponentProp(id, `document`) as ModelDocument; + (id: string[], sel: ModelSelection, style: Partial) => { + const doc = getComponentProp(id[0], `document`) as ModelDocument; const { blocks } = doc; - const { start, end } = isolateSelection(id, normaliseSelection(sel)); + const { start, end } = isolateSelection(id[0], normaliseSelection(sel)); if (style.alignment || style.lineHeight) { for (let i = sel.start!.blockI; i <= sel.end!.blockI; i++) { @@ -449,8 +449,8 @@ function getExtremeCursor(doc: ModelDocument) { } export const mergeDocs = modify( - (id: string, cursor: ModelCursor, doc: ModelDocument) => { - const original = getComponentProp(id, "document") as ModelDocument; + (id: string[], cursor: ModelCursor, doc: ModelDocument) => { + const original = getComponentProp(id[0], "document") as ModelDocument; squashSpanStyles(doc); const extreme = getExtremeCursor(doc); diff --git a/frontend/src/features/authoring/text/Cursor.tsx b/frontend/src/features/authoring/text/Cursor.tsx index 78d07ae3..4c56a774 100644 --- a/frontend/src/features/authoring/text/Cursor.tsx +++ b/frontend/src/features/authoring/text/Cursor.tsx @@ -12,7 +12,7 @@ function Cursor({ bounds }: { bounds: RelativeBounds }) { const { selected } = useEditorStore.getState(); const { components } = useVisualScene.getState(); - const { blocks } = components[selected!].document; + const { blocks } = components[selected[0]!].document; const { start, end } = visualSelection; if (start == null || (end && !shallow(start, end))) return null; @@ -35,7 +35,7 @@ function Cursor({ bounds }: { bounds: RelativeBounds }) { y: bounds.y + bounds.height / 2, }; const path = expandToPath({ ...box, origin }); - const color = getStyleForSelection(selected!, modelSelection)?.textColor; + const color = getStyleForSelection(selected[0]!, modelSelection)?.textColor; return ; } diff --git a/frontend/src/features/authoring/text/Text.tsx b/frontend/src/features/authoring/text/Text.tsx index 2d224fdd..bfc8a840 100644 --- a/frontend/src/features/authoring/text/Text.tsx +++ b/frontend/src/features/authoring/text/Text.tsx @@ -31,7 +31,7 @@ function Text({ doc, editable }: { doc: VisualDocument; editable?: boolean }) { editable ? state.selected : null ); - const isSelected = editable && selected === doc.id; + const isSelected = editable && selected && selected[0] === doc.id; const { bounds } = doc; const center = { diff --git a/frontend/src/features/authoring/text/cursor.ts b/frontend/src/features/authoring/text/cursor.ts index d15a0803..ea27ef76 100644 --- a/frontend/src/features/authoring/text/cursor.ts +++ b/frontend/src/features/authoring/text/cursor.ts @@ -114,13 +114,8 @@ export function syncModelSelection() { const editorState = useEditorStore.getState(); // Assume only one object is selected - if ( - !editorState.selected || - editorState.selected.length !== 1 || - !editorState.visualSelection.start - ) - return; - if (!editorState.selected || !editorState.visualSelection.start) return; + if (!editorState.selected || editorState.selected.length !== 1) return; + const blocks = useVisualScene.getState().components[editorState.selected[0]].document .blocks; From 4bbc166a30ee4e63b529b480bc77960226720809 Mon Sep 17 00:00:00 2001 From: K1mmyn Date: Mon, 1 Jun 2026 17:17:45 +1200 Subject: [PATCH 11/14] Fix copy paste in text mode --- .../authoring/handlers/keyboard/clipboard.ts | 61 +++++++------------ .../authoring/handlers/keyboard/keyboard.ts | 1 + .../authoring/handlers/pointer/pointer.ts | 9 ++- .../src/features/authoring/text/Highlight.tsx | 2 +- 4 files changed, 29 insertions(+), 44 deletions(-) diff --git a/frontend/src/features/authoring/handlers/keyboard/clipboard.ts b/frontend/src/features/authoring/handlers/keyboard/clipboard.ts index 0ea18958..f1b4a01e 100644 --- a/frontend/src/features/authoring/handlers/keyboard/clipboard.ts +++ b/frontend/src/features/authoring/handlers/keyboard/clipboard.ts @@ -1,8 +1,4 @@ -import { - defaults, - parseComponent, - stringifyComponent, -} from "../../scene/operations/component"; +import { defaults, parseComponent } from "../../scene/operations/component"; import { add, remove } from "../../scene/operations/modifiers"; import { getDocumentText, @@ -52,44 +48,30 @@ export function paste(e: ClipboardEvent) { const plainTexts = e.clipboardData?.getData("text/plain"); if (selected && mode.includes("text")) { - return; - // ! OLD - // if (components) { - // const obj = JSON.parse(components); - // const doc = obj.type === "textbox" ? obj.document : obj; - // const cursor = mergeDocs(selected, selection.start!, doc); - // setSelection({ start: cursor, end: null }); - // } else if (plainTexts) { - // const doc = plainToDoc(plainTexts) as ModelDocument; - // const cursor = mergeDocs(selected, selection.start!, doc); - // setSelection({ start: cursor, end: null }); - // } - // syncVisualCursor(); - - // * GPT answer might be correct but no way to test without text selection - // let cursor = selection.start!; - - // if (appData) { - // const parsed = JSON.parse(appData); - // // Ensure we are working with an array - // const items = Array.isArray(parsed) ? parsed : [parsed]; - - // // Merge all documents in the array sequentially - // for (const item of items) { - // const doc = item.type === "textbox" ? item.document : item; - // cursor = mergeDocs(selected, cursor, doc); - // } - // setSelection({ start: cursor, end: null }); - // } else if (textData) { - // const doc = plainToDoc(textData) as ModelDocument; - // cursor = mergeDocs(selected, cursor, doc); - // setSelection({ start: cursor, end: null }); - // } - // syncVisualCursor(); + // This one I got gpt to write lowkey not to sure if its good + let cursor = selection.start!; + + if (components) { + const parsed = JSON.parse(components); + const items = Array.isArray(parsed) ? parsed : [parsed]; + + for (const item of items) { + const doc = item.type === "textbox" ? item.document : item; + cursor = mergeDocs(selected, cursor, doc); + } + setSelection({ start: cursor, end: null }); + } else if (plainTexts) { + const doc = plainToDoc(plainTexts) as ModelDocument; + cursor = mergeDocs(selected, cursor, doc); + setSelection({ start: cursor, end: null }); + } + syncVisualCursor(); } else { if (!components) return; + setSelected([]); let newSelection: string[] = []; + const parsed = JSON.parse(components); const items = Array.isArray(parsed) ? parsed : [parsed]; @@ -103,7 +85,6 @@ export function paste(e: ClipboardEvent) { newSelection.push(add(component)); } }); - setSelected(newSelection); } } diff --git a/frontend/src/features/authoring/handlers/keyboard/keyboard.ts b/frontend/src/features/authoring/handlers/keyboard/keyboard.ts index 79fbd6fe..5cd01592 100644 --- a/frontend/src/features/authoring/handlers/keyboard/keyboard.ts +++ b/frontend/src/features/authoring/handlers/keyboard/keyboard.ts @@ -18,6 +18,7 @@ export function handleGlobal(e: KeyboardEvent) { const { selected } = useEditorStore.getState(); // don't want to interfere with input elements + const target = e.target as HTMLElement; if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return; diff --git a/frontend/src/features/authoring/handlers/pointer/pointer.ts b/frontend/src/features/authoring/handlers/pointer/pointer.ts index 3aac29bb..50bb7046 100644 --- a/frontend/src/features/authoring/handlers/pointer/pointer.ts +++ b/frontend/src/features/authoring/handlers/pointer/pointer.ts @@ -89,19 +89,22 @@ function handleComponentClick(e: React.MouseEvent, position: Vec2) { setSelected([...selected, id]); } - // ! Text Selection Broken - // ! Clipboard text selection + // * DONE Text Selection Broken + // * DONE Clipboard text selection // * DONE Front back implementation // * DONE Resize // * DONE Fix copy and paste // ! npm i --save-dev @types/uuid for another type // * DONE Object creation // * DONE fix object mutationbounds visual - // ! MultiSelect rotation // * DONE fix delete // * DONE Fix Undo Redo // * DONE Fix Copy Paste + // ! MultiSelect rotation + // ! Text mode needs undo redo support + // ! Paste undo redo bug where undo only undos one at a time + const component = scene[target.dataset.id as string]; setMutationBounds({ ...component.bounds }); diff --git a/frontend/src/features/authoring/text/Highlight.tsx b/frontend/src/features/authoring/text/Highlight.tsx index db76b0c8..e273825b 100644 --- a/frontend/src/features/authoring/text/Highlight.tsx +++ b/frontend/src/features/authoring/text/Highlight.tsx @@ -111,7 +111,7 @@ function Highlight({ color }: HighlightProps) { const { selected } = useEditorStore.getState(); const { components } = useVisualScene.getState(); - const { blocks, bounds } = components[selected!].document; + const { blocks, bounds } = components[selected[0]!].document; if (!isValidSelection(selection, blocks)) return null; From d18101b2d0b1258267fd959b740948684ab7953a Mon Sep 17 00:00:00 2001 From: K1mmyn Date: Mon, 1 Jun 2026 17:50:42 +1200 Subject: [PATCH 12/14] Fix resolve coderabbit commnets --- .../features/authoring/AuthoringToolPage.jsx | 5 ++-- .../src/features/authoring/canvas/Overlay.tsx | 9 ++++--- .../canvas/handles/ConstrainedHandle.tsx | 20 +++++++++----- .../canvas/handles/RotationHandle.tsx | 25 +++++++++++------- .../authoring/handlers/keyboard/clipboard.ts | 17 +++++++++--- .../authoring/handlers/pointer/pointer.ts | 26 +++++++++---------- .../src/features/authoring/scene/history.ts | 11 ++++---- .../authoring/scene/operations/component.ts | 11 +++++--- .../authoring/scene/operations/modifiers.ts | 19 ++++++++------ .../src/features/authoring/stores/editor.ts | 5 ++-- .../src/features/authoring/text/cursor.ts | 8 +----- .../src/features/authoring/topbar/Topbar.tsx | 2 +- 12 files changed, 93 insertions(+), 65 deletions(-) diff --git a/frontend/src/features/authoring/AuthoringToolPage.jsx b/frontend/src/features/authoring/AuthoringToolPage.jsx index b1d7bec3..8af74fd0 100644 --- a/frontend/src/features/authoring/AuthoringToolPage.jsx +++ b/frontend/src/features/authoring/AuthoringToolPage.jsx @@ -49,8 +49,9 @@ export default function AuthoringToolPage() { useEffect(() => { const activeScene = localStorage.getItem(`${scenarioId}:activeScene`); - if (activeScene) replace(scenes.find((s) => s._id === activeScene)); - else replace(scenes[0]); + const found = scenes.find((s) => s._id === activeScene); + const target = found ?? scenes[0]; + if (target) replace(target); useEditorStore.getState().clear(); diff --git a/frontend/src/features/authoring/canvas/Overlay.tsx b/frontend/src/features/authoring/canvas/Overlay.tsx index 573ff5cd..f553ad57 100644 --- a/frontend/src/features/authoring/canvas/Overlay.tsx +++ b/frontend/src/features/authoring/canvas/Overlay.tsx @@ -43,10 +43,11 @@ function ResolveHandles({ } function Overlay() { - const { selected, mode, createType, mutationBounds } = - useEditorStore.getState(); - - const components = useVisualScene.getState().components; + const selected = useEditorStore((s) => s.selected); + const mode = useEditorStore((s) => s.mode); + const createType = useEditorStore((s) => s.createType); + const mutationBounds = useEditorStore((s) => s.mutationBounds); + const components = useVisualScene((s) => s.components); const primaryComponent = selected.length === 0 ? null : components[selected[0]]; diff --git a/frontend/src/features/authoring/canvas/handles/ConstrainedHandle.tsx b/frontend/src/features/authoring/canvas/handles/ConstrainedHandle.tsx index d77c0d53..56849610 100644 --- a/frontend/src/features/authoring/canvas/handles/ConstrainedHandle.tsx +++ b/frontend/src/features/authoring/canvas/handles/ConstrainedHandle.tsx @@ -8,18 +8,24 @@ interface Props { } const ResizeHandle = ({ x, y }: Props) => { - const { mode } = useEditorStore.getState(); + const mode = useEditorStore((s) => s.mode); - const bounds = getSelectedComponentBounds(); - const verts = bounds.verts; - const componentRotation = bounds.rotation; + const componentBounds = getSelectedComponentBounds(); + const componentVerts = componentBounds.verts; + const componentRotation = componentBounds.rotation; let point = { - x: x === 0.5 ? (verts[0].x + verts[1].x) / 2 : verts[x].x, - y: y === 0.5 ? (verts[0].y + verts[1].y) / 2 : verts[y].y, + x: + x === 0.5 + ? (componentVerts[0].x + componentVerts[1].x) / 2 + : componentVerts[x].x, + y: + y === 0.5 + ? (componentVerts[0].y + componentVerts[1].y) / 2 + : componentVerts[y].y, }; - point = rotate(point, getBoxCenter(verts), componentRotation); + point = rotate(point, getBoxCenter(componentVerts), componentRotation); return ( { - const { mode } = useEditorStore.getState(); + const mode = useEditorStore((s) => s.mode); - const bounds = getSelectedComponentBounds(); - const verts = bounds.verts; - const componentRotation = bounds.rotation; + const componentBounds = getSelectedComponentBounds(); + const componentVerts = componentBounds.verts; + const componentRotation = componentBounds.rotation; - const center = getBoxCenter(bounds.verts); + const componentCenter = getBoxCenter(componentBounds.verts); - const y = Math.min(verts[0].y, verts[1].y); + const y = Math.min(componentVerts[0].y, componentVerts[1].y); - const initial = rotate({ x: center.x, y }, center, componentRotation); - const point = rotate({ x: center.x, y: y - 40 }, center, componentRotation); + const initial = rotate( + { x: componentCenter.x, y }, + componentCenter, + componentRotation + ); + const point = rotate( + { x: componentCenter.x, y: y - 40 }, + componentCenter, + componentRotation + ); return ( { diff --git a/frontend/src/features/authoring/handlers/pointer/pointer.ts b/frontend/src/features/authoring/handlers/pointer/pointer.ts index 50bb7046..939a78c5 100644 --- a/frontend/src/features/authoring/handlers/pointer/pointer.ts +++ b/frontend/src/features/authoring/handlers/pointer/pointer.ts @@ -94,16 +94,17 @@ function handleComponentClick(e: React.MouseEvent, position: Vec2) { // * DONE Front back implementation // * DONE Resize // * DONE Fix copy and paste - // ! npm i --save-dev @types/uuid for another type // * DONE Object creation // * DONE fix object mutationbounds visual // * DONE fix delete // * DONE Fix Undo Redo // * DONE Fix Copy Paste - // ! MultiSelect rotation - // ! Text mode needs undo redo support - // ! Paste undo redo bug where undo only undos one at a time + // ! TODO MultiSelect rotation + // ! TODO Text mode needs undo redo support + // ! TODO Paste undo redo bug where undo only undos one at a time + + // ! ERROR npm i --save-dev @types/uuid for another type const component = scene[target.dataset.id as string]; setMutationBounds({ ...component.bounds }); @@ -201,18 +202,16 @@ function getNewResizePosition( origin: Vec2, scaleVec: Vec2 ) { + const result: Vec2[] = []; for (let i = 0; i < 2; i++) { const vert = verts[i]; - - // Vert.var - origin.var is the distance from original top-left corner - // This is then scaled based on change in size and is mapped to new top-left corner - vert.x = newVerts[0].x + (vert.x - origin.x) * scaleVec.x; - vert.y = newVerts[0].y + (vert.y - origin.y) * scaleVec.y; - - verts[i] = vert; + result.push({ + x: newVerts[0].x + (vert.x - origin.x) * scaleVec.x, + y: newVerts[0].y + (vert.y - origin.y) * scaleVec.y, + }); } - return verts; + return result; } function getSelectedMinMaxXY() { @@ -241,7 +240,7 @@ function getSelectedMinMaxXY() { function findComponentRotation(id: string) { const components = useVisualScene.getState().components; - return components[id].bounds.rotation; + return components[id]?.bounds?.rotation ?? 0; } export function getSelectedComponentBounds() { @@ -293,6 +292,7 @@ function handleDocumentClick(e: React.MouseEvent, position: Vec2) { function handleTextSelection(_: React.MouseEvent, position: Vec2) { const { selected, setVisualSelection } = useEditorStore.getState(); + if (!selected || selected.length === 0) return; const { document: doc } = useVisualScene.getState().components[selected[0]]; const cursor = parseHit( getRelativePosition(position, doc.bounds), diff --git a/frontend/src/features/authoring/scene/history.ts b/frontend/src/features/authoring/scene/history.ts index 53812659..118b649a 100644 --- a/frontend/src/features/authoring/scene/history.ts +++ b/frontend/src/features/authoring/scene/history.ts @@ -75,14 +75,14 @@ export function handleUndoRedo(isUndo: boolean) { const ids = batch.map((obj) => obj.id); const isDelete = batch[0].state === null; - switchToScene(sceneID); + if (!switchToScene(sceneID)) return; const validChanges: HistoryObject[] = ids.map((id) => { const comp = getComponent(id); return { sceneId: sceneID, id, - state: structuredClone(comp), + state: comp ? structuredClone(comp) : null, }; }); @@ -105,12 +105,13 @@ export function handleUndoRedo(isUndo: boolean) { } } -function switchToScene(targetSceneId: string) { - if (targetSceneId === getSceneId()) return; +function switchToScene(targetSceneId: string): boolean { + if (targetSceneId === getSceneId()) return true; const targetScene = scenes.find((s) => s._id === targetSceneId); - if (!targetScene) return; + if (!targetScene) return false; if (saveScene) void saveCurrentScene(saveScene); applySceneSwitch(targetScene, scenarioId!); + return true; } function restoreComponent(id: string, state: Component | null) { diff --git a/frontend/src/features/authoring/scene/operations/component.ts b/frontend/src/features/authoring/scene/operations/component.ts index d23f676b..784b37fd 100644 --- a/frontend/src/features/authoring/scene/operations/component.ts +++ b/frontend/src/features/authoring/scene/operations/component.ts @@ -122,10 +122,13 @@ export function parseComponent(component: Component) { } export function duplicateComponent(ids: string[]) { - return ids.map((id: string) => { - const newComponent = structuredClone(getComponent(id)); - return parseComponent(newComponent); - }); + return ids + .map((id: string) => { + const newComponent = structuredClone(getComponent(id)); + if (!newComponent) return null; + return parseComponent(newComponent); + }) + .filter((id): id is string => id !== null); } export function createComponentFromBounds( diff --git a/frontend/src/features/authoring/scene/operations/modifiers.ts b/frontend/src/features/authoring/scene/operations/modifiers.ts index 65462ba5..5418aee1 100644 --- a/frontend/src/features/authoring/scene/operations/modifiers.ts +++ b/frontend/src/features/authoring/scene/operations/modifiers.ts @@ -29,17 +29,20 @@ export function modify( return function (...args: A): R { const ids = args[0]; - const previousStates: ChangeRecord[] = ids.map((id) => { - const comp = getComponent(id); - return { - id, - prevState: structuredClone(comp), - }; - }); + const previousStates: ChangeRecord[] = ids + .map((id) => { + const comp = getComponent(id); + if (!comp) return null; + return { + id, + prevState: structuredClone(comp), + }; + }) + .filter((record): record is ChangeRecord => record !== null); const output = fn(...args); - updateHistory(previousStates); + if (previousStates.length) updateHistory(previousStates); ids.forEach((id) => { const component = getComponent(id); diff --git a/frontend/src/features/authoring/stores/editor.ts b/frontend/src/features/authoring/stores/editor.ts index cd35dee1..4d91b45f 100644 --- a/frontend/src/features/authoring/stores/editor.ts +++ b/frontend/src/features/authoring/stores/editor.ts @@ -72,8 +72,9 @@ const useEditorStore = create((set) => ({ setSelection: (selection) => set(({ selected }) => { const mainTarget = selected[0]; - if (mainTarget && getComponent(mainTarget).type === "textbox") { - const activeStyle = getStyleForSelection(mainTarget, selection); + const component = mainTarget ? getComponent(mainTarget) : null; + if (component?.type === "textbox") { + const activeStyle = getStyleForSelection(mainTarget!, selection); return { selection, activeStyle }; } return { selection, activeStyle: null }; diff --git a/frontend/src/features/authoring/text/cursor.ts b/frontend/src/features/authoring/text/cursor.ts index ea27ef76..4a9989bb 100644 --- a/frontend/src/features/authoring/text/cursor.ts +++ b/frontend/src/features/authoring/text/cursor.ts @@ -128,13 +128,7 @@ export function syncVisualCursor() { const editorState = useEditorStore.getState(); // Assume only one object is selected - if ( - !editorState.selected || - editorState.selected.length !== 1 || - !editorState.visualSelection.start - ) - return; - if (!editorState.selected || !editorState.selection.start) return; + if (!editorState.selected || editorState.selected.length !== 1) return; const blocks = useVisualScene.getState().components[editorState.selected[0]].document .blocks; diff --git a/frontend/src/features/authoring/topbar/Topbar.tsx b/frontend/src/features/authoring/topbar/Topbar.tsx index 6dc5a48a..c15d7b47 100644 --- a/frontend/src/features/authoring/topbar/Topbar.tsx +++ b/frontend/src/features/authoring/topbar/Topbar.tsx @@ -69,7 +69,7 @@ function Topbar({ saving, save }: { saving: boolean; save: () => void }) { {/* element properties */} - {selected && ( + {hasSelection && ( <>
      {/* reorder */} From 2ce85c423e7f73f25783a58145cc12b7ea9dd382 Mon Sep 17 00:00:00 2001 From: K1mmyn Date: Mon, 1 Jun 2026 18:01:10 +1200 Subject: [PATCH 13/14] Fix remove comments --- .../authoring/handlers/pointer/pointer.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/frontend/src/features/authoring/handlers/pointer/pointer.ts b/frontend/src/features/authoring/handlers/pointer/pointer.ts index 939a78c5..6c152f5e 100644 --- a/frontend/src/features/authoring/handlers/pointer/pointer.ts +++ b/frontend/src/features/authoring/handlers/pointer/pointer.ts @@ -89,23 +89,6 @@ function handleComponentClick(e: React.MouseEvent, position: Vec2) { setSelected([...selected, id]); } - // * DONE Text Selection Broken - // * DONE Clipboard text selection - // * DONE Front back implementation - // * DONE Resize - // * DONE Fix copy and paste - // * DONE Object creation - // * DONE fix object mutationbounds visual - // * DONE fix delete - // * DONE Fix Undo Redo - // * DONE Fix Copy Paste - - // ! TODO MultiSelect rotation - // ! TODO Text mode needs undo redo support - // ! TODO Paste undo redo bug where undo only undos one at a time - - // ! ERROR npm i --save-dev @types/uuid for another type - const component = scene[target.dataset.id as string]; setMutationBounds({ ...component.bounds }); From 061cfbeba22329ed958f75885bb0adb3ecb30387 Mon Sep 17 00:00:00 2001 From: K1mmyn Date: Mon, 1 Jun 2026 18:01:49 +1200 Subject: [PATCH 14/14] Fix comments --- frontend/src/features/authoring/handlers/keyboard/clipboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/features/authoring/handlers/keyboard/clipboard.ts b/frontend/src/features/authoring/handlers/keyboard/clipboard.ts index b18d65cb..0ccca8f7 100644 --- a/frontend/src/features/authoring/handlers/keyboard/clipboard.ts +++ b/frontend/src/features/authoring/handlers/keyboard/clipboard.ts @@ -48,7 +48,7 @@ export function paste(e: ClipboardEvent) { const plainTexts = e.clipboardData?.getData("text/plain"); if (selected && mode.includes("text")) { - // This one I got gpt to write lowkey not to sure if its good + //! IMPORTANT This one I got gpt to write lowkey not to sure if its good if (!selection.start) return; let cursor = selection.start;