@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
|
@@ -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,27 @@ 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,
|
|
63
|
+
type SelectionSnapshot,
|
|
51
64
|
type SlidewiseHostCallbacks,
|
|
52
65
|
type SlidewiseIcons,
|
|
53
66
|
type RegionProps,
|
|
67
|
+
type TopBarProps,
|
|
68
|
+
type TopBarSlotId,
|
|
54
69
|
} from "./compound";
|
|
55
70
|
|
|
56
71
|
export { parsePptx, serializeDeck } from "./lib/pptx";
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createEditorStore } from "../store";
|
|
3
|
+
import { CURRENT_DECK_VERSION } from "../schema/migrate";
|
|
4
|
+
import type { Deck } from "../types";
|
|
5
|
+
|
|
6
|
+
function makeDeck(slideCount = 3): Deck {
|
|
7
|
+
return {
|
|
8
|
+
version: CURRENT_DECK_VERSION,
|
|
9
|
+
title: "fixture",
|
|
10
|
+
slides: Array.from({ length: slideCount }, (_, i) => ({
|
|
11
|
+
id: `s${i + 1}`,
|
|
12
|
+
background: "#FFFFFF",
|
|
13
|
+
elements: [],
|
|
14
|
+
})),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("store: zoom actions", () => {
|
|
19
|
+
it("zoomIn multiplies the current zoom by 1.25 (clamped to 4)", () => {
|
|
20
|
+
const store = createEditorStore(makeDeck());
|
|
21
|
+
store.getState().setZoom(1);
|
|
22
|
+
store.getState().zoomIn();
|
|
23
|
+
expect(store.getState().zoom).toBeCloseTo(1.25);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("zoomOut multiplies by 0.8 (clamped to 0.1)", () => {
|
|
27
|
+
const store = createEditorStore(makeDeck());
|
|
28
|
+
store.getState().setZoom(1);
|
|
29
|
+
store.getState().zoomOut();
|
|
30
|
+
expect(store.getState().zoom).toBeCloseTo(0.8);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("setZoom clamps the zoom to the valid range", () => {
|
|
34
|
+
const store = createEditorStore(makeDeck());
|
|
35
|
+
store.getState().setZoom(999);
|
|
36
|
+
expect(store.getState().zoom).toBe(4);
|
|
37
|
+
store.getState().setZoom(0);
|
|
38
|
+
expect(store.getState().zoom).toBe(0.1);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("store: slide CRUD return ids", () => {
|
|
43
|
+
it("addSlide returns the new slide's id and inserts after the target", () => {
|
|
44
|
+
const store = createEditorStore(makeDeck(2));
|
|
45
|
+
const newId = store.getState().addSlide("s1");
|
|
46
|
+
expect(typeof newId).toBe("string");
|
|
47
|
+
const slides = store.getState().deck.slides;
|
|
48
|
+
expect(slides).toHaveLength(3);
|
|
49
|
+
expect(slides[1].id).toBe(newId);
|
|
50
|
+
expect(store.getState().currentSlideId).toBe(newId);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("addSlide() with no afterId appends at the end", () => {
|
|
54
|
+
const store = createEditorStore(makeDeck(2));
|
|
55
|
+
const newId = store.getState().addSlide();
|
|
56
|
+
expect(store.getState().deck.slides[2].id).toBe(newId);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("duplicateSlide returns the new slide id and inserts after the original", () => {
|
|
60
|
+
const store = createEditorStore(makeDeck(2));
|
|
61
|
+
const copyId = store.getState().duplicateSlide("s1");
|
|
62
|
+
expect(typeof copyId).toBe("string");
|
|
63
|
+
expect(copyId).not.toBe("s1");
|
|
64
|
+
const slides = store.getState().deck.slides;
|
|
65
|
+
expect(slides).toHaveLength(3);
|
|
66
|
+
expect(slides[1].id).toBe(copyId);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("duplicateSlide returns null when slide id is not found", () => {
|
|
70
|
+
const store = createEditorStore(makeDeck(2));
|
|
71
|
+
expect(store.getState().duplicateSlide("does-not-exist")).toBeNull();
|
|
72
|
+
expect(store.getState().deck.slides).toHaveLength(2);
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/lib/store.ts
CHANGED
|
@@ -68,12 +68,18 @@ export interface EditorState {
|
|
|
68
68
|
setTool: (t: Tool) => void;
|
|
69
69
|
setTitle: (t: string) => void;
|
|
70
70
|
setZoom: (z: number) => void;
|
|
71
|
+
/** Multiplicative zoom-in step (×1.25), clamped to the same range as setZoom. */
|
|
72
|
+
zoomIn: () => void;
|
|
73
|
+
/** Multiplicative zoom-out step (×0.8), clamped to the same range as setZoom. */
|
|
74
|
+
zoomOut: () => void;
|
|
71
75
|
setFitMode: (f: "fit" | "fill" | "manual") => void;
|
|
72
76
|
selectSlide: (id: string) => void;
|
|
73
77
|
selectElement: (id: string | null, additive?: boolean) => void;
|
|
74
78
|
clearSelection: () => void;
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
/** Returns the id of the newly inserted slide. */
|
|
80
|
+
addSlide: (afterId?: string) => string;
|
|
81
|
+
/** Returns the id of the newly inserted duplicate, or null if `id` wasn't found. */
|
|
82
|
+
duplicateSlide: (id: string) => string | null;
|
|
77
83
|
deleteSlide: (id: string) => void;
|
|
78
84
|
reorderSlide: (id: string, toIndex: number) => void;
|
|
79
85
|
addElement: (partial: ElementDraft) => string;
|
|
@@ -194,6 +200,8 @@ export function createEditorStore(initialDeck: Deck): EditorStore {
|
|
|
194
200
|
},
|
|
195
201
|
setZoom: (z) =>
|
|
196
202
|
set({ zoom: Math.max(0.1, Math.min(4, z)), fitMode: "manual" }),
|
|
203
|
+
zoomIn: () => get().setZoom(get().zoom * 1.25),
|
|
204
|
+
zoomOut: () => get().setZoom(get().zoom * 0.8),
|
|
197
205
|
setFitMode: (f) => set({ fitMode: f }),
|
|
198
206
|
|
|
199
207
|
selectSlide: (id) =>
|
|
@@ -215,8 +223,8 @@ export function createEditorStore(initialDeck: Deck): EditorStore {
|
|
|
215
223
|
|
|
216
224
|
addSlide: (afterId) => {
|
|
217
225
|
get().pushHistory();
|
|
226
|
+
const slide = blankSlide();
|
|
218
227
|
set((s) => {
|
|
219
|
-
const slide = blankSlide();
|
|
220
228
|
const slides = [...s.deck.slides];
|
|
221
229
|
const idx = afterId
|
|
222
230
|
? slides.findIndex((sl) => sl.id === afterId)
|
|
@@ -228,26 +236,28 @@ export function createEditorStore(initialDeck: Deck): EditorStore {
|
|
|
228
236
|
selectedIds: [],
|
|
229
237
|
};
|
|
230
238
|
});
|
|
239
|
+
return slide.id;
|
|
231
240
|
},
|
|
232
241
|
|
|
233
242
|
duplicateSlide: (id) => {
|
|
243
|
+
const orig = get().deck.slides.find((sl) => sl.id === id);
|
|
244
|
+
if (!orig) return null;
|
|
234
245
|
get().pushHistory();
|
|
246
|
+
const copy: Slide = {
|
|
247
|
+
...structuredClone(orig),
|
|
248
|
+
id: nanoid(8),
|
|
249
|
+
elements: orig.elements.map((e) => ({ ...e, id: nanoid(8) })),
|
|
250
|
+
};
|
|
235
251
|
set((s) => {
|
|
236
252
|
const slides = [...s.deck.slides];
|
|
237
253
|
const idx = slides.findIndex((sl) => sl.id === id);
|
|
238
|
-
if (idx < 0) return s;
|
|
239
|
-
const orig = slides[idx];
|
|
240
|
-
const copy: Slide = {
|
|
241
|
-
...structuredClone(orig),
|
|
242
|
-
id: nanoid(8),
|
|
243
|
-
elements: orig.elements.map((e) => ({ ...e, id: nanoid(8) })),
|
|
244
|
-
};
|
|
245
254
|
slides.splice(idx + 1, 0, copy);
|
|
246
255
|
return {
|
|
247
256
|
deck: { ...s.deck, slides },
|
|
248
257
|
currentSlideId: copy.id,
|
|
249
258
|
};
|
|
250
259
|
});
|
|
260
|
+
return copy.id;
|
|
251
261
|
},
|
|
252
262
|
|
|
253
263
|
deleteSlide: (id) => {
|
|
@@ -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
|
-
}
|