返回 presentation-ai
presentation-history-state.ts
根目录 / src / states / presentation-history-state.ts
1 "use client";
2
3 import { create } from "zustand";
4
5 import { type PlateSlide } from "@/components/notebook/presentation/utils/parser";
6 import { type ThemeProperties, type Themes } from "@/lib/presentation/themes";
7 import { usePresentationState } from "./presentation-state";
8
9 // Maximum number of history entries to keep
10 const MAX_HISTORY_SIZE = 50;
11 // Rate limit: minimum ms between history pushes for same slide
12 const RATE_LIMIT_MS = 300;
13
14 /**
15 * Represents a snapshot of a change in the presentation state.
16 * optimization: Stores only what changed rather than the full state when possible.
17 */
18 export interface HistoryEntry {
19 timestamp: number;
20
21 // Type of change
22 changeType: "slide" | "theme" | "full";
23
24 // For 'slide' change
25 slide?: PlateSlide;
26 slideId?: string;
27
28 // For 'theme' change
29 theme?: Themes | string;
30 customThemeData?: ThemeProperties | null;
31
32 // For 'full' change (fallback for reordering or bulk updates)
33 slides?: PlateSlide[];
34 }
35
36 /**
37 * History state with past, present, and future stacks
38 */
39 export interface HistoryState {
40 past: HistoryEntry[];
41 present: HistoryEntry | null;
42 future: HistoryEntry[];
43 }
44
45 interface PresentationHistoryState {
46 history: HistoryState;
47 canUndo: boolean;
48 canRedo: boolean;
49
50 // Rate limiting state
51 lastPushTime: number;
52 lastPushedSlideId: string | null;
53
54 // Block pushes during restore (undo/redo)
55 isRestoring: boolean;
56
57 // Actions
58 pushSnapshot: (
59 changedSlideId?: string,
60 changeType?: "slide" | "theme" | "full",
61 ) => void;
62 undo: () => void;
63 redo: () => void;
64 clearHistory: () => void;
65 initializeHistory: (
66 slides: PlateSlide[],
67 theme?: Themes | string,
68 customThemeData?: ThemeProperties | null,
69 ) => void;
70 }
71
72 function createInitialHistory(): HistoryState {
73 return {
74 past: [],
75 present: null,
76 future: [],
77 };
78 }
79
80 export const usePresentationHistoryState = create<PresentationHistoryState>(
81 (set, get) => ({
82 history: createInitialHistory(),
83 canUndo: false,
84 canRedo: false,
85 lastPushTime: 0,
86 lastPushedSlideId: null,
87 isRestoring: false,
88
89 pushSnapshot: (
90 changedSlideId?: string,
91 changeType: "slide" | "theme" | "full" = "full",
92 ) => {
93 const state = get();
94
95 // Block first push after restore (this is the editor sync), then reset flag
96 if (state.isRestoring) {
97 set({ isRestoring: false });
98 // However, if this is a 'slide' update (user edit), we might want to capture it if it's not a restore artifact?
99 // But preventing loops is priority.
100 return;
101 }
102
103 const { slides, theme, customThemeData } =
104 usePresentationState.getState();
105
106 if (slides.length === 0) return;
107
108 const now = Date.now();
109 let newEntry: HistoryEntry | null = null;
110 let shouldMerge = false;
111
112 // --- HANDLE SLIDE CHANGE ---
113 if (changeType === "slide" && changedSlideId) {
114 const changedSlide = slides.find((s) => s.id === changedSlideId);
115 if (!changedSlide) return;
116
117 // Skip if identical to present
118 if (
119 state.history.present?.changeType === "slide" &&
120 state.history.present.slideId === changedSlideId
121 ) {
122 const currentSlideString = JSON.stringify(changedSlide);
123 const presentSlideString = JSON.stringify(
124 state.history.present.slide,
125 );
126 if (currentSlideString === presentSlideString) return;
127 }
128
129 // Rate limiting for same slide
130 if (
131 changedSlideId === state.lastPushedSlideId &&
132 now - state.lastPushTime < RATE_LIMIT_MS &&
133 state.history.present?.changeType === "slide" &&
134 state.history.present.slideId === changedSlideId
135 ) {
136 shouldMerge = true;
137 }
138
139 newEntry = {
140 timestamp: now,
141 changeType: "slide",
142 slide: JSON.parse(JSON.stringify(changedSlide)),
143 slideId: changedSlideId,
144 theme,
145 customThemeData,
146 };
147 }
148 // --- HANDLE THEME CHANGE ---
149 else if (changeType === "theme") {
150 if (
151 state.history.present?.changeType === "theme" &&
152 state.history.present.theme === theme &&
153 JSON.stringify(state.history.present.customThemeData) ===
154 JSON.stringify(customThemeData)
155 ) {
156 return;
157 }
158
159 newEntry = {
160 timestamp: now,
161 changeType: "theme",
162 theme,
163 customThemeData,
164 };
165 }
166 // --- HANDLE FULL CHANGE (Fallback) ---
167 else {
168 if (
169 state.history.present?.slides &&
170 JSON.stringify(slides) ===
171 JSON.stringify(state.history.present.slides)
172 ) {
173 return;
174 }
175
176 newEntry = {
177 timestamp: now,
178 changeType: "full",
179 slides: JSON.parse(JSON.stringify(slides)),
180 theme,
181 customThemeData,
182 };
183 }
184
185 if (!newEntry) return;
186
187 if (shouldMerge && state.history.present) {
188 set({
189 history: { ...state.history, present: newEntry },
190 lastPushTime: now,
191 lastPushedSlideId: changedSlideId ?? null,
192 });
193 return;
194 }
195
196 // If no present, this is the first entry
197 if (state.history.present === null) {
198 set({
199 history: {
200 past: [],
201 present: newEntry,
202 future: [],
203 },
204 canUndo: false,
205 canRedo: false,
206 lastPushTime: now,
207 lastPushedSlideId: changedSlideId ?? null,
208 });
209 return;
210 }
211
212 const newPast = [...state.history.past, state.history.present];
213 if (newPast.length > MAX_HISTORY_SIZE) {
214 newPast.splice(0, newPast.length - MAX_HISTORY_SIZE);
215 }
216
217 set({
218 history: {
219 past: newPast,
220 present: newEntry,
221 future: [],
222 },
223 canUndo: true,
224 canRedo: false,
225 lastPushTime: now,
226 lastPushedSlideId: changedSlideId ?? null,
227 });
228 },
229
230 undo: () => {
231 const state = get();
232 const { past, present, future } = state.history;
233
234 if (past.length === 0 || present === null) return;
235
236 const previous = past[past.length - 1];
237 if (!previous) return;
238
239 const newPast = past.slice(0, -1);
240 const newFuture = [present, ...future];
241
242 // Set isRestoring to block pushes during editor sync
243 set({
244 history: {
245 past: newPast,
246 present: previous,
247 future: newFuture,
248 },
249 canUndo: newPast.length > 0,
250 canRedo: true,
251 isRestoring: true,
252 });
253
254 const { slides, updateSlide, setSlides, setTheme } =
255 usePresentationState.getState();
256
257 if (previous.changeType === "full" && previous.slides) {
258 setSlides(previous.slides, "history");
259 } else if (
260 previous.changeType === "slide" &&
261 previous.slide &&
262 previous.slideId
263 ) {
264 // Find if this slide still exists
265 const slideExists = slides.some((s) => s.id === previous.slideId);
266 if (slideExists) {
267 updateSlide(
268 previous.slideId,
269 previous.slide as Partial<PlateSlide>,
270 "history",
271 );
272 }
273 }
274
275 // Restore theme if present
276 if (previous.theme !== undefined) {
277 setTheme(previous.theme, previous.customThemeData ?? null, "history");
278 }
279 },
280
281 redo: () => {
282 const state = get();
283 const { past, present, future } = state.history;
284
285 if (future.length === 0 || present === null) return;
286
287 const next = future[0];
288 if (!next) return;
289
290 const newFuture = future.slice(1);
291 const newPast = [...past, present];
292
293 // Set isRestoring to block pushes during editor sync
294 set({
295 history: {
296 past: newPast,
297 present: next,
298 future: newFuture,
299 },
300 canUndo: true,
301 canRedo: newFuture.length > 0,
302 isRestoring: true,
303 });
304
305 const { slides, updateSlide, setSlides, setTheme } =
306 usePresentationState.getState();
307
308 if (next.changeType === "full" && next.slides) {
309 setSlides(next.slides, "history");
310 } else if (next.changeType === "slide" && next.slide && next.slideId) {
311 const slideExists = slides.some((s) => s.id === next.slideId);
312 if (slideExists) {
313 updateSlide(
314 next.slideId,
315 next.slide as Partial<PlateSlide>,
316 "history",
317 );
318 }
319 }
320
321 // Restore theme if present
322 if (next.theme !== undefined) {
323 setTheme(next.theme, next.customThemeData ?? null, "history");
324 }
325 },
326
327 clearHistory: () => {
328 set({
329 history: createInitialHistory(),
330 canUndo: false,
331 canRedo: false,
332 lastPushTime: 0,
333 lastPushedSlideId: null,
334 isRestoring: false,
335 });
336 },
337
338 initializeHistory: (slides, theme, customThemeData) => {
339 // For initialization, we store the initial state so we can undo back to it
340 const initialEntry: HistoryEntry = {
341 timestamp: Date.now(),
342 changeType: "full",
343 slides: JSON.parse(JSON.stringify(slides)),
344 theme: theme ?? undefined,
345 customThemeData: customThemeData,
346 };
347
348 set({
349 history: {
350 past: [],
351 present: initialEntry,
352 future: [],
353 },
354 canUndo: false,
355 canRedo: false,
356 lastPushTime: Date.now(),
357 lastPushedSlideId: null,
358 isRestoring: false,
359 });
360 },
361 }),
362 );
363
363 lines TYPESCRIPT