@open-slide/core 0.0.11 → 0.0.12
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/{build-DHiRlpjn.js → build-aiY_8kwE.js} +2 -1
- package/dist/cli/bin.js +43 -4
- package/dist/{config-LZM903FE.js → config-CVqRAagl.js} +592 -63
- package/dist/design-CROQh0AA.js +35 -0
- package/dist/{dev-B3JzCYn7.js → dev-R2we2iaF.js} +2 -1
- package/dist/index.d.ts +55 -4
- package/dist/index.js +110 -1
- package/dist/{preview-UikovHEt.js → preview-CU4zSyGp.js} +2 -1
- package/dist/sync-3oqN1WyK.js +139 -0
- package/dist/sync-B4eLo2H6.js +3 -0
- package/dist/vite/index.d.ts +1 -1
- package/dist/vite/index.js +2 -1
- package/package.json +2 -1
- package/skills/apply-comments/SKILL.md +83 -0
- package/skills/create-slide/SKILL.md +81 -0
- package/skills/create-theme/SKILL.md +194 -0
- package/skills/slide-authoring/SKILL.md +288 -0
- package/src/app/{App.tsx → app.tsx} +8 -6
- package/src/app/components/{AssetView.tsx → asset-view.tsx} +41 -33
- package/src/app/components/{ClickNavZones.tsx → click-nav-zones.tsx} +1 -1
- package/src/app/components/history-provider.tsx +120 -0
- package/src/app/components/image-placeholder.tsx +121 -0
- package/src/app/components/inspector/{CommentWidget.tsx → comment-widget.tsx} +1 -1
- package/src/app/components/inspector/{InspectOverlay.tsx → inspect-overlay.tsx} +1 -1
- package/src/app/components/inspector/{InspectorPanel.tsx → inspector-panel.tsx} +164 -212
- package/src/app/components/inspector/{InspectorProvider.tsx → inspector-provider.tsx} +186 -18
- package/src/app/components/inspector/save-bar.tsx +47 -0
- package/src/app/components/panel/panel-fields.tsx +60 -0
- package/src/app/components/panel/panel-shell.tsx +78 -0
- package/src/app/components/panel/save-card.tsx +139 -0
- package/src/app/components/pdf-progress-toast.tsx +25 -0
- package/src/app/components/player.tsx +341 -0
- package/src/app/components/present/blackout-overlay.tsx +18 -0
- package/src/app/components/present/control-bar.tsx +204 -0
- package/src/app/components/present/help-overlay.tsx +56 -0
- package/src/app/components/present/jump-input.tsx +74 -0
- package/src/app/components/present/laser-pointer.tsx +40 -0
- package/src/app/components/present/overview-grid.tsx +184 -0
- package/src/app/components/present/progress-bar.tsx +26 -0
- package/src/app/components/present/use-idle.ts +44 -0
- package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
- package/src/app/components/present/use-presenter-channel.ts +71 -0
- package/src/app/components/present/use-touch-swipe.ts +63 -0
- package/src/app/components/sidebar/{FolderItem.tsx → folder-item.tsx} +62 -27
- package/src/app/components/sidebar/{IconPicker.tsx → icon-picker.tsx} +13 -10
- package/src/app/components/sidebar/{Sidebar.tsx → sidebar.tsx} +40 -34
- package/src/app/components/{SlideCanvas.tsx → slide-canvas.tsx} +35 -10
- package/src/app/components/style-panel/design-provider.tsx +139 -0
- package/src/app/components/style-panel/style-panel.tsx +326 -0
- package/src/app/components/style-panel/use-design.ts +112 -0
- package/src/app/components/theme-toggle.tsx +57 -0
- package/src/app/components/thumbnail-rail.tsx +151 -0
- package/src/app/components/ui/button.tsx +51 -19
- package/src/app/components/ui/card.tsx +1 -1
- package/src/app/components/ui/dialog.tsx +25 -9
- package/src/app/components/ui/dropdown-menu.tsx +29 -12
- package/src/app/components/ui/input.tsx +13 -9
- package/src/app/components/ui/popover.tsx +5 -2
- package/src/app/components/ui/progress.tsx +2 -2
- package/src/app/components/ui/select.tsx +11 -5
- package/src/app/components/ui/separator.tsx +1 -1
- package/src/app/components/ui/slider.tsx +4 -4
- package/src/app/components/ui/sonner.tsx +11 -1
- package/src/app/components/ui/tabs.tsx +6 -6
- package/src/app/components/ui/textarea.tsx +11 -7
- package/src/app/components/ui/toggle-group.tsx +2 -2
- package/src/app/components/ui/toggle.tsx +6 -6
- package/src/app/components/ui/tooltip.tsx +5 -2
- package/src/app/lib/export-html.ts +10 -1
- package/src/app/lib/export-pdf.ts +7 -0
- package/src/app/lib/folders.ts +1 -1
- package/src/app/lib/inspector/{useEditor.ts → use-editor.ts} +2 -1
- package/src/app/lib/sdk.ts +5 -0
- package/src/app/lib/slides.ts +1 -1
- package/src/app/lib/utils.ts +1 -1
- package/src/app/main.tsx +5 -2
- package/src/app/routes/{Home.tsx → home.tsx} +266 -97
- package/src/app/routes/presenter.tsx +400 -0
- package/src/app/routes/slide.tsx +519 -0
- package/src/app/styles.css +338 -67
- package/src/app/components/PdfProgressToast.tsx +0 -23
- package/src/app/components/Player.tsx +0 -100
- package/src/app/components/ThumbnailRail.tsx +0 -68
- package/src/app/components/inspector/SaveBar.tsx +0 -77
- package/src/app/routes/Slide.tsx +0 -478
- /package/dist/{config-SXL5qIl6.d.ts → config-DweCbRkQ.d.ts} +0 -0
- /package/src/app/lib/inspector/{useComments.ts → use-comments.ts} +0 -0
- /package/src/app/lib/{useWheelPageNavigation.ts → use-wheel-page-navigation.ts} +0 -0
|
@@ -9,9 +9,10 @@ import {
|
|
|
9
9
|
useRef,
|
|
10
10
|
useState,
|
|
11
11
|
} from 'react';
|
|
12
|
+
import { useHistory } from '@/components/history-provider';
|
|
12
13
|
import { Button } from '@/components/ui/button';
|
|
13
|
-
import { type SlideComment, useComments } from '@/lib/inspector/
|
|
14
|
-
import { type Edit, type EditOp, useEditor } from '@/lib/inspector/
|
|
14
|
+
import { type SlideComment, useComments } from '@/lib/inspector/use-comments';
|
|
15
|
+
import { type Edit, type EditOp, useEditor } from '@/lib/inspector/use-editor';
|
|
15
16
|
|
|
16
17
|
export type SelectedTarget = {
|
|
17
18
|
line: number;
|
|
@@ -71,6 +72,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
71
72
|
const [selected, setSelected] = useState<SelectedTarget | null>(null);
|
|
72
73
|
const { comments, error, refetch, add, remove } = useComments(slideId);
|
|
73
74
|
const { applyEdit, applyEdits } = useEditor(slideId);
|
|
75
|
+
const history = useHistory();
|
|
74
76
|
|
|
75
77
|
const pendingRef = useRef<Map<string, Bucket>>(new Map());
|
|
76
78
|
const [pendingCount, setPendingCount] = useState(0);
|
|
@@ -84,8 +86,17 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
84
86
|
setPendingCount(n);
|
|
85
87
|
}, []);
|
|
86
88
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
// Find the live anchor for a buffered loc. Used by history undo/redo
|
|
90
|
+
// since the original `anchor` reference may have unmounted.
|
|
91
|
+
const findAnchor = useCallback((line: number, column: number) => {
|
|
92
|
+
const root = document.querySelector<HTMLElement>('[data-inspector-root]');
|
|
93
|
+
return root?.querySelector<HTMLElement>(`[data-slide-loc="${line}:${column}"]`) ?? null;
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
// Mutate bucket + DOM without recording history. Shared by `bufferOps`
|
|
97
|
+
// (the public, history-recording entry point) and by `redo` closures.
|
|
98
|
+
const applyOpsRaw = useCallback(
|
|
99
|
+
(line: number, column: number, anchor: HTMLElement | null, ops: EditOp[]) => {
|
|
89
100
|
const key = `${line}:${column}`;
|
|
90
101
|
let bucket = pendingRef.current.get(key);
|
|
91
102
|
if (!bucket) {
|
|
@@ -101,29 +112,29 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
101
112
|
};
|
|
102
113
|
pendingRef.current.set(key, bucket);
|
|
103
114
|
}
|
|
104
|
-
const style = anchor
|
|
115
|
+
const style = (anchor?.style ?? {}) as unknown as Record<string, string>;
|
|
105
116
|
for (const op of ops) {
|
|
106
117
|
if (op.kind === 'set-style') {
|
|
107
|
-
if (!bucket.origStyle.has(op.key)) {
|
|
118
|
+
if (anchor && !bucket.origStyle.has(op.key)) {
|
|
108
119
|
bucket.origStyle.set(op.key, style[op.key] ?? '');
|
|
109
120
|
}
|
|
110
121
|
bucket.styleOps.set(op.key, op.value);
|
|
111
|
-
if (anchor
|
|
122
|
+
if (anchor?.isConnected) style[op.key] = op.value ?? '';
|
|
112
123
|
} else if (op.kind === 'set-text') {
|
|
113
|
-
if (bucket.origText === null) {
|
|
124
|
+
if (anchor && bucket.origText === null) {
|
|
114
125
|
bucket.origText = { value: anchor.textContent ?? '' };
|
|
115
126
|
}
|
|
116
127
|
bucket.textOp = { value: op.value };
|
|
117
|
-
if (anchor
|
|
128
|
+
if (anchor?.isConnected) anchor.textContent = op.value;
|
|
118
129
|
} else if (op.kind === 'set-attr-asset') {
|
|
119
|
-
if (!bucket.origAttrs.has(op.attr)) {
|
|
130
|
+
if (anchor && !bucket.origAttrs.has(op.attr)) {
|
|
120
131
|
bucket.origAttrs.set(
|
|
121
132
|
op.attr,
|
|
122
133
|
anchor.hasAttribute(op.attr) ? anchor.getAttribute(op.attr) : null,
|
|
123
134
|
);
|
|
124
135
|
}
|
|
125
136
|
bucket.attrOps.set(op.attr, { assetPath: op.assetPath, previewUrl: op.previewUrl });
|
|
126
|
-
if (anchor
|
|
137
|
+
if (anchor?.isConnected) anchor.setAttribute(op.attr, op.previewUrl);
|
|
127
138
|
}
|
|
128
139
|
}
|
|
129
140
|
refreshCount();
|
|
@@ -131,6 +142,149 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
131
142
|
[refreshCount],
|
|
132
143
|
);
|
|
133
144
|
|
|
145
|
+
// Pre-edit snapshot for history: capture the *currently effective* value of
|
|
146
|
+
// each touched field so undo can restore exactly the prior state, including
|
|
147
|
+
// the case where the bucket already had a buffered edit before this op.
|
|
148
|
+
type StyleSnap = { kind: 'style'; key: string; value: string | null; existed: boolean };
|
|
149
|
+
type TextSnap = { kind: 'text'; value: string | null; existed: boolean };
|
|
150
|
+
type AttrSnap = {
|
|
151
|
+
kind: 'attr';
|
|
152
|
+
attr: string;
|
|
153
|
+
value: AssetAttrOp | string | null;
|
|
154
|
+
source: 'op' | 'orig' | 'dom-missing' | 'dom-present';
|
|
155
|
+
};
|
|
156
|
+
type Snap = StyleSnap | TextSnap | AttrSnap;
|
|
157
|
+
|
|
158
|
+
const snapshotForOps = useCallback(
|
|
159
|
+
(line: number, column: number, anchor: HTMLElement, ops: EditOp[]): Snap[] => {
|
|
160
|
+
const key = `${line}:${column}`;
|
|
161
|
+
const bucket = pendingRef.current.get(key);
|
|
162
|
+
const style = anchor.style as unknown as Record<string, string>;
|
|
163
|
+
const snaps: Snap[] = [];
|
|
164
|
+
for (const op of ops) {
|
|
165
|
+
if (op.kind === 'set-style') {
|
|
166
|
+
if (bucket?.styleOps.has(op.key)) {
|
|
167
|
+
snaps.push({
|
|
168
|
+
kind: 'style',
|
|
169
|
+
key: op.key,
|
|
170
|
+
value: bucket.styleOps.get(op.key) ?? null,
|
|
171
|
+
existed: true,
|
|
172
|
+
});
|
|
173
|
+
} else {
|
|
174
|
+
snaps.push({
|
|
175
|
+
kind: 'style',
|
|
176
|
+
key: op.key,
|
|
177
|
+
value: style[op.key] ?? '',
|
|
178
|
+
existed: false,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
} else if (op.kind === 'set-text') {
|
|
182
|
+
if (bucket?.textOp) {
|
|
183
|
+
snaps.push({ kind: 'text', value: bucket.textOp.value, existed: true });
|
|
184
|
+
} else {
|
|
185
|
+
snaps.push({ kind: 'text', value: anchor.textContent ?? '', existed: false });
|
|
186
|
+
}
|
|
187
|
+
} else if (op.kind === 'set-attr-asset') {
|
|
188
|
+
const prev = bucket?.attrOps.get(op.attr);
|
|
189
|
+
if (prev) {
|
|
190
|
+
snaps.push({ kind: 'attr', attr: op.attr, value: prev, source: 'op' });
|
|
191
|
+
} else if (bucket?.origAttrs.has(op.attr)) {
|
|
192
|
+
snaps.push({
|
|
193
|
+
kind: 'attr',
|
|
194
|
+
attr: op.attr,
|
|
195
|
+
value: bucket.origAttrs.get(op.attr) ?? null,
|
|
196
|
+
source: 'orig',
|
|
197
|
+
});
|
|
198
|
+
} else if (anchor.hasAttribute(op.attr)) {
|
|
199
|
+
snaps.push({
|
|
200
|
+
kind: 'attr',
|
|
201
|
+
attr: op.attr,
|
|
202
|
+
value: anchor.getAttribute(op.attr),
|
|
203
|
+
source: 'dom-present',
|
|
204
|
+
});
|
|
205
|
+
} else {
|
|
206
|
+
snaps.push({ kind: 'attr', attr: op.attr, value: null, source: 'dom-missing' });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return snaps;
|
|
211
|
+
},
|
|
212
|
+
[],
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
// Restore the snapshotted values to bucket + DOM. Mirrors the bucket-empty
|
|
216
|
+
// logic of `cancelEdits` so an undo back to the absolute baseline cleans up.
|
|
217
|
+
const restoreSnapshot = useCallback(
|
|
218
|
+
(line: number, column: number, snaps: Snap[]) => {
|
|
219
|
+
const key = `${line}:${column}`;
|
|
220
|
+
const bucket = pendingRef.current.get(key);
|
|
221
|
+
if (!bucket) return;
|
|
222
|
+
const anchor = findAnchor(line, column);
|
|
223
|
+
const style = (anchor?.style ?? {}) as unknown as Record<string, string>;
|
|
224
|
+
for (const snap of snaps) {
|
|
225
|
+
if (snap.kind === 'style') {
|
|
226
|
+
if (snap.existed) {
|
|
227
|
+
const v = snap.value ?? '';
|
|
228
|
+
bucket.styleOps.set(snap.key, snap.value);
|
|
229
|
+
if (anchor?.isConnected) style[snap.key] = v;
|
|
230
|
+
} else {
|
|
231
|
+
bucket.styleOps.delete(snap.key);
|
|
232
|
+
const orig = bucket.origStyle.get(snap.key);
|
|
233
|
+
if (anchor?.isConnected) style[snap.key] = orig ?? '';
|
|
234
|
+
}
|
|
235
|
+
} else if (snap.kind === 'text') {
|
|
236
|
+
if (snap.existed) {
|
|
237
|
+
bucket.textOp = { value: snap.value ?? '' };
|
|
238
|
+
if (anchor?.isConnected) anchor.textContent = snap.value ?? '';
|
|
239
|
+
} else {
|
|
240
|
+
bucket.textOp = null;
|
|
241
|
+
if (anchor?.isConnected) anchor.textContent = bucket.origText?.value ?? '';
|
|
242
|
+
}
|
|
243
|
+
} else if (snap.kind === 'attr') {
|
|
244
|
+
if (snap.source === 'op') {
|
|
245
|
+
const op = snap.value as AssetAttrOp;
|
|
246
|
+
bucket.attrOps.set(snap.attr, op);
|
|
247
|
+
if (anchor?.isConnected) anchor.setAttribute(snap.attr, op.previewUrl);
|
|
248
|
+
} else {
|
|
249
|
+
bucket.attrOps.delete(snap.attr);
|
|
250
|
+
const orig = bucket.origAttrs.get(snap.attr);
|
|
251
|
+
if (anchor?.isConnected) {
|
|
252
|
+
if (orig === null || orig === undefined) anchor.removeAttribute(snap.attr);
|
|
253
|
+
else anchor.setAttribute(snap.attr, orig);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (bucket.styleOps.size === 0 && bucket.textOp === null && bucket.attrOps.size === 0) {
|
|
259
|
+
pendingRef.current.delete(key);
|
|
260
|
+
}
|
|
261
|
+
refreshCount();
|
|
262
|
+
},
|
|
263
|
+
[findAnchor, refreshCount],
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const bufferOps = useCallback(
|
|
267
|
+
(line: number, column: number, anchor: HTMLElement, ops: EditOp[]) => {
|
|
268
|
+
const snaps = snapshotForOps(line, column, anchor, ops);
|
|
269
|
+
applyOpsRaw(line, column, anchor, ops);
|
|
270
|
+
const first = ops[0];
|
|
271
|
+
const opKey = first
|
|
272
|
+
? first.kind === 'set-style'
|
|
273
|
+
? first.key
|
|
274
|
+
: first.kind === 'set-attr-asset'
|
|
275
|
+
? first.attr
|
|
276
|
+
: 'text'
|
|
277
|
+
: 'noop';
|
|
278
|
+
const coalesceKey = `inspector:${line}:${column}:${first?.kind ?? 'noop'}:${opKey}`;
|
|
279
|
+
history.record({
|
|
280
|
+
coalesceKey,
|
|
281
|
+
undo: () => restoreSnapshot(line, column, snaps),
|
|
282
|
+
redo: () => applyOpsRaw(line, column, findAnchor(line, column), ops),
|
|
283
|
+
});
|
|
284
|
+
},
|
|
285
|
+
[applyOpsRaw, snapshotForOps, restoreSnapshot, findAnchor, history],
|
|
286
|
+
);
|
|
287
|
+
|
|
134
288
|
const commitEdits = useCallback(async () => {
|
|
135
289
|
const buckets = pendingRef.current;
|
|
136
290
|
if (buckets.size === 0) return;
|
|
@@ -151,17 +305,24 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
151
305
|
}
|
|
152
306
|
pendingRef.current = new Map();
|
|
153
307
|
setPendingCount(0);
|
|
154
|
-
if (edits.length === 0)
|
|
308
|
+
if (edits.length === 0) {
|
|
309
|
+
history.clear();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
155
312
|
setCommitting(true);
|
|
156
313
|
try {
|
|
157
314
|
await applyEdits(edits);
|
|
158
315
|
} finally {
|
|
159
316
|
setCommitting(false);
|
|
317
|
+
history.clear();
|
|
160
318
|
}
|
|
161
|
-
}, [applyEdits]);
|
|
319
|
+
}, [applyEdits, history]);
|
|
162
320
|
|
|
163
321
|
const cancelEdits = useCallback(() => {
|
|
164
|
-
if (pendingRef.current.size === 0)
|
|
322
|
+
if (pendingRef.current.size === 0) {
|
|
323
|
+
history.clear();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
165
326
|
const root = document.querySelector<HTMLElement>('[data-inspector-root]');
|
|
166
327
|
for (const b of pendingRef.current.values()) {
|
|
167
328
|
const el = root?.querySelector<HTMLElement>(`[data-slide-loc="${b.line}:${b.column}"]`);
|
|
@@ -176,7 +337,8 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
176
337
|
}
|
|
177
338
|
pendingRef.current = new Map();
|
|
178
339
|
setPendingCount(0);
|
|
179
|
-
|
|
340
|
+
history.clear();
|
|
341
|
+
}, [history]);
|
|
180
342
|
|
|
181
343
|
// Auto-flush on inspector close and on route unmount so toggling
|
|
182
344
|
// off or navigating away doesn't drop buffered edits.
|
|
@@ -290,9 +452,15 @@ export function InspectToggleButton() {
|
|
|
290
452
|
const { active, toggle } = useInspector();
|
|
291
453
|
if (import.meta.env.PROD) return null;
|
|
292
454
|
return (
|
|
293
|
-
<Button
|
|
294
|
-
|
|
295
|
-
|
|
455
|
+
<Button
|
|
456
|
+
size="sm"
|
|
457
|
+
variant={active ? 'default' : 'ghost'}
|
|
458
|
+
onClick={toggle}
|
|
459
|
+
data-inspector-ui
|
|
460
|
+
title="Inspect"
|
|
461
|
+
>
|
|
462
|
+
<Crosshair className="size-3.5" />
|
|
463
|
+
<span className="hidden md:inline">Inspect</span>
|
|
296
464
|
</Button>
|
|
297
465
|
);
|
|
298
466
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useHistory } from '@/components/history-provider';
|
|
2
|
+
import { SaveCard } from '@/components/panel/save-card';
|
|
3
|
+
import { useDesignPanelState } from '@/components/style-panel/design-provider';
|
|
4
|
+
import { useInspector } from './inspector-provider';
|
|
5
|
+
|
|
6
|
+
// Single save card for both inspector edits and design-token edits.
|
|
7
|
+
// Counts the design draft as one unit; the user sees one combined
|
|
8
|
+
// "N unsaved changes" pill. Save/Discard fan out to both providers.
|
|
9
|
+
export function SaveBar() {
|
|
10
|
+
const insp = useInspector();
|
|
11
|
+
const design = useDesignPanelState();
|
|
12
|
+
const history = useHistory();
|
|
13
|
+
|
|
14
|
+
const inspectorCount = insp.pendingCount;
|
|
15
|
+
const designCount = design.dirty ? 1 : 0;
|
|
16
|
+
const total = inspectorCount + designCount;
|
|
17
|
+
|
|
18
|
+
const dirty = total > 0;
|
|
19
|
+
const committing = insp.committing || design.committing;
|
|
20
|
+
|
|
21
|
+
const onSave = async () => {
|
|
22
|
+
const tasks: Promise<void>[] = [];
|
|
23
|
+
if (inspectorCount > 0) tasks.push(Promise.resolve(insp.commitEdits()));
|
|
24
|
+
if (designCount > 0) tasks.push(Promise.resolve(design.commit()));
|
|
25
|
+
await Promise.all(tasks);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const onDiscard = () => {
|
|
29
|
+
if (inspectorCount > 0) insp.cancelEdits();
|
|
30
|
+
if (designCount > 0) design.discard();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<SaveCard
|
|
35
|
+
uiAttr="inspector"
|
|
36
|
+
dirty={dirty}
|
|
37
|
+
committing={committing}
|
|
38
|
+
onSave={onSave}
|
|
39
|
+
onDiscard={onDiscard}
|
|
40
|
+
unsavedLabel={`${total} unsaved ${total === 1 ? 'change' : 'changes'}`}
|
|
41
|
+
onUndo={history.undo}
|
|
42
|
+
onRedo={history.redo}
|
|
43
|
+
canUndo={history.canUndo}
|
|
44
|
+
canRedo={history.canRedo}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Label } from '@/components/ui/label';
|
|
2
|
+
|
|
3
|
+
export function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
4
|
+
return (
|
|
5
|
+
<section className="px-3.5 py-3.5">
|
|
6
|
+
<div className="mb-2.5 flex items-center gap-2">
|
|
7
|
+
<span className="eyebrow">{title}</span>
|
|
8
|
+
<span aria-hidden className="h-px flex-1 bg-hairline" />
|
|
9
|
+
</div>
|
|
10
|
+
<div className="flex flex-col gap-2.5">{children}</div>
|
|
11
|
+
</section>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="grid grid-cols-[68px_1fr] items-center gap-3">
|
|
18
|
+
<Label className="text-[11px] font-normal text-muted-foreground">{label}</Label>
|
|
19
|
+
<div className="flex min-w-0 items-center gap-1.5">{children}</div>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function NumberField({
|
|
25
|
+
value,
|
|
26
|
+
onChange,
|
|
27
|
+
min,
|
|
28
|
+
max,
|
|
29
|
+
step = 1,
|
|
30
|
+
suffix,
|
|
31
|
+
}: {
|
|
32
|
+
value: number;
|
|
33
|
+
onChange: (n: number) => void;
|
|
34
|
+
min?: number;
|
|
35
|
+
max?: number;
|
|
36
|
+
step?: number;
|
|
37
|
+
suffix?: string;
|
|
38
|
+
}) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex h-7 shrink-0 items-center rounded-[5px] border border-border bg-background pr-1.5 transition-colors focus-within:border-foreground/40 focus-within:ring-2 focus-within:ring-ring/30">
|
|
41
|
+
<input
|
|
42
|
+
type="number"
|
|
43
|
+
value={value}
|
|
44
|
+
onChange={(e) => {
|
|
45
|
+
const n = Number(e.target.value);
|
|
46
|
+
if (Number.isFinite(n)) onChange(n);
|
|
47
|
+
}}
|
|
48
|
+
min={min}
|
|
49
|
+
max={max}
|
|
50
|
+
step={step}
|
|
51
|
+
className="nums h-full w-12 bg-transparent px-2 text-right font-mono text-[11px] outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
52
|
+
/>
|
|
53
|
+
{suffix && (
|
|
54
|
+
<span className="font-mono text-[9.5px] uppercase tracking-[0.06em] text-muted-foreground/80">
|
|
55
|
+
{suffix}
|
|
56
|
+
</span>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
3
|
+
|
|
4
|
+
export const PANEL_W = 320;
|
|
5
|
+
export const PANEL_TRANSITION_MS = 240;
|
|
6
|
+
|
|
7
|
+
// Defer the width expansion to the next frame so the browser paints once
|
|
8
|
+
// at width=0 first; otherwise the transition has no starting frame.
|
|
9
|
+
export function useAnimatedOpen(open: boolean): boolean {
|
|
10
|
+
const [animVisible, setAnimVisible] = useState(false);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (open) {
|
|
13
|
+
const id = requestAnimationFrame(() => setAnimVisible(true));
|
|
14
|
+
return () => cancelAnimationFrame(id);
|
|
15
|
+
}
|
|
16
|
+
setAnimVisible(false);
|
|
17
|
+
}, [open]);
|
|
18
|
+
return animVisible;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Stay mounted through the close-out width transition so the panel
|
|
22
|
+
// visibly collapses instead of vanishing.
|
|
23
|
+
export function usePanelMount(open: boolean): { mounted: boolean; animVisible: boolean } {
|
|
24
|
+
const [mounted, setMounted] = useState(false);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (open) {
|
|
27
|
+
setMounted(true);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const t = setTimeout(() => setMounted(false), PANEL_TRANSITION_MS);
|
|
31
|
+
return () => clearTimeout(t);
|
|
32
|
+
}, [open]);
|
|
33
|
+
const animVisible = useAnimatedOpen(open && mounted);
|
|
34
|
+
return { mounted, animVisible };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type PanelShellProps = {
|
|
38
|
+
animVisible: boolean;
|
|
39
|
+
uiAttr: 'inspector' | 'design';
|
|
40
|
+
header: React.ReactNode;
|
|
41
|
+
banner?: React.ReactNode;
|
|
42
|
+
footer?: React.ReactNode;
|
|
43
|
+
children: React.ReactNode;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function PanelShell({
|
|
47
|
+
animVisible,
|
|
48
|
+
uiAttr,
|
|
49
|
+
header,
|
|
50
|
+
banner,
|
|
51
|
+
footer,
|
|
52
|
+
children,
|
|
53
|
+
}: PanelShellProps) {
|
|
54
|
+
const dataAttrs = uiAttr === 'inspector' ? { 'data-inspector-ui': '' } : { 'data-design-ui': '' };
|
|
55
|
+
return (
|
|
56
|
+
<aside
|
|
57
|
+
{...dataAttrs}
|
|
58
|
+
className="flex h-full shrink-0 justify-end overflow-hidden bg-sidebar transition-[width,border-left-width] ease-out"
|
|
59
|
+
style={{
|
|
60
|
+
width: animVisible ? PANEL_W : 0,
|
|
61
|
+
borderLeftWidth: animVisible ? 1 : 0,
|
|
62
|
+
borderLeftColor: 'var(--hairline)',
|
|
63
|
+
transitionDuration: `${PANEL_TRANSITION_MS}ms`,
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
<div style={{ width: PANEL_W }} className="flex h-full shrink-0 flex-col">
|
|
67
|
+
<header className="flex h-9 shrink-0 items-center justify-between gap-2 border-b border-hairline px-3">
|
|
68
|
+
{header}
|
|
69
|
+
</header>
|
|
70
|
+
{banner}
|
|
71
|
+
<ScrollArea className="flex flex-1 flex-col">
|
|
72
|
+
<div className="flex min-h-full flex-col">{children}</div>
|
|
73
|
+
</ScrollArea>
|
|
74
|
+
{footer && <div className="shrink-0 border-t border-hairline">{footer}</div>}
|
|
75
|
+
</div>
|
|
76
|
+
</aside>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { Check, Loader2, Redo2, Save, Undo2 } from 'lucide-react';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
|
|
5
|
+
type SaveCardProps = {
|
|
6
|
+
dirty: boolean;
|
|
7
|
+
committing: boolean;
|
|
8
|
+
onSave: () => Promise<void> | void;
|
|
9
|
+
onDiscard: () => void;
|
|
10
|
+
unsavedLabel: React.ReactNode;
|
|
11
|
+
savedLabel?: string;
|
|
12
|
+
uiAttr: 'inspector' | 'design';
|
|
13
|
+
onUndo?: () => void;
|
|
14
|
+
onRedo?: () => void;
|
|
15
|
+
canUndo?: boolean;
|
|
16
|
+
canRedo?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Optimistic DOM updates make the canvas *look* saved, so without this
|
|
20
|
+
// affordance a user could close the tab thinking their tweaks hit disk
|
|
21
|
+
// when they're still buffered in memory.
|
|
22
|
+
export function SaveCard({
|
|
23
|
+
dirty,
|
|
24
|
+
committing,
|
|
25
|
+
onSave,
|
|
26
|
+
onDiscard,
|
|
27
|
+
unsavedLabel,
|
|
28
|
+
savedLabel = 'Saved',
|
|
29
|
+
uiAttr,
|
|
30
|
+
onUndo,
|
|
31
|
+
onRedo,
|
|
32
|
+
canUndo = false,
|
|
33
|
+
canRedo = false,
|
|
34
|
+
}: SaveCardProps) {
|
|
35
|
+
const [justSaved, setJustSaved] = useState(false);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!justSaved) return;
|
|
39
|
+
const t = setTimeout(() => setJustSaved(false), 1200);
|
|
40
|
+
return () => clearTimeout(t);
|
|
41
|
+
}, [justSaved]);
|
|
42
|
+
|
|
43
|
+
const visible = dirty || committing || justSaved || canUndo || canRedo;
|
|
44
|
+
if (!visible) return null;
|
|
45
|
+
|
|
46
|
+
const handleSave = async () => {
|
|
47
|
+
await onSave();
|
|
48
|
+
setJustSaved(true);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const dataAttrs = uiAttr === 'inspector' ? { 'data-inspector-ui': '' } : { 'data-design-ui': '' };
|
|
52
|
+
|
|
53
|
+
const showHistory = !justSaved && (onUndo || onRedo);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
{...dataAttrs}
|
|
58
|
+
className="pointer-events-none absolute bottom-6 left-1/2 z-30 -translate-x-1/2 animate-in fade-in slide-in-from-bottom-2 duration-200 ease-out"
|
|
59
|
+
>
|
|
60
|
+
<div className="pointer-events-auto flex h-9 items-center gap-1 rounded-[8px] border border-border bg-popover/95 py-0.5 pr-0.5 pl-1 shadow-overlay backdrop-blur-md">
|
|
61
|
+
{showHistory && (
|
|
62
|
+
<div className="flex items-center">
|
|
63
|
+
<Button
|
|
64
|
+
size="icon-sm"
|
|
65
|
+
variant="ghost"
|
|
66
|
+
className="text-muted-foreground hover:text-foreground"
|
|
67
|
+
onClick={onUndo}
|
|
68
|
+
disabled={committing || !canUndo}
|
|
69
|
+
aria-label="Undo"
|
|
70
|
+
title="Undo"
|
|
71
|
+
>
|
|
72
|
+
<Undo2 className="size-3.5" />
|
|
73
|
+
</Button>
|
|
74
|
+
<Button
|
|
75
|
+
size="icon-sm"
|
|
76
|
+
variant="ghost"
|
|
77
|
+
className="text-muted-foreground hover:text-foreground"
|
|
78
|
+
onClick={onRedo}
|
|
79
|
+
disabled={committing || !canRedo}
|
|
80
|
+
aria-label="Redo"
|
|
81
|
+
title="Redo"
|
|
82
|
+
>
|
|
83
|
+
<Redo2 className="size-3.5" />
|
|
84
|
+
</Button>
|
|
85
|
+
{(justSaved || dirty || committing) && (
|
|
86
|
+
<span aria-hidden className="ml-1 mr-0.5 h-4 w-px bg-hairline" />
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
{justSaved ? (
|
|
91
|
+
<span className="flex items-center gap-1.5 px-2.5 text-[12px] font-medium text-foreground">
|
|
92
|
+
<Check className="size-3.5 text-[oklch(0.55_0.13_165)]" strokeWidth={2.5} />
|
|
93
|
+
{savedLabel}
|
|
94
|
+
</span>
|
|
95
|
+
) : dirty || committing ? (
|
|
96
|
+
<span className="inline-flex items-center gap-1.5 px-2.5 text-[12px] font-medium text-foreground">
|
|
97
|
+
<span
|
|
98
|
+
aria-hidden
|
|
99
|
+
className="size-1.5 rounded-full bg-brand shadow-[0_0_0_3px_var(--brand-soft)]"
|
|
100
|
+
/>
|
|
101
|
+
<span className="nums">{unsavedLabel}</span>
|
|
102
|
+
</span>
|
|
103
|
+
) : null}
|
|
104
|
+
{!justSaved && dirty && (
|
|
105
|
+
<Button
|
|
106
|
+
size="sm"
|
|
107
|
+
variant="ghost"
|
|
108
|
+
className="text-muted-foreground hover:text-foreground"
|
|
109
|
+
onClick={onDiscard}
|
|
110
|
+
disabled={committing || !dirty}
|
|
111
|
+
>
|
|
112
|
+
Discard
|
|
113
|
+
</Button>
|
|
114
|
+
)}
|
|
115
|
+
{(dirty || committing) && (
|
|
116
|
+
<Button
|
|
117
|
+
size="sm"
|
|
118
|
+
variant="brand"
|
|
119
|
+
className="h-7 px-3"
|
|
120
|
+
onClick={handleSave}
|
|
121
|
+
disabled={committing || !dirty}
|
|
122
|
+
>
|
|
123
|
+
{committing ? (
|
|
124
|
+
<>
|
|
125
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
126
|
+
Saving
|
|
127
|
+
</>
|
|
128
|
+
) : (
|
|
129
|
+
<>
|
|
130
|
+
<Save className="size-3.5" />
|
|
131
|
+
Save
|
|
132
|
+
</>
|
|
133
|
+
)}
|
|
134
|
+
</Button>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Loader2 } from 'lucide-react';
|
|
2
|
+
import type { PdfExportProgress } from '../lib/export-pdf';
|
|
3
|
+
import { Progress } from './ui/progress';
|
|
4
|
+
|
|
5
|
+
export function PdfProgressToast({ progress }: { progress: PdfExportProgress }) {
|
|
6
|
+
const text =
|
|
7
|
+
progress.phase === 'processing'
|
|
8
|
+
? `Processing page ${progress.current.toString().padStart(2, '0')} of ${progress.total.toString().padStart(2, '0')}`
|
|
9
|
+
: progress.phase === 'printing'
|
|
10
|
+
? 'Opening print dialog…'
|
|
11
|
+
: 'Done';
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="flex w-80 items-start gap-3 rounded-[8px] border border-border bg-popover px-3.5 py-3 text-popover-foreground shadow-floating">
|
|
15
|
+
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin text-brand" />
|
|
16
|
+
<div className="min-w-0 flex-1">
|
|
17
|
+
<p className="font-heading text-[12.5px] font-semibold tracking-tight">Exporting PDF</p>
|
|
18
|
+
<p className="truncate font-mono text-[10.5px] tracking-[0.04em] text-muted-foreground">
|
|
19
|
+
{text}
|
|
20
|
+
</p>
|
|
21
|
+
<Progress value={Math.round(progress.percent)} className="mt-2 h-[3px]" />
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|