@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.
Files changed (88) hide show
  1. package/dist/{build-DHiRlpjn.js → build-aiY_8kwE.js} +2 -1
  2. package/dist/cli/bin.js +43 -4
  3. package/dist/{config-LZM903FE.js → config-CVqRAagl.js} +592 -63
  4. package/dist/design-CROQh0AA.js +35 -0
  5. package/dist/{dev-B3JzCYn7.js → dev-R2we2iaF.js} +2 -1
  6. package/dist/index.d.ts +55 -4
  7. package/dist/index.js +110 -1
  8. package/dist/{preview-UikovHEt.js → preview-CU4zSyGp.js} +2 -1
  9. package/dist/sync-3oqN1WyK.js +139 -0
  10. package/dist/sync-B4eLo2H6.js +3 -0
  11. package/dist/vite/index.d.ts +1 -1
  12. package/dist/vite/index.js +2 -1
  13. package/package.json +2 -1
  14. package/skills/apply-comments/SKILL.md +83 -0
  15. package/skills/create-slide/SKILL.md +81 -0
  16. package/skills/create-theme/SKILL.md +194 -0
  17. package/skills/slide-authoring/SKILL.md +288 -0
  18. package/src/app/{App.tsx → app.tsx} +8 -6
  19. package/src/app/components/{AssetView.tsx → asset-view.tsx} +41 -33
  20. package/src/app/components/{ClickNavZones.tsx → click-nav-zones.tsx} +1 -1
  21. package/src/app/components/history-provider.tsx +120 -0
  22. package/src/app/components/image-placeholder.tsx +121 -0
  23. package/src/app/components/inspector/{CommentWidget.tsx → comment-widget.tsx} +1 -1
  24. package/src/app/components/inspector/{InspectOverlay.tsx → inspect-overlay.tsx} +1 -1
  25. package/src/app/components/inspector/{InspectorPanel.tsx → inspector-panel.tsx} +164 -212
  26. package/src/app/components/inspector/{InspectorProvider.tsx → inspector-provider.tsx} +186 -18
  27. package/src/app/components/inspector/save-bar.tsx +47 -0
  28. package/src/app/components/panel/panel-fields.tsx +60 -0
  29. package/src/app/components/panel/panel-shell.tsx +78 -0
  30. package/src/app/components/panel/save-card.tsx +139 -0
  31. package/src/app/components/pdf-progress-toast.tsx +25 -0
  32. package/src/app/components/player.tsx +341 -0
  33. package/src/app/components/present/blackout-overlay.tsx +18 -0
  34. package/src/app/components/present/control-bar.tsx +204 -0
  35. package/src/app/components/present/help-overlay.tsx +56 -0
  36. package/src/app/components/present/jump-input.tsx +74 -0
  37. package/src/app/components/present/laser-pointer.tsx +40 -0
  38. package/src/app/components/present/overview-grid.tsx +184 -0
  39. package/src/app/components/present/progress-bar.tsx +26 -0
  40. package/src/app/components/present/use-idle.ts +44 -0
  41. package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
  42. package/src/app/components/present/use-presenter-channel.ts +71 -0
  43. package/src/app/components/present/use-touch-swipe.ts +63 -0
  44. package/src/app/components/sidebar/{FolderItem.tsx → folder-item.tsx} +62 -27
  45. package/src/app/components/sidebar/{IconPicker.tsx → icon-picker.tsx} +13 -10
  46. package/src/app/components/sidebar/{Sidebar.tsx → sidebar.tsx} +40 -34
  47. package/src/app/components/{SlideCanvas.tsx → slide-canvas.tsx} +35 -10
  48. package/src/app/components/style-panel/design-provider.tsx +139 -0
  49. package/src/app/components/style-panel/style-panel.tsx +326 -0
  50. package/src/app/components/style-panel/use-design.ts +112 -0
  51. package/src/app/components/theme-toggle.tsx +57 -0
  52. package/src/app/components/thumbnail-rail.tsx +151 -0
  53. package/src/app/components/ui/button.tsx +51 -19
  54. package/src/app/components/ui/card.tsx +1 -1
  55. package/src/app/components/ui/dialog.tsx +25 -9
  56. package/src/app/components/ui/dropdown-menu.tsx +29 -12
  57. package/src/app/components/ui/input.tsx +13 -9
  58. package/src/app/components/ui/popover.tsx +5 -2
  59. package/src/app/components/ui/progress.tsx +2 -2
  60. package/src/app/components/ui/select.tsx +11 -5
  61. package/src/app/components/ui/separator.tsx +1 -1
  62. package/src/app/components/ui/slider.tsx +4 -4
  63. package/src/app/components/ui/sonner.tsx +11 -1
  64. package/src/app/components/ui/tabs.tsx +6 -6
  65. package/src/app/components/ui/textarea.tsx +11 -7
  66. package/src/app/components/ui/toggle-group.tsx +2 -2
  67. package/src/app/components/ui/toggle.tsx +6 -6
  68. package/src/app/components/ui/tooltip.tsx +5 -2
  69. package/src/app/lib/export-html.ts +10 -1
  70. package/src/app/lib/export-pdf.ts +7 -0
  71. package/src/app/lib/folders.ts +1 -1
  72. package/src/app/lib/inspector/{useEditor.ts → use-editor.ts} +2 -1
  73. package/src/app/lib/sdk.ts +5 -0
  74. package/src/app/lib/slides.ts +1 -1
  75. package/src/app/lib/utils.ts +1 -1
  76. package/src/app/main.tsx +5 -2
  77. package/src/app/routes/{Home.tsx → home.tsx} +266 -97
  78. package/src/app/routes/presenter.tsx +400 -0
  79. package/src/app/routes/slide.tsx +519 -0
  80. package/src/app/styles.css +338 -67
  81. package/src/app/components/PdfProgressToast.tsx +0 -23
  82. package/src/app/components/Player.tsx +0 -100
  83. package/src/app/components/ThumbnailRail.tsx +0 -68
  84. package/src/app/components/inspector/SaveBar.tsx +0 -77
  85. package/src/app/routes/Slide.tsx +0 -478
  86. /package/dist/{config-SXL5qIl6.d.ts → config-DweCbRkQ.d.ts} +0 -0
  87. /package/src/app/lib/inspector/{useComments.ts → use-comments.ts} +0 -0
  88. /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/useComments';
14
- import { type Edit, type EditOp, useEditor } from '@/lib/inspector/useEditor';
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
- const bufferOps = useCallback(
88
- (line: number, column: number, anchor: HTMLElement, ops: EditOp[]) => {
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.style as unknown as Record<string, string>;
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.isConnected) style[op.key] = op.value ?? '';
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.isConnected) anchor.textContent = op.value;
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.isConnected) anchor.setAttribute(op.attr, op.previewUrl);
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) return;
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) return;
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 size="sm" variant={active ? 'default' : 'outline'} onClick={toggle} data-inspector-ui>
294
- <Crosshair className="size-4" />
295
- Inspect
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
+ }