@textcortex/slidewise 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textcortex/slidewise",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Embeddable React PPTX editor.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,7 +1,12 @@
1
1
  import { forwardRef, type CSSProperties } from "react";
2
- import { Root, type SlidewiseRootHandle, type SlidewiseRootProps } from "./compound/SlidewiseRoot";
3
2
  import {
4
- TopBar,
3
+ Root,
4
+ type SlidewiseRootHandle,
5
+ type SlidewiseRootProps,
6
+ type HistoryState,
7
+ } from "./compound/SlidewiseRoot";
8
+ import { TopBar } from "./compound/topbar";
9
+ import {
5
10
  SlideRail,
6
11
  Canvas,
7
12
  BottomToolbar,
@@ -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);
@@ -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
  /**
@@ -36,6 +38,11 @@ export interface SlidewiseRootProps {
36
38
  onExport?: (deck: Deck) => void;
37
39
  /** Fires when the dirty flag flips. */
38
40
  onDirtyChange?: (dirty: boolean) => void;
41
+ /**
42
+ * Fires whenever the undo/redo stacks change depth. Use this to update
43
+ * "Undo"/"Redo" button enabled state without polling `canUndo()`/`canRedo()`.
44
+ */
45
+ onHistoryChange?: (state: HistoryState) => void;
39
46
  /**
40
47
  * Hide editing affordances (save / undo / redo) and disable canvas
41
48
  * mutations. Use this when the host viewer doesn't have write access.
@@ -59,11 +66,32 @@ export interface SlidewiseRootProps {
59
66
  style?: CSSProperties;
60
67
  }
61
68
 
69
+ export interface HistoryState {
70
+ canUndo: boolean;
71
+ canRedo: boolean;
72
+ /** Snapshot counts. Useful for "X steps to redo" indicators. */
73
+ undoSize: number;
74
+ redoSize: number;
75
+ }
76
+
62
77
  export interface SlidewiseRootHandle {
63
78
  play(): void;
64
79
  stop(): void;
65
80
  undo(): void;
66
81
  redo(): void;
82
+ /** True iff there's at least one snapshot to undo back to. */
83
+ canUndo(): boolean;
84
+ /** True iff there's at least one snapshot to redo forward to. */
85
+ canRedo(): boolean;
86
+ /** Current undo/redo stack depths. */
87
+ getHistorySize(): { undo: number; redo: number };
88
+ /**
89
+ * End the in-flight coalesce burst. Call on natural commit boundaries
90
+ * (mouseup after drag, blur on a text input) so the next mutation starts
91
+ * a fresh history step. Most hosts won't need this — the 500ms idle
92
+ * window handles typical typing/drag bursts.
93
+ */
94
+ endCoalesce(): void;
67
95
  getDeck(): Deck;
68
96
  isDirty(): boolean;
69
97
  resetDirty(): void;
@@ -95,6 +123,7 @@ function RootInner({
95
123
  onSave,
96
124
  onExport,
97
125
  onDirtyChange,
126
+ onHistoryChange: props_onHistoryChange,
98
127
  readOnly = false,
99
128
  theme,
100
129
  initialSlideId,
@@ -110,17 +139,20 @@ function RootInner({
110
139
  const store = useEditorStore();
111
140
  const savedDeckRef = useRef<Deck>(deck);
112
141
  const dirtyRef = useRef(false);
142
+ const [dirty, setDirty] = useState(false);
113
143
  const onChangeRef = useRef(onChange);
114
144
  const onDirtyChangeRef = useRef(onDirtyChange);
115
145
  const onSaveRef = useRef(onSave);
116
146
  const onExportRef = useRef(onExport);
147
+ const onHistoryChangeRef = useRef(props_onHistoryChange);
117
148
 
118
149
  useEffect(() => {
119
150
  onChangeRef.current = onChange;
120
151
  onDirtyChangeRef.current = onDirtyChange;
121
152
  onSaveRef.current = onSave;
122
153
  onExportRef.current = onExport;
123
- }, [onChange, onDirtyChange, onSave, onExport]);
154
+ onHistoryChangeRef.current = props_onHistoryChange;
155
+ }, [onChange, onDirtyChange, onSave, onExport, props_onHistoryChange]);
124
156
 
125
157
  useEffect(() => {
126
158
  if (theme) {
@@ -147,6 +179,7 @@ function RootInner({
147
179
  savedDeckRef.current = deck;
148
180
  if (dirtyRef.current) {
149
181
  dirtyRef.current = false;
182
+ setDirty(false);
150
183
  onDirtyChangeRef.current?.(false);
151
184
  }
152
185
  }
@@ -159,11 +192,27 @@ function RootInner({
159
192
  collectFontFamilies(store.getState().deck)
160
193
  );
161
194
  return store.subscribe((state, prev) => {
195
+ // Fire onHistoryChange whenever stack depths change. Independent of
196
+ // deck identity so undo/redo always emit, even if the resulting deck
197
+ // happens to be reference-equal (shouldn't, but defensive).
198
+ const prevHist = prev.history.length;
199
+ const prevFut = prev.future.length;
200
+ const nextHist = state.history.length;
201
+ const nextFut = state.future.length;
202
+ if (prevHist !== nextHist || prevFut !== nextFut) {
203
+ onHistoryChangeRef.current?.({
204
+ canUndo: nextHist > 0,
205
+ canRedo: nextFut > 0,
206
+ undoSize: nextHist,
207
+ redoSize: nextFut,
208
+ });
209
+ }
162
210
  if (state.deck === prev.deck) return;
163
211
  onChangeRef.current?.(state.deck);
164
212
  const nextDirty = state.deck !== savedDeckRef.current;
165
213
  if (nextDirty !== dirtyRef.current) {
166
214
  dirtyRef.current = nextDirty;
215
+ setDirty(nextDirty);
167
216
  onDirtyChangeRef.current?.(nextDirty);
168
217
  }
169
218
  ensureGoogleFontsLoaded(instanceId, collectFontFamilies(state.deck));
@@ -183,6 +232,13 @@ function RootInner({
183
232
  stop: () => store.getState().stop(),
184
233
  undo: () => store.getState().undo(),
185
234
  redo: () => store.getState().redo(),
235
+ canUndo: () => store.getState().canUndo(),
236
+ canRedo: () => store.getState().canRedo(),
237
+ getHistorySize: () => {
238
+ const s = store.getState();
239
+ return { undo: s.history.length, redo: s.future.length };
240
+ },
241
+ endCoalesce: () => store.getState().endCoalesce(),
186
242
  getDeck: () => store.getState().deck,
187
243
  isDirty: () => dirtyRef.current,
188
244
  resetDirty: () => {
@@ -212,15 +268,17 @@ function RootInner({
212
268
  return (
213
269
  <ReadOnlyProvider readOnly={readOnly}>
214
270
  <IconProvider icons={icons ?? {}}>
215
- <HostProvider callbacks={{ onSave: wrappedSave, onExport }}>
216
- <RootShell
217
- fontFamily={fontFamily}
218
- className={className}
219
- style={style}
220
- >
221
- {children}
222
- </RootShell>
223
- </HostProvider>
271
+ <DirtyProvider dirty={dirty}>
272
+ <HostProvider callbacks={{ onSave: wrappedSave, onExport }}>
273
+ <RootShell
274
+ fontFamily={fontFamily}
275
+ className={className}
276
+ style={style}
277
+ >
278
+ {children}
279
+ </RootShell>
280
+ </HostProvider>
281
+ </DirtyProvider>
224
282
  </IconProvider>
225
283
  </ReadOnlyProvider>
226
284
  );
@@ -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
+ }
@@ -5,7 +5,7 @@
5
5
  * thin wrapper rendering this same tree:
6
6
  *
7
7
  * ```tsx
8
- * <Slidewise.Root deck={deck} onChange={} onSave={}>
8
+ * <Slidewise.Root deck={deck} onChange={...} onSave={...}>
9
9
  * <Slidewise.TopBar />
10
10
  * <Slidewise.Body>
11
11
  * <Slidewise.SlideRail />
@@ -17,19 +17,28 @@
17
17
  * </Slidewise.Root>
18
18
  * ```
19
19
  *
20
- * Use the namespace import to keep call sites tidy:
20
+ * For full control over the top bar (host buttons mixed in, subparts
21
+ * reordered, individual buttons skinned), drop down to its subparts:
21
22
  *
22
23
  * ```tsx
23
- * import * as Slidewise from "@textcortex/slidewise";
24
+ * <Slidewise.TopBar.Root>
25
+ * <MyExitButton />
26
+ * <Slidewise.TopBar.Spacer />
27
+ * <Slidewise.TopBar.Group>
28
+ * <Slidewise.TopBar.Undo />
29
+ * <Slidewise.TopBar.Redo />
30
+ * </Slidewise.TopBar.Group>
31
+ * <Slidewise.TopBar.Save />
32
+ * </Slidewise.TopBar.Root>
24
33
  * ```
25
34
  */
26
35
  export {
27
36
  Root,
28
37
  type SlidewiseRootProps,
29
38
  type SlidewiseRootHandle,
39
+ type HistoryState,
30
40
  } from "./SlidewiseRoot";
31
41
  export {
32
- TopBar,
33
42
  SlideRail,
34
43
  Canvas,
35
44
  BottomToolbar,
@@ -38,6 +47,21 @@ export {
38
47
  CanvasFrame,
39
48
  type RegionProps,
40
49
  } from "./parts";
50
+
51
+ export { TopBar, type TopBarProps, type TopBarSlotId } from "./topbar";
52
+ export type {
53
+ TopBarRootProps,
54
+ TopBarTitleProps,
55
+ TopBarUndoProps,
56
+ TopBarRedoProps,
57
+ TopBarSaveProps,
58
+ TopBarPlayProps,
59
+ TopBarThemeToggleProps,
60
+ TopBarExportProps,
61
+ TopBarSpacerProps,
62
+ TopBarGroupProps,
63
+ } from "./topbar";
64
+
41
65
  export {
42
66
  useHostCallbacks,
43
67
  type SlidewiseHostCallbacks,
@@ -48,3 +72,22 @@ export {
48
72
  type SlidewiseIcons,
49
73
  } from "./IconContext";
50
74
  export { ReadOnlyProvider, useReadOnly } from "./ReadOnlyContext";
75
+ export { DirtyProvider, useDirty } from "./DirtyContext";
76
+
77
+ /**
78
+ * Store hooks. Use these from host components anywhere under
79
+ * `<Slidewise.Root>` to read or write editor state without prop drilling.
80
+ */
81
+ export {
82
+ useEditor,
83
+ useEditorStore,
84
+ useSlides,
85
+ useActiveSlide,
86
+ useActiveSlideId,
87
+ useSelection,
88
+ useSelectedElements,
89
+ useTheme,
90
+ useZoom,
91
+ usePlaying,
92
+ useHistory,
93
+ } from "./hooks";
@@ -1,14 +1,16 @@
1
1
  import type { CSSProperties, ReactNode } from "react";
2
- import { TopBar as TopBarInternal } from "@/components/editor/TopBar";
3
2
  import { SlideRail as SlideRailInternal } from "@/components/editor/SlideRail";
4
3
  import { Canvas as CanvasInternal } from "@/components/editor/Canvas";
5
4
  import { BottomToolbar as BottomToolbarInternal } from "@/components/editor/BottomToolbar";
6
- import { useHostCallbacks } from "./HostContext";
7
5
 
8
6
  /**
9
7
  * Region-level compound parts. Each consumes the editor store via context,
10
8
  * so any part can be omitted, wrapped, or replaced. None of these accept
11
9
  * deck/onChange/onSave props — those live on `<Slidewise.Root>`.
10
+ *
11
+ * Note: `<Slidewise.TopBar>` is defined separately in `./topbar/` because
12
+ * it itself decomposes into subparts (`TopBar.Root`, `TopBar.Title`,
13
+ * `TopBar.Undo`, etc.) and ships a `hide` prop for per-button removal.
12
14
  */
13
15
 
14
16
  export interface RegionProps {
@@ -16,19 +18,6 @@ export interface RegionProps {
16
18
  style?: CSSProperties;
17
19
  }
18
20
 
19
- /**
20
- * The default top bar (title input, undo/redo, save, play, theme toggle,
21
- * export). Reads host callbacks from context, so the Save and Export
22
- * buttons fire the host's `onSave` / `onExport` from `<Slidewise.Root>`.
23
- *
24
- * Omit it from the tree to hide the whole bar; or render your own toolbar
25
- * alongside `<Slidewise.Canvas>` for full control.
26
- */
27
- export function TopBar(_props: RegionProps = {}) {
28
- const { onSave, onExport } = useHostCallbacks();
29
- return <TopBarInternal onSave={onSave} onExport={onExport} />;
30
- }
31
-
32
21
  /**
33
22
  * Left-side slide thumbnail rail with add/duplicate/delete.
34
23
  */
@@ -0,0 +1,65 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import { Download } from "lucide-react";
3
+ import { useEditorStore } from "@/lib/StoreProvider";
4
+ import { useHostCallbacks } from "../HostContext";
5
+ import { useIcons } from "../IconContext";
6
+ import { primaryBtnStyle, primaryHoverHandlers } from "./styles";
7
+
8
+ /**
9
+ * Export button. Calls the host's `onExport` (from `<Slidewise.Root
10
+ * onExport>`) with the current deck. If no host callback is registered,
11
+ * falls back to downloading a `.slidewise.json` of the deck.
12
+ *
13
+ * Visually emphasized vs the chrome buttons — uses `--primary-bg` so hosts
14
+ * retheming the primary surface get a consistent affordance.
15
+ */
16
+ export interface TopBarExportProps {
17
+ className?: string;
18
+ style?: CSSProperties;
19
+ ariaLabel?: string;
20
+ label?: string;
21
+ children?: ReactNode;
22
+ }
23
+
24
+ export function Export({
25
+ className,
26
+ style,
27
+ ariaLabel = "Export",
28
+ label = "Export",
29
+ children,
30
+ }: TopBarExportProps = {}) {
31
+ const store = useEditorStore();
32
+ const { onExport: onExportHost } = useHostCallbacks();
33
+ const icons = useIcons();
34
+
35
+ const onClick = () => {
36
+ const deck = store.getState().deck;
37
+ if (onExportHost) {
38
+ onExportHost(deck);
39
+ return;
40
+ }
41
+ const blob = new Blob([JSON.stringify(deck, null, 2)], {
42
+ type: "application/json",
43
+ });
44
+ const url = URL.createObjectURL(blob);
45
+ const a = document.createElement("a");
46
+ a.href = url;
47
+ a.download = `${(deck.title || "deck").replace(/[^a-z0-9-_]+/gi, "-")}.slidewise.json`;
48
+ a.click();
49
+ URL.revokeObjectURL(url);
50
+ };
51
+
52
+ return (
53
+ <button
54
+ type="button"
55
+ className={className}
56
+ aria-label={ariaLabel}
57
+ onClick={onClick}
58
+ style={{ ...primaryBtnStyle(), ...style }}
59
+ {...primaryHoverHandlers()}
60
+ >
61
+ {children ?? icons.export ?? <Download size={14} />}
62
+ {label}
63
+ </button>
64
+ );
65
+ }
@@ -0,0 +1,36 @@
1
+ import type { CSSProperties, PropsWithChildren } from "react";
2
+
3
+ /**
4
+ * Visual cluster wrapper for a related set of buttons (e.g. Undo + Redo).
5
+ * Just a flex row with a small inner gap — keeps clusters visually together
6
+ * separate from the bar's default 10px gap.
7
+ *
8
+ * ```tsx
9
+ * <Slidewise.TopBar.Group>
10
+ * <Slidewise.TopBar.Undo />
11
+ * <Slidewise.TopBar.Redo />
12
+ * </Slidewise.TopBar.Group>
13
+ * ```
14
+ */
15
+ export interface TopBarGroupProps {
16
+ className?: string;
17
+ style?: CSSProperties;
18
+ /** Gap between children inside the group. Default 2px. */
19
+ gap?: number;
20
+ }
21
+
22
+ export function Group({
23
+ className,
24
+ style,
25
+ gap = 2,
26
+ children,
27
+ }: PropsWithChildren<TopBarGroupProps>) {
28
+ return (
29
+ <div
30
+ className={className}
31
+ style={{ display: "flex", alignItems: "center", gap, ...style }}
32
+ >
33
+ {children}
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,42 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import { Play as PlayIcon } from "lucide-react";
3
+ import { useEditor } from "@/lib/StoreProvider";
4
+ import { useIcons } from "../IconContext";
5
+ import { chromeBtnStyle, hoverHandlers } from "./styles";
6
+
7
+ /**
8
+ * Play button. Enters slideshow / play mode. Always rendered (read-only
9
+ * viewers should still be able to present).
10
+ */
11
+ export interface TopBarPlayProps {
12
+ className?: string;
13
+ style?: CSSProperties;
14
+ ariaLabel?: string;
15
+ label?: string;
16
+ children?: ReactNode;
17
+ }
18
+
19
+ export function Play({
20
+ className,
21
+ style,
22
+ ariaLabel = "Play",
23
+ label = "Play",
24
+ children,
25
+ }: TopBarPlayProps = {}) {
26
+ const play = useEditor((s) => s.play);
27
+ const icons = useIcons();
28
+
29
+ return (
30
+ <button
31
+ type="button"
32
+ className={className}
33
+ aria-label={ariaLabel}
34
+ onClick={play}
35
+ style={{ ...chromeBtnStyle(), ...style }}
36
+ {...hoverHandlers()}
37
+ >
38
+ {children ?? icons.play ?? <PlayIcon size={14} />}
39
+ {label}
40
+ </button>
41
+ );
42
+ }