@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.
@@ -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
- addSlide: (afterId?: string) => void;
76
- duplicateSlide: (id: string) => void;
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
- }