Skip to content

Commit 8d00974

Browse files
feat(EditorApp, History): implement undo/redo functionality and enhance session state management
- Added history management using Zustand for undo and redo actions. - Integrated keyboard shortcuts (Ctrl+Z for undo, Ctrl+Shift+Z for redo) in EditorApp. - Updated HoldInspector, MainCanvas, SidebarHoldsSection, and SidebarWallsSection to record history on relevant actions. - Localized undo and redo labels in multiple languages.
1 parent 5ec98c4 commit 8d00974

11 files changed

Lines changed: 171 additions & 1 deletion

File tree

frontend/src/features/editor/EditorApp.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { posthog } from "@/shared/analytics/posthog";
66

77
import useWallSessionQuery from "./utils/WallSessionQuery";
88
import { usePlacementStore } from "./store";
9+
import { useHistoryStore } from "./history";
910
import type { SessionHoldInstance } from "./store";
1011
import { useHandleLoadSession } from "./utils/HandleLoadSession";
1112

@@ -33,6 +34,8 @@ function EditorApp() {
3334
const setColoredTexture = usePlacementStore((s) => s.setColoredTexture);
3435
const setHasUnsavedChanges = usePlacementStore((s) => s.setHasUnsavedChanges);
3536
const hasUnsavedChanges = usePlacementStore((s) => s.hasUnsavedChanges);
37+
const canUndo = useHistoryStore((s) => s.past.length > 0);
38+
const canRedo = useHistoryStore((s) => s.future.length > 0);
3639

3740
const { data, isLoading, error, isError } = useQuery({
3841
queryKey: ["wallsession", wallId],
@@ -53,9 +56,34 @@ function EditorApp() {
5356
setHoldColors({});
5457
setColoredTexture(true);
5558
setHasUnsavedChanges(false);
59+
useHistoryStore.getState().clear();
5660
}
5761
}, [wallId, setObjects, setWallColors, setHoldColors, setColoredTexture, setHasUnsavedChanges]);
5862

63+
useEffect(() => {
64+
const handleKeyDown = (e: KeyboardEvent) => {
65+
const target = e.target as HTMLElement | null;
66+
if (
67+
target &&
68+
(target.tagName === "INPUT" ||
69+
target.tagName === "TEXTAREA" ||
70+
target.isContentEditable)
71+
) {
72+
return;
73+
}
74+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
75+
e.preventDefault();
76+
if (e.shiftKey) {
77+
useHistoryStore.getState().redo();
78+
} else {
79+
useHistoryStore.getState().undo();
80+
}
81+
}
82+
};
83+
window.addEventListener("keydown", handleKeyDown);
84+
return () => window.removeEventListener("keydown", handleKeyDown);
85+
}, []);
86+
5987
useEffect(() => {
6088
if (session_data?.id && wallModels.length > 0 && !sessionOpenedRef.current) {
6189
sessionOpenedRef.current = true;
@@ -132,6 +160,31 @@ function EditorApp() {
132160
<div className="w-fit max-w-full rounded-xl bg-surface-low/90 p-2 shadow-[0_8px_32px_0_rgba(0,0,0,0.5)] backdrop-blur-md">
133161
<FileManager session_data={session_data} />
134162
</div>
163+
<div className="flex w-fit gap-1 self-start rounded-xl bg-surface-low/90 p-1 shadow-[0_8px_32px_0_rgba(0,0,0,0.5)] backdrop-blur-md">
164+
{([
165+
{ id: "undo", icon: "undo", label: t("editor.undo"), hint: "Ctrl+Z", enabled: canUndo, action: () => useHistoryStore.getState().undo() },
166+
{ id: "redo", icon: "redo", label: t("editor.redo"), hint: "Ctrl+Shift+Z", enabled: canRedo, action: () => useHistoryStore.getState().redo() },
167+
]).map((tool) => (
168+
<div key={tool.id} className="group relative">
169+
<button
170+
disabled={!tool.enabled}
171+
onClick={tool.action}
172+
className={`w-10 h-10 flex shrink-0 items-center justify-center rounded-lg transition-all ${
173+
tool.enabled
174+
? "text-on-surface-variant hover:text-on-surface hover:bg-surface-high active:scale-95 cursor-pointer"
175+
: "text-on-surface-variant opacity-40 cursor-not-allowed"
176+
}`}
177+
>
178+
<span className="material-symbols-outlined">{tool.icon}</span>
179+
</button>
180+
<div className="absolute left-full top-1/2 ml-2 -translate-y-1/2 whitespace-nowrap rounded-lg bg-surface-high px-2.5 py-1.5 text-xs text-on-surface-variant opacity-0 shadow-[0_4px_16px_0_rgba(0,0,0,0.4)] transition-opacity group-hover:opacity-100 pointer-events-none">
181+
<span className="font-medium text-on-surface">{tool.label}</span>
182+
<span className="mx-1.5 opacity-40">·</span>
183+
{tool.hint}
184+
</div>
185+
</div>
186+
))}
187+
</div>
135188
<div className="flex w-fit flex-col gap-1 self-start rounded-xl bg-surface-low/90 p-1 shadow-[0_8px_32px_0_rgba(0,0,0,0.5)] backdrop-blur-md">
136189
{transformTools.map((tool) => (
137190
<div key={tool.id} className="group relative">

frontend/src/features/editor/components/HoldInspector.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import React, { useRef } from "react";
22
import { usePlacementStore } from "../store";
3+
import { useHistoryStore } from "../history";
34
import { useTranslation } from "react-i18next";
45
import { posthog } from "@/shared/analytics/posthog";
56

67
function RotationHandle({
78
rotation,
89
onRotate,
10+
onRotateStart,
911
}: {
1012
rotation: number;
1113
onRotate: (angle: number) => void;
14+
onRotateStart?: () => void;
1215
}) {
1316
const radius = 24;
1417
const handleRadius = 18;
@@ -17,6 +20,7 @@ function RotationHandle({
1720
const handleY = radius + handleRadius * Math.sin(rotation - Math.PI / 2);
1821
const onPointerDown = (e: React.PointerEvent) => {
1922
e.preventDefault();
23+
onRotateStart?.();
2024
window.addEventListener("pointermove", onPointerMove as EventListener);
2125
window.addEventListener("pointerup", onPointerUp as EventListener);
2226
};
@@ -139,6 +143,7 @@ const HoldInspector = () => {
139143
onRotate={(angle) =>
140144
updateObject(selected.id, { customRotation: angle })
141145
}
146+
onRotateStart={() => useHistoryStore.getState().record()}
142147
/>
143148
</div>
144149
</div>
@@ -149,6 +154,7 @@ const HoldInspector = () => {
149154
className="flex-1 inline-flex items-center justify-center px-3 py-2 text-sm font-medium text-red-700 bg-red-50 border border-red-200 rounded-lg hover:bg-red-100 transition-colors"
150155
onClick={() => {
151156
posthog.capture('hold removed', { hold_name: selected.name, hold_id: selected.id });
157+
useHistoryStore.getState().record();
152158
removeObject(selected.id);
153159
}}
154160
>
@@ -206,6 +212,7 @@ const HoldInspector = () => {
206212
onRotate={(angle) =>
207213
updateObject(child.id, { customRotation: angle })
208214
}
215+
onRotateStart={() => useHistoryStore.getState().record()}
209216
/>
210217
</div>
211218

@@ -214,6 +221,7 @@ const HoldInspector = () => {
214221
title="Delete child hold"
215222
onClick={() => {
216223
posthog.capture('hold removed', { hold_name: child.name, hold_id: child.id, is_child: true });
224+
useHistoryStore.getState().record();
217225
removeObject(child.id);
218226
}}
219227
>

frontend/src/features/editor/components/MainCanvas.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Canvas, useThree } from "@react-three/fiber";
22
import { OrbitControls } from "@react-three/drei";
33
import { Suspense } from "react";
44
import { useDragStore, usePlacementStore } from "../store";
5+
import { useHistoryStore } from "../history";
56
import ModelViewer from "./ModelViewer";
67
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
78
import * as THREE from "three";
@@ -112,6 +113,10 @@ function DragPreview() {
112113
holdName = match ? match[1] : model.url;
113114
}
114115
const newId = model.id || uuidv4();
116+
// Re-drags (model.id set) are already recorded at drag start, before removeObject
117+
if (!model.id) {
118+
useHistoryStore.getState().record();
119+
}
115120
addObject({
116121
id: newId,
117122
type: model.type,
@@ -298,6 +303,7 @@ function PlacedObjects({
298303
pointerDownHoldIdRef.current = obj.id;
299304
dragTimer = window.setTimeout(() => {
300305
dragStarted = true;
306+
useHistoryStore.getState().record();
301307
removeObject(obj.id);
302308
startDrag({
303309
type: obj.type,

frontend/src/features/editor/components/SidebarHoldsSection.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
useCallback,
88
} from "react";
99
import { useDragStore, usePlacementStore } from "../store";
10+
import { useHistoryStore } from "../history";
1011
import type { HoldModel, SessionData } from "../store";
1112
import Hold360, { HoldScrollContext } from "../stubs/Hold360";
1213
import React from "react";
@@ -218,7 +219,10 @@ const SidebarHoldsSection = forwardRef<
218219
{userColors.map((color: string) => (
219220
<button
220221
key={color}
221-
onClick={() => setHoldColor(hold.file, color)}
222+
onClick={() => {
223+
useHistoryStore.getState().record();
224+
setHoldColor(hold.file, color);
225+
}}
222226
className={`w-4 h-4 border border-ghost-border cursor-pointer ${
223227
holdColors[hold.file] === color
224228
? "ring-2 ring-offset-1 ring-mint ring-offset-surface-low"

frontend/src/features/editor/components/SidebarWallsSection.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useRef, useState } from "react";
22
import type { ChangeEvent } from "react";
33
import { useDragStore, usePlacementStore } from "../store";
4+
import { useHistoryStore } from "../history";
45
import { useTranslation } from "react-i18next";
56

67
type WallModel = { name: string; file: string; orientation?: "y-up" | "z-up" };
@@ -110,6 +111,7 @@ const SidebarWallsSection = ({ wallModels }: { wallModels: WallModel[] }) => {
110111
<input
111112
type="color"
112113
value={wallColors[wall.file] || "#d9c4ff"}
114+
onFocus={() => useHistoryStore.getState().record()}
113115
onChange={(e) => setWallColor(wall.file, e.target.value)}
114116
className="w-6 h-6 border border-gray-300 cursor-pointer shadow-sm"
115117
title={`Pick color for ${wall.name}`}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { create } from "zustand";
2+
import { usePlacementStore } from "./store";
3+
import type { PlacedObject } from "./store";
4+
5+
interface Snapshot {
6+
objects: PlacedObject[];
7+
wallColors: Record<string, string>;
8+
holdColors: Record<string, string>;
9+
}
10+
11+
interface HistoryState {
12+
past: Snapshot[];
13+
future: Snapshot[];
14+
record: () => void;
15+
undo: () => void;
16+
redo: () => void;
17+
clear: () => void;
18+
}
19+
20+
const MAX_HISTORY = 50;
21+
22+
function currentSnapshot(): Snapshot {
23+
const { objects, wallColors, holdColors } = usePlacementStore.getState();
24+
return { objects, wallColors, holdColors };
25+
}
26+
27+
function restore(snapshot: Snapshot) {
28+
const { selectedObjId } = usePlacementStore.getState();
29+
const selectionStillExists =
30+
selectedObjId !== null &&
31+
snapshot.objects.some((obj) => obj.id === selectedObjId);
32+
usePlacementStore.setState({
33+
objects: snapshot.objects,
34+
wallColors: snapshot.wallColors,
35+
holdColors: snapshot.holdColors,
36+
selectedObjId: selectionStillExists ? selectedObjId : null,
37+
hasUnsavedChanges: true,
38+
});
39+
}
40+
41+
function sameSnapshot(a: Snapshot, b: Snapshot) {
42+
return (
43+
a.objects === b.objects &&
44+
a.wallColors === b.wallColors &&
45+
a.holdColors === b.holdColors
46+
);
47+
}
48+
49+
export const useHistoryStore = create<HistoryState>((set, get) => ({
50+
past: [],
51+
future: [],
52+
53+
record: () => {
54+
const snapshot = currentSnapshot();
55+
const { past } = get();
56+
const top = past[past.length - 1];
57+
if (top && sameSnapshot(top, snapshot)) return;
58+
set({
59+
past: [...past, snapshot].slice(-MAX_HISTORY),
60+
future: [],
61+
});
62+
},
63+
64+
undo: () => {
65+
const { past, future } = get();
66+
const previous = past[past.length - 1];
67+
if (!previous) return;
68+
set({
69+
past: past.slice(0, -1),
70+
future: [...future, currentSnapshot()],
71+
});
72+
restore(previous);
73+
},
74+
75+
redo: () => {
76+
const { past, future } = get();
77+
const next = future[future.length - 1];
78+
if (!next) return;
79+
set({
80+
past: [...past, currentSnapshot()],
81+
future: future.slice(0, -1),
82+
});
83+
restore(next);
84+
},
85+
86+
clear: () => set({ past: [], future: [] }),
87+
}));

frontend/src/locales/cn.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
"stats_forks_label": "分叉"
2929
},
3030
"editor": {
31+
"undo": "撤销",
32+
"redo": "重做",
3133
"loading_session": "正在加载岩壁会话...",
3234
"error_session": "加载岩壁会话出错",
3335
"unsaved_warning": "您有未保存的更改。确定要离开吗?",

frontend/src/locales/de.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
"stats_forks_label": "Forks"
2929
},
3030
"editor": {
31+
"undo": "Rückgängig",
32+
"redo": "Wiederholen",
3133
"loading_session": "Wandsession wird geladen...",
3234
"error_session": "Fehler beim Laden der Wandsession",
3335
"unsaved_warning": "Du hast ungespeicherte Änderungen. Möchtest du wirklich die Seite verlassen?",

frontend/src/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
"stats_forks_label": "Forks"
2929
},
3030
"editor": {
31+
"undo": "Undo",
32+
"redo": "Redo",
3133
"loading_session": "Loading wall session...",
3234
"error_session": "Error loading wall session",
3335
"unsaved_warning": "You have unsaved changes. Are you sure you want to leave?",

frontend/src/locales/fr.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
"stats_forks_label": "Forks"
2929
},
3030
"editor": {
31+
"undo": "Annuler",
32+
"redo": "Rétablir",
3133
"loading_session": "Chargement de la session mur...",
3234
"error_session": "Erreur de chargement de la session",
3335
"unsaved_warning": "Vous avez des modifications non sauvegardées. Voulez-vous vraiment quitter ?",

0 commit comments

Comments
 (0)