@textcortex/slidewise 1.2.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.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Embeddable React PPTX editor.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4,9 +4,10 @@ import {
4
4
  type SlidewiseRootHandle,
5
5
  type SlidewiseRootProps,
6
6
  type HistoryState,
7
+ type SelectionSnapshot,
7
8
  } from "./compound/SlidewiseRoot";
9
+ import { TopBar } from "./compound/topbar";
8
10
  import {
9
- TopBar,
10
11
  SlideRail,
11
12
  Canvas,
12
13
  BottomToolbar,
@@ -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);
@@ -0,0 +1,30 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+
3
+ const DirtyContext = createContext<boolean>(false);
4
+
5
+ export function DirtyProvider({
6
+ dirty,
7
+ children,
8
+ }: {
9
+ dirty: boolean;
10
+ children: ReactNode;
11
+ }) {
12
+ return <DirtyContext.Provider value={dirty}>{children}</DirtyContext.Provider>;
13
+ }
14
+
15
+ /**
16
+ * Reactive dirty flag. `true` when the deck has uncommitted edits since the
17
+ * last save / mount. Use this from host components anywhere under
18
+ * `<Slidewise.Root>` to render "Unsaved changes" UI without polling
19
+ * `api.isDirty()`.
20
+ *
21
+ * ```tsx
22
+ * function MyHeader() {
23
+ * const dirty = useDirty();
24
+ * return dirty ? <Badge>Unsaved</Badge> : null;
25
+ * }
26
+ * ```
27
+ */
28
+ export function useDirty(): boolean {
29
+ return useContext(DirtyContext);
30
+ }
@@ -4,6 +4,7 @@ import {
4
4
  useId,
5
5
  useImperativeHandle,
6
6
  useRef,
7
+ useState,
7
8
  type CSSProperties,
8
9
  type PropsWithChildren,
9
10
  type Ref,
@@ -20,6 +21,7 @@ import { PlayMode } from "@/components/editor/PlayMode";
20
21
  import { HostProvider } from "./HostContext";
21
22
  import { IconProvider, type SlidewiseIcons } from "./IconContext";
22
23
  import { ReadOnlyProvider } from "./ReadOnlyContext";
24
+ import { DirtyProvider } from "./DirtyContext";
23
25
 
24
26
  export interface SlidewiseRootProps {
25
27
  /**
@@ -41,6 +43,18 @@ export interface SlidewiseRootProps {
41
43
  * "Undo"/"Redo" button enabled state without polling `canUndo()`/`canRedo()`.
42
44
  */
43
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;
44
58
  /**
45
59
  * Hide editing affordances (save / undo / redo) and disable canvas
46
60
  * mutations. Use this when the host viewer doesn't have write access.
@@ -72,6 +86,12 @@ export interface HistoryState {
72
86
  redoSize: number;
73
87
  }
74
88
 
89
+ /** Snapshot of the current selection, scoped to the active slide. */
90
+ export interface SelectionSnapshot {
91
+ slideId: string;
92
+ elementIds: string[];
93
+ }
94
+
75
95
  export interface SlidewiseRootHandle {
76
96
  play(): void;
77
97
  stop(): void;
@@ -90,6 +110,44 @@ export interface SlidewiseRootHandle {
90
110
  * window handles typical typing/drag bursts.
91
111
  */
92
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
+
93
151
  getDeck(): Deck;
94
152
  isDirty(): boolean;
95
153
  resetDirty(): void;
@@ -122,6 +180,12 @@ function RootInner({
122
180
  onExport,
123
181
  onDirtyChange,
124
182
  onHistoryChange: props_onHistoryChange,
183
+ onActiveSlideChange,
184
+ onSelectionChange,
185
+ onZoomChange,
186
+ onSaveStart,
187
+ onSaveSuccess,
188
+ onSaveError,
125
189
  readOnly = false,
126
190
  theme,
127
191
  initialSlideId,
@@ -137,11 +201,18 @@ function RootInner({
137
201
  const store = useEditorStore();
138
202
  const savedDeckRef = useRef<Deck>(deck);
139
203
  const dirtyRef = useRef(false);
204
+ const [dirty, setDirty] = useState(false);
140
205
  const onChangeRef = useRef(onChange);
141
206
  const onDirtyChangeRef = useRef(onDirtyChange);
142
207
  const onSaveRef = useRef(onSave);
143
208
  const onExportRef = useRef(onExport);
144
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);
145
216
 
146
217
  useEffect(() => {
147
218
  onChangeRef.current = onChange;
@@ -149,7 +220,25 @@ function RootInner({
149
220
  onSaveRef.current = onSave;
150
221
  onExportRef.current = onExport;
151
222
  onHistoryChangeRef.current = props_onHistoryChange;
152
- }, [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
+ ]);
153
242
 
154
243
  useEffect(() => {
155
244
  if (theme) {
@@ -176,6 +265,7 @@ function RootInner({
176
265
  savedDeckRef.current = deck;
177
266
  if (dirtyRef.current) {
178
267
  dirtyRef.current = false;
268
+ setDirty(false);
179
269
  onDirtyChangeRef.current?.(false);
180
270
  }
181
271
  }
@@ -203,11 +293,35 @@ function RootInner({
203
293
  redoSize: nextFut,
204
294
  });
205
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
+
206
319
  if (state.deck === prev.deck) return;
207
320
  onChangeRef.current?.(state.deck);
208
321
  const nextDirty = state.deck !== savedDeckRef.current;
209
322
  if (nextDirty !== dirtyRef.current) {
210
323
  dirtyRef.current = nextDirty;
324
+ setDirty(nextDirty);
211
325
  onDirtyChangeRef.current?.(nextDirty);
212
326
  }
213
327
  ensureGoogleFontsLoaded(instanceId, collectFontFamilies(state.deck));
@@ -234,12 +348,54 @@ function RootInner({
234
348
  return { undo: s.history.length, redo: s.future.length };
235
349
  },
236
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
+
237
392
  getDeck: () => store.getState().deck,
238
393
  isDirty: () => dirtyRef.current,
239
394
  resetDirty: () => {
240
395
  savedDeckRef.current = store.getState().deck;
241
396
  if (dirtyRef.current) {
242
397
  dirtyRef.current = false;
398
+ setDirty(false);
243
399
  onDirtyChangeRef.current?.(false);
244
400
  }
245
401
  },
@@ -247,15 +403,28 @@ function RootInner({
247
403
  [store]
248
404
  );
249
405
 
250
- // Wrap host save with dirty-flag reset so any TopBar.Save / imperative save
251
- // 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.
252
411
  const wrappedSave = onSave
253
412
  ? async (d: Deck) => {
254
- await onSaveRef.current!(d);
255
- savedDeckRef.current = d;
256
- if (dirtyRef.current) {
257
- dirtyRef.current = false;
258
- 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;
259
428
  }
260
429
  }
261
430
  : undefined;
@@ -263,15 +432,17 @@ function RootInner({
263
432
  return (
264
433
  <ReadOnlyProvider readOnly={readOnly}>
265
434
  <IconProvider icons={icons ?? {}}>
266
- <HostProvider callbacks={{ onSave: wrappedSave, onExport }}>
267
- <RootShell
268
- fontFamily={fontFamily}
269
- className={className}
270
- style={style}
271
- >
272
- {children}
273
- </RootShell>
274
- </HostProvider>
435
+ <DirtyProvider dirty={dirty}>
436
+ <HostProvider callbacks={{ onSave: wrappedSave, onExport }}>
437
+ <RootShell
438
+ fontFamily={fontFamily}
439
+ className={className}
440
+ style={style}
441
+ >
442
+ {children}
443
+ </RootShell>
444
+ </HostProvider>
445
+ </DirtyProvider>
275
446
  </IconProvider>
276
447
  </ReadOnlyProvider>
277
448
  );
@@ -323,3 +494,12 @@ function RootShell({
323
494
  </div>
324
495
  );
325
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
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Public store hooks. These let host components anywhere inside
3
+ * `<Slidewise.Root>` read or write editor state without prop drilling.
4
+ *
5
+ * `useEditor` is the generic selector hook — pass any function that takes
6
+ * the editor state and returns the slice you care about. The convenience
7
+ * hooks below cover the common cases.
8
+ *
9
+ * Example:
10
+ *
11
+ * ```tsx
12
+ * import { useEditor, useSlides, useActiveSlide } from "@textcortex/slidewise";
13
+ *
14
+ * function HostHeader() {
15
+ * const slideCount = useSlides().length;
16
+ * const active = useActiveSlide();
17
+ * return <span>Slide {active.id} of {slideCount}</span>;
18
+ * }
19
+ * ```
20
+ */
21
+ import { useEditor } from "@/lib/StoreProvider";
22
+ import type { Slide, SlideElement } from "@/lib/types";
23
+
24
+ export { useEditor, useEditorStore } from "@/lib/StoreProvider";
25
+
26
+ /** All slides in the current deck, in display order. */
27
+ export function useSlides(): Slide[] {
28
+ return useEditor((s) => s.deck.slides);
29
+ }
30
+
31
+ /**
32
+ * The currently focused slide. Falls back to the first slide if the
33
+ * `currentSlideId` no longer exists (shouldn't happen, but defensive).
34
+ */
35
+ export function useActiveSlide(): Slide {
36
+ return useEditor((s) => {
37
+ const found = s.deck.slides.find((sl) => sl.id === s.currentSlideId);
38
+ return found ?? s.deck.slides[0];
39
+ });
40
+ }
41
+
42
+ /** Id of the currently focused slide. */
43
+ export function useActiveSlideId(): string {
44
+ return useEditor((s) => s.currentSlideId);
45
+ }
46
+
47
+ /** Ids of currently selected elements on the active slide. */
48
+ export function useSelection(): string[] {
49
+ return useEditor((s) => s.selectedIds);
50
+ }
51
+
52
+ /**
53
+ * Resolved selected element objects on the active slide. Returns `[]` when
54
+ * nothing is selected. Use this when you need to read element properties
55
+ * (position, text, fill) — not just their ids.
56
+ */
57
+ export function useSelectedElements(): SlideElement[] {
58
+ return useEditor((s) => {
59
+ const slide = s.deck.slides.find((sl) => sl.id === s.currentSlideId);
60
+ if (!slide) return [];
61
+ return slide.elements.filter((e) => s.selectedIds.includes(e.id));
62
+ });
63
+ }
64
+
65
+ /** Current theme ("light" or "dark"). */
66
+ export function useTheme(): "light" | "dark" {
67
+ return useEditor((s) => s.theme);
68
+ }
69
+
70
+ /** Current zoom scale (1 = 100%). */
71
+ export function useZoom(): number {
72
+ return useEditor((s) => s.zoom);
73
+ }
74
+
75
+ /** True when the editor is in present-mode. */
76
+ export function usePlaying(): boolean {
77
+ return useEditor((s) => s.playing);
78
+ }
79
+
80
+ /** Live history depth — useful for host-rendered Undo/Redo button state. */
81
+ export function useHistory(): {
82
+ canUndo: boolean;
83
+ canRedo: boolean;
84
+ undoSize: number;
85
+ redoSize: number;
86
+ } {
87
+ return useEditor((s) => ({
88
+ canUndo: s.history.length > 0,
89
+ canRedo: s.future.length > 0,
90
+ undoSize: s.history.length,
91
+ redoSize: s.future.length,
92
+ }));
93
+ }