@textcortex/slidewise 1.2.0 → 1.4.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 +5130 -4789
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/SlidewiseEditor.tsx +26 -1
- package/src/SlidewiseFileEditor.tsx +91 -1
- package/src/compound/DirtyContext.tsx +30 -0
- package/src/compound/SlidewiseRoot.tsx +197 -17
- 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__/api-extensions.test.ts +74 -0
- package/src/lib/store.ts +20 -10
- package/src/components/editor/TopBar.tsx +0 -253
package/package.json
CHANGED
package/src/SlidewiseEditor.tsx
CHANGED
|
@@ -4,9 +4,10 @@ import {
|
|
|
4
4
|
type SlidewiseRootHandle,
|
|
5
5
|
type SlidewiseRootProps,
|
|
6
6
|
type HistoryState,
|
|
7
|
+
type SelectionSnapshot,
|
|
7
8
|
} from "./compound/SlidewiseRoot";
|
|
9
|
+
import { TopBar } from "./compound/topbar";
|
|
8
10
|
import {
|
|
9
|
-
TopBar,
|
|
10
11
|
SlideRail,
|
|
11
12
|
Canvas,
|
|
12
13
|
BottomToolbar,
|
|
@@ -40,6 +41,18 @@ export interface SlidewiseEditorProps {
|
|
|
40
41
|
* Undo and Redo buttons reactively without polling `api.canUndo()`.
|
|
41
42
|
*/
|
|
42
43
|
onHistoryChange?: (state: HistoryState) => void;
|
|
44
|
+
/** Fires when the active slide changes (user click, programmatic goToSlide). */
|
|
45
|
+
onActiveSlideChange?: (slideId: string) => void;
|
|
46
|
+
/** Fires when the selected element ids change. */
|
|
47
|
+
onSelectionChange?: (selection: SelectionSnapshot) => void;
|
|
48
|
+
/** Fires when the canvas zoom level changes. */
|
|
49
|
+
onZoomChange?: (scale: number) => void;
|
|
50
|
+
/** Fires immediately before the host's `onSave` is invoked. */
|
|
51
|
+
onSaveStart?: () => void;
|
|
52
|
+
/** Fires after the host's `onSave` resolves successfully. */
|
|
53
|
+
onSaveSuccess?: () => void;
|
|
54
|
+
/** Fires when the host's `onSave` throws. The error still propagates. */
|
|
55
|
+
onSaveError?: (err: Error) => void;
|
|
43
56
|
/** Reserved for future use; not enforced yet. */
|
|
44
57
|
readOnly?: boolean;
|
|
45
58
|
/** "light" or "dark"; defaults to "light". Ignored after first render. */
|
|
@@ -97,6 +110,12 @@ export const SlidewiseEditor = forwardRef<
|
|
|
97
110
|
onExport,
|
|
98
111
|
onDirtyChange,
|
|
99
112
|
onHistoryChange,
|
|
113
|
+
onActiveSlideChange,
|
|
114
|
+
onSelectionChange,
|
|
115
|
+
onZoomChange,
|
|
116
|
+
onSaveStart,
|
|
117
|
+
onSaveSuccess,
|
|
118
|
+
onSaveError,
|
|
100
119
|
readOnly,
|
|
101
120
|
theme,
|
|
102
121
|
initialSlideId,
|
|
@@ -116,6 +135,12 @@ export const SlidewiseEditor = forwardRef<
|
|
|
116
135
|
onExport,
|
|
117
136
|
onDirtyChange,
|
|
118
137
|
onHistoryChange,
|
|
138
|
+
onActiveSlideChange,
|
|
139
|
+
onSelectionChange,
|
|
140
|
+
onZoomChange,
|
|
141
|
+
onSaveStart,
|
|
142
|
+
onSaveSuccess,
|
|
143
|
+
onSaveError,
|
|
119
144
|
readOnly,
|
|
120
145
|
theme,
|
|
121
146
|
initialSlideId,
|
|
@@ -10,7 +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
|
+
import type { HistoryState, SelectionSnapshot } from "./compound/SlidewiseRoot";
|
|
14
14
|
|
|
15
15
|
export interface SlidewiseFileEditorProps {
|
|
16
16
|
/**
|
|
@@ -55,6 +55,18 @@ export interface SlidewiseFileEditorProps {
|
|
|
55
55
|
* `api.canUndo()` / `api.canRedo()`.
|
|
56
56
|
*/
|
|
57
57
|
onHistoryChange?: (state: HistoryState) => void;
|
|
58
|
+
/** Fires when the active slide changes (user click, programmatic goToSlide). */
|
|
59
|
+
onActiveSlideChange?: (slideId: string) => void;
|
|
60
|
+
/** Fires when the selected element ids change. */
|
|
61
|
+
onSelectionChange?: (selection: SelectionSnapshot) => void;
|
|
62
|
+
/** Fires when the canvas zoom level changes. */
|
|
63
|
+
onZoomChange?: (scale: number) => void;
|
|
64
|
+
/** Fires immediately before the host's `saveBlob` is invoked. */
|
|
65
|
+
onSaveStart?: () => void;
|
|
66
|
+
/** Fires after `saveBlob` resolves successfully. */
|
|
67
|
+
onSaveSuccess?: () => void;
|
|
68
|
+
/** Fires when `saveBlob` throws. The error still propagates. */
|
|
69
|
+
onSaveError?: (err: Error) => void;
|
|
58
70
|
/**
|
|
59
71
|
* Fires when `loadBlob` or `parse` throws on mount. The default render
|
|
60
72
|
* still shows an in-editor "Could not open file" message, but hosts that
|
|
@@ -114,6 +126,37 @@ export interface SlidewiseFileEditorApi {
|
|
|
114
126
|
* handles typical typing/drag bursts automatically.
|
|
115
127
|
*/
|
|
116
128
|
endCoalesce(): void;
|
|
129
|
+
|
|
130
|
+
/** Switch the active slide. No-op when `slideId` is not in the deck. */
|
|
131
|
+
goToSlide(slideId: string): void;
|
|
132
|
+
/** Advance to the next slide. No-op past the last slide. */
|
|
133
|
+
nextSlide(): void;
|
|
134
|
+
/** Step back to the previous slide. No-op past the first. */
|
|
135
|
+
prevSlide(): void;
|
|
136
|
+
|
|
137
|
+
/** Zoom out by one step. */
|
|
138
|
+
zoomOut(): void;
|
|
139
|
+
/** Zoom in by one step. */
|
|
140
|
+
zoomIn(): void;
|
|
141
|
+
/** Set absolute zoom (1 = 100%); clamped to [0.1, 4]. */
|
|
142
|
+
setZoom(scale: number): void;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Insert a blank slide after `afterId`, or at the end. Returns the new
|
|
146
|
+
* slide's id. The new slide becomes active.
|
|
147
|
+
*/
|
|
148
|
+
addSlide(afterId?: string): string | null;
|
|
149
|
+
/**
|
|
150
|
+
* Duplicate `slideId`. Returns the new slide's id, or `null` if not
|
|
151
|
+
* found. The duplicate becomes active.
|
|
152
|
+
*/
|
|
153
|
+
duplicateSlide(slideId: string): string | null;
|
|
154
|
+
/** Delete `slideId`. No-op when the deck would be left empty. */
|
|
155
|
+
deleteSlide(slideId: string): void;
|
|
156
|
+
|
|
157
|
+
/** Current selection snapshot (slide id + selected element ids). */
|
|
158
|
+
getSelection(): SelectionSnapshot;
|
|
159
|
+
|
|
117
160
|
/** Live deck snapshot. Hosts use this for header badges (slide count, etc.). */
|
|
118
161
|
getDeck(): Deck | null;
|
|
119
162
|
getInitialSha256(): string | null;
|
|
@@ -138,6 +181,12 @@ export const SlidewiseFileEditor = forwardRef<
|
|
|
138
181
|
onDirtyChange,
|
|
139
182
|
onLoadError,
|
|
140
183
|
onHistoryChange,
|
|
184
|
+
onActiveSlideChange,
|
|
185
|
+
onSelectionChange,
|
|
186
|
+
onZoomChange,
|
|
187
|
+
onSaveStart,
|
|
188
|
+
onSaveSuccess,
|
|
189
|
+
onSaveError,
|
|
141
190
|
theme,
|
|
142
191
|
initialSlideId,
|
|
143
192
|
showTopBar,
|
|
@@ -211,6 +260,23 @@ export const SlidewiseFileEditor = forwardRef<
|
|
|
211
260
|
getHistorySize: () =>
|
|
212
261
|
editorRef.current?.getHistorySize() ?? { undo: 0, redo: 0 },
|
|
213
262
|
endCoalesce: () => editorRef.current?.endCoalesce(),
|
|
263
|
+
goToSlide: (slideId: string) => editorRef.current?.goToSlide(slideId),
|
|
264
|
+
nextSlide: () => editorRef.current?.nextSlide(),
|
|
265
|
+
prevSlide: () => editorRef.current?.prevSlide(),
|
|
266
|
+
zoomIn: () => editorRef.current?.zoomIn(),
|
|
267
|
+
zoomOut: () => editorRef.current?.zoomOut(),
|
|
268
|
+
setZoom: (scale: number) => editorRef.current?.setZoom(scale),
|
|
269
|
+
addSlide: (afterId?: string) =>
|
|
270
|
+
editorRef.current?.addSlide(afterId) ?? null,
|
|
271
|
+
duplicateSlide: (slideId: string) =>
|
|
272
|
+
editorRef.current?.duplicateSlide(slideId) ?? null,
|
|
273
|
+
deleteSlide: (slideId: string) =>
|
|
274
|
+
editorRef.current?.deleteSlide(slideId),
|
|
275
|
+
getSelection: () =>
|
|
276
|
+
editorRef.current?.getSelection() ?? {
|
|
277
|
+
slideId: state.deck.slides[0]?.id ?? "",
|
|
278
|
+
elementIds: [],
|
|
279
|
+
},
|
|
214
280
|
getDeck: () => editorRef.current?.getDeck() ?? state.deck,
|
|
215
281
|
getInitialSha256: () => initialSha256,
|
|
216
282
|
};
|
|
@@ -241,6 +307,24 @@ export const SlidewiseFileEditor = forwardRef<
|
|
|
241
307
|
getHistorySize: () =>
|
|
242
308
|
editorRef.current?.getHistorySize() ?? { undo: 0, redo: 0 },
|
|
243
309
|
endCoalesce: () => editorRef.current?.endCoalesce(),
|
|
310
|
+
goToSlide: (slideId: string) => editorRef.current?.goToSlide(slideId),
|
|
311
|
+
nextSlide: () => editorRef.current?.nextSlide(),
|
|
312
|
+
prevSlide: () => editorRef.current?.prevSlide(),
|
|
313
|
+
zoomIn: () => editorRef.current?.zoomIn(),
|
|
314
|
+
zoomOut: () => editorRef.current?.zoomOut(),
|
|
315
|
+
setZoom: (scale: number) => editorRef.current?.setZoom(scale),
|
|
316
|
+
addSlide: (afterId?: string) =>
|
|
317
|
+
editorRef.current?.addSlide(afterId) ?? null,
|
|
318
|
+
duplicateSlide: (slideId: string) =>
|
|
319
|
+
editorRef.current?.duplicateSlide(slideId) ?? null,
|
|
320
|
+
deleteSlide: (slideId: string) =>
|
|
321
|
+
editorRef.current?.deleteSlide(slideId),
|
|
322
|
+
getSelection: () =>
|
|
323
|
+
editorRef.current?.getSelection() ?? {
|
|
324
|
+
slideId:
|
|
325
|
+
state.status === "ready" ? state.deck.slides[0]?.id ?? "" : "",
|
|
326
|
+
elementIds: [],
|
|
327
|
+
},
|
|
244
328
|
getDeck: () =>
|
|
245
329
|
state.status === "ready"
|
|
246
330
|
? editorRef.current?.getDeck() ?? state.deck
|
|
@@ -287,6 +371,12 @@ export const SlidewiseFileEditor = forwardRef<
|
|
|
287
371
|
onDirtyChangeRef.current?.(d);
|
|
288
372
|
}}
|
|
289
373
|
onHistoryChange={onHistoryChange}
|
|
374
|
+
onActiveSlideChange={onActiveSlideChange}
|
|
375
|
+
onSelectionChange={onSelectionChange}
|
|
376
|
+
onZoomChange={onZoomChange}
|
|
377
|
+
onSaveStart={onSaveStart}
|
|
378
|
+
onSaveSuccess={onSaveSuccess}
|
|
379
|
+
onSaveError={onSaveError}
|
|
290
380
|
onSave={async (next) => {
|
|
291
381
|
const blob = await serialize(next);
|
|
292
382
|
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
|
/**
|
|
@@ -41,6 +43,18 @@ export interface SlidewiseRootProps {
|
|
|
41
43
|
* "Undo"/"Redo" button enabled state without polling `canUndo()`/`canRedo()`.
|
|
42
44
|
*/
|
|
43
45
|
onHistoryChange?: (state: HistoryState) => void;
|
|
46
|
+
/** Fires when the active slide changes (user click, programmatic goToSlide). */
|
|
47
|
+
onActiveSlideChange?: (slideId: string) => void;
|
|
48
|
+
/** Fires when the selected element ids change. */
|
|
49
|
+
onSelectionChange?: (selection: SelectionSnapshot) => void;
|
|
50
|
+
/** Fires when the canvas zoom level changes. */
|
|
51
|
+
onZoomChange?: (scale: number) => void;
|
|
52
|
+
/** Fires immediately before the host's `onSave` is invoked. */
|
|
53
|
+
onSaveStart?: () => void;
|
|
54
|
+
/** Fires after the host's `onSave` resolves successfully. */
|
|
55
|
+
onSaveSuccess?: () => void;
|
|
56
|
+
/** Fires when the host's `onSave` throws. The error still propagates. */
|
|
57
|
+
onSaveError?: (err: Error) => void;
|
|
44
58
|
/**
|
|
45
59
|
* Hide editing affordances (save / undo / redo) and disable canvas
|
|
46
60
|
* mutations. Use this when the host viewer doesn't have write access.
|
|
@@ -72,6 +86,12 @@ export interface HistoryState {
|
|
|
72
86
|
redoSize: number;
|
|
73
87
|
}
|
|
74
88
|
|
|
89
|
+
/** Snapshot of the current selection, scoped to the active slide. */
|
|
90
|
+
export interface SelectionSnapshot {
|
|
91
|
+
slideId: string;
|
|
92
|
+
elementIds: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
75
95
|
export interface SlidewiseRootHandle {
|
|
76
96
|
play(): void;
|
|
77
97
|
stop(): void;
|
|
@@ -90,6 +110,44 @@ export interface SlidewiseRootHandle {
|
|
|
90
110
|
* window handles typical typing/drag bursts.
|
|
91
111
|
*/
|
|
92
112
|
endCoalesce(): void;
|
|
113
|
+
|
|
114
|
+
// ---- Navigation ----
|
|
115
|
+
/** Switch the active slide. No-op when `slideId` is not in the deck. */
|
|
116
|
+
goToSlide(slideId: string): void;
|
|
117
|
+
/** Advance to the slide after the current one. No-op past the last slide. */
|
|
118
|
+
nextSlide(): void;
|
|
119
|
+
/** Step back to the slide before the current one. No-op past the first. */
|
|
120
|
+
prevSlide(): void;
|
|
121
|
+
|
|
122
|
+
// ---- Zoom ----
|
|
123
|
+
/** Zoom out by one step (×0.8), clamped to the editor's min zoom. */
|
|
124
|
+
zoomOut(): void;
|
|
125
|
+
/** Zoom in by one step (×1.25), clamped to the editor's max zoom. */
|
|
126
|
+
zoomIn(): void;
|
|
127
|
+
/** Set the absolute zoom (1 = 100%); clamped to [0.1, 4]. */
|
|
128
|
+
setZoom(scale: number): void;
|
|
129
|
+
|
|
130
|
+
// ---- Slide CRUD ----
|
|
131
|
+
/**
|
|
132
|
+
* Insert a blank slide after `afterId`, or at the end if `afterId` is
|
|
133
|
+
* omitted. Returns the new slide's id. The new slide becomes active.
|
|
134
|
+
*/
|
|
135
|
+
addSlide(afterId?: string): string;
|
|
136
|
+
/**
|
|
137
|
+
* Insert a copy of `slideId` immediately after it. Returns the new
|
|
138
|
+
* slide's id, or `null` if `slideId` wasn't found. The copy becomes
|
|
139
|
+
* active.
|
|
140
|
+
*/
|
|
141
|
+
duplicateSlide(slideId: string): string | null;
|
|
142
|
+
/**
|
|
143
|
+
* Delete a slide. No-op when the deck would be left with zero slides.
|
|
144
|
+
*/
|
|
145
|
+
deleteSlide(slideId: string): void;
|
|
146
|
+
|
|
147
|
+
// ---- Selection ----
|
|
148
|
+
/** Current selection snapshot (slide id + selected element ids). */
|
|
149
|
+
getSelection(): SelectionSnapshot;
|
|
150
|
+
|
|
93
151
|
getDeck(): Deck;
|
|
94
152
|
isDirty(): boolean;
|
|
95
153
|
resetDirty(): void;
|
|
@@ -122,6 +180,12 @@ function RootInner({
|
|
|
122
180
|
onExport,
|
|
123
181
|
onDirtyChange,
|
|
124
182
|
onHistoryChange: props_onHistoryChange,
|
|
183
|
+
onActiveSlideChange,
|
|
184
|
+
onSelectionChange,
|
|
185
|
+
onZoomChange,
|
|
186
|
+
onSaveStart,
|
|
187
|
+
onSaveSuccess,
|
|
188
|
+
onSaveError,
|
|
125
189
|
readOnly = false,
|
|
126
190
|
theme,
|
|
127
191
|
initialSlideId,
|
|
@@ -137,11 +201,18 @@ function RootInner({
|
|
|
137
201
|
const store = useEditorStore();
|
|
138
202
|
const savedDeckRef = useRef<Deck>(deck);
|
|
139
203
|
const dirtyRef = useRef(false);
|
|
204
|
+
const [dirty, setDirty] = useState(false);
|
|
140
205
|
const onChangeRef = useRef(onChange);
|
|
141
206
|
const onDirtyChangeRef = useRef(onDirtyChange);
|
|
142
207
|
const onSaveRef = useRef(onSave);
|
|
143
208
|
const onExportRef = useRef(onExport);
|
|
144
209
|
const onHistoryChangeRef = useRef(props_onHistoryChange);
|
|
210
|
+
const onActiveSlideChangeRef = useRef(onActiveSlideChange);
|
|
211
|
+
const onSelectionChangeRef = useRef(onSelectionChange);
|
|
212
|
+
const onZoomChangeRef = useRef(onZoomChange);
|
|
213
|
+
const onSaveStartRef = useRef(onSaveStart);
|
|
214
|
+
const onSaveSuccessRef = useRef(onSaveSuccess);
|
|
215
|
+
const onSaveErrorRef = useRef(onSaveError);
|
|
145
216
|
|
|
146
217
|
useEffect(() => {
|
|
147
218
|
onChangeRef.current = onChange;
|
|
@@ -149,7 +220,25 @@ function RootInner({
|
|
|
149
220
|
onSaveRef.current = onSave;
|
|
150
221
|
onExportRef.current = onExport;
|
|
151
222
|
onHistoryChangeRef.current = props_onHistoryChange;
|
|
152
|
-
|
|
223
|
+
onActiveSlideChangeRef.current = onActiveSlideChange;
|
|
224
|
+
onSelectionChangeRef.current = onSelectionChange;
|
|
225
|
+
onZoomChangeRef.current = onZoomChange;
|
|
226
|
+
onSaveStartRef.current = onSaveStart;
|
|
227
|
+
onSaveSuccessRef.current = onSaveSuccess;
|
|
228
|
+
onSaveErrorRef.current = onSaveError;
|
|
229
|
+
}, [
|
|
230
|
+
onChange,
|
|
231
|
+
onDirtyChange,
|
|
232
|
+
onSave,
|
|
233
|
+
onExport,
|
|
234
|
+
props_onHistoryChange,
|
|
235
|
+
onActiveSlideChange,
|
|
236
|
+
onSelectionChange,
|
|
237
|
+
onZoomChange,
|
|
238
|
+
onSaveStart,
|
|
239
|
+
onSaveSuccess,
|
|
240
|
+
onSaveError,
|
|
241
|
+
]);
|
|
153
242
|
|
|
154
243
|
useEffect(() => {
|
|
155
244
|
if (theme) {
|
|
@@ -176,6 +265,7 @@ function RootInner({
|
|
|
176
265
|
savedDeckRef.current = deck;
|
|
177
266
|
if (dirtyRef.current) {
|
|
178
267
|
dirtyRef.current = false;
|
|
268
|
+
setDirty(false);
|
|
179
269
|
onDirtyChangeRef.current?.(false);
|
|
180
270
|
}
|
|
181
271
|
}
|
|
@@ -203,11 +293,35 @@ function RootInner({
|
|
|
203
293
|
redoSize: nextFut,
|
|
204
294
|
});
|
|
205
295
|
}
|
|
296
|
+
|
|
297
|
+
// Active slide changes (click in rail, programmatic goToSlide).
|
|
298
|
+
if (state.currentSlideId !== prev.currentSlideId) {
|
|
299
|
+
onActiveSlideChangeRef.current?.(state.currentSlideId);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Selection — shallow compare ids since the array is rebuilt on
|
|
303
|
+
// every selectElement call. Same slideId + same ids = no emit.
|
|
304
|
+
if (
|
|
305
|
+
state.selectedIds !== prev.selectedIds &&
|
|
306
|
+
!shallowEqualIds(state.selectedIds, prev.selectedIds)
|
|
307
|
+
) {
|
|
308
|
+
onSelectionChangeRef.current?.({
|
|
309
|
+
slideId: state.currentSlideId,
|
|
310
|
+
elementIds: state.selectedIds,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Zoom.
|
|
315
|
+
if (state.zoom !== prev.zoom) {
|
|
316
|
+
onZoomChangeRef.current?.(state.zoom);
|
|
317
|
+
}
|
|
318
|
+
|
|
206
319
|
if (state.deck === prev.deck) return;
|
|
207
320
|
onChangeRef.current?.(state.deck);
|
|
208
321
|
const nextDirty = state.deck !== savedDeckRef.current;
|
|
209
322
|
if (nextDirty !== dirtyRef.current) {
|
|
210
323
|
dirtyRef.current = nextDirty;
|
|
324
|
+
setDirty(nextDirty);
|
|
211
325
|
onDirtyChangeRef.current?.(nextDirty);
|
|
212
326
|
}
|
|
213
327
|
ensureGoogleFontsLoaded(instanceId, collectFontFamilies(state.deck));
|
|
@@ -234,12 +348,54 @@ function RootInner({
|
|
|
234
348
|
return { undo: s.history.length, redo: s.future.length };
|
|
235
349
|
},
|
|
236
350
|
endCoalesce: () => store.getState().endCoalesce(),
|
|
351
|
+
|
|
352
|
+
goToSlide: (slideId: string) => {
|
|
353
|
+
const s = store.getState();
|
|
354
|
+
if (s.deck.slides.some((sl) => sl.id === slideId)) {
|
|
355
|
+
s.selectSlide(slideId);
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
nextSlide: () => {
|
|
359
|
+
const s = store.getState();
|
|
360
|
+
const idx = s.deck.slides.findIndex(
|
|
361
|
+
(sl) => sl.id === s.currentSlideId
|
|
362
|
+
);
|
|
363
|
+
const next = s.deck.slides[idx + 1];
|
|
364
|
+
if (next) s.selectSlide(next.id);
|
|
365
|
+
},
|
|
366
|
+
prevSlide: () => {
|
|
367
|
+
const s = store.getState();
|
|
368
|
+
const idx = s.deck.slides.findIndex(
|
|
369
|
+
(sl) => sl.id === s.currentSlideId
|
|
370
|
+
);
|
|
371
|
+
const prev = s.deck.slides[idx - 1];
|
|
372
|
+
if (prev) s.selectSlide(prev.id);
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
zoomIn: () => store.getState().zoomIn(),
|
|
376
|
+
zoomOut: () => store.getState().zoomOut(),
|
|
377
|
+
setZoom: (scale: number) => store.getState().setZoom(scale),
|
|
378
|
+
|
|
379
|
+
addSlide: (afterId?: string) => store.getState().addSlide(afterId),
|
|
380
|
+
duplicateSlide: (slideId: string) =>
|
|
381
|
+
store.getState().duplicateSlide(slideId),
|
|
382
|
+
deleteSlide: (slideId: string) => store.getState().deleteSlide(slideId),
|
|
383
|
+
|
|
384
|
+
getSelection: (): SelectionSnapshot => {
|
|
385
|
+
const s = store.getState();
|
|
386
|
+
return {
|
|
387
|
+
slideId: s.currentSlideId,
|
|
388
|
+
elementIds: [...s.selectedIds],
|
|
389
|
+
};
|
|
390
|
+
},
|
|
391
|
+
|
|
237
392
|
getDeck: () => store.getState().deck,
|
|
238
393
|
isDirty: () => dirtyRef.current,
|
|
239
394
|
resetDirty: () => {
|
|
240
395
|
savedDeckRef.current = store.getState().deck;
|
|
241
396
|
if (dirtyRef.current) {
|
|
242
397
|
dirtyRef.current = false;
|
|
398
|
+
setDirty(false);
|
|
243
399
|
onDirtyChangeRef.current?.(false);
|
|
244
400
|
}
|
|
245
401
|
},
|
|
@@ -247,15 +403,28 @@ function RootInner({
|
|
|
247
403
|
[store]
|
|
248
404
|
);
|
|
249
405
|
|
|
250
|
-
// Wrap host save with
|
|
251
|
-
//
|
|
406
|
+
// Wrap host save with:
|
|
407
|
+
// - onSaveStart / onSaveSuccess / onSaveError lifecycle hooks
|
|
408
|
+
// - dirty-flag reset on success
|
|
409
|
+
// The error still propagates so TopBar.Save's local "Saving…" → "idle"
|
|
410
|
+
// transition kicks in correctly.
|
|
252
411
|
const wrappedSave = onSave
|
|
253
412
|
? async (d: Deck) => {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
413
|
+
onSaveStartRef.current?.();
|
|
414
|
+
try {
|
|
415
|
+
await onSaveRef.current!(d);
|
|
416
|
+
savedDeckRef.current = d;
|
|
417
|
+
if (dirtyRef.current) {
|
|
418
|
+
dirtyRef.current = false;
|
|
419
|
+
setDirty(false);
|
|
420
|
+
onDirtyChangeRef.current?.(false);
|
|
421
|
+
}
|
|
422
|
+
onSaveSuccessRef.current?.();
|
|
423
|
+
} catch (err) {
|
|
424
|
+
onSaveErrorRef.current?.(
|
|
425
|
+
err instanceof Error ? err : new Error(String(err))
|
|
426
|
+
);
|
|
427
|
+
throw err;
|
|
259
428
|
}
|
|
260
429
|
}
|
|
261
430
|
: undefined;
|
|
@@ -263,15 +432,17 @@ function RootInner({
|
|
|
263
432
|
return (
|
|
264
433
|
<ReadOnlyProvider readOnly={readOnly}>
|
|
265
434
|
<IconProvider icons={icons ?? {}}>
|
|
266
|
-
<
|
|
267
|
-
<
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
435
|
+
<DirtyProvider dirty={dirty}>
|
|
436
|
+
<HostProvider callbacks={{ onSave: wrappedSave, onExport }}>
|
|
437
|
+
<RootShell
|
|
438
|
+
fontFamily={fontFamily}
|
|
439
|
+
className={className}
|
|
440
|
+
style={style}
|
|
441
|
+
>
|
|
442
|
+
{children}
|
|
443
|
+
</RootShell>
|
|
444
|
+
</HostProvider>
|
|
445
|
+
</DirtyProvider>
|
|
275
446
|
</IconProvider>
|
|
276
447
|
</ReadOnlyProvider>
|
|
277
448
|
);
|
|
@@ -323,3 +494,12 @@ function RootShell({
|
|
|
323
494
|
</div>
|
|
324
495
|
);
|
|
325
496
|
}
|
|
497
|
+
|
|
498
|
+
function shallowEqualIds(a: readonly string[], b: readonly string[]): boolean {
|
|
499
|
+
if (a === b) return true;
|
|
500
|
+
if (a.length !== b.length) return false;
|
|
501
|
+
for (let i = 0; i < a.length; i++) {
|
|
502
|
+
if (a[i] !== b[i]) return false;
|
|
503
|
+
}
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
@@ -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
|
+
}
|