@textcortex/slidewise 1.1.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 +5082 -4810
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/SlidewiseEditor.tsx +14 -2
- package/src/SlidewiseFileEditor.tsx +32 -0
- package/src/compound/DirtyContext.tsx +30 -0
- package/src/compound/SlidewiseRoot.tsx +68 -10
- 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__/history.test.ts +164 -0
- package/src/lib/store.ts +81 -4
- package/src/components/editor/TopBar.tsx +0 -253
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import { createEditorStore } from "../store";
|
|
3
|
+
import { CURRENT_DECK_VERSION } from "../schema/migrate";
|
|
4
|
+
import type { Deck } from "../types";
|
|
5
|
+
|
|
6
|
+
const baseElement = { rotation: 0, z: 1 } as const;
|
|
7
|
+
|
|
8
|
+
function fixtureDeck(): Deck {
|
|
9
|
+
return {
|
|
10
|
+
version: CURRENT_DECK_VERSION,
|
|
11
|
+
title: "Hist test",
|
|
12
|
+
slides: [
|
|
13
|
+
{
|
|
14
|
+
id: "s1",
|
|
15
|
+
background: "#FFFFFF",
|
|
16
|
+
elements: [
|
|
17
|
+
{
|
|
18
|
+
...baseElement,
|
|
19
|
+
id: "t1",
|
|
20
|
+
type: "text",
|
|
21
|
+
x: 100,
|
|
22
|
+
y: 100,
|
|
23
|
+
w: 400,
|
|
24
|
+
h: 80,
|
|
25
|
+
text: "Hello",
|
|
26
|
+
fontFamily: "Inter",
|
|
27
|
+
fontSize: 24,
|
|
28
|
+
fontWeight: 400,
|
|
29
|
+
italic: false,
|
|
30
|
+
underline: false,
|
|
31
|
+
strike: false,
|
|
32
|
+
color: "#000000",
|
|
33
|
+
align: "left",
|
|
34
|
+
vAlign: "top",
|
|
35
|
+
lineHeight: 1.2,
|
|
36
|
+
letterSpacing: 0,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("editor store history", () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.useFakeTimers({ now: 1_000_000 });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("undo reverts an updateElement", () => {
|
|
50
|
+
const store = createEditorStore(fixtureDeck());
|
|
51
|
+
expect(store.getState().canUndo()).toBe(false);
|
|
52
|
+
|
|
53
|
+
store.getState().updateElement("t1", { x: 200 });
|
|
54
|
+
expect(store.getState().canUndo()).toBe(true);
|
|
55
|
+
const slide = () => store.getState().deck.slides[0];
|
|
56
|
+
expect((slide().elements[0] as { x: number }).x).toBe(200);
|
|
57
|
+
|
|
58
|
+
store.getState().undo();
|
|
59
|
+
expect((slide().elements[0] as { x: number }).x).toBe(100);
|
|
60
|
+
expect(store.getState().canUndo()).toBe(false);
|
|
61
|
+
expect(store.getState().canRedo()).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("undo reverts a setTitle", () => {
|
|
65
|
+
const store = createEditorStore(fixtureDeck());
|
|
66
|
+
store.getState().setTitle("Renamed");
|
|
67
|
+
expect(store.getState().deck.title).toBe("Renamed");
|
|
68
|
+
store.getState().undo();
|
|
69
|
+
expect(store.getState().deck.title).toBe("Hist test");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("coalesces a typing burst into one undo step", () => {
|
|
73
|
+
const store = createEditorStore(fixtureDeck());
|
|
74
|
+
// simulate keystrokes within the idle window
|
|
75
|
+
store.getState().updateElement("t1", { x: 110 });
|
|
76
|
+
vi.advanceTimersByTime(50);
|
|
77
|
+
store.getState().updateElement("t1", { x: 120 });
|
|
78
|
+
vi.advanceTimersByTime(50);
|
|
79
|
+
store.getState().updateElement("t1", { x: 130 });
|
|
80
|
+
|
|
81
|
+
expect(store.getState().history.length).toBe(1);
|
|
82
|
+
|
|
83
|
+
store.getState().undo();
|
|
84
|
+
const elX = (store.getState().deck.slides[0].elements[0] as { x: number }).x;
|
|
85
|
+
expect(elX).toBe(100);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("starts a new history step after the idle window expires", () => {
|
|
89
|
+
const store = createEditorStore(fixtureDeck());
|
|
90
|
+
store.getState().updateElement("t1", { x: 110 });
|
|
91
|
+
expect(store.getState().history.length).toBe(1);
|
|
92
|
+
|
|
93
|
+
// advance past the 500ms idle window
|
|
94
|
+
vi.advanceTimersByTime(600);
|
|
95
|
+
|
|
96
|
+
store.getState().updateElement("t1", { x: 120 });
|
|
97
|
+
expect(store.getState().history.length).toBe(2);
|
|
98
|
+
|
|
99
|
+
store.getState().undo();
|
|
100
|
+
expect((store.getState().deck.slides[0].elements[0] as { x: number }).x).toBe(110);
|
|
101
|
+
|
|
102
|
+
store.getState().undo();
|
|
103
|
+
expect((store.getState().deck.slides[0].elements[0] as { x: number }).x).toBe(100);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("endCoalesce starts a fresh step on the next mutation", () => {
|
|
107
|
+
const store = createEditorStore(fixtureDeck());
|
|
108
|
+
store.getState().updateElement("t1", { x: 110 });
|
|
109
|
+
store.getState().updateElement("t1", { x: 120 });
|
|
110
|
+
expect(store.getState().history.length).toBe(1);
|
|
111
|
+
|
|
112
|
+
store.getState().endCoalesce();
|
|
113
|
+
store.getState().updateElement("t1", { x: 130 });
|
|
114
|
+
expect(store.getState().history.length).toBe(2);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("different patch keys on the same element start a fresh step", () => {
|
|
118
|
+
const store = createEditorStore(fixtureDeck());
|
|
119
|
+
store.getState().updateElement("t1", { x: 110 });
|
|
120
|
+
store.getState().updateElement("t1", { y: 110 });
|
|
121
|
+
// Different keys → different coalesce key → second step pushed.
|
|
122
|
+
expect(store.getState().history.length).toBe(2);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("setDeck clears history", () => {
|
|
126
|
+
const store = createEditorStore(fixtureDeck());
|
|
127
|
+
store.getState().updateElement("t1", { x: 200 });
|
|
128
|
+
expect(store.getState().history.length).toBe(1);
|
|
129
|
+
|
|
130
|
+
store.getState().setDeck(fixtureDeck());
|
|
131
|
+
expect(store.getState().history.length).toBe(0);
|
|
132
|
+
expect(store.getState().future.length).toBe(0);
|
|
133
|
+
expect(store.getState().canUndo()).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("undo/redo update canUndo/canRedo correctly", () => {
|
|
137
|
+
const store = createEditorStore(fixtureDeck());
|
|
138
|
+
store.getState().updateElement("t1", { x: 200 });
|
|
139
|
+
expect(store.getState().canUndo()).toBe(true);
|
|
140
|
+
expect(store.getState().canRedo()).toBe(false);
|
|
141
|
+
|
|
142
|
+
store.getState().undo();
|
|
143
|
+
expect(store.getState().canUndo()).toBe(false);
|
|
144
|
+
expect(store.getState().canRedo()).toBe(true);
|
|
145
|
+
|
|
146
|
+
store.getState().redo();
|
|
147
|
+
expect(store.getState().canUndo()).toBe(true);
|
|
148
|
+
expect(store.getState().canRedo()).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("undo replaces the deck reference (so subscribers see a change)", () => {
|
|
152
|
+
const store = createEditorStore(fixtureDeck());
|
|
153
|
+
const before = store.getState().deck;
|
|
154
|
+
store.getState().updateElement("t1", { x: 200 });
|
|
155
|
+
const afterEdit = store.getState().deck;
|
|
156
|
+
expect(afterEdit).not.toBe(before);
|
|
157
|
+
|
|
158
|
+
store.getState().undo();
|
|
159
|
+
const afterUndo = store.getState().deck;
|
|
160
|
+
expect(afterUndo).not.toBe(afterEdit);
|
|
161
|
+
// value matches the pre-edit deck even if the reference is a clone
|
|
162
|
+
expect((afterUndo.slides[0].elements[0] as { x: number }).x).toBe(100);
|
|
163
|
+
});
|
|
164
|
+
});
|
package/src/lib/store.ts
CHANGED
|
@@ -29,6 +29,17 @@ interface HistorySnapshot {
|
|
|
29
29
|
type Theme = "light" | "dark";
|
|
30
30
|
type View = "editor" | "grid";
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Idle window in ms during which consecutive mutations with the same coalesce
|
|
34
|
+
* key collapse into a single history step. After this many ms with no edit,
|
|
35
|
+
* the next edit starts a fresh step. Drags on a single element typically run
|
|
36
|
+
* 60+ frames in well under this window; typing pauses around word boundaries
|
|
37
|
+
* usually exceed it. Hosts that want stricter granularity can call
|
|
38
|
+
* `endCoalesce()` explicitly (e.g. on mouseup or input blur).
|
|
39
|
+
*/
|
|
40
|
+
const COALESCE_IDLE_MS = 500;
|
|
41
|
+
const HISTORY_LIMIT = 50;
|
|
42
|
+
|
|
32
43
|
export interface EditorState {
|
|
33
44
|
deck: Deck;
|
|
34
45
|
currentSlideId: string;
|
|
@@ -41,6 +52,14 @@ export interface EditorState {
|
|
|
41
52
|
view: View;
|
|
42
53
|
history: HistorySnapshot[];
|
|
43
54
|
future: HistorySnapshot[];
|
|
55
|
+
/**
|
|
56
|
+
* Coalesce key for the in-flight mutation burst. Two consecutive mutations
|
|
57
|
+
* with the same key (within `COALESCE_IDLE_MS`) collapse into one history
|
|
58
|
+
* step. `null` means "no burst in progress" — the next edit pushes a fresh
|
|
59
|
+
* snapshot.
|
|
60
|
+
*/
|
|
61
|
+
_coalesceKey: string | null;
|
|
62
|
+
_coalesceUntil: number;
|
|
44
63
|
|
|
45
64
|
// selectors
|
|
46
65
|
currentSlide: () => Slide;
|
|
@@ -68,6 +87,23 @@ export interface EditorState {
|
|
|
68
87
|
undo: () => void;
|
|
69
88
|
redo: () => void;
|
|
70
89
|
pushHistory: () => void;
|
|
90
|
+
/**
|
|
91
|
+
* Push a history snapshot, but only if `key` differs from the in-flight
|
|
92
|
+
* coalesce key OR more than `COALESCE_IDLE_MS` have passed since the last
|
|
93
|
+
* mutation. Use this for high-frequency edits (text typing, drag) so the
|
|
94
|
+
* burst collapses into one undo step.
|
|
95
|
+
*/
|
|
96
|
+
pushHistoryCoalesced: (key: string) => void;
|
|
97
|
+
/**
|
|
98
|
+
* End the current coalesce burst. Hosts call this on natural commit
|
|
99
|
+
* boundaries (mouseup after a drag, blur on a text input) so the next
|
|
100
|
+
* mutation starts a fresh history step even within `COALESCE_IDLE_MS`.
|
|
101
|
+
*/
|
|
102
|
+
endCoalesce: () => void;
|
|
103
|
+
/** True iff there's at least one snapshot to undo back to. */
|
|
104
|
+
canUndo: () => boolean;
|
|
105
|
+
/** True iff there's at least one snapshot to redo forward to. */
|
|
106
|
+
canRedo: () => boolean;
|
|
71
107
|
setDeck: (deck: Deck) => void;
|
|
72
108
|
setTheme: (t: Theme) => void;
|
|
73
109
|
toggleTheme: () => void;
|
|
@@ -107,6 +143,8 @@ export function createEditorStore(initialDeck: Deck): EditorStore {
|
|
|
107
143
|
view: "editor",
|
|
108
144
|
history: [],
|
|
109
145
|
future: [],
|
|
146
|
+
_coalesceKey: null,
|
|
147
|
+
_coalesceUntil: 0,
|
|
110
148
|
|
|
111
149
|
currentSlide: () => {
|
|
112
150
|
const s = get();
|
|
@@ -118,20 +156,48 @@ export function createEditorStore(initialDeck: Deck): EditorStore {
|
|
|
118
156
|
|
|
119
157
|
pushHistory: () => {
|
|
120
158
|
set((s) => ({
|
|
121
|
-
history: [...s.history, snap(s)].slice(-
|
|
159
|
+
history: [...s.history, snap(s)].slice(-HISTORY_LIMIT),
|
|
122
160
|
future: [],
|
|
161
|
+
_coalesceKey: null,
|
|
162
|
+
_coalesceUntil: 0,
|
|
123
163
|
}));
|
|
124
164
|
},
|
|
125
165
|
|
|
166
|
+
pushHistoryCoalesced: (key) => {
|
|
167
|
+
const now = Date.now();
|
|
168
|
+
set((s) => {
|
|
169
|
+
// Same burst within the idle window → don't push, just extend.
|
|
170
|
+
if (s._coalesceKey === key && now < s._coalesceUntil) {
|
|
171
|
+
return { _coalesceUntil: now + COALESCE_IDLE_MS };
|
|
172
|
+
}
|
|
173
|
+
// New burst — snapshot the pre-mutation state and start coalescing.
|
|
174
|
+
return {
|
|
175
|
+
history: [...s.history, snap(s)].slice(-HISTORY_LIMIT),
|
|
176
|
+
future: [],
|
|
177
|
+
_coalesceKey: key,
|
|
178
|
+
_coalesceUntil: now + COALESCE_IDLE_MS,
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
endCoalesce: () => {
|
|
184
|
+
set({ _coalesceKey: null, _coalesceUntil: 0 });
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
canUndo: () => get().history.length > 0,
|
|
188
|
+
canRedo: () => get().future.length > 0,
|
|
189
|
+
|
|
126
190
|
setTool: (t) => set({ tool: t }),
|
|
127
191
|
setTitle: (t) => {
|
|
192
|
+
get().pushHistoryCoalesced("setTitle");
|
|
128
193
|
set((s) => ({ deck: { ...s.deck, title: t } }));
|
|
129
194
|
},
|
|
130
195
|
setZoom: (z) =>
|
|
131
196
|
set({ zoom: Math.max(0.1, Math.min(4, z)), fitMode: "manual" }),
|
|
132
197
|
setFitMode: (f) => set({ fitMode: f }),
|
|
133
198
|
|
|
134
|
-
selectSlide: (id) =>
|
|
199
|
+
selectSlide: (id) =>
|
|
200
|
+
set({ currentSlideId: id, selectedIds: [], _coalesceKey: null }),
|
|
135
201
|
selectElement: (id, additive) =>
|
|
136
202
|
set((s) => {
|
|
137
203
|
if (id == null) return { selectedIds: [] };
|
|
@@ -228,6 +294,11 @@ export function createEditorStore(initialDeck: Deck): EditorStore {
|
|
|
228
294
|
},
|
|
229
295
|
|
|
230
296
|
updateElement: (id, patch) => {
|
|
297
|
+
// Coalesce key: same element, same patch shape = same burst.
|
|
298
|
+
// Drag (x,y) coalesces; resize (w,h) starts a new burst even on the
|
|
299
|
+
// same element; switching elements also starts fresh.
|
|
300
|
+
const key = `updateElement:${id}:${Object.keys(patch).sort().join(",")}`;
|
|
301
|
+
get().pushHistoryCoalesced(key);
|
|
231
302
|
set((s) => {
|
|
232
303
|
const slides = s.deck.slides.map((sl) => {
|
|
233
304
|
if (sl.id !== s.currentSlideId) return sl;
|
|
@@ -320,8 +391,10 @@ export function createEditorStore(initialDeck: Deck): EditorStore {
|
|
|
320
391
|
deck: last.deck,
|
|
321
392
|
currentSlideId: last.currentSlideId,
|
|
322
393
|
history: s.history.slice(0, -1),
|
|
323
|
-
future: [...s.future, snapshot].slice(-
|
|
394
|
+
future: [...s.future, snapshot].slice(-HISTORY_LIMIT),
|
|
324
395
|
selectedIds: survivingIds,
|
|
396
|
+
_coalesceKey: null,
|
|
397
|
+
_coalesceUntil: 0,
|
|
325
398
|
};
|
|
326
399
|
});
|
|
327
400
|
},
|
|
@@ -342,9 +415,11 @@ export function createEditorStore(initialDeck: Deck): EditorStore {
|
|
|
342
415
|
return {
|
|
343
416
|
deck: next.deck,
|
|
344
417
|
currentSlideId: next.currentSlideId,
|
|
345
|
-
history: [...s.history, snapshot].slice(-
|
|
418
|
+
history: [...s.history, snapshot].slice(-HISTORY_LIMIT),
|
|
346
419
|
future: s.future.slice(0, -1),
|
|
347
420
|
selectedIds: survivingIds,
|
|
421
|
+
_coalesceKey: null,
|
|
422
|
+
_coalesceUntil: 0,
|
|
348
423
|
};
|
|
349
424
|
});
|
|
350
425
|
},
|
|
@@ -357,6 +432,8 @@ export function createEditorStore(initialDeck: Deck): EditorStore {
|
|
|
357
432
|
selectedIds: [],
|
|
358
433
|
history: [],
|
|
359
434
|
future: [],
|
|
435
|
+
_coalesceKey: null,
|
|
436
|
+
_coalesceUntil: 0,
|
|
360
437
|
});
|
|
361
438
|
},
|
|
362
439
|
|
|
@@ -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
|
-
}
|