@textcortex/slidewise 1.0.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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/dist/__vite-browser-external-DYxpcVy9.js +5 -0
  4. package/dist/__vite-browser-external-DYxpcVy9.js.map +1 -0
  5. package/dist/file.svg +1 -0
  6. package/dist/globe.svg +1 -0
  7. package/dist/index.mjs +16697 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/dist/slidewise.css +1 -0
  10. package/dist/types/SlidewiseEditor.d.ts +47 -0
  11. package/dist/types/SlidewiseFileEditor.d.ts +54 -0
  12. package/dist/types/components/editor/BottomToolbar.d.ts +1 -0
  13. package/dist/types/components/editor/Canvas.d.ts +1 -0
  14. package/dist/types/components/editor/Editor.d.ts +8 -0
  15. package/dist/types/components/editor/ElementView.d.ts +6 -0
  16. package/dist/types/components/editor/FloatingToolbar.d.ts +6 -0
  17. package/dist/types/components/editor/GridView.d.ts +1 -0
  18. package/dist/types/components/editor/PlayMode.d.ts +1 -0
  19. package/dist/types/components/editor/SelectionFrame.d.ts +8 -0
  20. package/dist/types/components/editor/SlideRail.d.ts +1 -0
  21. package/dist/types/components/editor/SlideView.d.ts +5 -0
  22. package/dist/types/components/editor/TopBar.d.ts +7 -0
  23. package/dist/types/index.d.ts +7 -0
  24. package/dist/types/lib/StoreProvider.d.ts +8 -0
  25. package/dist/types/lib/fonts.d.ts +9 -0
  26. package/dist/types/lib/pptx/deckToPptx.d.ts +9 -0
  27. package/dist/types/lib/pptx/index.d.ts +3 -0
  28. package/dist/types/lib/pptx/pptxToDeck.d.ts +18 -0
  29. package/dist/types/lib/pptx/types.d.ts +15 -0
  30. package/dist/types/lib/pptx/units.d.ts +25 -0
  31. package/dist/types/lib/schema/migrate.d.ts +25 -0
  32. package/dist/types/lib/seed.d.ts +2 -0
  33. package/dist/types/lib/store.d.ts +55 -0
  34. package/dist/types/lib/types.d.ts +141 -0
  35. package/dist/window.svg +1 -0
  36. package/package.json +86 -0
  37. package/src/App.tsx +261 -0
  38. package/src/SlidewiseEditor.css +146 -0
  39. package/src/SlidewiseEditor.tsx +214 -0
  40. package/src/SlidewiseFileEditor.tsx +242 -0
  41. package/src/components/editor/BottomToolbar.tsx +216 -0
  42. package/src/components/editor/Canvas.tsx +467 -0
  43. package/src/components/editor/Editor.tsx +53 -0
  44. package/src/components/editor/ElementView.tsx +588 -0
  45. package/src/components/editor/FloatingToolbar.tsx +729 -0
  46. package/src/components/editor/GridView.tsx +232 -0
  47. package/src/components/editor/PlayMode.tsx +260 -0
  48. package/src/components/editor/SelectionFrame.tsx +241 -0
  49. package/src/components/editor/SlideRail.tsx +285 -0
  50. package/src/components/editor/SlideView.tsx +55 -0
  51. package/src/components/editor/TopBar.tsx +240 -0
  52. package/src/fonts.css +2 -0
  53. package/src/index.css +13 -0
  54. package/src/index.ts +36 -0
  55. package/src/lib/StoreProvider.tsx +43 -0
  56. package/src/lib/__tests__/css-scope.test.ts +133 -0
  57. package/src/lib/fonts.ts +104 -0
  58. package/src/lib/pptx/__tests__/roundtrip.test.ts +240 -0
  59. package/src/lib/pptx/deckToPptx.ts +300 -0
  60. package/src/lib/pptx/index.ts +3 -0
  61. package/src/lib/pptx/pptxToDeck.ts +1515 -0
  62. package/src/lib/pptx/types.ts +17 -0
  63. package/src/lib/pptx/units.ts +32 -0
  64. package/src/lib/schema/__tests__/migrate.test.ts +70 -0
  65. package/src/lib/schema/migrate.ts +102 -0
  66. package/src/lib/seed.ts +777 -0
  67. package/src/lib/store.ts +384 -0
  68. package/src/lib/types.ts +185 -0
  69. package/src/main.tsx +10 -0
  70. package/src/vite-env.d.ts +3 -0
@@ -0,0 +1,146 @@
1
+ /*
2
+ * Slidewise editor stylesheet. Shipped with the library and loaded inside
3
+ * host applications, so every rule must be nested under `.slidewise-editor`
4
+ * to avoid colliding with host styles. At-rules (@font-face, @media, …) are
5
+ * fine at the top level. The `library CSS scope` test in
6
+ * src/lib/__tests__/css-scope.test.ts enforces this.
7
+ */
8
+ @import "./fonts.css";
9
+
10
+ .slidewise-editor {
11
+ --app-bg: #ffffff;
12
+ --rail-bg: #fafafb;
13
+ --canvas-bg-from: #f4f6fb;
14
+ --canvas-bg-to: #e7eaf2;
15
+ --border: rgba(15, 23, 42, 0.08);
16
+ --border-strong: rgba(15, 23, 42, 0.1);
17
+ --border-dashed: rgba(15, 23, 42, 0.18);
18
+ --ink: #0e1330;
19
+ --ink-muted: #5b6178;
20
+ --hover: rgba(15, 23, 42, 0.05);
21
+ --hover-strong: rgba(15, 23, 42, 0.06);
22
+ --active: rgba(15, 23, 42, 0.08);
23
+ --accent: #4f5bd5;
24
+ --accent-soft: rgba(79, 91, 213, 0.14);
25
+ --toolbar-bg: rgba(255, 255, 255, 0.94);
26
+ --toolbar-shadow: 0 1px 2px rgba(15, 23, 42, 0.04),
27
+ 0 12px 32px rgba(15, 23, 42, 0.1);
28
+ --rail-shadow: 4px 0 14px rgba(15, 23, 42, 0.04);
29
+ --topbar-shadow: 0 1px 0 rgba(15, 23, 42, 0.04),
30
+ 0 4px 14px rgba(15, 23, 42, 0.04);
31
+ --slide-shadow: 0 1px 2px rgba(15, 23, 42, 0.06),
32
+ 0 24px 60px rgba(15, 23, 42, 0.16);
33
+ --thumb-shadow: 0 1px 2px rgba(15, 23, 42, 0.04),
34
+ 0 6px 20px rgba(15, 23, 42, 0.08);
35
+ --tool-active-bg: #0e1330;
36
+ --tool-active-fg: #ffffff;
37
+ --primary-bg: #0e1330;
38
+ --primary-bg-hover: #1a2147;
39
+ --primary-fg: #ffffff;
40
+ --input-bg: rgba(15, 23, 42, 0.04);
41
+ --menu-bg: #ffffff;
42
+ --menu-shadow: 0 12px 24px rgba(15, 23, 42, 0.12);
43
+ --gap-icon-bg: #ffffff;
44
+ --scroll-thumb: rgba(15, 23, 42, 0.14);
45
+ --scroll-thumb-hover: rgba(15, 23, 42, 0.24);
46
+ --smart-grad: linear-gradient(135deg, #e7e9ff, #fce7df);
47
+ --smart-fg: #4f5bd5;
48
+ --grid-overlay-bg: rgba(245, 246, 250, 0.96);
49
+ --font-geist-sans: "Geist Variable";
50
+ --font-geist-mono: "Geist Mono Variable";
51
+ color-scheme: light;
52
+
53
+ font-family: var(--font-geist-sans), Inter, system-ui, sans-serif;
54
+ -webkit-font-smoothing: antialiased;
55
+ background: var(--app-bg);
56
+ color: var(--ink);
57
+ }
58
+
59
+ .slidewise-editor.theme-dark {
60
+ --app-bg: #0a0d1c;
61
+ --rail-bg: #0d1124;
62
+ --canvas-bg-from: #181c38;
63
+ --canvas-bg-to: #0c1027;
64
+ --border: rgba(255, 255, 255, 0.08);
65
+ --border-strong: rgba(255, 255, 255, 0.14);
66
+ --border-dashed: rgba(255, 255, 255, 0.18);
67
+ --ink: #ecedfa;
68
+ --ink-muted: #969ab8;
69
+ --hover: rgba(255, 255, 255, 0.06);
70
+ --hover-strong: rgba(255, 255, 255, 0.09);
71
+ --active: rgba(255, 255, 255, 0.12);
72
+ --accent: #8a96f0;
73
+ --accent-soft: rgba(138, 150, 240, 0.22);
74
+ --toolbar-bg: rgba(22, 26, 56, 0.88);
75
+ --toolbar-shadow: 0 1px 2px rgba(0, 0, 0, 0.5),
76
+ 0 12px 32px rgba(0, 0, 0, 0.5);
77
+ --rail-shadow: 4px 0 14px rgba(0, 0, 0, 0.35);
78
+ --topbar-shadow: 0 1px 0 rgba(0, 0, 0, 0.5),
79
+ 0 4px 14px rgba(0, 0, 0, 0.4);
80
+ --slide-shadow: 0 1px 2px rgba(0, 0, 0, 0.5),
81
+ 0 30px 80px rgba(0, 0, 0, 0.55);
82
+ --thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.5),
83
+ 0 8px 24px rgba(0, 0, 0, 0.45);
84
+ --tool-active-bg: #ecedfa;
85
+ --tool-active-fg: #0a0d1c;
86
+ --primary-bg: #ecedfa;
87
+ --primary-bg-hover: #ffffff;
88
+ --primary-fg: #0a0d1c;
89
+ --input-bg: rgba(255, 255, 255, 0.07);
90
+ --menu-bg: #181c3a;
91
+ --menu-shadow: 0 12px 28px rgba(0, 0, 0, 0.55);
92
+ --gap-icon-bg: #181c3a;
93
+ --scroll-thumb: rgba(255, 255, 255, 0.16);
94
+ --scroll-thumb-hover: rgba(255, 255, 255, 0.3);
95
+ --smart-grad: linear-gradient(
96
+ 135deg,
97
+ rgba(138, 150, 240, 0.32),
98
+ rgba(243, 142, 121, 0.22)
99
+ );
100
+ --smart-fg: #c8cefb;
101
+ --grid-overlay-bg: rgba(10, 13, 28, 0.96);
102
+ color-scheme: dark;
103
+ }
104
+
105
+ .slidewise-editor,
106
+ .slidewise-editor *,
107
+ .slidewise-editor *::before,
108
+ .slidewise-editor *::after {
109
+ box-sizing: border-box;
110
+ }
111
+
112
+ .slidewise-editor ::-webkit-scrollbar {
113
+ width: 8px;
114
+ height: 8px;
115
+ }
116
+ .slidewise-editor ::-webkit-scrollbar-track {
117
+ background: transparent;
118
+ }
119
+ .slidewise-editor ::-webkit-scrollbar-thumb {
120
+ background: var(--scroll-thumb);
121
+ border-radius: 999px;
122
+ }
123
+ .slidewise-editor ::-webkit-scrollbar-thumb:hover {
124
+ background: var(--scroll-thumb-hover);
125
+ }
126
+
127
+ .slidewise-editor input[type="number"]::-webkit-inner-spin-button,
128
+ .slidewise-editor input[type="number"]::-webkit-outer-spin-button {
129
+ -webkit-appearance: none;
130
+ margin: 0;
131
+ }
132
+
133
+ .slidewise-editor button:focus-visible,
134
+ .slidewise-editor [role="button"]:focus-visible,
135
+ .slidewise-editor input:focus-visible,
136
+ .slidewise-editor select:focus-visible,
137
+ .slidewise-editor summary:focus-visible,
138
+ .slidewise-editor [contenteditable]:focus-visible {
139
+ outline: 2px solid var(--accent);
140
+ outline-offset: 2px;
141
+ border-radius: 6px;
142
+ }
143
+
144
+ .slidewise-editor button {
145
+ font-family: inherit;
146
+ }
@@ -0,0 +1,214 @@
1
+ import {
2
+ forwardRef,
3
+ useEffect,
4
+ useId,
5
+ useImperativeHandle,
6
+ useRef,
7
+ type CSSProperties,
8
+ type Ref,
9
+ } from "react";
10
+ import { Editor } from "@/components/editor/Editor";
11
+ import {
12
+ EditorStoreProvider,
13
+ useEditorStore,
14
+ } from "@/lib/StoreProvider";
15
+ import { collectFontFamilies, ensureGoogleFontsLoaded } from "@/lib/fonts";
16
+ import type { Deck } from "@/lib/types";
17
+ import "./SlidewiseEditor.css";
18
+
19
+ export interface SlidewiseEditorProps {
20
+ /**
21
+ * The deck to edit. Loaded into the editor on mount. If a different
22
+ * Deck reference is later passed, the editor's internal state is reset
23
+ * to it (dirty flag reset). Do NOT pass a new reference on every
24
+ * `onChange` — that would loop. Hold the deck in a stable ref, and
25
+ * only pass a new one when you intentionally want to reset the editor
26
+ * (e.g. discard changes, load a different file).
27
+ */
28
+ deck: Deck;
29
+ /** Fires after every committed mutation; receives the updated deck. */
30
+ onChange?: (deck: Deck) => void;
31
+ /** Fires when the user clicks "Save" in the top bar. */
32
+ onSave?: (deck: Deck) => void | Promise<void>;
33
+ /** Optional override for the default `.slidewise.json` export. */
34
+ onExport?: (deck: Deck) => void;
35
+ /** Fires when the dirty flag flips. Useful for "unsaved changes" banners. */
36
+ onDirtyChange?: (dirty: boolean) => void;
37
+ /** Reserved for future use; not enforced yet. */
38
+ readOnly?: boolean;
39
+ /** "light" or "dark"; defaults to "light". Ignored after first render. */
40
+ theme?: "light" | "dark";
41
+ /** Slide id to land on; falls back to the first slide. */
42
+ initialSlideId?: string;
43
+ /** Render the built-in top bar (title, undo/redo, save, play). Default true. */
44
+ showTopBar?: boolean;
45
+ /** Override the bundled Geist font; sets `--font-geist-sans` on the root. */
46
+ fontFamily?: string;
47
+ /** Extra class names appended to the editor root. */
48
+ className?: string;
49
+ /** Inline style applied to the editor root. */
50
+ style?: CSSProperties;
51
+ }
52
+
53
+ export interface SlidewiseEditorHandle {
54
+ play(): void;
55
+ stop(): void;
56
+ undo(): void;
57
+ redo(): void;
58
+ getDeck(): Deck;
59
+ isDirty(): boolean;
60
+ resetDirty(): void;
61
+ }
62
+
63
+ export const SlidewiseEditor = forwardRef<
64
+ SlidewiseEditorHandle,
65
+ SlidewiseEditorProps
66
+ >(function SlidewiseEditor(props, ref) {
67
+ return (
68
+ <EditorStoreProvider initialDeck={props.deck}>
69
+ <SlidewiseEditorInner {...props} forwardedRef={ref} />
70
+ </EditorStoreProvider>
71
+ );
72
+ });
73
+
74
+ function SlidewiseEditorInner({
75
+ deck,
76
+ onChange,
77
+ onSave,
78
+ onExport,
79
+ onDirtyChange,
80
+ theme,
81
+ initialSlideId,
82
+ showTopBar,
83
+ fontFamily,
84
+ className,
85
+ style,
86
+ forwardedRef,
87
+ }: SlidewiseEditorProps & { forwardedRef: Ref<SlidewiseEditorHandle> }) {
88
+ const store = useEditorStore();
89
+ const savedDeckRef = useRef<Deck>(deck);
90
+ const dirtyRef = useRef(false);
91
+ const onChangeRef = useRef(onChange);
92
+ const onDirtyChangeRef = useRef(onDirtyChange);
93
+
94
+ // Keep callback refs current without re-subscribing.
95
+ useEffect(() => {
96
+ onChangeRef.current = onChange;
97
+ onDirtyChangeRef.current = onDirtyChange;
98
+ }, [onChange, onDirtyChange]);
99
+
100
+ // Apply theme on first render and whenever it changes.
101
+ useEffect(() => {
102
+ if (theme) {
103
+ store.getState().setTheme(theme);
104
+ }
105
+ }, [theme, store]);
106
+
107
+ // Land on the requested slide.
108
+ useEffect(() => {
109
+ if (initialSlideId) {
110
+ const exists = store
111
+ .getState()
112
+ .deck.slides.some((s) => s.id === initialSlideId);
113
+ if (exists) {
114
+ store.getState().selectSlide(initialSlideId);
115
+ }
116
+ }
117
+ // run once on mount
118
+ // eslint-disable-next-line react-hooks/exhaustive-deps
119
+ }, []);
120
+
121
+ // External deck reset: if a new Deck reference comes in, replace the store's
122
+ // deck and clear dirty. The first run is a no-op (savedDeckRef === deck).
123
+ useEffect(() => {
124
+ if (deck !== savedDeckRef.current) {
125
+ store.getState().setDeck(deck);
126
+ savedDeckRef.current = deck;
127
+ if (dirtyRef.current) {
128
+ dirtyRef.current = false;
129
+ onDirtyChangeRef.current?.(false);
130
+ }
131
+ }
132
+ }, [deck, store]);
133
+
134
+ // Subscribe once: emit onChange, recompute dirty, and refresh the Google
135
+ // Fonts <link> whenever the deck changes.
136
+ const instanceId = useId().replace(/[^a-z0-9]/gi, "");
137
+ useEffect(() => {
138
+ ensureGoogleFontsLoaded(
139
+ instanceId,
140
+ collectFontFamilies(store.getState().deck)
141
+ );
142
+ return store.subscribe((state, prev) => {
143
+ if (state.deck === prev.deck) return;
144
+ onChangeRef.current?.(state.deck);
145
+ const nextDirty = state.deck !== savedDeckRef.current;
146
+ if (nextDirty !== dirtyRef.current) {
147
+ dirtyRef.current = nextDirty;
148
+ onDirtyChangeRef.current?.(nextDirty);
149
+ }
150
+ ensureGoogleFontsLoaded(instanceId, collectFontFamilies(state.deck));
151
+ });
152
+ }, [store, instanceId]);
153
+
154
+ // Remove our font <link> when the editor unmounts.
155
+ useEffect(() => {
156
+ return () => {
157
+ ensureGoogleFontsLoaded(instanceId, []);
158
+ };
159
+ }, [instanceId]);
160
+
161
+ useImperativeHandle(
162
+ forwardedRef,
163
+ () => ({
164
+ play: () => store.getState().play(),
165
+ stop: () => store.getState().stop(),
166
+ undo: () => store.getState().undo(),
167
+ redo: () => store.getState().redo(),
168
+ getDeck: () => store.getState().deck,
169
+ isDirty: () => dirtyRef.current,
170
+ resetDirty: () => {
171
+ savedDeckRef.current = store.getState().deck;
172
+ if (dirtyRef.current) {
173
+ dirtyRef.current = false;
174
+ onDirtyChangeRef.current?.(false);
175
+ }
176
+ },
177
+ }),
178
+ [store]
179
+ );
180
+
181
+ // Wrap the host save callback so a successful save resets the dirty flag.
182
+ const handleSave = onSave
183
+ ? async (d: Deck) => {
184
+ await onSave(d);
185
+ savedDeckRef.current = d;
186
+ if (dirtyRef.current) {
187
+ dirtyRef.current = false;
188
+ onDirtyChangeRef.current?.(false);
189
+ }
190
+ }
191
+ : undefined;
192
+
193
+ const rootStyle: CSSProperties = {
194
+ width: "100%",
195
+ height: "100%",
196
+ ...(fontFamily ? { ["--font-geist-sans" as string]: fontFamily } : null),
197
+ ...style,
198
+ };
199
+
200
+ return (
201
+ <div
202
+ className={className ? `slidewise-editor-host ${className}` : "slidewise-editor-host"}
203
+ style={rootStyle}
204
+ >
205
+ <Editor
206
+ showTopBar={showTopBar !== false}
207
+ onSave={handleSave}
208
+ onExport={onExport}
209
+ />
210
+ </div>
211
+ );
212
+ }
213
+
214
+ export default SlidewiseEditor;
@@ -0,0 +1,242 @@
1
+ import {
2
+ forwardRef,
3
+ useEffect,
4
+ useImperativeHandle,
5
+ useRef,
6
+ useState,
7
+ type CSSProperties,
8
+ } from "react";
9
+ import { SlidewiseEditor, type SlidewiseEditorHandle } from "./SlidewiseEditor";
10
+ import { parsePptx, serializeDeck } from "@/lib/pptx";
11
+ import type { Deck } from "@/lib/types";
12
+
13
+ export interface SlidewiseFileEditorProps {
14
+ /**
15
+ * Async loader for the file's bytes. Host is responsible for fetching the
16
+ * blob (e.g. via the platform's `getFile(fileId, { preview: true })`).
17
+ * Called once on mount.
18
+ */
19
+ loadBlob: () => Promise<Blob | ArrayBuffer>;
20
+ /**
21
+ * Async saver for a serialized PPTX blob. Host is responsible for the
22
+ * upload and conflict handling (e.g. via `saveFileContent(fileId, …)`).
23
+ * Called when `save()` is invoked on the imperative API.
24
+ */
25
+ saveBlob: (blob: Blob) => Promise<void>;
26
+ /** Disables editing affordances (TODO: not yet enforced). */
27
+ editable?: boolean;
28
+ /**
29
+ * The sha256 of the file's contents at load time, if the host wants to do
30
+ * conflict detection. Stored verbatim and surfaced via `getInitialSha256()`
31
+ * — Slidewise itself doesn't read it; the host's saveBlob implementation does.
32
+ */
33
+ initialSha256?: string | null;
34
+ /**
35
+ * Receives an imperative API for save / dirty-tracking / play once the
36
+ * editor is mounted. Called with `null` on unmount.
37
+ */
38
+ onEditorApiChange?: (api: SlidewiseFileEditorApi | null) => void;
39
+ theme?: "light" | "dark";
40
+ className?: string;
41
+ style?: CSSProperties;
42
+ /**
43
+ * Optional override for how the file is parsed. Default uses Slidewise's
44
+ * built-in PPTX parser. Useful for testing or for hosting a different
45
+ * binary deck format on top of the editor.
46
+ */
47
+ parse?: (blob: Blob | ArrayBuffer) => Promise<Deck>;
48
+ /**
49
+ * Optional override for how the file is serialized. Default uses
50
+ * Slidewise's built-in PPTX writer.
51
+ */
52
+ serialize?: (deck: Deck) => Promise<Blob>;
53
+ }
54
+
55
+ export interface SlidewiseFileEditorApi {
56
+ save(): Promise<void>;
57
+ isDirty(): boolean;
58
+ play(): void;
59
+ stop(): void;
60
+ undo(): void;
61
+ redo(): void;
62
+ getInitialSha256(): string | null;
63
+ }
64
+
65
+ type LoadState =
66
+ | { status: "loading" }
67
+ | { status: "error"; error: Error }
68
+ | { status: "ready"; deck: Deck };
69
+
70
+ export const SlidewiseFileEditor = forwardRef<
71
+ SlidewiseFileEditorApi,
72
+ SlidewiseFileEditorProps
73
+ >(function SlidewiseFileEditor(
74
+ {
75
+ loadBlob,
76
+ saveBlob,
77
+ initialSha256 = null,
78
+ onEditorApiChange,
79
+ theme,
80
+ className,
81
+ style,
82
+ parse = parsePptx,
83
+ serialize = serializeDeck,
84
+ },
85
+ ref
86
+ ) {
87
+ const [state, setState] = useState<LoadState>({ status: "loading" });
88
+ const editorRef = useRef<SlidewiseEditorHandle>(null);
89
+ const [dirty, setDirty] = useState(false);
90
+ const apiCallbackRef = useRef(onEditorApiChange);
91
+
92
+ useEffect(() => {
93
+ apiCallbackRef.current = onEditorApiChange;
94
+ }, [onEditorApiChange]);
95
+
96
+ // Load file once on mount.
97
+ useEffect(() => {
98
+ let cancelled = false;
99
+ setState({ status: "loading" });
100
+ (async () => {
101
+ try {
102
+ const blob = await loadBlob();
103
+ const deck = await parse(blob);
104
+ if (!cancelled) setState({ status: "ready", deck });
105
+ } catch (err) {
106
+ if (!cancelled) {
107
+ setState({
108
+ status: "error",
109
+ error: err instanceof Error ? err : new Error(String(err)),
110
+ });
111
+ }
112
+ }
113
+ })();
114
+ return () => {
115
+ cancelled = true;
116
+ };
117
+ // loadBlob/parse intentionally omitted: we load exactly once.
118
+ // eslint-disable-next-line react-hooks/exhaustive-deps
119
+ }, []);
120
+
121
+ // Build and publish the imperative API. Republish whenever inputs change
122
+ // so closures see the latest serialize/saveBlob.
123
+ useEffect(() => {
124
+ if (state.status !== "ready") return;
125
+
126
+ const api: SlidewiseFileEditorApi = {
127
+ save: async () => {
128
+ const current =
129
+ editorRef.current?.getDeck() ?? state.deck;
130
+ const blob = await serialize(current);
131
+ await saveBlob(blob);
132
+ editorRef.current?.resetDirty();
133
+ },
134
+ isDirty: () => editorRef.current?.isDirty() ?? false,
135
+ play: () => editorRef.current?.play(),
136
+ stop: () => editorRef.current?.stop(),
137
+ undo: () => editorRef.current?.undo(),
138
+ redo: () => editorRef.current?.redo(),
139
+ getInitialSha256: () => initialSha256,
140
+ };
141
+
142
+ apiCallbackRef.current?.(api);
143
+ return () => {
144
+ apiCallbackRef.current?.(null);
145
+ };
146
+ }, [state, serialize, saveBlob, initialSha256]);
147
+
148
+ useImperativeHandle(
149
+ ref,
150
+ () => ({
151
+ save: async () => {
152
+ if (state.status !== "ready") return;
153
+ const current = editorRef.current?.getDeck() ?? state.deck;
154
+ const blob = await serialize(current);
155
+ await saveBlob(blob);
156
+ editorRef.current?.resetDirty();
157
+ },
158
+ isDirty: () => editorRef.current?.isDirty() ?? false,
159
+ play: () => editorRef.current?.play(),
160
+ stop: () => editorRef.current?.stop(),
161
+ undo: () => editorRef.current?.undo(),
162
+ redo: () => editorRef.current?.redo(),
163
+ getInitialSha256: () => initialSha256,
164
+ }),
165
+ [state, serialize, saveBlob, initialSha256]
166
+ );
167
+
168
+ if (state.status === "loading") {
169
+ return (
170
+ <div style={{ ...frameStyle, ...style }} className={className}>
171
+ <div style={messageStyle}>Loading…</div>
172
+ </div>
173
+ );
174
+ }
175
+ if (state.status === "error") {
176
+ return (
177
+ <div style={{ ...frameStyle, ...style }} className={className}>
178
+ <div style={{ ...messageStyle, color: "#E8504C" }}>
179
+ Could not open file: {state.error.message}
180
+ </div>
181
+ </div>
182
+ );
183
+ }
184
+
185
+ return (
186
+ <div style={{ ...frameStyle, ...style }} className={className}>
187
+ <SlidewiseEditor
188
+ ref={editorRef}
189
+ deck={state.deck}
190
+ theme={theme}
191
+ onDirtyChange={setDirty}
192
+ onSave={async (next) => {
193
+ const blob = await serialize(next);
194
+ await saveBlob(blob);
195
+ }}
196
+ />
197
+ {dirty && <UnsavedBadge />}
198
+ </div>
199
+ );
200
+ });
201
+
202
+ const frameStyle: CSSProperties = {
203
+ width: "100%",
204
+ height: "100%",
205
+ position: "relative",
206
+ display: "flex",
207
+ flexDirection: "column",
208
+ background: "#ffffff",
209
+ };
210
+
211
+ const messageStyle: CSSProperties = {
212
+ margin: "auto",
213
+ fontFamily: "Inter, system-ui, sans-serif",
214
+ fontSize: 14,
215
+ color: "#5b6178",
216
+ };
217
+
218
+ function UnsavedBadge() {
219
+ return (
220
+ <div
221
+ style={{
222
+ position: "absolute",
223
+ top: 12,
224
+ right: 12,
225
+ padding: "4px 10px",
226
+ background: "rgba(232, 80, 76, 0.12)",
227
+ color: "#E8504C",
228
+ borderRadius: 999,
229
+ fontFamily: "Inter, system-ui, sans-serif",
230
+ fontSize: 11,
231
+ fontWeight: 600,
232
+ letterSpacing: 0.2,
233
+ zIndex: 50,
234
+ pointerEvents: "none",
235
+ }}
236
+ >
237
+ Unsaved changes
238
+ </div>
239
+ );
240
+ }
241
+
242
+ export default SlidewiseFileEditor;