@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/dist/index.mjs +5082 -4810
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/SlidewiseEditor.tsx +14 -2
- package/src/SlidewiseFileEditor.tsx +32 -0
- package/src/compound/DirtyContext.tsx +30 -0
- package/src/compound/SlidewiseRoot.tsx +68 -10
- package/src/compound/hooks.ts +93 -0
- package/src/compound/index.ts +47 -4
- package/src/compound/parts.tsx +4 -15
- package/src/compound/topbar/Export.tsx +65 -0
- package/src/compound/topbar/Group.tsx +36 -0
- package/src/compound/topbar/Play.tsx +42 -0
- package/src/compound/topbar/Redo.tsx +50 -0
- package/src/compound/topbar/Root.tsx +51 -0
- package/src/compound/topbar/Save.tsx +80 -0
- package/src/compound/topbar/Spacer.tsx +27 -0
- package/src/compound/topbar/ThemeToggle.tsx +49 -0
- package/src/compound/topbar/Title.tsx +79 -0
- package/src/compound/topbar/Undo.tsx +62 -0
- package/src/compound/topbar/index.tsx +107 -0
- package/src/compound/topbar/styles.ts +81 -0
- package/src/index.ts +15 -0
- package/src/lib/__tests__/history.test.ts +164 -0
- package/src/lib/store.ts +81 -4
- package/src/components/editor/TopBar.tsx +0 -253
package/package.json
CHANGED
package/src/SlidewiseEditor.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
216
|
-
<
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
+
}
|
package/src/compound/index.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* thin wrapper rendering this same tree:
|
|
6
6
|
*
|
|
7
7
|
* ```tsx
|
|
8
|
-
* <Slidewise.Root deck={deck} onChange={
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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";
|
package/src/compound/parts.tsx
CHANGED
|
@@ -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
|
+
}
|