@textcortex/slidewise 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textcortex/slidewise",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Embeddable React PPTX editor.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,5 +1,10 @@
1
1
  import { forwardRef, type CSSProperties } from "react";
2
- import { Root, type SlidewiseRootHandle, type SlidewiseRootProps } from "./compound/SlidewiseRoot";
2
+ import {
3
+ Root,
4
+ type SlidewiseRootHandle,
5
+ type SlidewiseRootProps,
6
+ type HistoryState,
7
+ } from "./compound/SlidewiseRoot";
3
8
  import {
4
9
  TopBar,
5
10
  SlideRail,
@@ -30,6 +35,11 @@ export interface SlidewiseEditorProps {
30
35
  onExport?: (deck: Deck) => void;
31
36
  /** Fires when the dirty flag flips. Useful for "unsaved changes" banners. */
32
37
  onDirtyChange?: (dirty: boolean) => void;
38
+ /**
39
+ * Fires whenever the undo/redo stack depths change. Use this to enable/disable
40
+ * Undo and Redo buttons reactively without polling `api.canUndo()`.
41
+ */
42
+ onHistoryChange?: (state: HistoryState) => void;
33
43
  /** Reserved for future use; not enforced yet. */
34
44
  readOnly?: boolean;
35
45
  /** "light" or "dark"; defaults to "light". Ignored after first render. */
@@ -86,6 +96,7 @@ export const SlidewiseEditor = forwardRef<
86
96
  onSave,
87
97
  onExport,
88
98
  onDirtyChange,
99
+ onHistoryChange,
89
100
  readOnly,
90
101
  theme,
91
102
  initialSlideId,
@@ -104,6 +115,7 @@ export const SlidewiseEditor = forwardRef<
104
115
  onSave,
105
116
  onExport,
106
117
  onDirtyChange,
118
+ onHistoryChange,
107
119
  readOnly,
108
120
  theme,
109
121
  initialSlideId,
@@ -10,6 +10,7 @@ import { SlidewiseEditor, type SlidewiseEditorHandle } from "./SlidewiseEditor";
10
10
  import { parsePptx, serializeDeck } from "@/lib/pptx";
11
11
  import type { Deck } from "@/lib/types";
12
12
  import type { SlidewiseIcons } from "./compound/IconContext";
13
+ import type { HistoryState } from "./compound/SlidewiseRoot";
13
14
 
14
15
  export interface SlidewiseFileEditorProps {
15
16
  /**
@@ -48,6 +49,12 @@ export interface SlidewiseFileEditorProps {
48
49
  * `api.isDirty()` for "unsaved changes" UI.
49
50
  */
50
51
  onDirtyChange?: (dirty: boolean) => void;
52
+ /**
53
+ * Fires whenever the undo/redo stack depths change. Use this to enable/
54
+ * disable host-rendered Undo/Redo buttons reactively without polling
55
+ * `api.canUndo()` / `api.canRedo()`.
56
+ */
57
+ onHistoryChange?: (state: HistoryState) => void;
51
58
  /**
52
59
  * Fires when `loadBlob` or `parse` throws on mount. The default render
53
60
  * still shows an in-editor "Could not open file" message, but hosts that
@@ -94,6 +101,19 @@ export interface SlidewiseFileEditorApi {
94
101
  stop(): void;
95
102
  undo(): void;
96
103
  redo(): void;
104
+ /** True iff there's at least one snapshot to undo back to. */
105
+ canUndo(): boolean;
106
+ /** True iff there's at least one snapshot to redo forward to. */
107
+ canRedo(): boolean;
108
+ /** Current undo/redo stack depths. */
109
+ getHistorySize(): { undo: number; redo: number };
110
+ /**
111
+ * End the in-flight coalesce burst. Call on natural commit boundaries
112
+ * (mouseup after drag, blur on a text input) so the next mutation starts
113
+ * a fresh history step. Most hosts won't need this — a 500ms idle window
114
+ * handles typical typing/drag bursts automatically.
115
+ */
116
+ endCoalesce(): void;
97
117
  /** Live deck snapshot. Hosts use this for header badges (slide count, etc.). */
98
118
  getDeck(): Deck | null;
99
119
  getInitialSha256(): string | null;
@@ -117,6 +137,7 @@ export const SlidewiseFileEditor = forwardRef<
117
137
  onChange,
118
138
  onDirtyChange,
119
139
  onLoadError,
140
+ onHistoryChange,
120
141
  theme,
121
142
  initialSlideId,
122
143
  showTopBar,
@@ -185,6 +206,11 @@ export const SlidewiseFileEditor = forwardRef<
185
206
  stop: () => editorRef.current?.stop(),
186
207
  undo: () => editorRef.current?.undo(),
187
208
  redo: () => editorRef.current?.redo(),
209
+ canUndo: () => editorRef.current?.canUndo() ?? false,
210
+ canRedo: () => editorRef.current?.canRedo() ?? false,
211
+ getHistorySize: () =>
212
+ editorRef.current?.getHistorySize() ?? { undo: 0, redo: 0 },
213
+ endCoalesce: () => editorRef.current?.endCoalesce(),
188
214
  getDeck: () => editorRef.current?.getDeck() ?? state.deck,
189
215
  getInitialSha256: () => initialSha256,
190
216
  };
@@ -210,6 +236,11 @@ export const SlidewiseFileEditor = forwardRef<
210
236
  stop: () => editorRef.current?.stop(),
211
237
  undo: () => editorRef.current?.undo(),
212
238
  redo: () => editorRef.current?.redo(),
239
+ canUndo: () => editorRef.current?.canUndo() ?? false,
240
+ canRedo: () => editorRef.current?.canRedo() ?? false,
241
+ getHistorySize: () =>
242
+ editorRef.current?.getHistorySize() ?? { undo: 0, redo: 0 },
243
+ endCoalesce: () => editorRef.current?.endCoalesce(),
213
244
  getDeck: () =>
214
245
  state.status === "ready"
215
246
  ? editorRef.current?.getDeck() ?? state.deck
@@ -255,6 +286,7 @@ export const SlidewiseFileEditor = forwardRef<
255
286
  setDirty(d);
256
287
  onDirtyChangeRef.current?.(d);
257
288
  }}
289
+ onHistoryChange={onHistoryChange}
258
290
  onSave={async (next) => {
259
291
  const blob = await serialize(next);
260
292
  await saveBlob(blob);
@@ -36,6 +36,11 @@ export interface SlidewiseRootProps {
36
36
  onExport?: (deck: Deck) => void;
37
37
  /** Fires when the dirty flag flips. */
38
38
  onDirtyChange?: (dirty: boolean) => void;
39
+ /**
40
+ * Fires whenever the undo/redo stacks change depth. Use this to update
41
+ * "Undo"/"Redo" button enabled state without polling `canUndo()`/`canRedo()`.
42
+ */
43
+ onHistoryChange?: (state: HistoryState) => void;
39
44
  /**
40
45
  * Hide editing affordances (save / undo / redo) and disable canvas
41
46
  * mutations. Use this when the host viewer doesn't have write access.
@@ -59,11 +64,32 @@ export interface SlidewiseRootProps {
59
64
  style?: CSSProperties;
60
65
  }
61
66
 
67
+ export interface HistoryState {
68
+ canUndo: boolean;
69
+ canRedo: boolean;
70
+ /** Snapshot counts. Useful for "X steps to redo" indicators. */
71
+ undoSize: number;
72
+ redoSize: number;
73
+ }
74
+
62
75
  export interface SlidewiseRootHandle {
63
76
  play(): void;
64
77
  stop(): void;
65
78
  undo(): void;
66
79
  redo(): void;
80
+ /** True iff there's at least one snapshot to undo back to. */
81
+ canUndo(): boolean;
82
+ /** True iff there's at least one snapshot to redo forward to. */
83
+ canRedo(): boolean;
84
+ /** Current undo/redo stack depths. */
85
+ getHistorySize(): { undo: number; redo: number };
86
+ /**
87
+ * End the in-flight coalesce burst. Call on natural commit boundaries
88
+ * (mouseup after drag, blur on a text input) so the next mutation starts
89
+ * a fresh history step. Most hosts won't need this — the 500ms idle
90
+ * window handles typical typing/drag bursts.
91
+ */
92
+ endCoalesce(): void;
67
93
  getDeck(): Deck;
68
94
  isDirty(): boolean;
69
95
  resetDirty(): void;
@@ -95,6 +121,7 @@ function RootInner({
95
121
  onSave,
96
122
  onExport,
97
123
  onDirtyChange,
124
+ onHistoryChange: props_onHistoryChange,
98
125
  readOnly = false,
99
126
  theme,
100
127
  initialSlideId,
@@ -114,13 +141,15 @@ function RootInner({
114
141
  const onDirtyChangeRef = useRef(onDirtyChange);
115
142
  const onSaveRef = useRef(onSave);
116
143
  const onExportRef = useRef(onExport);
144
+ const onHistoryChangeRef = useRef(props_onHistoryChange);
117
145
 
118
146
  useEffect(() => {
119
147
  onChangeRef.current = onChange;
120
148
  onDirtyChangeRef.current = onDirtyChange;
121
149
  onSaveRef.current = onSave;
122
150
  onExportRef.current = onExport;
123
- }, [onChange, onDirtyChange, onSave, onExport]);
151
+ onHistoryChangeRef.current = props_onHistoryChange;
152
+ }, [onChange, onDirtyChange, onSave, onExport, props_onHistoryChange]);
124
153
 
125
154
  useEffect(() => {
126
155
  if (theme) {
@@ -159,6 +188,21 @@ function RootInner({
159
188
  collectFontFamilies(store.getState().deck)
160
189
  );
161
190
  return store.subscribe((state, prev) => {
191
+ // Fire onHistoryChange whenever stack depths change. Independent of
192
+ // deck identity so undo/redo always emit, even if the resulting deck
193
+ // happens to be reference-equal (shouldn't, but defensive).
194
+ const prevHist = prev.history.length;
195
+ const prevFut = prev.future.length;
196
+ const nextHist = state.history.length;
197
+ const nextFut = state.future.length;
198
+ if (prevHist !== nextHist || prevFut !== nextFut) {
199
+ onHistoryChangeRef.current?.({
200
+ canUndo: nextHist > 0,
201
+ canRedo: nextFut > 0,
202
+ undoSize: nextHist,
203
+ redoSize: nextFut,
204
+ });
205
+ }
162
206
  if (state.deck === prev.deck) return;
163
207
  onChangeRef.current?.(state.deck);
164
208
  const nextDirty = state.deck !== savedDeckRef.current;
@@ -183,6 +227,13 @@ function RootInner({
183
227
  stop: () => store.getState().stop(),
184
228
  undo: () => store.getState().undo(),
185
229
  redo: () => store.getState().redo(),
230
+ canUndo: () => store.getState().canUndo(),
231
+ canRedo: () => store.getState().canRedo(),
232
+ getHistorySize: () => {
233
+ const s = store.getState();
234
+ return { undo: s.history.length, redo: s.future.length };
235
+ },
236
+ endCoalesce: () => store.getState().endCoalesce(),
186
237
  getDeck: () => store.getState().deck,
187
238
  isDirty: () => dirtyRef.current,
188
239
  resetDirty: () => {
@@ -27,6 +27,7 @@ export {
27
27
  Root,
28
28
  type SlidewiseRootProps,
29
29
  type SlidewiseRootHandle,
30
+ type HistoryState,
30
31
  } from "./SlidewiseRoot";
31
32
  export {
32
33
  TopBar,
package/src/index.ts CHANGED
@@ -47,6 +47,7 @@ export {
47
47
  useReadOnly,
48
48
  type SlidewiseRootProps,
49
49
  type SlidewiseRootHandle,
50
+ type HistoryState,
50
51
  type SlidewiseHostCallbacks,
51
52
  type SlidewiseIcons,
52
53
  type RegionProps,
@@ -0,0 +1,164 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { createEditorStore } from "../store";
3
+ import { CURRENT_DECK_VERSION } from "../schema/migrate";
4
+ import type { Deck } from "../types";
5
+
6
+ const baseElement = { rotation: 0, z: 1 } as const;
7
+
8
+ function fixtureDeck(): Deck {
9
+ return {
10
+ version: CURRENT_DECK_VERSION,
11
+ title: "Hist test",
12
+ slides: [
13
+ {
14
+ id: "s1",
15
+ background: "#FFFFFF",
16
+ elements: [
17
+ {
18
+ ...baseElement,
19
+ id: "t1",
20
+ type: "text",
21
+ x: 100,
22
+ y: 100,
23
+ w: 400,
24
+ h: 80,
25
+ text: "Hello",
26
+ fontFamily: "Inter",
27
+ fontSize: 24,
28
+ fontWeight: 400,
29
+ italic: false,
30
+ underline: false,
31
+ strike: false,
32
+ color: "#000000",
33
+ align: "left",
34
+ vAlign: "top",
35
+ lineHeight: 1.2,
36
+ letterSpacing: 0,
37
+ },
38
+ ],
39
+ },
40
+ ],
41
+ };
42
+ }
43
+
44
+ describe("editor store history", () => {
45
+ beforeEach(() => {
46
+ vi.useFakeTimers({ now: 1_000_000 });
47
+ });
48
+
49
+ it("undo reverts an updateElement", () => {
50
+ const store = createEditorStore(fixtureDeck());
51
+ expect(store.getState().canUndo()).toBe(false);
52
+
53
+ store.getState().updateElement("t1", { x: 200 });
54
+ expect(store.getState().canUndo()).toBe(true);
55
+ const slide = () => store.getState().deck.slides[0];
56
+ expect((slide().elements[0] as { x: number }).x).toBe(200);
57
+
58
+ store.getState().undo();
59
+ expect((slide().elements[0] as { x: number }).x).toBe(100);
60
+ expect(store.getState().canUndo()).toBe(false);
61
+ expect(store.getState().canRedo()).toBe(true);
62
+ });
63
+
64
+ it("undo reverts a setTitle", () => {
65
+ const store = createEditorStore(fixtureDeck());
66
+ store.getState().setTitle("Renamed");
67
+ expect(store.getState().deck.title).toBe("Renamed");
68
+ store.getState().undo();
69
+ expect(store.getState().deck.title).toBe("Hist test");
70
+ });
71
+
72
+ it("coalesces a typing burst into one undo step", () => {
73
+ const store = createEditorStore(fixtureDeck());
74
+ // simulate keystrokes within the idle window
75
+ store.getState().updateElement("t1", { x: 110 });
76
+ vi.advanceTimersByTime(50);
77
+ store.getState().updateElement("t1", { x: 120 });
78
+ vi.advanceTimersByTime(50);
79
+ store.getState().updateElement("t1", { x: 130 });
80
+
81
+ expect(store.getState().history.length).toBe(1);
82
+
83
+ store.getState().undo();
84
+ const elX = (store.getState().deck.slides[0].elements[0] as { x: number }).x;
85
+ expect(elX).toBe(100);
86
+ });
87
+
88
+ it("starts a new history step after the idle window expires", () => {
89
+ const store = createEditorStore(fixtureDeck());
90
+ store.getState().updateElement("t1", { x: 110 });
91
+ expect(store.getState().history.length).toBe(1);
92
+
93
+ // advance past the 500ms idle window
94
+ vi.advanceTimersByTime(600);
95
+
96
+ store.getState().updateElement("t1", { x: 120 });
97
+ expect(store.getState().history.length).toBe(2);
98
+
99
+ store.getState().undo();
100
+ expect((store.getState().deck.slides[0].elements[0] as { x: number }).x).toBe(110);
101
+
102
+ store.getState().undo();
103
+ expect((store.getState().deck.slides[0].elements[0] as { x: number }).x).toBe(100);
104
+ });
105
+
106
+ it("endCoalesce starts a fresh step on the next mutation", () => {
107
+ const store = createEditorStore(fixtureDeck());
108
+ store.getState().updateElement("t1", { x: 110 });
109
+ store.getState().updateElement("t1", { x: 120 });
110
+ expect(store.getState().history.length).toBe(1);
111
+
112
+ store.getState().endCoalesce();
113
+ store.getState().updateElement("t1", { x: 130 });
114
+ expect(store.getState().history.length).toBe(2);
115
+ });
116
+
117
+ it("different patch keys on the same element start a fresh step", () => {
118
+ const store = createEditorStore(fixtureDeck());
119
+ store.getState().updateElement("t1", { x: 110 });
120
+ store.getState().updateElement("t1", { y: 110 });
121
+ // Different keys → different coalesce key → second step pushed.
122
+ expect(store.getState().history.length).toBe(2);
123
+ });
124
+
125
+ it("setDeck clears history", () => {
126
+ const store = createEditorStore(fixtureDeck());
127
+ store.getState().updateElement("t1", { x: 200 });
128
+ expect(store.getState().history.length).toBe(1);
129
+
130
+ store.getState().setDeck(fixtureDeck());
131
+ expect(store.getState().history.length).toBe(0);
132
+ expect(store.getState().future.length).toBe(0);
133
+ expect(store.getState().canUndo()).toBe(false);
134
+ });
135
+
136
+ it("undo/redo update canUndo/canRedo correctly", () => {
137
+ const store = createEditorStore(fixtureDeck());
138
+ store.getState().updateElement("t1", { x: 200 });
139
+ expect(store.getState().canUndo()).toBe(true);
140
+ expect(store.getState().canRedo()).toBe(false);
141
+
142
+ store.getState().undo();
143
+ expect(store.getState().canUndo()).toBe(false);
144
+ expect(store.getState().canRedo()).toBe(true);
145
+
146
+ store.getState().redo();
147
+ expect(store.getState().canUndo()).toBe(true);
148
+ expect(store.getState().canRedo()).toBe(false);
149
+ });
150
+
151
+ it("undo replaces the deck reference (so subscribers see a change)", () => {
152
+ const store = createEditorStore(fixtureDeck());
153
+ const before = store.getState().deck;
154
+ store.getState().updateElement("t1", { x: 200 });
155
+ const afterEdit = store.getState().deck;
156
+ expect(afterEdit).not.toBe(before);
157
+
158
+ store.getState().undo();
159
+ const afterUndo = store.getState().deck;
160
+ expect(afterUndo).not.toBe(afterEdit);
161
+ // value matches the pre-edit deck even if the reference is a clone
162
+ expect((afterUndo.slides[0].elements[0] as { x: number }).x).toBe(100);
163
+ });
164
+ });
package/src/lib/store.ts CHANGED
@@ -29,6 +29,17 @@ interface HistorySnapshot {
29
29
  type Theme = "light" | "dark";
30
30
  type View = "editor" | "grid";
31
31
 
32
+ /**
33
+ * Idle window in ms during which consecutive mutations with the same coalesce
34
+ * key collapse into a single history step. After this many ms with no edit,
35
+ * the next edit starts a fresh step. Drags on a single element typically run
36
+ * 60+ frames in well under this window; typing pauses around word boundaries
37
+ * usually exceed it. Hosts that want stricter granularity can call
38
+ * `endCoalesce()` explicitly (e.g. on mouseup or input blur).
39
+ */
40
+ const COALESCE_IDLE_MS = 500;
41
+ const HISTORY_LIMIT = 50;
42
+
32
43
  export interface EditorState {
33
44
  deck: Deck;
34
45
  currentSlideId: string;
@@ -41,6 +52,14 @@ export interface EditorState {
41
52
  view: View;
42
53
  history: HistorySnapshot[];
43
54
  future: HistorySnapshot[];
55
+ /**
56
+ * Coalesce key for the in-flight mutation burst. Two consecutive mutations
57
+ * with the same key (within `COALESCE_IDLE_MS`) collapse into one history
58
+ * step. `null` means "no burst in progress" — the next edit pushes a fresh
59
+ * snapshot.
60
+ */
61
+ _coalesceKey: string | null;
62
+ _coalesceUntil: number;
44
63
 
45
64
  // selectors
46
65
  currentSlide: () => Slide;
@@ -68,6 +87,23 @@ export interface EditorState {
68
87
  undo: () => void;
69
88
  redo: () => void;
70
89
  pushHistory: () => void;
90
+ /**
91
+ * Push a history snapshot, but only if `key` differs from the in-flight
92
+ * coalesce key OR more than `COALESCE_IDLE_MS` have passed since the last
93
+ * mutation. Use this for high-frequency edits (text typing, drag) so the
94
+ * burst collapses into one undo step.
95
+ */
96
+ pushHistoryCoalesced: (key: string) => void;
97
+ /**
98
+ * End the current coalesce burst. Hosts call this on natural commit
99
+ * boundaries (mouseup after a drag, blur on a text input) so the next
100
+ * mutation starts a fresh history step even within `COALESCE_IDLE_MS`.
101
+ */
102
+ endCoalesce: () => void;
103
+ /** True iff there's at least one snapshot to undo back to. */
104
+ canUndo: () => boolean;
105
+ /** True iff there's at least one snapshot to redo forward to. */
106
+ canRedo: () => boolean;
71
107
  setDeck: (deck: Deck) => void;
72
108
  setTheme: (t: Theme) => void;
73
109
  toggleTheme: () => void;
@@ -107,6 +143,8 @@ export function createEditorStore(initialDeck: Deck): EditorStore {
107
143
  view: "editor",
108
144
  history: [],
109
145
  future: [],
146
+ _coalesceKey: null,
147
+ _coalesceUntil: 0,
110
148
 
111
149
  currentSlide: () => {
112
150
  const s = get();
@@ -118,20 +156,48 @@ export function createEditorStore(initialDeck: Deck): EditorStore {
118
156
 
119
157
  pushHistory: () => {
120
158
  set((s) => ({
121
- history: [...s.history, snap(s)].slice(-50),
159
+ history: [...s.history, snap(s)].slice(-HISTORY_LIMIT),
122
160
  future: [],
161
+ _coalesceKey: null,
162
+ _coalesceUntil: 0,
123
163
  }));
124
164
  },
125
165
 
166
+ pushHistoryCoalesced: (key) => {
167
+ const now = Date.now();
168
+ set((s) => {
169
+ // Same burst within the idle window → don't push, just extend.
170
+ if (s._coalesceKey === key && now < s._coalesceUntil) {
171
+ return { _coalesceUntil: now + COALESCE_IDLE_MS };
172
+ }
173
+ // New burst — snapshot the pre-mutation state and start coalescing.
174
+ return {
175
+ history: [...s.history, snap(s)].slice(-HISTORY_LIMIT),
176
+ future: [],
177
+ _coalesceKey: key,
178
+ _coalesceUntil: now + COALESCE_IDLE_MS,
179
+ };
180
+ });
181
+ },
182
+
183
+ endCoalesce: () => {
184
+ set({ _coalesceKey: null, _coalesceUntil: 0 });
185
+ },
186
+
187
+ canUndo: () => get().history.length > 0,
188
+ canRedo: () => get().future.length > 0,
189
+
126
190
  setTool: (t) => set({ tool: t }),
127
191
  setTitle: (t) => {
192
+ get().pushHistoryCoalesced("setTitle");
128
193
  set((s) => ({ deck: { ...s.deck, title: t } }));
129
194
  },
130
195
  setZoom: (z) =>
131
196
  set({ zoom: Math.max(0.1, Math.min(4, z)), fitMode: "manual" }),
132
197
  setFitMode: (f) => set({ fitMode: f }),
133
198
 
134
- selectSlide: (id) => set({ currentSlideId: id, selectedIds: [] }),
199
+ selectSlide: (id) =>
200
+ set({ currentSlideId: id, selectedIds: [], _coalesceKey: null }),
135
201
  selectElement: (id, additive) =>
136
202
  set((s) => {
137
203
  if (id == null) return { selectedIds: [] };
@@ -228,6 +294,11 @@ export function createEditorStore(initialDeck: Deck): EditorStore {
228
294
  },
229
295
 
230
296
  updateElement: (id, patch) => {
297
+ // Coalesce key: same element, same patch shape = same burst.
298
+ // Drag (x,y) coalesces; resize (w,h) starts a new burst even on the
299
+ // same element; switching elements also starts fresh.
300
+ const key = `updateElement:${id}:${Object.keys(patch).sort().join(",")}`;
301
+ get().pushHistoryCoalesced(key);
231
302
  set((s) => {
232
303
  const slides = s.deck.slides.map((sl) => {
233
304
  if (sl.id !== s.currentSlideId) return sl;
@@ -320,8 +391,10 @@ export function createEditorStore(initialDeck: Deck): EditorStore {
320
391
  deck: last.deck,
321
392
  currentSlideId: last.currentSlideId,
322
393
  history: s.history.slice(0, -1),
323
- future: [...s.future, snapshot].slice(-50),
394
+ future: [...s.future, snapshot].slice(-HISTORY_LIMIT),
324
395
  selectedIds: survivingIds,
396
+ _coalesceKey: null,
397
+ _coalesceUntil: 0,
325
398
  };
326
399
  });
327
400
  },
@@ -342,9 +415,11 @@ export function createEditorStore(initialDeck: Deck): EditorStore {
342
415
  return {
343
416
  deck: next.deck,
344
417
  currentSlideId: next.currentSlideId,
345
- history: [...s.history, snapshot].slice(-50),
418
+ history: [...s.history, snapshot].slice(-HISTORY_LIMIT),
346
419
  future: s.future.slice(0, -1),
347
420
  selectedIds: survivingIds,
421
+ _coalesceKey: null,
422
+ _coalesceUntil: 0,
348
423
  };
349
424
  });
350
425
  },
@@ -357,6 +432,8 @@ export function createEditorStore(initialDeck: Deck): EditorStore {
357
432
  selectedIds: [],
358
433
  history: [],
359
434
  future: [],
435
+ _coalesceKey: null,
436
+ _coalesceUntil: 0,
360
437
  });
361
438
  },
362
439