Skip to content

Commit f6971b7

Browse files
kubeclaude
andauthored
H-6281, FE-501: Add undo/redo support to Petrinaut demo app (#8505)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a9fe023 commit f6971b7

16 files changed

Lines changed: 415 additions & 106 deletions

File tree

.changeset/undo-redo-support.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hashintel/petrinaut": patch
3+
---
4+
5+
Add optional undo/redo support with version history UI, keyboard shortcuts (Cmd|Ctrl+Z / Cmd|Ctrl+Shift+Z), and drag debouncing

libs/@hashintel/petrinaut/demo-site/main/app.tsx

Lines changed: 103 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { produce } from "immer";
2-
import { useCallback, useEffect, useMemo, useState } from "react";
2+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
33

44
import type { MinimalNetMetadata, SDCPN } from "../../src/core/types/sdcpn";
55
import { convertOldFormatToSDCPN } from "../../src/old-formats/convert-old-format";
@@ -9,6 +9,7 @@ import {
99
type SDCPNInLocalStorage,
1010
useLocalStorageSDCPNs,
1111
} from "./app/use-local-storage-sdcpns";
12+
import { useUndoRedo } from "./app/use-undo-redo";
1213

1314
export const DevApp = () => {
1415
const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs();
@@ -72,25 +73,109 @@ export const DevApp = () => {
7273
[currentNetId, setStoredSDCPNs],
7374
);
7475

75-
const mutatePetriNetDefinition = useCallback(
76-
(definitionMutationFn: (draft: SDCPN) => void) => {
77-
if (!currentNetId) {
78-
return;
76+
const setSDCPNDirectly = (sdcpn: SDCPN) => {
77+
if (!currentNetId) {
78+
return;
79+
}
80+
setStoredSDCPNs((prev) =>
81+
produce(prev, (draft) => {
82+
if (draft[currentNetId]) {
83+
draft[currentNetId].sdcpn = sdcpn;
84+
}
85+
}),
86+
);
87+
};
88+
89+
const emptySDCPN: SDCPN = {
90+
places: [],
91+
transitions: [],
92+
types: [],
93+
parameters: [],
94+
differentialEquations: [],
95+
};
96+
97+
const {
98+
pushState,
99+
undo: undoHistory,
100+
redo: redoHistory,
101+
goToIndex: goToHistoryIndex,
102+
canUndo,
103+
canRedo,
104+
history,
105+
currentIndex,
106+
reset: resetHistory,
107+
} = useUndoRedo(
108+
currentNet && !isOldFormatInLocalStorage(currentNet)
109+
? currentNet.sdcpn
110+
: emptySDCPN,
111+
);
112+
113+
const mutatePetriNetDefinition = (
114+
definitionMutationFn: (draft: SDCPN) => void,
115+
) => {
116+
if (!currentNetId) {
117+
return;
118+
}
119+
120+
let newSDCPN: SDCPN | undefined;
121+
122+
// Use the updater form so that multiple calls before a re-render
123+
// (e.g. multi-node drag end) each see the latest state.
124+
setStoredSDCPNs((prev) => {
125+
const net = prev[currentNetId];
126+
if (!net || isOldFormatInLocalStorage(net)) {
127+
return prev;
79128
}
129+
const updatedSDCPN = produce(net.sdcpn, definitionMutationFn);
130+
newSDCPN = updatedSDCPN;
131+
return {
132+
...prev,
133+
[currentNetId]: {
134+
...net,
135+
sdcpn: updatedSDCPN,
136+
},
137+
};
138+
});
80139

81-
setStoredSDCPNs((prev) =>
82-
produce(prev, (draft) => {
83-
if (draft[currentNetId]) {
84-
draft[currentNetId].sdcpn = produce(
85-
draft[currentNetId].sdcpn,
86-
definitionMutationFn,
87-
);
88-
}
89-
}),
90-
);
140+
if (newSDCPN) {
141+
pushState(newSDCPN);
142+
}
143+
};
144+
145+
const prevNetIdRef = useRef(currentNetId);
146+
useEffect(() => {
147+
if (currentNetId !== prevNetIdRef.current) {
148+
prevNetIdRef.current = currentNetId;
149+
if (currentNet && !isOldFormatInLocalStorage(currentNet)) {
150+
resetHistory(currentNet.sdcpn);
151+
}
152+
}
153+
}, [currentNetId, currentNet, resetHistory]);
154+
155+
const undoRedo = {
156+
undo: () => {
157+
const sdcpn = undoHistory();
158+
if (sdcpn) {
159+
setSDCPNDirectly(sdcpn);
160+
}
91161
},
92-
[currentNetId, setStoredSDCPNs],
93-
);
162+
redo: () => {
163+
const sdcpn = redoHistory();
164+
if (sdcpn) {
165+
setSDCPNDirectly(sdcpn);
166+
}
167+
},
168+
canUndo,
169+
canRedo,
170+
history: history.current,
171+
currentIndex,
172+
goToIndex: (index: number) => {
173+
const sdcpn = goToHistoryIndex(index);
174+
if (sdcpn) {
175+
setSDCPNDirectly(sdcpn);
176+
}
177+
},
178+
};
94179

95180
// Initialize with a default net if none exists
96181
useEffect(() => {
@@ -168,6 +253,7 @@ export const DevApp = () => {
168253
readonly={false}
169254
setTitle={setTitle}
170255
title={currentNet.title}
256+
undoRedo={undoRedo}
171257
/>
172258
</div>
173259
);
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { useRef, useState } from "react";
2+
3+
import type { SDCPN } from "../../../src/core/types/sdcpn";
4+
import { isSDCPNEqual } from "../../../src/petrinaut";
5+
6+
export type HistoryEntry = {
7+
sdcpn: SDCPN;
8+
timestamp: string;
9+
};
10+
11+
const MAX_HISTORY = 50;
12+
const DEBOUNCE_MS = 500;
13+
14+
export function useUndoRedo(initialSDCPN: SDCPN) {
15+
const historyRef = useRef<HistoryEntry[]>([
16+
{ sdcpn: initialSDCPN, timestamp: new Date().toISOString() },
17+
]);
18+
const currentIndexRef = useRef(0);
19+
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
20+
21+
/**
22+
* Snapshot of render-visible values derived from the refs.
23+
* Updated via `bump()` after every mutation so consumers re-render
24+
* without reading refs during render.
25+
*/
26+
const [snapshot, setSnapshot] = useState({
27+
currentIndex: 0,
28+
historyLength: 1,
29+
});
30+
const bump = () =>
31+
setSnapshot({
32+
currentIndex: currentIndexRef.current,
33+
historyLength: historyRef.current.length,
34+
});
35+
36+
const canUndo = snapshot.currentIndex > 0;
37+
const canRedo = snapshot.currentIndex < snapshot.historyLength - 1;
38+
39+
const pushState = (sdcpn: SDCPN) => {
40+
const current = historyRef.current[currentIndexRef.current];
41+
42+
// No-op detection
43+
if (current && isSDCPNEqual(current.sdcpn, sdcpn)) {
44+
return;
45+
}
46+
47+
// Debounce: coalesce rapid mutations into one entry
48+
if (debounceTimerRef.current !== null) {
49+
clearTimeout(debounceTimerRef.current);
50+
// Replace the entry at currentIndex (it was a pending debounced entry)
51+
historyRef.current[currentIndexRef.current] = {
52+
sdcpn,
53+
timestamp: new Date().toISOString(),
54+
};
55+
56+
debounceTimerRef.current = setTimeout(() => {
57+
debounceTimerRef.current = null;
58+
}, DEBOUNCE_MS);
59+
60+
bump();
61+
return;
62+
}
63+
64+
// Truncate any redo entries
65+
historyRef.current = historyRef.current.slice(
66+
0,
67+
currentIndexRef.current + 1,
68+
);
69+
70+
// Push new entry
71+
historyRef.current.push({
72+
sdcpn,
73+
timestamp: new Date().toISOString(),
74+
});
75+
76+
// Enforce max history size
77+
if (historyRef.current.length > MAX_HISTORY) {
78+
historyRef.current = historyRef.current.slice(
79+
historyRef.current.length - MAX_HISTORY,
80+
);
81+
}
82+
83+
currentIndexRef.current = historyRef.current.length - 1;
84+
85+
// Start debounce window
86+
debounceTimerRef.current = setTimeout(() => {
87+
debounceTimerRef.current = null;
88+
}, DEBOUNCE_MS);
89+
90+
bump();
91+
};
92+
93+
const clearDebounce = () => {
94+
if (debounceTimerRef.current !== null) {
95+
clearTimeout(debounceTimerRef.current);
96+
debounceTimerRef.current = null;
97+
}
98+
};
99+
100+
const undo = (): SDCPN | null => {
101+
if (currentIndexRef.current <= 0) {
102+
return null;
103+
}
104+
clearDebounce();
105+
currentIndexRef.current -= 1;
106+
bump();
107+
return historyRef.current[currentIndexRef.current]!.sdcpn;
108+
};
109+
110+
const redo = (): SDCPN | null => {
111+
if (currentIndexRef.current >= historyRef.current.length - 1) {
112+
return null;
113+
}
114+
clearDebounce();
115+
currentIndexRef.current += 1;
116+
bump();
117+
return historyRef.current[currentIndexRef.current]!.sdcpn;
118+
};
119+
120+
const goToIndex = (index: number): SDCPN | null => {
121+
if (index < 0 || index >= historyRef.current.length) {
122+
return null;
123+
}
124+
clearDebounce();
125+
currentIndexRef.current = index;
126+
bump();
127+
return historyRef.current[index]!.sdcpn;
128+
};
129+
130+
const reset = (sdcpn: SDCPN) => {
131+
historyRef.current = [{ sdcpn, timestamp: new Date().toISOString() }];
132+
currentIndexRef.current = 0;
133+
if (debounceTimerRef.current !== null) {
134+
clearTimeout(debounceTimerRef.current);
135+
debounceTimerRef.current = null;
136+
}
137+
bump();
138+
};
139+
140+
return {
141+
pushState,
142+
undo,
143+
redo,
144+
goToIndex,
145+
canUndo,
146+
canRedo,
147+
history: historyRef,
148+
currentIndex: snapshot.currentIndex,
149+
reset,
150+
};
151+
}

libs/@hashintel/petrinaut/src/components/menu.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ const itemDescriptionStyle = css({
147147
});
148148

149149
const itemSuffixStyle = css({
150+
display: "flex",
151+
alignItems: "center",
152+
gap: "1",
150153
marginLeft: "auto",
151154
fontSize: "xs",
152155
color: "neutral.s80",
@@ -204,6 +207,10 @@ export interface MenuProps {
204207
items: MenuItem[] | MenuGroup[];
205208
/** Whether to animate the menu open/close. Adapts direction automatically. */
206209
animated?: boolean;
210+
/** Maximum height of the menu content (enables scrolling). */
211+
maxHeight?: string;
212+
/** Whether to close the menu when an item is selected. Defaults to true. */
213+
closeOnSelect?: boolean;
207214
/** Preferred placement of the menu relative to the trigger. */
208215
placement?:
209216
| "top"
@@ -265,6 +272,8 @@ export const Menu: React.FC<MenuProps> = ({
265272
trigger,
266273
items,
267274
animated,
275+
maxHeight,
276+
closeOnSelect,
268277
placement,
269278
}) => {
270279
const portalContainerRef = usePortalContainerRef();
@@ -274,12 +283,16 @@ export const Menu: React.FC<MenuProps> = ({
274283
<ArkMenu.Root
275284
lazyMount={!!animated}
276285
unmountOnExit={!!animated}
286+
closeOnSelect={closeOnSelect}
277287
positioning={placement ? { placement, gutter: 8 } : { gutter: 8 }}
278288
>
279289
<ArkMenu.Trigger asChild>{trigger}</ArkMenu.Trigger>
280290
<Portal container={portalContainerRef}>
281291
<ArkMenu.Positioner>
282-
<ArkMenu.Content className={menuContentStyle({ animated })}>
292+
<ArkMenu.Content
293+
className={menuContentStyle({ animated })}
294+
style={maxHeight ? { maxHeight, overflowY: "auto" } : undefined}
295+
>
283296
{groups.map((group, groupIndex) => (
284297
<div key={group.title ?? `group-${String(groupIndex)}`}>
285298
{groupIndex > 0 && <div className={separatorStyle} />}

0 commit comments

Comments
 (0)