@textcortex/slidewise 1.2.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 +4361 -4140
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/SlidewiseEditor.tsx +1 -1
- package/src/compound/DirtyContext.tsx +30 -0
- package/src/compound/SlidewiseRoot.tsx +16 -9
- package/src/compound/hooks.ts +93 -0
- package/src/compound/index.ts +46 -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 +14 -0
- package/src/components/editor/TopBar.tsx +0 -253
package/package.json
CHANGED
package/src/SlidewiseEditor.tsx
CHANGED
|
@@ -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
|
/**
|
|
@@ -137,6 +139,7 @@ function RootInner({
|
|
|
137
139
|
const store = useEditorStore();
|
|
138
140
|
const savedDeckRef = useRef<Deck>(deck);
|
|
139
141
|
const dirtyRef = useRef(false);
|
|
142
|
+
const [dirty, setDirty] = useState(false);
|
|
140
143
|
const onChangeRef = useRef(onChange);
|
|
141
144
|
const onDirtyChangeRef = useRef(onDirtyChange);
|
|
142
145
|
const onSaveRef = useRef(onSave);
|
|
@@ -176,6 +179,7 @@ function RootInner({
|
|
|
176
179
|
savedDeckRef.current = deck;
|
|
177
180
|
if (dirtyRef.current) {
|
|
178
181
|
dirtyRef.current = false;
|
|
182
|
+
setDirty(false);
|
|
179
183
|
onDirtyChangeRef.current?.(false);
|
|
180
184
|
}
|
|
181
185
|
}
|
|
@@ -208,6 +212,7 @@ function RootInner({
|
|
|
208
212
|
const nextDirty = state.deck !== savedDeckRef.current;
|
|
209
213
|
if (nextDirty !== dirtyRef.current) {
|
|
210
214
|
dirtyRef.current = nextDirty;
|
|
215
|
+
setDirty(nextDirty);
|
|
211
216
|
onDirtyChangeRef.current?.(nextDirty);
|
|
212
217
|
}
|
|
213
218
|
ensureGoogleFontsLoaded(instanceId, collectFontFamilies(state.deck));
|
|
@@ -263,15 +268,17 @@ function RootInner({
|
|
|
263
268
|
return (
|
|
264
269
|
<ReadOnlyProvider readOnly={readOnly}>
|
|
265
270
|
<IconProvider icons={icons ?? {}}>
|
|
266
|
-
<
|
|
267
|
-
<
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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>
|
|
275
282
|
</IconProvider>
|
|
276
283
|
</ReadOnlyProvider>
|
|
277
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,10 +17,19 @@
|
|
|
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 {
|
|
@@ -30,7 +39,6 @@ export {
|
|
|
30
39
|
type HistoryState,
|
|
31
40
|
} from "./SlidewiseRoot";
|
|
32
41
|
export {
|
|
33
|
-
TopBar,
|
|
34
42
|
SlideRail,
|
|
35
43
|
Canvas,
|
|
36
44
|
BottomToolbar,
|
|
@@ -39,6 +47,21 @@ export {
|
|
|
39
47
|
CanvasFrame,
|
|
40
48
|
type RegionProps,
|
|
41
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
|
+
|
|
42
65
|
export {
|
|
43
66
|
useHostCallbacks,
|
|
44
67
|
type SlidewiseHostCallbacks,
|
|
@@ -49,3 +72,22 @@ export {
|
|
|
49
72
|
type SlidewiseIcons,
|
|
50
73
|
} from "./IconContext";
|
|
51
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
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
import { Redo2 } from "lucide-react";
|
|
3
|
+
import { useEditor } from "@/lib/StoreProvider";
|
|
4
|
+
import { useIcons } from "../IconContext";
|
|
5
|
+
import { useReadOnly } from "../ReadOnlyContext";
|
|
6
|
+
import { iconBtnStyle, hoverHandlers } from "./styles";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Redo button. Calls `store.redo()`. Hidden in read-only mode. Disables
|
|
10
|
+
* itself when the redo stack is empty.
|
|
11
|
+
*/
|
|
12
|
+
export interface TopBarRedoProps {
|
|
13
|
+
className?: string;
|
|
14
|
+
style?: CSSProperties;
|
|
15
|
+
ariaLabel?: string;
|
|
16
|
+
children?: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Redo({
|
|
20
|
+
className,
|
|
21
|
+
style,
|
|
22
|
+
ariaLabel = "Redo",
|
|
23
|
+
children,
|
|
24
|
+
}: TopBarRedoProps = {}) {
|
|
25
|
+
const redo = useEditor((s) => s.redo);
|
|
26
|
+
const canRedo = useEditor((s) => s.future.length > 0);
|
|
27
|
+
const icons = useIcons();
|
|
28
|
+
const readOnly = useReadOnly();
|
|
29
|
+
if (readOnly) return null;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
className={className}
|
|
35
|
+
title={ariaLabel}
|
|
36
|
+
aria-label={ariaLabel}
|
|
37
|
+
disabled={!canRedo}
|
|
38
|
+
onClick={redo}
|
|
39
|
+
style={{
|
|
40
|
+
...iconBtnStyle(),
|
|
41
|
+
cursor: canRedo ? "pointer" : "default",
|
|
42
|
+
opacity: canRedo ? 1 : 0.4,
|
|
43
|
+
...style,
|
|
44
|
+
}}
|
|
45
|
+
{...hoverHandlers()}
|
|
46
|
+
>
|
|
47
|
+
{children ?? icons.redo ?? <Redo2 size={16} />}
|
|
48
|
+
</button>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { CSSProperties, PropsWithChildren } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Container for the TopBar subparts. Owns the bar's height, padding, theme
|
|
5
|
+
* background, and shadow. Hosts replace this when they want a different
|
|
6
|
+
* shell shape (taller bar, different alignment, etc.); otherwise just render
|
|
7
|
+
* subparts inside it.
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* <Slidewise.TopBar.Root>
|
|
11
|
+
* <Slidewise.TopBar.Title />
|
|
12
|
+
* <Slidewise.TopBar.Spacer />
|
|
13
|
+
* <Slidewise.TopBar.Save />
|
|
14
|
+
* </Slidewise.TopBar.Root>
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export interface TopBarRootProps {
|
|
18
|
+
className?: string;
|
|
19
|
+
style?: CSSProperties;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function Root({
|
|
23
|
+
className,
|
|
24
|
+
style,
|
|
25
|
+
children,
|
|
26
|
+
}: PropsWithChildren<TopBarRootProps>) {
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
className={
|
|
30
|
+
className ? `slidewise-topbar ${className}` : "slidewise-topbar"
|
|
31
|
+
}
|
|
32
|
+
style={{
|
|
33
|
+
height: 56,
|
|
34
|
+
display: "flex",
|
|
35
|
+
alignItems: "center",
|
|
36
|
+
padding: "0 14px",
|
|
37
|
+
gap: 10,
|
|
38
|
+
background: "var(--slidewise-bar-bg, var(--app-bg))",
|
|
39
|
+
borderBottom: "1px solid var(--border)",
|
|
40
|
+
boxShadow: "var(--topbar-shadow)",
|
|
41
|
+
fontFamily: "Inter, system-ui, sans-serif",
|
|
42
|
+
position: "relative",
|
|
43
|
+
zIndex: 10,
|
|
44
|
+
color: "var(--ink)",
|
|
45
|
+
...style,
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
{children}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useState, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { Save as SaveIcon } from "lucide-react";
|
|
3
|
+
import { useEditorStore } from "@/lib/StoreProvider";
|
|
4
|
+
import { useHostCallbacks } from "../HostContext";
|
|
5
|
+
import { useIcons } from "../IconContext";
|
|
6
|
+
import { useReadOnly } from "../ReadOnlyContext";
|
|
7
|
+
import { chromeBtnStyle, hoverHandlers } from "./styles";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Save button. Calls the host's `onSave` (from `<Slidewise.Root onSave>`)
|
|
11
|
+
* with the current deck. If no host callback is registered, falls back to
|
|
12
|
+
* `localStorage.setItem("slidewise-deck", ...)` so the dev shell works
|
|
13
|
+
* without wiring.
|
|
14
|
+
*
|
|
15
|
+
* Cycles through idle → saving → saved labels around the click; hosts that
|
|
16
|
+
* want their own loading affordance render their own button using
|
|
17
|
+
* `useHostCallbacks().onSave` directly.
|
|
18
|
+
*/
|
|
19
|
+
export interface TopBarSaveProps {
|
|
20
|
+
className?: string;
|
|
21
|
+
style?: CSSProperties;
|
|
22
|
+
ariaLabel?: string;
|
|
23
|
+
labels?: { idle?: string; saving?: string; saved?: string };
|
|
24
|
+
children?: ReactNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Save({
|
|
28
|
+
className,
|
|
29
|
+
style,
|
|
30
|
+
ariaLabel = "Save",
|
|
31
|
+
labels,
|
|
32
|
+
children,
|
|
33
|
+
}: TopBarSaveProps = {}) {
|
|
34
|
+
const store = useEditorStore();
|
|
35
|
+
const { onSave: onSaveHost } = useHostCallbacks();
|
|
36
|
+
const icons = useIcons();
|
|
37
|
+
const readOnly = useReadOnly();
|
|
38
|
+
const [phase, setPhase] = useState<"idle" | "saving" | "saved">("idle");
|
|
39
|
+
|
|
40
|
+
if (readOnly) return null;
|
|
41
|
+
|
|
42
|
+
const onClick = async () => {
|
|
43
|
+
setPhase("saving");
|
|
44
|
+
const deck = store.getState().deck;
|
|
45
|
+
try {
|
|
46
|
+
if (onSaveHost) {
|
|
47
|
+
await onSaveHost(deck);
|
|
48
|
+
} else {
|
|
49
|
+
try {
|
|
50
|
+
localStorage.setItem("slidewise-deck", JSON.stringify(deck));
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
setTimeout(() => setPhase("saved"), 320);
|
|
54
|
+
setTimeout(() => setPhase("idle"), 1600);
|
|
55
|
+
} catch {
|
|
56
|
+
setPhase("idle");
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const text =
|
|
61
|
+
phase === "saving"
|
|
62
|
+
? (labels?.saving ?? "Saving…")
|
|
63
|
+
: phase === "saved"
|
|
64
|
+
? (labels?.saved ?? "Saved")
|
|
65
|
+
: (labels?.idle ?? "Save");
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
className={className}
|
|
71
|
+
aria-label={ariaLabel}
|
|
72
|
+
onClick={onClick}
|
|
73
|
+
style={{ ...chromeBtnStyle(), ...style }}
|
|
74
|
+
{...hoverHandlers()}
|
|
75
|
+
>
|
|
76
|
+
{children ?? icons.save ?? <SaveIcon size={14} />}
|
|
77
|
+
{text}
|
|
78
|
+
</button>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pushes subsequent subparts to the far edge of `<TopBar.Root>`.
|
|
5
|
+
*
|
|
6
|
+
* ```tsx
|
|
7
|
+
* <Slidewise.TopBar.Root>
|
|
8
|
+
* <MyExitButton />
|
|
9
|
+
* <Slidewise.TopBar.Spacer />
|
|
10
|
+
* <Slidewise.TopBar.Save />
|
|
11
|
+
* </Slidewise.TopBar.Root>
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export interface TopBarSpacerProps {
|
|
15
|
+
className?: string;
|
|
16
|
+
style?: CSSProperties;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Spacer({ className, style }: TopBarSpacerProps = {}) {
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
className={className}
|
|
23
|
+
aria-hidden="true"
|
|
24
|
+
style={{ flex: 1, minWidth: 0, ...style }}
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
}
|