@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.
- package/dist/index.mjs +6261 -6108
- package/dist/index.mjs.map +1 -1
- package/dist/slidewise.css +1 -1
- package/package.json +5 -20
- package/src/SlidewiseEditor.css +121 -4
- package/src/SlidewiseEditor.tsx +82 -166
- package/src/SlidewiseFileEditor.tsx +77 -11
- package/src/components/editor/TopBar.tsx +37 -24
- package/src/compound/HostContext.tsx +29 -0
- package/src/compound/IconContext.tsx +42 -0
- package/src/compound/ReadOnlyContext.tsx +23 -0
- package/src/compound/SlidewiseRoot.tsx +274 -0
- package/src/compound/index.ts +50 -0
- package/src/compound/parts.tsx +160 -0
- package/src/css.d.ts +4 -0
- package/src/index.ts +42 -0
- package/README.md +0 -112
- package/dist/file.svg +0 -1
- package/dist/globe.svg +0 -1
- package/dist/types/SlidewiseEditor.d.ts +0 -47
- package/dist/types/SlidewiseFileEditor.d.ts +0 -54
- package/dist/types/components/editor/BottomToolbar.d.ts +0 -1
- package/dist/types/components/editor/Canvas.d.ts +0 -1
- package/dist/types/components/editor/Editor.d.ts +0 -8
- package/dist/types/components/editor/ElementView.d.ts +0 -6
- package/dist/types/components/editor/FloatingToolbar.d.ts +0 -6
- package/dist/types/components/editor/GridView.d.ts +0 -1
- package/dist/types/components/editor/PlayMode.d.ts +0 -1
- package/dist/types/components/editor/SelectionFrame.d.ts +0 -8
- package/dist/types/components/editor/SlideRail.d.ts +0 -1
- package/dist/types/components/editor/SlideView.d.ts +0 -5
- package/dist/types/components/editor/TopBar.d.ts +0 -7
- package/dist/types/index.d.ts +0 -7
- package/dist/types/lib/StoreProvider.d.ts +0 -8
- package/dist/types/lib/fonts.d.ts +0 -9
- package/dist/types/lib/pptx/deckToPptx.d.ts +0 -9
- package/dist/types/lib/pptx/index.d.ts +0 -3
- package/dist/types/lib/pptx/pptxToDeck.d.ts +0 -18
- package/dist/types/lib/pptx/types.d.ts +0 -15
- package/dist/types/lib/pptx/units.d.ts +0 -25
- package/dist/types/lib/schema/migrate.d.ts +0 -25
- package/dist/types/lib/seed.d.ts +0 -2
- package/dist/types/lib/store.d.ts +0 -55
- package/dist/types/lib/types.d.ts +0 -141
- package/dist/window.svg +0 -1
- package/src/App.tsx +0 -261
- package/src/components/editor/Editor.tsx +0 -53
- package/src/index.css +0 -13
- package/src/lib/seed.ts +0 -777
- package/src/main.tsx +0 -10
- 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
|
-
/**
|
|
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
|
-
|
|
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 (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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"
|
|
147
|
+
{theme === "dark"
|
|
148
|
+
? (icons.themeLight ?? <Sun size={16} />)
|
|
149
|
+
: (icons.themeDark ?? <Moon size={16} />)}
|
|
139
150
|
</IconBtn>
|
|
140
151
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
+
}
|