@textcortex/slidewise 1.0.0 → 1.1.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.
Files changed (51) hide show
  1. package/dist/index.mjs +6261 -6108
  2. package/dist/index.mjs.map +1 -1
  3. package/dist/slidewise.css +1 -1
  4. package/package.json +5 -20
  5. package/src/SlidewiseEditor.css +121 -4
  6. package/src/SlidewiseEditor.tsx +82 -166
  7. package/src/SlidewiseFileEditor.tsx +77 -11
  8. package/src/components/editor/TopBar.tsx +37 -24
  9. package/src/compound/HostContext.tsx +29 -0
  10. package/src/compound/IconContext.tsx +42 -0
  11. package/src/compound/ReadOnlyContext.tsx +23 -0
  12. package/src/compound/SlidewiseRoot.tsx +274 -0
  13. package/src/compound/index.ts +50 -0
  14. package/src/compound/parts.tsx +160 -0
  15. package/src/css.d.ts +4 -0
  16. package/src/index.ts +42 -0
  17. package/README.md +0 -112
  18. package/dist/file.svg +0 -1
  19. package/dist/globe.svg +0 -1
  20. package/dist/types/SlidewiseEditor.d.ts +0 -47
  21. package/dist/types/SlidewiseFileEditor.d.ts +0 -54
  22. package/dist/types/components/editor/BottomToolbar.d.ts +0 -1
  23. package/dist/types/components/editor/Canvas.d.ts +0 -1
  24. package/dist/types/components/editor/Editor.d.ts +0 -8
  25. package/dist/types/components/editor/ElementView.d.ts +0 -6
  26. package/dist/types/components/editor/FloatingToolbar.d.ts +0 -6
  27. package/dist/types/components/editor/GridView.d.ts +0 -1
  28. package/dist/types/components/editor/PlayMode.d.ts +0 -1
  29. package/dist/types/components/editor/SelectionFrame.d.ts +0 -8
  30. package/dist/types/components/editor/SlideRail.d.ts +0 -1
  31. package/dist/types/components/editor/SlideView.d.ts +0 -5
  32. package/dist/types/components/editor/TopBar.d.ts +0 -7
  33. package/dist/types/index.d.ts +0 -7
  34. package/dist/types/lib/StoreProvider.d.ts +0 -8
  35. package/dist/types/lib/fonts.d.ts +0 -9
  36. package/dist/types/lib/pptx/deckToPptx.d.ts +0 -9
  37. package/dist/types/lib/pptx/index.d.ts +0 -3
  38. package/dist/types/lib/pptx/pptxToDeck.d.ts +0 -18
  39. package/dist/types/lib/pptx/types.d.ts +0 -15
  40. package/dist/types/lib/pptx/units.d.ts +0 -25
  41. package/dist/types/lib/schema/migrate.d.ts +0 -25
  42. package/dist/types/lib/seed.d.ts +0 -2
  43. package/dist/types/lib/store.d.ts +0 -55
  44. package/dist/types/lib/types.d.ts +0 -141
  45. package/dist/window.svg +0 -1
  46. package/src/App.tsx +0 -261
  47. package/src/components/editor/Editor.tsx +0 -53
  48. package/src/index.css +0 -13
  49. package/src/lib/seed.ts +0 -777
  50. package/src/main.tsx +0 -10
  51. package/src/vite-env.d.ts +0 -3
@@ -9,6 +9,7 @@ import {
9
9
  import { SlidewiseEditor, type SlidewiseEditorHandle } from "./SlidewiseEditor";
10
10
  import { parsePptx, serializeDeck } from "@/lib/pptx";
11
11
  import type { Deck } from "@/lib/types";
12
+ import type { SlidewiseIcons } from "./compound/IconContext";
12
13
 
13
14
  export interface SlidewiseFileEditorProps {
14
15
  /**
@@ -23,7 +24,11 @@ export interface SlidewiseFileEditorProps {
23
24
  * Called when `save()` is invoked on the imperative API.
24
25
  */
25
26
  saveBlob: (blob: Blob) => Promise<void>;
26
- /** Disables editing affordances (TODO: not yet enforced). */
27
+ /**
28
+ * When `false`, the top bar's save / undo / redo buttons are hidden and
29
+ * the title input is read-only. Mirrors the host's "viewer doesn't have
30
+ * write access" state. Defaults to `true`.
31
+ */
27
32
  editable?: boolean;
28
33
  /**
29
34
  * The sha256 of the file's contents at load time, if the host wants to do
@@ -36,7 +41,37 @@ export interface SlidewiseFileEditorProps {
36
41
  * editor is mounted. Called with `null` on unmount.
37
42
  */
38
43
  onEditorApiChange?: (api: SlidewiseFileEditorApi | null) => void;
44
+ /** Fires after every committed mutation. Mirrors `SlidewiseEditor.onChange`. */
45
+ onChange?: (deck: Deck) => void;
46
+ /**
47
+ * Fires reactively when the dirty flag flips. Use this instead of polling
48
+ * `api.isDirty()` for "unsaved changes" UI.
49
+ */
50
+ onDirtyChange?: (dirty: boolean) => void;
51
+ /**
52
+ * Fires when `loadBlob` or `parse` throws on mount. The default render
53
+ * still shows an in-editor "Could not open file" message, but hosts that
54
+ * want to surface their own error UI can replace it via this callback.
55
+ */
56
+ onLoadError?: (err: Error) => void;
39
57
  theme?: "light" | "dark";
58
+ /** Slide id to land on; falls back to the first. */
59
+ initialSlideId?: string;
60
+ /** Render the built-in top bar. Default `true`. */
61
+ showTopBar?: boolean;
62
+ /** Render the floating bottom toolbar. Default `true`. */
63
+ showBottomToolbar?: boolean;
64
+ /**
65
+ * Override the bundled Geist font; sets `--font-geist-sans` on the editor
66
+ * root.
67
+ */
68
+ fontFamily?: string;
69
+ /**
70
+ * Per-action icon overrides. Pass a ReactNode for any of `undo`, `redo`,
71
+ * `save`, `play`, `themeLight`, `themeDark`, `export`, `smart` to skin the
72
+ * editor's chrome with your own icon set.
73
+ */
74
+ icons?: SlidewiseIcons;
40
75
  className?: string;
41
76
  style?: CSSProperties;
42
77
  /**
@@ -59,6 +94,8 @@ export interface SlidewiseFileEditorApi {
59
94
  stop(): void;
60
95
  undo(): void;
61
96
  redo(): void;
97
+ /** Live deck snapshot. Hosts use this for header badges (slide count, etc.). */
98
+ getDeck(): Deck | null;
62
99
  getInitialSha256(): string | null;
63
100
  }
64
101
 
@@ -74,9 +111,18 @@ export const SlidewiseFileEditor = forwardRef<
74
111
  {
75
112
  loadBlob,
76
113
  saveBlob,
114
+ editable = true,
77
115
  initialSha256 = null,
78
116
  onEditorApiChange,
117
+ onChange,
118
+ onDirtyChange,
119
+ onLoadError,
79
120
  theme,
121
+ initialSlideId,
122
+ showTopBar,
123
+ showBottomToolbar,
124
+ fontFamily,
125
+ icons,
80
126
  className,
81
127
  style,
82
128
  parse = parsePptx,
@@ -88,10 +134,16 @@ export const SlidewiseFileEditor = forwardRef<
88
134
  const editorRef = useRef<SlidewiseEditorHandle>(null);
89
135
  const [dirty, setDirty] = useState(false);
90
136
  const apiCallbackRef = useRef(onEditorApiChange);
137
+ const onChangeRef = useRef(onChange);
138
+ const onDirtyChangeRef = useRef(onDirtyChange);
139
+ const onLoadErrorRef = useRef(onLoadError);
91
140
 
92
141
  useEffect(() => {
93
142
  apiCallbackRef.current = onEditorApiChange;
94
- }, [onEditorApiChange]);
143
+ onChangeRef.current = onChange;
144
+ onDirtyChangeRef.current = onDirtyChange;
145
+ onLoadErrorRef.current = onLoadError;
146
+ }, [onEditorApiChange, onChange, onDirtyChange, onLoadError]);
95
147
 
96
148
  // Load file once on mount.
97
149
  useEffect(() => {
@@ -103,12 +155,10 @@ export const SlidewiseFileEditor = forwardRef<
103
155
  const deck = await parse(blob);
104
156
  if (!cancelled) setState({ status: "ready", deck });
105
157
  } catch (err) {
106
- if (!cancelled) {
107
- setState({
108
- status: "error",
109
- error: err instanceof Error ? err : new Error(String(err)),
110
- });
111
- }
158
+ if (cancelled) return;
159
+ const error = err instanceof Error ? err : new Error(String(err));
160
+ setState({ status: "error", error });
161
+ onLoadErrorRef.current?.(error);
112
162
  }
113
163
  })();
114
164
  return () => {
@@ -125,8 +175,7 @@ export const SlidewiseFileEditor = forwardRef<
125
175
 
126
176
  const api: SlidewiseFileEditorApi = {
127
177
  save: async () => {
128
- const current =
129
- editorRef.current?.getDeck() ?? state.deck;
178
+ const current = editorRef.current?.getDeck() ?? state.deck;
130
179
  const blob = await serialize(current);
131
180
  await saveBlob(blob);
132
181
  editorRef.current?.resetDirty();
@@ -136,6 +185,7 @@ export const SlidewiseFileEditor = forwardRef<
136
185
  stop: () => editorRef.current?.stop(),
137
186
  undo: () => editorRef.current?.undo(),
138
187
  redo: () => editorRef.current?.redo(),
188
+ getDeck: () => editorRef.current?.getDeck() ?? state.deck,
139
189
  getInitialSha256: () => initialSha256,
140
190
  };
141
191
 
@@ -160,6 +210,10 @@ export const SlidewiseFileEditor = forwardRef<
160
210
  stop: () => editorRef.current?.stop(),
161
211
  undo: () => editorRef.current?.undo(),
162
212
  redo: () => editorRef.current?.redo(),
213
+ getDeck: () =>
214
+ state.status === "ready"
215
+ ? editorRef.current?.getDeck() ?? state.deck
216
+ : null,
163
217
  getInitialSha256: () => initialSha256,
164
218
  }),
165
219
  [state, serialize, saveBlob, initialSha256]
@@ -188,7 +242,19 @@ export const SlidewiseFileEditor = forwardRef<
188
242
  ref={editorRef}
189
243
  deck={state.deck}
190
244
  theme={theme}
191
- onDirtyChange={setDirty}
245
+ readOnly={!editable}
246
+ initialSlideId={initialSlideId}
247
+ showTopBar={showTopBar}
248
+ showBottomToolbar={showBottomToolbar}
249
+ fontFamily={fontFamily}
250
+ icons={icons}
251
+ onChange={(next) => {
252
+ onChangeRef.current?.(next);
253
+ }}
254
+ onDirtyChange={(d) => {
255
+ setDirty(d);
256
+ onDirtyChangeRef.current?.(d);
257
+ }}
192
258
  onSave={async (next) => {
193
259
  const blob = await serialize(next);
194
260
  await saveBlob(blob);
@@ -9,8 +9,10 @@ import {
9
9
  Moon,
10
10
  } from "lucide-react";
11
11
  import { useEditor, useEditorStore } from "@/lib/StoreProvider";
12
- import { useState } from "react";
12
+ import { useState, type ReactNode } from "react";
13
13
  import type { Deck } from "@/lib/types";
14
+ import { useIcons } from "@/compound/IconContext";
15
+ import { useReadOnly } from "@/compound/ReadOnlyContext";
14
16
 
15
17
  interface TopBarProps {
16
18
  onSave?: (deck: Deck) => void | Promise<void>;
@@ -26,6 +28,8 @@ export function TopBar({ onSave: onSaveProp, onExport: onExportProp }: TopBarPro
26
28
  const play = useEditor((s) => s.play);
27
29
  const theme = useEditor((s) => s.theme);
28
30
  const toggleTheme = useEditor((s) => s.toggleTheme);
31
+ const icons = useIcons();
32
+ const readOnly = useReadOnly();
29
33
  const [saved, setSaved] = useState<"idle" | "saving" | "saved">("idle");
30
34
 
31
35
  const onSave = async () => {
@@ -71,7 +75,7 @@ export function TopBar({ onSave: onSaveProp, onExport: onExportProp }: TopBarPro
71
75
  alignItems: "center",
72
76
  padding: "0 14px",
73
77
  gap: 10,
74
- background: "var(--app-bg)",
78
+ background: "var(--slidewise-bar-bg, var(--app-bg))",
75
79
  borderBottom: "1px solid var(--border)",
76
80
  boxShadow: "var(--topbar-shadow)",
77
81
  fontFamily: "Inter, system-ui, sans-serif",
@@ -80,12 +84,16 @@ export function TopBar({ onSave: onSaveProp, onExport: onExportProp }: TopBarPro
80
84
  color: "var(--ink)",
81
85
  }}
82
86
  >
83
- <IconBtn onClick={undo} title="Undo">
84
- <Undo2 size={16} />
85
- </IconBtn>
86
- <IconBtn onClick={redo} title="Redo">
87
- <Redo2 size={16} />
88
- </IconBtn>
87
+ {!readOnly && (
88
+ <>
89
+ <IconBtn onClick={undo} title="Undo">
90
+ {icons.undo ?? <Undo2 size={16} />}
91
+ </IconBtn>
92
+ <IconBtn onClick={redo} title="Redo">
93
+ {icons.redo ?? <Redo2 size={16} />}
94
+ </IconBtn>
95
+ </>
96
+ )}
89
97
 
90
98
  <div
91
99
  style={{
@@ -111,12 +119,13 @@ export function TopBar({ onSave: onSaveProp, onExport: onExportProp }: TopBarPro
111
119
  letterSpacing: 0.2,
112
120
  }}
113
121
  >
114
- <Sparkles size={11} />
122
+ {icons.smart ?? <Sparkles size={11} />}
115
123
  Smart
116
124
  </span>
117
125
  <input
118
126
  aria-label="Deck title"
119
127
  value={title}
128
+ readOnly={readOnly}
120
129
  onChange={(e) => setTitle(e.target.value)}
121
130
  style={{
122
131
  background: "transparent",
@@ -135,18 +144,22 @@ export function TopBar({ onSave: onSaveProp, onExport: onExportProp }: TopBarPro
135
144
  onClick={toggleTheme}
136
145
  title={theme === "dark" ? "Light mode" : "Dark mode"}
137
146
  >
138
- {theme === "dark" ? <Sun size={16} /> : <Moon size={16} />}
147
+ {theme === "dark"
148
+ ? (icons.themeLight ?? <Sun size={16} />)
149
+ : (icons.themeDark ?? <Moon size={16} />)}
139
150
  </IconBtn>
140
151
 
141
- <button
142
- onClick={onSave}
143
- style={chromeBtnStyle()}
144
- onMouseEnter={(e) => (e.currentTarget.style.background = "var(--hover)")}
145
- onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
146
- >
147
- <Save size={14} />
148
- {saved === "saving" ? "Saving…" : saved === "saved" ? "Saved" : "Save"}
149
- </button>
152
+ {!readOnly && (
153
+ <button
154
+ onClick={onSave}
155
+ style={chromeBtnStyle()}
156
+ onMouseEnter={(e) => (e.currentTarget.style.background = "var(--hover)")}
157
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
158
+ >
159
+ {icons.save ?? <Save size={14} />}
160
+ {saved === "saving" ? "Saving…" : saved === "saved" ? "Saved" : "Save"}
161
+ </button>
162
+ )}
150
163
 
151
164
  <button
152
165
  onClick={play}
@@ -154,7 +167,7 @@ export function TopBar({ onSave: onSaveProp, onExport: onExportProp }: TopBarPro
154
167
  onMouseEnter={(e) => (e.currentTarget.style.background = "var(--hover)")}
155
168
  onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
156
169
  >
157
- <Play size={14} />
170
+ {icons.play ?? <Play size={14} />}
158
171
  Play
159
172
  </button>
160
173
 
@@ -168,7 +181,7 @@ export function TopBar({ onSave: onSaveProp, onExport: onExportProp }: TopBarPro
168
181
  gap: 6,
169
182
  background: "var(--primary-bg)",
170
183
  border: "1px solid var(--primary-bg)",
171
- borderRadius: 10,
184
+ borderRadius: "var(--slidewise-radius, 10px)",
172
185
  cursor: "pointer",
173
186
  color: "var(--primary-fg)",
174
187
  fontSize: 13,
@@ -181,7 +194,7 @@ export function TopBar({ onSave: onSaveProp, onExport: onExportProp }: TopBarPro
181
194
  (e.currentTarget.style.background = "var(--primary-bg)")
182
195
  }
183
196
  >
184
- <Download size={14} />
197
+ {icons.export ?? <Download size={14} />}
185
198
  Export
186
199
  </button>
187
200
  </div>
@@ -197,7 +210,7 @@ function chromeBtnStyle(): React.CSSProperties {
197
210
  gap: 6,
198
211
  background: "transparent",
199
212
  border: "1px solid var(--border-strong)",
200
- borderRadius: 10,
213
+ borderRadius: "var(--slidewise-radius, 10px)",
201
214
  cursor: "pointer",
202
215
  color: "var(--ink)",
203
216
  fontSize: 13,
@@ -210,7 +223,7 @@ function IconBtn({
210
223
  onClick,
211
224
  title,
212
225
  }: {
213
- children: React.ReactNode;
226
+ children: ReactNode;
214
227
  onClick: () => void;
215
228
  title: string;
216
229
  }) {
@@ -0,0 +1,29 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import type { Deck } from "@/lib/types";
3
+
4
+ /**
5
+ * Host-supplied callbacks consumed by leaf compound parts (e.g. TopBar's
6
+ * save/export buttons). Distinct from the editor store, which owns deck +
7
+ * UI state. This context exists so child parts can invoke host effects
8
+ * without prop-drilling through every region.
9
+ */
10
+ export interface SlidewiseHostCallbacks {
11
+ onSave?: (deck: Deck) => void | Promise<void>;
12
+ onExport?: (deck: Deck) => void;
13
+ }
14
+
15
+ const HostContext = createContext<SlidewiseHostCallbacks>({});
16
+
17
+ export function HostProvider({
18
+ callbacks,
19
+ children,
20
+ }: {
21
+ callbacks: SlidewiseHostCallbacks;
22
+ children: ReactNode;
23
+ }) {
24
+ return <HostContext.Provider value={callbacks}>{children}</HostContext.Provider>;
25
+ }
26
+
27
+ export function useHostCallbacks(): SlidewiseHostCallbacks {
28
+ return useContext(HostContext);
29
+ }
@@ -0,0 +1,42 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+
3
+ /**
4
+ * Per-action icon overrides for the editor's chrome. Pass a `ReactNode` for
5
+ * any button you want to skin with your own icon set (Nucleo, custom SVG,
6
+ * etc.). Slots you don't override fall back to the bundled lucide-react
7
+ * icons so partial overrides are fine.
8
+ *
9
+ * The icons are rendered inline at ~14–16px next to text labels; pick an
10
+ * SVG that has a transparent fill and uses `currentColor` for the stroke
11
+ * so it inherits the surrounding `--ink` / `--primary-fg` color.
12
+ */
13
+ export interface SlidewiseIcons {
14
+ undo?: ReactNode;
15
+ redo?: ReactNode;
16
+ save?: ReactNode;
17
+ play?: ReactNode;
18
+ stop?: ReactNode;
19
+ /** Sun icon shown in the theme toggle when the dark theme is active. */
20
+ themeLight?: ReactNode;
21
+ /** Moon icon shown in the theme toggle when the light theme is active. */
22
+ themeDark?: ReactNode;
23
+ export?: ReactNode;
24
+ /** "Smart" pill in the title bar. */
25
+ smart?: ReactNode;
26
+ }
27
+
28
+ const IconContext = createContext<SlidewiseIcons>({});
29
+
30
+ export function IconProvider({
31
+ icons,
32
+ children,
33
+ }: {
34
+ icons: SlidewiseIcons;
35
+ children: ReactNode;
36
+ }) {
37
+ return <IconContext.Provider value={icons}>{children}</IconContext.Provider>;
38
+ }
39
+
40
+ export function useIcons(): SlidewiseIcons {
41
+ return useContext(IconContext);
42
+ }
@@ -0,0 +1,23 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+
3
+ const ReadOnlyContext = createContext<boolean>(false);
4
+
5
+ export function ReadOnlyProvider({
6
+ readOnly,
7
+ children,
8
+ }: {
9
+ readOnly: boolean;
10
+ children: ReactNode;
11
+ }) {
12
+ return (
13
+ <ReadOnlyContext.Provider value={readOnly}>{children}</ReadOnlyContext.Provider>
14
+ );
15
+ }
16
+
17
+ /**
18
+ * Read-only mode flag. Region parts (TopBar, Canvas) hide editing affordances
19
+ * and skip mutation handlers when this is `true`.
20
+ */
21
+ export function useReadOnly(): boolean {
22
+ return useContext(ReadOnlyContext);
23
+ }
@@ -0,0 +1,274 @@
1
+ import {
2
+ forwardRef,
3
+ useEffect,
4
+ useId,
5
+ useImperativeHandle,
6
+ useRef,
7
+ type CSSProperties,
8
+ type PropsWithChildren,
9
+ type Ref,
10
+ } from "react";
11
+ import {
12
+ EditorStoreProvider,
13
+ useEditor,
14
+ useEditorStore,
15
+ } from "@/lib/StoreProvider";
16
+ import { collectFontFamilies, ensureGoogleFontsLoaded } from "@/lib/fonts";
17
+ import type { Deck } from "@/lib/types";
18
+ import { GridView } from "@/components/editor/GridView";
19
+ import { PlayMode } from "@/components/editor/PlayMode";
20
+ import { HostProvider } from "./HostContext";
21
+ import { IconProvider, type SlidewiseIcons } from "./IconContext";
22
+ import { ReadOnlyProvider } from "./ReadOnlyContext";
23
+
24
+ export interface SlidewiseRootProps {
25
+ /**
26
+ * Deck to load on mount. Pass a new reference only when you intend to
27
+ * reset the editor's state (e.g. discard changes, load a different file)
28
+ * — passing a new reference on every `onChange` would loop.
29
+ */
30
+ deck: Deck;
31
+ /** Fires after every committed mutation. */
32
+ onChange?: (deck: Deck) => void;
33
+ /** Fires when the user invokes save (top bar button or imperative API). */
34
+ onSave?: (deck: Deck) => void | Promise<void>;
35
+ /** Override the default `.slidewise.json` export. */
36
+ onExport?: (deck: Deck) => void;
37
+ /** Fires when the dirty flag flips. */
38
+ onDirtyChange?: (dirty: boolean) => void;
39
+ /**
40
+ * Hide editing affordances (save / undo / redo) and disable canvas
41
+ * mutations. Use this when the host viewer doesn't have write access.
42
+ */
43
+ readOnly?: boolean;
44
+ /** "light" | "dark"; first-render only. */
45
+ theme?: "light" | "dark";
46
+ /** Slide id to land on; falls back to the first. */
47
+ initialSlideId?: string;
48
+ /** Override the default Geist font; sets `--slidewise-font-sans`. */
49
+ fontFamily?: string;
50
+ /**
51
+ * Per-action icon overrides for the chrome. Hosts pass any subset to
52
+ * skin Slidewise with their own icon set; missing slots fall back to
53
+ * the bundled lucide icons.
54
+ */
55
+ icons?: SlidewiseIcons;
56
+ /** Extra class names appended to the root. */
57
+ className?: string;
58
+ /** Inline style applied to the root. */
59
+ style?: CSSProperties;
60
+ }
61
+
62
+ export interface SlidewiseRootHandle {
63
+ play(): void;
64
+ stop(): void;
65
+ undo(): void;
66
+ redo(): void;
67
+ getDeck(): Deck;
68
+ isDirty(): boolean;
69
+ resetDirty(): void;
70
+ }
71
+
72
+ /**
73
+ * Top-level compound part. Provides the editor's store via context to all
74
+ * descendants and renders the themed root container. Compose any subset of
75
+ * `<Slidewise.TopBar />`, `<Slidewise.SlideRail />`, `<Slidewise.Canvas />`,
76
+ * `<Slidewise.RightPanel />`, `<Slidewise.BottomToolbar />` as children — or
77
+ * mix them with host UI to wrap, replace, or omit any region.
78
+ *
79
+ * Hosts that want the unopinionated default tree can use `<SlidewiseEditor>`
80
+ * which is just `<Slidewise.Root>` rendering the standard layout.
81
+ */
82
+ export const Root = forwardRef<SlidewiseRootHandle, PropsWithChildren<SlidewiseRootProps>>(
83
+ function SlidewiseRoot(props, ref) {
84
+ return (
85
+ <EditorStoreProvider initialDeck={props.deck}>
86
+ <RootInner {...props} forwardedRef={ref} />
87
+ </EditorStoreProvider>
88
+ );
89
+ }
90
+ );
91
+
92
+ function RootInner({
93
+ deck,
94
+ onChange,
95
+ onSave,
96
+ onExport,
97
+ onDirtyChange,
98
+ readOnly = false,
99
+ theme,
100
+ initialSlideId,
101
+ fontFamily,
102
+ icons,
103
+ className,
104
+ style,
105
+ children,
106
+ forwardedRef,
107
+ }: PropsWithChildren<SlidewiseRootProps> & {
108
+ forwardedRef: Ref<SlidewiseRootHandle>;
109
+ }) {
110
+ const store = useEditorStore();
111
+ const savedDeckRef = useRef<Deck>(deck);
112
+ const dirtyRef = useRef(false);
113
+ const onChangeRef = useRef(onChange);
114
+ const onDirtyChangeRef = useRef(onDirtyChange);
115
+ const onSaveRef = useRef(onSave);
116
+ const onExportRef = useRef(onExport);
117
+
118
+ useEffect(() => {
119
+ onChangeRef.current = onChange;
120
+ onDirtyChangeRef.current = onDirtyChange;
121
+ onSaveRef.current = onSave;
122
+ onExportRef.current = onExport;
123
+ }, [onChange, onDirtyChange, onSave, onExport]);
124
+
125
+ useEffect(() => {
126
+ if (theme) {
127
+ store.getState().setTheme(theme);
128
+ }
129
+ }, [theme, store]);
130
+
131
+ useEffect(() => {
132
+ if (initialSlideId) {
133
+ const exists = store
134
+ .getState()
135
+ .deck.slides.some((s) => s.id === initialSlideId);
136
+ if (exists) {
137
+ store.getState().selectSlide(initialSlideId);
138
+ }
139
+ }
140
+ // run once on mount
141
+ // eslint-disable-next-line react-hooks/exhaustive-deps
142
+ }, []);
143
+
144
+ useEffect(() => {
145
+ if (deck !== savedDeckRef.current) {
146
+ store.getState().setDeck(deck);
147
+ savedDeckRef.current = deck;
148
+ if (dirtyRef.current) {
149
+ dirtyRef.current = false;
150
+ onDirtyChangeRef.current?.(false);
151
+ }
152
+ }
153
+ }, [deck, store]);
154
+
155
+ const instanceId = useId().replace(/[^a-z0-9]/gi, "");
156
+ useEffect(() => {
157
+ ensureGoogleFontsLoaded(
158
+ instanceId,
159
+ collectFontFamilies(store.getState().deck)
160
+ );
161
+ return store.subscribe((state, prev) => {
162
+ if (state.deck === prev.deck) return;
163
+ onChangeRef.current?.(state.deck);
164
+ const nextDirty = state.deck !== savedDeckRef.current;
165
+ if (nextDirty !== dirtyRef.current) {
166
+ dirtyRef.current = nextDirty;
167
+ onDirtyChangeRef.current?.(nextDirty);
168
+ }
169
+ ensureGoogleFontsLoaded(instanceId, collectFontFamilies(state.deck));
170
+ });
171
+ }, [store, instanceId]);
172
+
173
+ useEffect(() => {
174
+ return () => {
175
+ ensureGoogleFontsLoaded(instanceId, []);
176
+ };
177
+ }, [instanceId]);
178
+
179
+ useImperativeHandle(
180
+ forwardedRef,
181
+ () => ({
182
+ play: () => store.getState().play(),
183
+ stop: () => store.getState().stop(),
184
+ undo: () => store.getState().undo(),
185
+ redo: () => store.getState().redo(),
186
+ getDeck: () => store.getState().deck,
187
+ isDirty: () => dirtyRef.current,
188
+ resetDirty: () => {
189
+ savedDeckRef.current = store.getState().deck;
190
+ if (dirtyRef.current) {
191
+ dirtyRef.current = false;
192
+ onDirtyChangeRef.current?.(false);
193
+ }
194
+ },
195
+ }),
196
+ [store]
197
+ );
198
+
199
+ // Wrap host save with dirty-flag reset so any TopBar.Save / imperative save
200
+ // path that funnels through here clears the dirty state on success.
201
+ const wrappedSave = onSave
202
+ ? async (d: Deck) => {
203
+ await onSaveRef.current!(d);
204
+ savedDeckRef.current = d;
205
+ if (dirtyRef.current) {
206
+ dirtyRef.current = false;
207
+ onDirtyChangeRef.current?.(false);
208
+ }
209
+ }
210
+ : undefined;
211
+
212
+ return (
213
+ <ReadOnlyProvider readOnly={readOnly}>
214
+ <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>
224
+ </IconProvider>
225
+ </ReadOnlyProvider>
226
+ );
227
+ }
228
+
229
+ /**
230
+ * Renders the themed container. Split out so the children read the theme
231
+ * via the store (not props), letting them re-render when the theme flips.
232
+ */
233
+ function RootShell({
234
+ fontFamily,
235
+ className,
236
+ style,
237
+ children,
238
+ }: PropsWithChildren<{
239
+ fontFamily?: string;
240
+ className?: string;
241
+ style?: CSSProperties;
242
+ }>) {
243
+ const theme = useEditor((s) => s.theme);
244
+ const playing = useEditor((s) => s.playing);
245
+ const view = useEditor((s) => s.view);
246
+
247
+ const rootStyle: CSSProperties = {
248
+ width: "100%",
249
+ height: "100%",
250
+ display: "flex",
251
+ flexDirection: "column",
252
+ background: "var(--app-bg)",
253
+ color: "var(--ink)",
254
+ overflow: "hidden",
255
+ ...(fontFamily ? { ["--font-geist-sans" as string]: fontFamily } : null),
256
+ ...style,
257
+ };
258
+
259
+ return (
260
+ <div
261
+ className={
262
+ className
263
+ ? `slidewise-editor theme-${theme} ${className}`
264
+ : `slidewise-editor theme-${theme}`
265
+ }
266
+ data-slidewise-theme={theme}
267
+ style={rootStyle}
268
+ >
269
+ {children}
270
+ {view === "grid" && <GridView />}
271
+ {playing && <PlayMode />}
272
+ </div>
273
+ );
274
+ }