@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textcortex/slidewise",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Embeddable React PPTX editor.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- }, [onChange, onDirtyChange, onSave, onExport, props_onHistoryChange]);
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 dirty-flag reset so any TopBar.Save / imperative save
256
- // path that funnels through here clears the dirty state on success.
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
- await onSaveRef.current!(d);
260
- savedDeckRef.current = d;
261
- if (dirtyRef.current) {
262
- dirtyRef.current = false;
263
- onDirtyChangeRef.current?.(false);
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
+ }
@@ -37,6 +37,7 @@ export {
37
37
  type SlidewiseRootProps,
38
38
  type SlidewiseRootHandle,
39
39
  type HistoryState,
40
+ type SelectionSnapshot,
40
41
  } from "./SlidewiseRoot";
41
42
  export {
42
43
  SlideRail,
package/src/index.ts CHANGED
@@ -60,6 +60,7 @@ export {
60
60
  type SlidewiseRootProps,
61
61
  type SlidewiseRootHandle,
62
62
  type HistoryState,
63
+ type SelectionSnapshot,
63
64
  type SlidewiseHostCallbacks,
64
65
  type SlidewiseIcons,
65
66
  type RegionProps,
@@ -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
- addSlide: (afterId?: string) => void;
76
- duplicateSlide: (id: string) => void;
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) => {