@textcortex/slidewise 1.3.0 → 1.4.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 +1739 -1619
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/SlidewiseEditor.tsx +25 -0
- package/src/SlidewiseFileEditor.tsx +91 -1
- package/src/compound/SlidewiseRoot.tsx +181 -8
- package/src/compound/index.ts +1 -0
- package/src/index.ts +1 -0
- package/src/lib/__tests__/api-extensions.test.ts +74 -0
- package/src/lib/store.ts +20 -10
package/package.json
CHANGED
package/src/SlidewiseEditor.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type SlidewiseRootHandle,
|
|
5
5
|
type SlidewiseRootProps,
|
|
6
6
|
type HistoryState,
|
|
7
|
+
type SelectionSnapshot,
|
|
7
8
|
} from "./compound/SlidewiseRoot";
|
|
8
9
|
import { TopBar } from "./compound/topbar";
|
|
9
10
|
import {
|
|
@@ -40,6 +41,18 @@ export interface SlidewiseEditorProps {
|
|
|
40
41
|
* Undo and Redo buttons reactively without polling `api.canUndo()`.
|
|
41
42
|
*/
|
|
42
43
|
onHistoryChange?: (state: HistoryState) => void;
|
|
44
|
+
/** Fires when the active slide changes (user click, programmatic goToSlide). */
|
|
45
|
+
onActiveSlideChange?: (slideId: string) => void;
|
|
46
|
+
/** Fires when the selected element ids change. */
|
|
47
|
+
onSelectionChange?: (selection: SelectionSnapshot) => void;
|
|
48
|
+
/** Fires when the canvas zoom level changes. */
|
|
49
|
+
onZoomChange?: (scale: number) => void;
|
|
50
|
+
/** Fires immediately before the host's `onSave` is invoked. */
|
|
51
|
+
onSaveStart?: () => void;
|
|
52
|
+
/** Fires after the host's `onSave` resolves successfully. */
|
|
53
|
+
onSaveSuccess?: () => void;
|
|
54
|
+
/** Fires when the host's `onSave` throws. The error still propagates. */
|
|
55
|
+
onSaveError?: (err: Error) => void;
|
|
43
56
|
/** Reserved for future use; not enforced yet. */
|
|
44
57
|
readOnly?: boolean;
|
|
45
58
|
/** "light" or "dark"; defaults to "light". Ignored after first render. */
|
|
@@ -97,6 +110,12 @@ export const SlidewiseEditor = forwardRef<
|
|
|
97
110
|
onExport,
|
|
98
111
|
onDirtyChange,
|
|
99
112
|
onHistoryChange,
|
|
113
|
+
onActiveSlideChange,
|
|
114
|
+
onSelectionChange,
|
|
115
|
+
onZoomChange,
|
|
116
|
+
onSaveStart,
|
|
117
|
+
onSaveSuccess,
|
|
118
|
+
onSaveError,
|
|
100
119
|
readOnly,
|
|
101
120
|
theme,
|
|
102
121
|
initialSlideId,
|
|
@@ -116,6 +135,12 @@ export const SlidewiseEditor = forwardRef<
|
|
|
116
135
|
onExport,
|
|
117
136
|
onDirtyChange,
|
|
118
137
|
onHistoryChange,
|
|
138
|
+
onActiveSlideChange,
|
|
139
|
+
onSelectionChange,
|
|
140
|
+
onZoomChange,
|
|
141
|
+
onSaveStart,
|
|
142
|
+
onSaveSuccess,
|
|
143
|
+
onSaveError,
|
|
119
144
|
readOnly,
|
|
120
145
|
theme,
|
|
121
146
|
initialSlideId,
|
|
@@ -10,7 +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
|
+
import type { HistoryState, SelectionSnapshot } from "./compound/SlidewiseRoot";
|
|
14
14
|
|
|
15
15
|
export interface SlidewiseFileEditorProps {
|
|
16
16
|
/**
|
|
@@ -55,6 +55,18 @@ export interface SlidewiseFileEditorProps {
|
|
|
55
55
|
* `api.canUndo()` / `api.canRedo()`.
|
|
56
56
|
*/
|
|
57
57
|
onHistoryChange?: (state: HistoryState) => void;
|
|
58
|
+
/** Fires when the active slide changes (user click, programmatic goToSlide). */
|
|
59
|
+
onActiveSlideChange?: (slideId: string) => void;
|
|
60
|
+
/** Fires when the selected element ids change. */
|
|
61
|
+
onSelectionChange?: (selection: SelectionSnapshot) => void;
|
|
62
|
+
/** Fires when the canvas zoom level changes. */
|
|
63
|
+
onZoomChange?: (scale: number) => void;
|
|
64
|
+
/** Fires immediately before the host's `saveBlob` is invoked. */
|
|
65
|
+
onSaveStart?: () => void;
|
|
66
|
+
/** Fires after `saveBlob` resolves successfully. */
|
|
67
|
+
onSaveSuccess?: () => void;
|
|
68
|
+
/** Fires when `saveBlob` throws. The error still propagates. */
|
|
69
|
+
onSaveError?: (err: Error) => void;
|
|
58
70
|
/**
|
|
59
71
|
* Fires when `loadBlob` or `parse` throws on mount. The default render
|
|
60
72
|
* still shows an in-editor "Could not open file" message, but hosts that
|
|
@@ -114,6 +126,37 @@ export interface SlidewiseFileEditorApi {
|
|
|
114
126
|
* handles typical typing/drag bursts automatically.
|
|
115
127
|
*/
|
|
116
128
|
endCoalesce(): void;
|
|
129
|
+
|
|
130
|
+
/** Switch the active slide. No-op when `slideId` is not in the deck. */
|
|
131
|
+
goToSlide(slideId: string): void;
|
|
132
|
+
/** Advance to the next slide. No-op past the last slide. */
|
|
133
|
+
nextSlide(): void;
|
|
134
|
+
/** Step back to the previous slide. No-op past the first. */
|
|
135
|
+
prevSlide(): void;
|
|
136
|
+
|
|
137
|
+
/** Zoom out by one step. */
|
|
138
|
+
zoomOut(): void;
|
|
139
|
+
/** Zoom in by one step. */
|
|
140
|
+
zoomIn(): void;
|
|
141
|
+
/** Set absolute zoom (1 = 100%); clamped to [0.1, 4]. */
|
|
142
|
+
setZoom(scale: number): void;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Insert a blank slide after `afterId`, or at the end. Returns the new
|
|
146
|
+
* slide's id. The new slide becomes active.
|
|
147
|
+
*/
|
|
148
|
+
addSlide(afterId?: string): string | null;
|
|
149
|
+
/**
|
|
150
|
+
* Duplicate `slideId`. Returns the new slide's id, or `null` if not
|
|
151
|
+
* found. The duplicate becomes active.
|
|
152
|
+
*/
|
|
153
|
+
duplicateSlide(slideId: string): string | null;
|
|
154
|
+
/** Delete `slideId`. No-op when the deck would be left empty. */
|
|
155
|
+
deleteSlide(slideId: string): void;
|
|
156
|
+
|
|
157
|
+
/** Current selection snapshot (slide id + selected element ids). */
|
|
158
|
+
getSelection(): SelectionSnapshot;
|
|
159
|
+
|
|
117
160
|
/** Live deck snapshot. Hosts use this for header badges (slide count, etc.). */
|
|
118
161
|
getDeck(): Deck | null;
|
|
119
162
|
getInitialSha256(): string | null;
|
|
@@ -138,6 +181,12 @@ export const SlidewiseFileEditor = forwardRef<
|
|
|
138
181
|
onDirtyChange,
|
|
139
182
|
onLoadError,
|
|
140
183
|
onHistoryChange,
|
|
184
|
+
onActiveSlideChange,
|
|
185
|
+
onSelectionChange,
|
|
186
|
+
onZoomChange,
|
|
187
|
+
onSaveStart,
|
|
188
|
+
onSaveSuccess,
|
|
189
|
+
onSaveError,
|
|
141
190
|
theme,
|
|
142
191
|
initialSlideId,
|
|
143
192
|
showTopBar,
|
|
@@ -211,6 +260,23 @@ export const SlidewiseFileEditor = forwardRef<
|
|
|
211
260
|
getHistorySize: () =>
|
|
212
261
|
editorRef.current?.getHistorySize() ?? { undo: 0, redo: 0 },
|
|
213
262
|
endCoalesce: () => editorRef.current?.endCoalesce(),
|
|
263
|
+
goToSlide: (slideId: string) => editorRef.current?.goToSlide(slideId),
|
|
264
|
+
nextSlide: () => editorRef.current?.nextSlide(),
|
|
265
|
+
prevSlide: () => editorRef.current?.prevSlide(),
|
|
266
|
+
zoomIn: () => editorRef.current?.zoomIn(),
|
|
267
|
+
zoomOut: () => editorRef.current?.zoomOut(),
|
|
268
|
+
setZoom: (scale: number) => editorRef.current?.setZoom(scale),
|
|
269
|
+
addSlide: (afterId?: string) =>
|
|
270
|
+
editorRef.current?.addSlide(afterId) ?? null,
|
|
271
|
+
duplicateSlide: (slideId: string) =>
|
|
272
|
+
editorRef.current?.duplicateSlide(slideId) ?? null,
|
|
273
|
+
deleteSlide: (slideId: string) =>
|
|
274
|
+
editorRef.current?.deleteSlide(slideId),
|
|
275
|
+
getSelection: () =>
|
|
276
|
+
editorRef.current?.getSelection() ?? {
|
|
277
|
+
slideId: state.deck.slides[0]?.id ?? "",
|
|
278
|
+
elementIds: [],
|
|
279
|
+
},
|
|
214
280
|
getDeck: () => editorRef.current?.getDeck() ?? state.deck,
|
|
215
281
|
getInitialSha256: () => initialSha256,
|
|
216
282
|
};
|
|
@@ -241,6 +307,24 @@ export const SlidewiseFileEditor = forwardRef<
|
|
|
241
307
|
getHistorySize: () =>
|
|
242
308
|
editorRef.current?.getHistorySize() ?? { undo: 0, redo: 0 },
|
|
243
309
|
endCoalesce: () => editorRef.current?.endCoalesce(),
|
|
310
|
+
goToSlide: (slideId: string) => editorRef.current?.goToSlide(slideId),
|
|
311
|
+
nextSlide: () => editorRef.current?.nextSlide(),
|
|
312
|
+
prevSlide: () => editorRef.current?.prevSlide(),
|
|
313
|
+
zoomIn: () => editorRef.current?.zoomIn(),
|
|
314
|
+
zoomOut: () => editorRef.current?.zoomOut(),
|
|
315
|
+
setZoom: (scale: number) => editorRef.current?.setZoom(scale),
|
|
316
|
+
addSlide: (afterId?: string) =>
|
|
317
|
+
editorRef.current?.addSlide(afterId) ?? null,
|
|
318
|
+
duplicateSlide: (slideId: string) =>
|
|
319
|
+
editorRef.current?.duplicateSlide(slideId) ?? null,
|
|
320
|
+
deleteSlide: (slideId: string) =>
|
|
321
|
+
editorRef.current?.deleteSlide(slideId),
|
|
322
|
+
getSelection: () =>
|
|
323
|
+
editorRef.current?.getSelection() ?? {
|
|
324
|
+
slideId:
|
|
325
|
+
state.status === "ready" ? state.deck.slides[0]?.id ?? "" : "",
|
|
326
|
+
elementIds: [],
|
|
327
|
+
},
|
|
244
328
|
getDeck: () =>
|
|
245
329
|
state.status === "ready"
|
|
246
330
|
? editorRef.current?.getDeck() ?? state.deck
|
|
@@ -287,6 +371,12 @@ export const SlidewiseFileEditor = forwardRef<
|
|
|
287
371
|
onDirtyChangeRef.current?.(d);
|
|
288
372
|
}}
|
|
289
373
|
onHistoryChange={onHistoryChange}
|
|
374
|
+
onActiveSlideChange={onActiveSlideChange}
|
|
375
|
+
onSelectionChange={onSelectionChange}
|
|
376
|
+
onZoomChange={onZoomChange}
|
|
377
|
+
onSaveStart={onSaveStart}
|
|
378
|
+
onSaveSuccess={onSaveSuccess}
|
|
379
|
+
onSaveError={onSaveError}
|
|
290
380
|
onSave={async (next) => {
|
|
291
381
|
const blob = await serialize(next);
|
|
292
382
|
await saveBlob(blob);
|
|
@@ -43,6 +43,18 @@ export interface SlidewiseRootProps {
|
|
|
43
43
|
* "Undo"/"Redo" button enabled state without polling `canUndo()`/`canRedo()`.
|
|
44
44
|
*/
|
|
45
45
|
onHistoryChange?: (state: HistoryState) => void;
|
|
46
|
+
/** Fires when the active slide changes (user click, programmatic goToSlide). */
|
|
47
|
+
onActiveSlideChange?: (slideId: string) => void;
|
|
48
|
+
/** Fires when the selected element ids change. */
|
|
49
|
+
onSelectionChange?: (selection: SelectionSnapshot) => void;
|
|
50
|
+
/** Fires when the canvas zoom level changes. */
|
|
51
|
+
onZoomChange?: (scale: number) => void;
|
|
52
|
+
/** Fires immediately before the host's `onSave` is invoked. */
|
|
53
|
+
onSaveStart?: () => void;
|
|
54
|
+
/** Fires after the host's `onSave` resolves successfully. */
|
|
55
|
+
onSaveSuccess?: () => void;
|
|
56
|
+
/** Fires when the host's `onSave` throws. The error still propagates. */
|
|
57
|
+
onSaveError?: (err: Error) => void;
|
|
46
58
|
/**
|
|
47
59
|
* Hide editing affordances (save / undo / redo) and disable canvas
|
|
48
60
|
* mutations. Use this when the host viewer doesn't have write access.
|
|
@@ -74,6 +86,12 @@ export interface HistoryState {
|
|
|
74
86
|
redoSize: number;
|
|
75
87
|
}
|
|
76
88
|
|
|
89
|
+
/** Snapshot of the current selection, scoped to the active slide. */
|
|
90
|
+
export interface SelectionSnapshot {
|
|
91
|
+
slideId: string;
|
|
92
|
+
elementIds: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
77
95
|
export interface SlidewiseRootHandle {
|
|
78
96
|
play(): void;
|
|
79
97
|
stop(): void;
|
|
@@ -92,6 +110,44 @@ export interface SlidewiseRootHandle {
|
|
|
92
110
|
* window handles typical typing/drag bursts.
|
|
93
111
|
*/
|
|
94
112
|
endCoalesce(): void;
|
|
113
|
+
|
|
114
|
+
// ---- Navigation ----
|
|
115
|
+
/** Switch the active slide. No-op when `slideId` is not in the deck. */
|
|
116
|
+
goToSlide(slideId: string): void;
|
|
117
|
+
/** Advance to the slide after the current one. No-op past the last slide. */
|
|
118
|
+
nextSlide(): void;
|
|
119
|
+
/** Step back to the slide before the current one. No-op past the first. */
|
|
120
|
+
prevSlide(): void;
|
|
121
|
+
|
|
122
|
+
// ---- Zoom ----
|
|
123
|
+
/** Zoom out by one step (×0.8), clamped to the editor's min zoom. */
|
|
124
|
+
zoomOut(): void;
|
|
125
|
+
/** Zoom in by one step (×1.25), clamped to the editor's max zoom. */
|
|
126
|
+
zoomIn(): void;
|
|
127
|
+
/** Set the absolute zoom (1 = 100%); clamped to [0.1, 4]. */
|
|
128
|
+
setZoom(scale: number): void;
|
|
129
|
+
|
|
130
|
+
// ---- Slide CRUD ----
|
|
131
|
+
/**
|
|
132
|
+
* Insert a blank slide after `afterId`, or at the end if `afterId` is
|
|
133
|
+
* omitted. Returns the new slide's id. The new slide becomes active.
|
|
134
|
+
*/
|
|
135
|
+
addSlide(afterId?: string): string;
|
|
136
|
+
/**
|
|
137
|
+
* Insert a copy of `slideId` immediately after it. Returns the new
|
|
138
|
+
* slide's id, or `null` if `slideId` wasn't found. The copy becomes
|
|
139
|
+
* active.
|
|
140
|
+
*/
|
|
141
|
+
duplicateSlide(slideId: string): string | null;
|
|
142
|
+
/**
|
|
143
|
+
* Delete a slide. No-op when the deck would be left with zero slides.
|
|
144
|
+
*/
|
|
145
|
+
deleteSlide(slideId: string): void;
|
|
146
|
+
|
|
147
|
+
// ---- Selection ----
|
|
148
|
+
/** Current selection snapshot (slide id + selected element ids). */
|
|
149
|
+
getSelection(): SelectionSnapshot;
|
|
150
|
+
|
|
95
151
|
getDeck(): Deck;
|
|
96
152
|
isDirty(): boolean;
|
|
97
153
|
resetDirty(): void;
|
|
@@ -124,6 +180,12 @@ function RootInner({
|
|
|
124
180
|
onExport,
|
|
125
181
|
onDirtyChange,
|
|
126
182
|
onHistoryChange: props_onHistoryChange,
|
|
183
|
+
onActiveSlideChange,
|
|
184
|
+
onSelectionChange,
|
|
185
|
+
onZoomChange,
|
|
186
|
+
onSaveStart,
|
|
187
|
+
onSaveSuccess,
|
|
188
|
+
onSaveError,
|
|
127
189
|
readOnly = false,
|
|
128
190
|
theme,
|
|
129
191
|
initialSlideId,
|
|
@@ -145,6 +207,12 @@ function RootInner({
|
|
|
145
207
|
const onSaveRef = useRef(onSave);
|
|
146
208
|
const onExportRef = useRef(onExport);
|
|
147
209
|
const onHistoryChangeRef = useRef(props_onHistoryChange);
|
|
210
|
+
const onActiveSlideChangeRef = useRef(onActiveSlideChange);
|
|
211
|
+
const onSelectionChangeRef = useRef(onSelectionChange);
|
|
212
|
+
const onZoomChangeRef = useRef(onZoomChange);
|
|
213
|
+
const onSaveStartRef = useRef(onSaveStart);
|
|
214
|
+
const onSaveSuccessRef = useRef(onSaveSuccess);
|
|
215
|
+
const onSaveErrorRef = useRef(onSaveError);
|
|
148
216
|
|
|
149
217
|
useEffect(() => {
|
|
150
218
|
onChangeRef.current = onChange;
|
|
@@ -152,7 +220,25 @@ function RootInner({
|
|
|
152
220
|
onSaveRef.current = onSave;
|
|
153
221
|
onExportRef.current = onExport;
|
|
154
222
|
onHistoryChangeRef.current = props_onHistoryChange;
|
|
155
|
-
|
|
223
|
+
onActiveSlideChangeRef.current = onActiveSlideChange;
|
|
224
|
+
onSelectionChangeRef.current = onSelectionChange;
|
|
225
|
+
onZoomChangeRef.current = onZoomChange;
|
|
226
|
+
onSaveStartRef.current = onSaveStart;
|
|
227
|
+
onSaveSuccessRef.current = onSaveSuccess;
|
|
228
|
+
onSaveErrorRef.current = onSaveError;
|
|
229
|
+
}, [
|
|
230
|
+
onChange,
|
|
231
|
+
onDirtyChange,
|
|
232
|
+
onSave,
|
|
233
|
+
onExport,
|
|
234
|
+
props_onHistoryChange,
|
|
235
|
+
onActiveSlideChange,
|
|
236
|
+
onSelectionChange,
|
|
237
|
+
onZoomChange,
|
|
238
|
+
onSaveStart,
|
|
239
|
+
onSaveSuccess,
|
|
240
|
+
onSaveError,
|
|
241
|
+
]);
|
|
156
242
|
|
|
157
243
|
useEffect(() => {
|
|
158
244
|
if (theme) {
|
|
@@ -207,6 +293,29 @@ function RootInner({
|
|
|
207
293
|
redoSize: nextFut,
|
|
208
294
|
});
|
|
209
295
|
}
|
|
296
|
+
|
|
297
|
+
// Active slide changes (click in rail, programmatic goToSlide).
|
|
298
|
+
if (state.currentSlideId !== prev.currentSlideId) {
|
|
299
|
+
onActiveSlideChangeRef.current?.(state.currentSlideId);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Selection — shallow compare ids since the array is rebuilt on
|
|
303
|
+
// every selectElement call. Same slideId + same ids = no emit.
|
|
304
|
+
if (
|
|
305
|
+
state.selectedIds !== prev.selectedIds &&
|
|
306
|
+
!shallowEqualIds(state.selectedIds, prev.selectedIds)
|
|
307
|
+
) {
|
|
308
|
+
onSelectionChangeRef.current?.({
|
|
309
|
+
slideId: state.currentSlideId,
|
|
310
|
+
elementIds: state.selectedIds,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Zoom.
|
|
315
|
+
if (state.zoom !== prev.zoom) {
|
|
316
|
+
onZoomChangeRef.current?.(state.zoom);
|
|
317
|
+
}
|
|
318
|
+
|
|
210
319
|
if (state.deck === prev.deck) return;
|
|
211
320
|
onChangeRef.current?.(state.deck);
|
|
212
321
|
const nextDirty = state.deck !== savedDeckRef.current;
|
|
@@ -239,12 +348,54 @@ function RootInner({
|
|
|
239
348
|
return { undo: s.history.length, redo: s.future.length };
|
|
240
349
|
},
|
|
241
350
|
endCoalesce: () => store.getState().endCoalesce(),
|
|
351
|
+
|
|
352
|
+
goToSlide: (slideId: string) => {
|
|
353
|
+
const s = store.getState();
|
|
354
|
+
if (s.deck.slides.some((sl) => sl.id === slideId)) {
|
|
355
|
+
s.selectSlide(slideId);
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
nextSlide: () => {
|
|
359
|
+
const s = store.getState();
|
|
360
|
+
const idx = s.deck.slides.findIndex(
|
|
361
|
+
(sl) => sl.id === s.currentSlideId
|
|
362
|
+
);
|
|
363
|
+
const next = s.deck.slides[idx + 1];
|
|
364
|
+
if (next) s.selectSlide(next.id);
|
|
365
|
+
},
|
|
366
|
+
prevSlide: () => {
|
|
367
|
+
const s = store.getState();
|
|
368
|
+
const idx = s.deck.slides.findIndex(
|
|
369
|
+
(sl) => sl.id === s.currentSlideId
|
|
370
|
+
);
|
|
371
|
+
const prev = s.deck.slides[idx - 1];
|
|
372
|
+
if (prev) s.selectSlide(prev.id);
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
zoomIn: () => store.getState().zoomIn(),
|
|
376
|
+
zoomOut: () => store.getState().zoomOut(),
|
|
377
|
+
setZoom: (scale: number) => store.getState().setZoom(scale),
|
|
378
|
+
|
|
379
|
+
addSlide: (afterId?: string) => store.getState().addSlide(afterId),
|
|
380
|
+
duplicateSlide: (slideId: string) =>
|
|
381
|
+
store.getState().duplicateSlide(slideId),
|
|
382
|
+
deleteSlide: (slideId: string) => store.getState().deleteSlide(slideId),
|
|
383
|
+
|
|
384
|
+
getSelection: (): SelectionSnapshot => {
|
|
385
|
+
const s = store.getState();
|
|
386
|
+
return {
|
|
387
|
+
slideId: s.currentSlideId,
|
|
388
|
+
elementIds: [...s.selectedIds],
|
|
389
|
+
};
|
|
390
|
+
},
|
|
391
|
+
|
|
242
392
|
getDeck: () => store.getState().deck,
|
|
243
393
|
isDirty: () => dirtyRef.current,
|
|
244
394
|
resetDirty: () => {
|
|
245
395
|
savedDeckRef.current = store.getState().deck;
|
|
246
396
|
if (dirtyRef.current) {
|
|
247
397
|
dirtyRef.current = false;
|
|
398
|
+
setDirty(false);
|
|
248
399
|
onDirtyChangeRef.current?.(false);
|
|
249
400
|
}
|
|
250
401
|
},
|
|
@@ -252,15 +403,28 @@ function RootInner({
|
|
|
252
403
|
[store]
|
|
253
404
|
);
|
|
254
405
|
|
|
255
|
-
// Wrap host save with
|
|
256
|
-
//
|
|
406
|
+
// Wrap host save with:
|
|
407
|
+
// - onSaveStart / onSaveSuccess / onSaveError lifecycle hooks
|
|
408
|
+
// - dirty-flag reset on success
|
|
409
|
+
// The error still propagates so TopBar.Save's local "Saving…" → "idle"
|
|
410
|
+
// transition kicks in correctly.
|
|
257
411
|
const wrappedSave = onSave
|
|
258
412
|
? async (d: Deck) => {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
413
|
+
onSaveStartRef.current?.();
|
|
414
|
+
try {
|
|
415
|
+
await onSaveRef.current!(d);
|
|
416
|
+
savedDeckRef.current = d;
|
|
417
|
+
if (dirtyRef.current) {
|
|
418
|
+
dirtyRef.current = false;
|
|
419
|
+
setDirty(false);
|
|
420
|
+
onDirtyChangeRef.current?.(false);
|
|
421
|
+
}
|
|
422
|
+
onSaveSuccessRef.current?.();
|
|
423
|
+
} catch (err) {
|
|
424
|
+
onSaveErrorRef.current?.(
|
|
425
|
+
err instanceof Error ? err : new Error(String(err))
|
|
426
|
+
);
|
|
427
|
+
throw err;
|
|
264
428
|
}
|
|
265
429
|
}
|
|
266
430
|
: undefined;
|
|
@@ -330,3 +494,12 @@ function RootShell({
|
|
|
330
494
|
</div>
|
|
331
495
|
);
|
|
332
496
|
}
|
|
497
|
+
|
|
498
|
+
function shallowEqualIds(a: readonly string[], b: readonly string[]): boolean {
|
|
499
|
+
if (a === b) return true;
|
|
500
|
+
if (a.length !== b.length) return false;
|
|
501
|
+
for (let i = 0; i < a.length; i++) {
|
|
502
|
+
if (a[i] !== b[i]) return false;
|
|
503
|
+
}
|
|
504
|
+
return true;
|
|
505
|
+
}
|
package/src/compound/index.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createEditorStore } from "../store";
|
|
3
|
+
import { CURRENT_DECK_VERSION } from "../schema/migrate";
|
|
4
|
+
import type { Deck } from "../types";
|
|
5
|
+
|
|
6
|
+
function makeDeck(slideCount = 3): Deck {
|
|
7
|
+
return {
|
|
8
|
+
version: CURRENT_DECK_VERSION,
|
|
9
|
+
title: "fixture",
|
|
10
|
+
slides: Array.from({ length: slideCount }, (_, i) => ({
|
|
11
|
+
id: `s${i + 1}`,
|
|
12
|
+
background: "#FFFFFF",
|
|
13
|
+
elements: [],
|
|
14
|
+
})),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("store: zoom actions", () => {
|
|
19
|
+
it("zoomIn multiplies the current zoom by 1.25 (clamped to 4)", () => {
|
|
20
|
+
const store = createEditorStore(makeDeck());
|
|
21
|
+
store.getState().setZoom(1);
|
|
22
|
+
store.getState().zoomIn();
|
|
23
|
+
expect(store.getState().zoom).toBeCloseTo(1.25);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("zoomOut multiplies by 0.8 (clamped to 0.1)", () => {
|
|
27
|
+
const store = createEditorStore(makeDeck());
|
|
28
|
+
store.getState().setZoom(1);
|
|
29
|
+
store.getState().zoomOut();
|
|
30
|
+
expect(store.getState().zoom).toBeCloseTo(0.8);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("setZoom clamps the zoom to the valid range", () => {
|
|
34
|
+
const store = createEditorStore(makeDeck());
|
|
35
|
+
store.getState().setZoom(999);
|
|
36
|
+
expect(store.getState().zoom).toBe(4);
|
|
37
|
+
store.getState().setZoom(0);
|
|
38
|
+
expect(store.getState().zoom).toBe(0.1);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("store: slide CRUD return ids", () => {
|
|
43
|
+
it("addSlide returns the new slide's id and inserts after the target", () => {
|
|
44
|
+
const store = createEditorStore(makeDeck(2));
|
|
45
|
+
const newId = store.getState().addSlide("s1");
|
|
46
|
+
expect(typeof newId).toBe("string");
|
|
47
|
+
const slides = store.getState().deck.slides;
|
|
48
|
+
expect(slides).toHaveLength(3);
|
|
49
|
+
expect(slides[1].id).toBe(newId);
|
|
50
|
+
expect(store.getState().currentSlideId).toBe(newId);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("addSlide() with no afterId appends at the end", () => {
|
|
54
|
+
const store = createEditorStore(makeDeck(2));
|
|
55
|
+
const newId = store.getState().addSlide();
|
|
56
|
+
expect(store.getState().deck.slides[2].id).toBe(newId);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("duplicateSlide returns the new slide id and inserts after the original", () => {
|
|
60
|
+
const store = createEditorStore(makeDeck(2));
|
|
61
|
+
const copyId = store.getState().duplicateSlide("s1");
|
|
62
|
+
expect(typeof copyId).toBe("string");
|
|
63
|
+
expect(copyId).not.toBe("s1");
|
|
64
|
+
const slides = store.getState().deck.slides;
|
|
65
|
+
expect(slides).toHaveLength(3);
|
|
66
|
+
expect(slides[1].id).toBe(copyId);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("duplicateSlide returns null when slide id is not found", () => {
|
|
70
|
+
const store = createEditorStore(makeDeck(2));
|
|
71
|
+
expect(store.getState().duplicateSlide("does-not-exist")).toBeNull();
|
|
72
|
+
expect(store.getState().deck.slides).toHaveLength(2);
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/lib/store.ts
CHANGED
|
@@ -68,12 +68,18 @@ export interface EditorState {
|
|
|
68
68
|
setTool: (t: Tool) => void;
|
|
69
69
|
setTitle: (t: string) => void;
|
|
70
70
|
setZoom: (z: number) => void;
|
|
71
|
+
/** Multiplicative zoom-in step (×1.25), clamped to the same range as setZoom. */
|
|
72
|
+
zoomIn: () => void;
|
|
73
|
+
/** Multiplicative zoom-out step (×0.8), clamped to the same range as setZoom. */
|
|
74
|
+
zoomOut: () => void;
|
|
71
75
|
setFitMode: (f: "fit" | "fill" | "manual") => void;
|
|
72
76
|
selectSlide: (id: string) => void;
|
|
73
77
|
selectElement: (id: string | null, additive?: boolean) => void;
|
|
74
78
|
clearSelection: () => void;
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
/** Returns the id of the newly inserted slide. */
|
|
80
|
+
addSlide: (afterId?: string) => string;
|
|
81
|
+
/** Returns the id of the newly inserted duplicate, or null if `id` wasn't found. */
|
|
82
|
+
duplicateSlide: (id: string) => string | null;
|
|
77
83
|
deleteSlide: (id: string) => void;
|
|
78
84
|
reorderSlide: (id: string, toIndex: number) => void;
|
|
79
85
|
addElement: (partial: ElementDraft) => string;
|
|
@@ -194,6 +200,8 @@ export function createEditorStore(initialDeck: Deck): EditorStore {
|
|
|
194
200
|
},
|
|
195
201
|
setZoom: (z) =>
|
|
196
202
|
set({ zoom: Math.max(0.1, Math.min(4, z)), fitMode: "manual" }),
|
|
203
|
+
zoomIn: () => get().setZoom(get().zoom * 1.25),
|
|
204
|
+
zoomOut: () => get().setZoom(get().zoom * 0.8),
|
|
197
205
|
setFitMode: (f) => set({ fitMode: f }),
|
|
198
206
|
|
|
199
207
|
selectSlide: (id) =>
|
|
@@ -215,8 +223,8 @@ export function createEditorStore(initialDeck: Deck): EditorStore {
|
|
|
215
223
|
|
|
216
224
|
addSlide: (afterId) => {
|
|
217
225
|
get().pushHistory();
|
|
226
|
+
const slide = blankSlide();
|
|
218
227
|
set((s) => {
|
|
219
|
-
const slide = blankSlide();
|
|
220
228
|
const slides = [...s.deck.slides];
|
|
221
229
|
const idx = afterId
|
|
222
230
|
? slides.findIndex((sl) => sl.id === afterId)
|
|
@@ -228,26 +236,28 @@ export function createEditorStore(initialDeck: Deck): EditorStore {
|
|
|
228
236
|
selectedIds: [],
|
|
229
237
|
};
|
|
230
238
|
});
|
|
239
|
+
return slide.id;
|
|
231
240
|
},
|
|
232
241
|
|
|
233
242
|
duplicateSlide: (id) => {
|
|
243
|
+
const orig = get().deck.slides.find((sl) => sl.id === id);
|
|
244
|
+
if (!orig) return null;
|
|
234
245
|
get().pushHistory();
|
|
246
|
+
const copy: Slide = {
|
|
247
|
+
...structuredClone(orig),
|
|
248
|
+
id: nanoid(8),
|
|
249
|
+
elements: orig.elements.map((e) => ({ ...e, id: nanoid(8) })),
|
|
250
|
+
};
|
|
235
251
|
set((s) => {
|
|
236
252
|
const slides = [...s.deck.slides];
|
|
237
253
|
const idx = slides.findIndex((sl) => sl.id === id);
|
|
238
|
-
if (idx < 0) return s;
|
|
239
|
-
const orig = slides[idx];
|
|
240
|
-
const copy: Slide = {
|
|
241
|
-
...structuredClone(orig),
|
|
242
|
-
id: nanoid(8),
|
|
243
|
-
elements: orig.elements.map((e) => ({ ...e, id: nanoid(8) })),
|
|
244
|
-
};
|
|
245
254
|
slides.splice(idx + 1, 0, copy);
|
|
246
255
|
return {
|
|
247
256
|
deck: { ...s.deck, slides },
|
|
248
257
|
currentSlideId: copy.id,
|
|
249
258
|
};
|
|
250
259
|
});
|
|
260
|
+
return copy.id;
|
|
251
261
|
},
|
|
252
262
|
|
|
253
263
|
deleteSlide: (id) => {
|