@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
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
import { Sun, Moon } from "lucide-react";
|
|
3
|
+
import { useEditor } from "@/lib/StoreProvider";
|
|
4
|
+
import { useIcons } from "../IconContext";
|
|
5
|
+
import { iconBtnStyle, hoverHandlers } from "./styles";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Light/dark theme toggle. Always rendered; theme is a viewer concern,
|
|
9
|
+
* not an editing one.
|
|
10
|
+
*/
|
|
11
|
+
export interface TopBarThemeToggleProps {
|
|
12
|
+
className?: string;
|
|
13
|
+
style?: CSSProperties;
|
|
14
|
+
labels?: { toggleToDark?: string; toggleToLight?: string };
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ThemeToggle({
|
|
19
|
+
className,
|
|
20
|
+
style,
|
|
21
|
+
labels,
|
|
22
|
+
children,
|
|
23
|
+
}: TopBarThemeToggleProps = {}) {
|
|
24
|
+
const theme = useEditor((s) => s.theme);
|
|
25
|
+
const toggleTheme = useEditor((s) => s.toggleTheme);
|
|
26
|
+
const icons = useIcons();
|
|
27
|
+
|
|
28
|
+
const label =
|
|
29
|
+
theme === "dark"
|
|
30
|
+
? (labels?.toggleToLight ?? "Light mode")
|
|
31
|
+
: (labels?.toggleToDark ?? "Dark mode");
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
className={className}
|
|
37
|
+
title={label}
|
|
38
|
+
aria-label={label}
|
|
39
|
+
onClick={toggleTheme}
|
|
40
|
+
style={{ ...iconBtnStyle(), ...style }}
|
|
41
|
+
{...hoverHandlers()}
|
|
42
|
+
>
|
|
43
|
+
{children ??
|
|
44
|
+
(theme === "dark"
|
|
45
|
+
? (icons.themeLight ?? <Sun size={16} />)
|
|
46
|
+
: (icons.themeDark ?? <Moon size={16} />))}
|
|
47
|
+
</button>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { Sparkles } from "lucide-react";
|
|
3
|
+
import { useEditor } from "@/lib/StoreProvider";
|
|
4
|
+
import { useIcons } from "../IconContext";
|
|
5
|
+
import { useReadOnly } from "../ReadOnlyContext";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Deck title input wrapped in the "Smart" pill. Reads + writes
|
|
9
|
+
* `deck.title` on the editor store. In read-only mode the input is locked.
|
|
10
|
+
*
|
|
11
|
+
* Hosts that want a different title affordance (a static label, a custom
|
|
12
|
+
* input style) drop this subpart and render their own — `useEditor` is
|
|
13
|
+
* exported so the host's component can read `s.deck.title` and call
|
|
14
|
+
* `s.setTitle(...)`.
|
|
15
|
+
*/
|
|
16
|
+
export interface TopBarTitleProps {
|
|
17
|
+
className?: string;
|
|
18
|
+
style?: CSSProperties;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Title({ className, style }: TopBarTitleProps = {}) {
|
|
22
|
+
const title = useEditor((s) => s.deck.title);
|
|
23
|
+
const setTitle = useEditor((s) => s.setTitle);
|
|
24
|
+
const icons = useIcons();
|
|
25
|
+
const readOnly = useReadOnly();
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
className={
|
|
30
|
+
className
|
|
31
|
+
? `slidewise-topbar-title ${className}`
|
|
32
|
+
: "slidewise-topbar-title"
|
|
33
|
+
}
|
|
34
|
+
style={{
|
|
35
|
+
flex: 1,
|
|
36
|
+
display: "flex",
|
|
37
|
+
alignItems: "center",
|
|
38
|
+
justifyContent: "center",
|
|
39
|
+
gap: 8,
|
|
40
|
+
minWidth: 0,
|
|
41
|
+
...style,
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
<span
|
|
45
|
+
style={{
|
|
46
|
+
display: "inline-flex",
|
|
47
|
+
alignItems: "center",
|
|
48
|
+
gap: 4,
|
|
49
|
+
padding: "3px 8px",
|
|
50
|
+
background: "var(--smart-grad)",
|
|
51
|
+
color: "var(--smart-fg)",
|
|
52
|
+
borderRadius: 999,
|
|
53
|
+
fontSize: 11,
|
|
54
|
+
fontWeight: 600,
|
|
55
|
+
letterSpacing: 0.2,
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{icons.smart ?? <Sparkles size={11} />}
|
|
59
|
+
Smart
|
|
60
|
+
</span>
|
|
61
|
+
<input
|
|
62
|
+
aria-label="Deck title"
|
|
63
|
+
value={title}
|
|
64
|
+
readOnly={readOnly}
|
|
65
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
66
|
+
style={{
|
|
67
|
+
background: "transparent",
|
|
68
|
+
border: "none",
|
|
69
|
+
fontSize: 14,
|
|
70
|
+
fontWeight: 500,
|
|
71
|
+
color: "var(--ink)",
|
|
72
|
+
textAlign: "center",
|
|
73
|
+
minWidth: 240,
|
|
74
|
+
maxWidth: 520,
|
|
75
|
+
}}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
import { Undo2 } 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
|
+
* Undo button. Calls `store.undo()`. Hidden in read-only mode. Disables
|
|
10
|
+
* itself when the undo stack is empty.
|
|
11
|
+
*
|
|
12
|
+
* Hosts replace this when they need a different shape (e.g. their own
|
|
13
|
+
* tooltip primitive or icon set with custom layout). The hook + store
|
|
14
|
+
* action are public, so a host replacement is trivially:
|
|
15
|
+
*
|
|
16
|
+
* ```tsx
|
|
17
|
+
* const MyUndo = () => {
|
|
18
|
+
* const undo = useEditor(s => s.undo);
|
|
19
|
+
* const canUndo = useEditor(s => s.history.length > 0);
|
|
20
|
+
* return <MyIconButton disabled={!canUndo} onClick={undo}>↶</MyIconButton>;
|
|
21
|
+
* };
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export interface TopBarUndoProps {
|
|
25
|
+
className?: string;
|
|
26
|
+
style?: CSSProperties;
|
|
27
|
+
ariaLabel?: string;
|
|
28
|
+
children?: ReactNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function Undo({
|
|
32
|
+
className,
|
|
33
|
+
style,
|
|
34
|
+
ariaLabel = "Undo",
|
|
35
|
+
children,
|
|
36
|
+
}: TopBarUndoProps = {}) {
|
|
37
|
+
const undo = useEditor((s) => s.undo);
|
|
38
|
+
const canUndo = useEditor((s) => s.history.length > 0);
|
|
39
|
+
const icons = useIcons();
|
|
40
|
+
const readOnly = useReadOnly();
|
|
41
|
+
if (readOnly) return null;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
className={className}
|
|
47
|
+
title={ariaLabel}
|
|
48
|
+
aria-label={ariaLabel}
|
|
49
|
+
disabled={!canUndo}
|
|
50
|
+
onClick={undo}
|
|
51
|
+
style={{
|
|
52
|
+
...iconBtnStyle(),
|
|
53
|
+
cursor: canUndo ? "pointer" : "default",
|
|
54
|
+
opacity: canUndo ? 1 : 0.4,
|
|
55
|
+
...style,
|
|
56
|
+
}}
|
|
57
|
+
{...hoverHandlers()}
|
|
58
|
+
>
|
|
59
|
+
{children ?? icons.undo ?? <Undo2 size={16} />}
|
|
60
|
+
</button>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { Root, type TopBarRootProps } from "./Root";
|
|
3
|
+
import { Title, type TopBarTitleProps } from "./Title";
|
|
4
|
+
import { Undo, type TopBarUndoProps } from "./Undo";
|
|
5
|
+
import { Redo, type TopBarRedoProps } from "./Redo";
|
|
6
|
+
import { Save, type TopBarSaveProps } from "./Save";
|
|
7
|
+
import { Play, type TopBarPlayProps } from "./Play";
|
|
8
|
+
import { ThemeToggle, type TopBarThemeToggleProps } from "./ThemeToggle";
|
|
9
|
+
import { Export, type TopBarExportProps } from "./Export";
|
|
10
|
+
import { Spacer, type TopBarSpacerProps } from "./Spacer";
|
|
11
|
+
import { Group, type TopBarGroupProps } from "./Group";
|
|
12
|
+
|
|
13
|
+
export type TopBarSlotId =
|
|
14
|
+
| "undo"
|
|
15
|
+
| "redo"
|
|
16
|
+
| "title"
|
|
17
|
+
| "themeToggle"
|
|
18
|
+
| "save"
|
|
19
|
+
| "play"
|
|
20
|
+
| "export";
|
|
21
|
+
|
|
22
|
+
export interface TopBarProps {
|
|
23
|
+
/**
|
|
24
|
+
* Hide individual default buttons without dropping to the compound API.
|
|
25
|
+
* Pass any subset of slot ids; the rest render as usual. Read-only mode
|
|
26
|
+
* already hides save/undo/redo regardless of this prop.
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* <Slidewise.TopBar hide={["export", "play"]} />
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
hide?: TopBarSlotId[];
|
|
33
|
+
className?: string;
|
|
34
|
+
style?: CSSProperties;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Default TopBar arrangement. Equivalent to:
|
|
39
|
+
*
|
|
40
|
+
* ```tsx
|
|
41
|
+
* <Slidewise.TopBar.Root>
|
|
42
|
+
* <Slidewise.TopBar.Group>
|
|
43
|
+
* <Slidewise.TopBar.Undo />
|
|
44
|
+
* <Slidewise.TopBar.Redo />
|
|
45
|
+
* </Slidewise.TopBar.Group>
|
|
46
|
+
* <Slidewise.TopBar.Title />
|
|
47
|
+
* <Slidewise.TopBar.ThemeToggle />
|
|
48
|
+
* <Slidewise.TopBar.Save />
|
|
49
|
+
* <Slidewise.TopBar.Play />
|
|
50
|
+
* <Slidewise.TopBar.Export />
|
|
51
|
+
* </Slidewise.TopBar.Root>
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* For full control over which subparts render, in what order, and
|
|
55
|
+
* intermixed with host UI, drop down to `<Slidewise.TopBar.Root>` and the
|
|
56
|
+
* named subparts directly.
|
|
57
|
+
*/
|
|
58
|
+
function DefaultTopBar({ hide, className, style }: TopBarProps = {}) {
|
|
59
|
+
const hidden = new Set(hide ?? []);
|
|
60
|
+
return (
|
|
61
|
+
<Root className={className} style={style}>
|
|
62
|
+
{(!hidden.has("undo") || !hidden.has("redo")) && (
|
|
63
|
+
<Group>
|
|
64
|
+
{!hidden.has("undo") && <Undo />}
|
|
65
|
+
{!hidden.has("redo") && <Redo />}
|
|
66
|
+
</Group>
|
|
67
|
+
)}
|
|
68
|
+
{!hidden.has("title") && <Title />}
|
|
69
|
+
{!hidden.has("themeToggle") && <ThemeToggle />}
|
|
70
|
+
{!hidden.has("save") && <Save />}
|
|
71
|
+
{!hidden.has("play") && <Play />}
|
|
72
|
+
{!hidden.has("export") && <Export />}
|
|
73
|
+
</Root>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `<Slidewise.TopBar />` is both a callable component (rendering the
|
|
79
|
+
* default arrangement) and a namespace of subparts (`TopBar.Root`,
|
|
80
|
+
* `TopBar.Title`, etc.) for full compound composition. The dual API
|
|
81
|
+
* mirrors dialux's Dialog / Radix's Dialog patterns.
|
|
82
|
+
*/
|
|
83
|
+
export const TopBar = Object.assign(DefaultTopBar, {
|
|
84
|
+
Root,
|
|
85
|
+
Title,
|
|
86
|
+
Undo,
|
|
87
|
+
Redo,
|
|
88
|
+
Save,
|
|
89
|
+
Play,
|
|
90
|
+
ThemeToggle,
|
|
91
|
+
Export,
|
|
92
|
+
Spacer,
|
|
93
|
+
Group,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export type {
|
|
97
|
+
TopBarRootProps,
|
|
98
|
+
TopBarTitleProps,
|
|
99
|
+
TopBarUndoProps,
|
|
100
|
+
TopBarRedoProps,
|
|
101
|
+
TopBarSaveProps,
|
|
102
|
+
TopBarPlayProps,
|
|
103
|
+
TopBarThemeToggleProps,
|
|
104
|
+
TopBarExportProps,
|
|
105
|
+
TopBarSpacerProps,
|
|
106
|
+
TopBarGroupProps,
|
|
107
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared button styles for TopBar subparts. Kept here so a host that swaps
|
|
5
|
+
* out one subpart (e.g. their own Save button) can match the visual weight
|
|
6
|
+
* of the remaining built-in subparts by importing these and applying them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function chromeBtnStyle(): CSSProperties {
|
|
10
|
+
return {
|
|
11
|
+
height: 32,
|
|
12
|
+
padding: "0 12px",
|
|
13
|
+
display: "flex",
|
|
14
|
+
alignItems: "center",
|
|
15
|
+
gap: 6,
|
|
16
|
+
background: "transparent",
|
|
17
|
+
border: "1px solid var(--border-strong)",
|
|
18
|
+
borderRadius: "var(--slidewise-radius, 10px)",
|
|
19
|
+
cursor: "pointer",
|
|
20
|
+
color: "var(--ink)",
|
|
21
|
+
fontSize: 13,
|
|
22
|
+
fontWeight: 500,
|
|
23
|
+
fontFamily: "inherit",
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function iconBtnStyle(): CSSProperties {
|
|
28
|
+
return {
|
|
29
|
+
width: 32,
|
|
30
|
+
height: 32,
|
|
31
|
+
borderRadius: 8,
|
|
32
|
+
border: "none",
|
|
33
|
+
background: "transparent",
|
|
34
|
+
cursor: "pointer",
|
|
35
|
+
display: "flex",
|
|
36
|
+
alignItems: "center",
|
|
37
|
+
justifyContent: "center",
|
|
38
|
+
color: "var(--ink)",
|
|
39
|
+
fontFamily: "inherit",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function primaryBtnStyle(): CSSProperties {
|
|
44
|
+
return {
|
|
45
|
+
height: 32,
|
|
46
|
+
padding: "0 12px",
|
|
47
|
+
display: "flex",
|
|
48
|
+
alignItems: "center",
|
|
49
|
+
gap: 6,
|
|
50
|
+
background: "var(--primary-bg)",
|
|
51
|
+
border: "1px solid var(--primary-bg)",
|
|
52
|
+
borderRadius: "var(--slidewise-radius, 10px)",
|
|
53
|
+
cursor: "pointer",
|
|
54
|
+
color: "var(--primary-fg)",
|
|
55
|
+
fontSize: 13,
|
|
56
|
+
fontWeight: 500,
|
|
57
|
+
fontFamily: "inherit",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function hoverHandlers(bg: string = "var(--hover)") {
|
|
62
|
+
return {
|
|
63
|
+
onMouseEnter: (e: React.MouseEvent<HTMLElement>) => {
|
|
64
|
+
(e.currentTarget as HTMLElement).style.background = bg;
|
|
65
|
+
},
|
|
66
|
+
onMouseLeave: (e: React.MouseEvent<HTMLElement>) => {
|
|
67
|
+
(e.currentTarget as HTMLElement).style.background = "transparent";
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function primaryHoverHandlers() {
|
|
73
|
+
return {
|
|
74
|
+
onMouseEnter: (e: React.MouseEvent<HTMLElement>) => {
|
|
75
|
+
(e.currentTarget as HTMLElement).style.background = "var(--primary-bg-hover)";
|
|
76
|
+
},
|
|
77
|
+
onMouseLeave: (e: React.MouseEvent<HTMLElement>) => {
|
|
78
|
+
(e.currentTarget as HTMLElement).style.background = "var(--primary-bg)";
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -45,12 +45,26 @@ export {
|
|
|
45
45
|
useHostCallbacks,
|
|
46
46
|
useIcons,
|
|
47
47
|
useReadOnly,
|
|
48
|
+
useDirty,
|
|
49
|
+
useEditor,
|
|
50
|
+
useEditorStore,
|
|
51
|
+
useSlides,
|
|
52
|
+
useActiveSlide,
|
|
53
|
+
useActiveSlideId,
|
|
54
|
+
useSelection,
|
|
55
|
+
useSelectedElements,
|
|
56
|
+
useTheme,
|
|
57
|
+
useZoom,
|
|
58
|
+
usePlaying,
|
|
59
|
+
useHistory,
|
|
48
60
|
type SlidewiseRootProps,
|
|
49
61
|
type SlidewiseRootHandle,
|
|
50
62
|
type HistoryState,
|
|
51
63
|
type SlidewiseHostCallbacks,
|
|
52
64
|
type SlidewiseIcons,
|
|
53
65
|
type RegionProps,
|
|
66
|
+
type TopBarProps,
|
|
67
|
+
type TopBarSlotId,
|
|
54
68
|
} from "./compound";
|
|
55
69
|
|
|
56
70
|
export { parsePptx, serializeDeck } from "./lib/pptx";
|
|
@@ -1,253 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Undo2,
|
|
3
|
-
Redo2,
|
|
4
|
-
Save,
|
|
5
|
-
Play,
|
|
6
|
-
Download,
|
|
7
|
-
Sparkles,
|
|
8
|
-
Sun,
|
|
9
|
-
Moon,
|
|
10
|
-
} from "lucide-react";
|
|
11
|
-
import { useEditor, useEditorStore } from "@/lib/StoreProvider";
|
|
12
|
-
import { useState, type ReactNode } from "react";
|
|
13
|
-
import type { Deck } from "@/lib/types";
|
|
14
|
-
import { useIcons } from "@/compound/IconContext";
|
|
15
|
-
import { useReadOnly } from "@/compound/ReadOnlyContext";
|
|
16
|
-
|
|
17
|
-
interface TopBarProps {
|
|
18
|
-
onSave?: (deck: Deck) => void | Promise<void>;
|
|
19
|
-
onExport?: (deck: Deck) => void;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function TopBar({ onSave: onSaveProp, onExport: onExportProp }: TopBarProps = {}) {
|
|
23
|
-
const store = useEditorStore();
|
|
24
|
-
const title = useEditor((s) => s.deck.title);
|
|
25
|
-
const setTitle = useEditor((s) => s.setTitle);
|
|
26
|
-
const undo = useEditor((s) => s.undo);
|
|
27
|
-
const redo = useEditor((s) => s.redo);
|
|
28
|
-
const play = useEditor((s) => s.play);
|
|
29
|
-
const theme = useEditor((s) => s.theme);
|
|
30
|
-
const toggleTheme = useEditor((s) => s.toggleTheme);
|
|
31
|
-
const icons = useIcons();
|
|
32
|
-
const readOnly = useReadOnly();
|
|
33
|
-
const [saved, setSaved] = useState<"idle" | "saving" | "saved">("idle");
|
|
34
|
-
|
|
35
|
-
const onSave = async () => {
|
|
36
|
-
setSaved("saving");
|
|
37
|
-
const deck = store.getState().deck;
|
|
38
|
-
try {
|
|
39
|
-
if (onSaveProp) {
|
|
40
|
-
await onSaveProp(deck);
|
|
41
|
-
} else {
|
|
42
|
-
try {
|
|
43
|
-
localStorage.setItem("slidewise-deck", JSON.stringify(deck));
|
|
44
|
-
} catch {}
|
|
45
|
-
}
|
|
46
|
-
setTimeout(() => setSaved("saved"), 320);
|
|
47
|
-
setTimeout(() => setSaved("idle"), 1600);
|
|
48
|
-
} catch {
|
|
49
|
-
setSaved("idle");
|
|
50
|
-
}
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const onExport = () => {
|
|
54
|
-
const deck = store.getState().deck;
|
|
55
|
-
if (onExportProp) {
|
|
56
|
-
onExportProp(deck);
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
const blob = new Blob([JSON.stringify(deck, null, 2)], {
|
|
60
|
-
type: "application/json",
|
|
61
|
-
});
|
|
62
|
-
const url = URL.createObjectURL(blob);
|
|
63
|
-
const a = document.createElement("a");
|
|
64
|
-
a.href = url;
|
|
65
|
-
a.download = `${(deck.title || "deck").replace(/[^a-z0-9-_]+/gi, "-")}.slidewise.json`;
|
|
66
|
-
a.click();
|
|
67
|
-
URL.revokeObjectURL(url);
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
return (
|
|
71
|
-
<div
|
|
72
|
-
style={{
|
|
73
|
-
height: 56,
|
|
74
|
-
display: "flex",
|
|
75
|
-
alignItems: "center",
|
|
76
|
-
padding: "0 14px",
|
|
77
|
-
gap: 10,
|
|
78
|
-
background: "var(--slidewise-bar-bg, var(--app-bg))",
|
|
79
|
-
borderBottom: "1px solid var(--border)",
|
|
80
|
-
boxShadow: "var(--topbar-shadow)",
|
|
81
|
-
fontFamily: "Inter, system-ui, sans-serif",
|
|
82
|
-
position: "relative",
|
|
83
|
-
zIndex: 10,
|
|
84
|
-
color: "var(--ink)",
|
|
85
|
-
}}
|
|
86
|
-
>
|
|
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
|
-
)}
|
|
97
|
-
|
|
98
|
-
<div
|
|
99
|
-
style={{
|
|
100
|
-
flex: 1,
|
|
101
|
-
display: "flex",
|
|
102
|
-
alignItems: "center",
|
|
103
|
-
justifyContent: "center",
|
|
104
|
-
gap: 8,
|
|
105
|
-
minWidth: 0,
|
|
106
|
-
}}
|
|
107
|
-
>
|
|
108
|
-
<span
|
|
109
|
-
style={{
|
|
110
|
-
display: "inline-flex",
|
|
111
|
-
alignItems: "center",
|
|
112
|
-
gap: 4,
|
|
113
|
-
padding: "3px 8px",
|
|
114
|
-
background: "var(--smart-grad)",
|
|
115
|
-
color: "var(--smart-fg)",
|
|
116
|
-
borderRadius: 999,
|
|
117
|
-
fontSize: 11,
|
|
118
|
-
fontWeight: 600,
|
|
119
|
-
letterSpacing: 0.2,
|
|
120
|
-
}}
|
|
121
|
-
>
|
|
122
|
-
{icons.smart ?? <Sparkles size={11} />}
|
|
123
|
-
Smart
|
|
124
|
-
</span>
|
|
125
|
-
<input
|
|
126
|
-
aria-label="Deck title"
|
|
127
|
-
value={title}
|
|
128
|
-
readOnly={readOnly}
|
|
129
|
-
onChange={(e) => setTitle(e.target.value)}
|
|
130
|
-
style={{
|
|
131
|
-
background: "transparent",
|
|
132
|
-
border: "none",
|
|
133
|
-
fontSize: 14,
|
|
134
|
-
fontWeight: 500,
|
|
135
|
-
color: "var(--ink)",
|
|
136
|
-
textAlign: "center",
|
|
137
|
-
minWidth: 240,
|
|
138
|
-
maxWidth: 520,
|
|
139
|
-
}}
|
|
140
|
-
/>
|
|
141
|
-
</div>
|
|
142
|
-
|
|
143
|
-
<IconBtn
|
|
144
|
-
onClick={toggleTheme}
|
|
145
|
-
title={theme === "dark" ? "Light mode" : "Dark mode"}
|
|
146
|
-
>
|
|
147
|
-
{theme === "dark"
|
|
148
|
-
? (icons.themeLight ?? <Sun size={16} />)
|
|
149
|
-
: (icons.themeDark ?? <Moon size={16} />)}
|
|
150
|
-
</IconBtn>
|
|
151
|
-
|
|
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
|
-
)}
|
|
163
|
-
|
|
164
|
-
<button
|
|
165
|
-
onClick={play}
|
|
166
|
-
style={chromeBtnStyle()}
|
|
167
|
-
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--hover)")}
|
|
168
|
-
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
|
169
|
-
>
|
|
170
|
-
{icons.play ?? <Play size={14} />}
|
|
171
|
-
Play
|
|
172
|
-
</button>
|
|
173
|
-
|
|
174
|
-
<button
|
|
175
|
-
onClick={onExport}
|
|
176
|
-
style={{
|
|
177
|
-
height: 32,
|
|
178
|
-
padding: "0 12px",
|
|
179
|
-
display: "flex",
|
|
180
|
-
alignItems: "center",
|
|
181
|
-
gap: 6,
|
|
182
|
-
background: "var(--primary-bg)",
|
|
183
|
-
border: "1px solid var(--primary-bg)",
|
|
184
|
-
borderRadius: "var(--slidewise-radius, 10px)",
|
|
185
|
-
cursor: "pointer",
|
|
186
|
-
color: "var(--primary-fg)",
|
|
187
|
-
fontSize: 13,
|
|
188
|
-
fontWeight: 500,
|
|
189
|
-
}}
|
|
190
|
-
onMouseEnter={(e) =>
|
|
191
|
-
(e.currentTarget.style.background = "var(--primary-bg-hover)")
|
|
192
|
-
}
|
|
193
|
-
onMouseLeave={(e) =>
|
|
194
|
-
(e.currentTarget.style.background = "var(--primary-bg)")
|
|
195
|
-
}
|
|
196
|
-
>
|
|
197
|
-
{icons.export ?? <Download size={14} />}
|
|
198
|
-
Export
|
|
199
|
-
</button>
|
|
200
|
-
</div>
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function chromeBtnStyle(): React.CSSProperties {
|
|
205
|
-
return {
|
|
206
|
-
height: 32,
|
|
207
|
-
padding: "0 12px",
|
|
208
|
-
display: "flex",
|
|
209
|
-
alignItems: "center",
|
|
210
|
-
gap: 6,
|
|
211
|
-
background: "transparent",
|
|
212
|
-
border: "1px solid var(--border-strong)",
|
|
213
|
-
borderRadius: "var(--slidewise-radius, 10px)",
|
|
214
|
-
cursor: "pointer",
|
|
215
|
-
color: "var(--ink)",
|
|
216
|
-
fontSize: 13,
|
|
217
|
-
fontWeight: 500,
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function IconBtn({
|
|
222
|
-
children,
|
|
223
|
-
onClick,
|
|
224
|
-
title,
|
|
225
|
-
}: {
|
|
226
|
-
children: ReactNode;
|
|
227
|
-
onClick: () => void;
|
|
228
|
-
title: string;
|
|
229
|
-
}) {
|
|
230
|
-
return (
|
|
231
|
-
<button
|
|
232
|
-
title={title}
|
|
233
|
-
aria-label={title}
|
|
234
|
-
onClick={onClick}
|
|
235
|
-
style={{
|
|
236
|
-
width: 32,
|
|
237
|
-
height: 32,
|
|
238
|
-
borderRadius: 8,
|
|
239
|
-
border: "none",
|
|
240
|
-
background: "transparent",
|
|
241
|
-
cursor: "pointer",
|
|
242
|
-
display: "flex",
|
|
243
|
-
alignItems: "center",
|
|
244
|
-
justifyContent: "center",
|
|
245
|
-
color: "var(--ink)",
|
|
246
|
-
}}
|
|
247
|
-
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--hover)")}
|
|
248
|
-
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
|
249
|
-
>
|
|
250
|
-
{children}
|
|
251
|
-
</button>
|
|
252
|
-
);
|
|
253
|
-
}
|