@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/dist/index.mjs +3192 -3141
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/SlidewiseEditor.tsx +13 -1
- package/src/SlidewiseFileEditor.tsx +32 -0
- package/src/compound/SlidewiseRoot.tsx +52 -1
- package/src/compound/index.ts +1 -0
- package/src/index.ts +1 -0
- package/src/lib/__tests__/history.test.ts +164 -0
- package/src/lib/store.ts +81 -4
package/package.json
CHANGED
package/src/SlidewiseEditor.tsx
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { forwardRef, type CSSProperties } from "react";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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: () => {
|
package/src/compound/index.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -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(-
|
|
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) =>
|
|
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(-
|
|
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(-
|
|
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
|
|