@open-slide/core 0.0.8 → 0.0.10

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 (34) hide show
  1. package/dist/{build-CXY2DSzy.js → build-DHiRlpjn.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-BYTf0qVz.js → config-LZM903FE.js} +742 -44
  4. package/dist/{dev-BxCKugi3.js → dev-B3JzCYn7.js} +1 -1
  5. package/dist/{preview-C1F-rHfx.js → preview-UikovHEt.js} +1 -1
  6. package/dist/vite/index.js +1 -1
  7. package/package.json +3 -1
  8. package/src/app/App.tsx +2 -0
  9. package/src/app/components/AssetView.tsx +846 -0
  10. package/src/app/components/ClickNavZones.tsx +2 -2
  11. package/src/app/components/PdfProgressToast.tsx +23 -0
  12. package/src/app/components/ThumbnailRail.tsx +2 -2
  13. package/src/app/components/inspector/CommentWidget.tsx +1 -1
  14. package/src/app/components/inspector/InspectOverlay.tsx +81 -41
  15. package/src/app/components/inspector/InspectorPanel.tsx +948 -0
  16. package/src/app/components/inspector/InspectorProvider.tsx +229 -13
  17. package/src/app/components/inspector/SaveBar.tsx +77 -0
  18. package/src/app/components/ui/input.tsx +21 -0
  19. package/src/app/components/ui/label.tsx +24 -0
  20. package/src/app/components/ui/progress.tsx +31 -0
  21. package/src/app/components/ui/select.tsx +190 -0
  22. package/src/app/components/ui/slider.tsx +61 -0
  23. package/src/app/components/ui/sonner.tsx +38 -0
  24. package/src/app/components/ui/textarea.tsx +18 -0
  25. package/src/app/components/ui/toggle-group.tsx +83 -0
  26. package/src/app/components/ui/toggle.tsx +45 -0
  27. package/src/app/components/ui/tooltip.tsx +55 -0
  28. package/src/app/lib/assets.ts +166 -0
  29. package/src/app/lib/export-pdf.ts +194 -0
  30. package/src/app/lib/inspector/fiber.ts +40 -5
  31. package/src/app/lib/inspector/useEditor.ts +62 -0
  32. package/src/app/lib/print-ready.ts +58 -0
  33. package/src/app/routes/Slide.tsx +140 -51
  34. package/src/app/components/inspector/CommentPopover.tsx +0 -94
@@ -0,0 +1,948 @@
1
+ import {
2
+ AlignCenter,
3
+ AlignJustify,
4
+ AlignLeft,
5
+ AlignRight,
6
+ Bold,
7
+ ImageIcon,
8
+ Italic,
9
+ Trash2,
10
+ X,
11
+ } from 'lucide-react';
12
+ import { useCallback, useEffect, useRef, useState } from 'react';
13
+ import { Button } from '@/components/ui/button';
14
+ import {
15
+ Dialog,
16
+ DialogContent,
17
+ DialogDescription,
18
+ DialogHeader,
19
+ DialogTitle,
20
+ } from '@/components/ui/dialog';
21
+ import { Input } from '@/components/ui/input';
22
+ import { Label } from '@/components/ui/label';
23
+ import { ScrollArea } from '@/components/ui/scroll-area';
24
+ import {
25
+ Select,
26
+ SelectContent,
27
+ SelectItem,
28
+ SelectTrigger,
29
+ SelectValue,
30
+ } from '@/components/ui/select';
31
+ import { Separator } from '@/components/ui/separator';
32
+ import { Slider } from '@/components/ui/slider';
33
+ import { Textarea } from '@/components/ui/textarea';
34
+ import { Toggle } from '@/components/ui/toggle';
35
+ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
36
+ import { type AssetEntry, useAssets } from '@/lib/assets';
37
+ import { findSlideSource } from '@/lib/inspector/fiber';
38
+ import type { SlideComment } from '@/lib/inspector/useComments';
39
+ import type { EditOp } from '@/lib/inspector/useEditor';
40
+ import { cn } from '@/lib/utils';
41
+ import { type SelectedTarget, useInspector } from './InspectorProvider';
42
+
43
+ const PANEL_W = 340;
44
+ const PANEL_TRANSITION_MS = 280;
45
+
46
+ type ElementSnapshot = {
47
+ fontSize: number;
48
+ fontWeight: number;
49
+ fontStyle: 'normal' | 'italic';
50
+ color: string;
51
+ backgroundColor: string | null;
52
+ textAlign: 'left' | 'center' | 'right' | 'justify';
53
+ lineHeight: number | null;
54
+ letterSpacing: number;
55
+ text: string | null;
56
+ imageSrc: string | null;
57
+ };
58
+
59
+ export function InspectorPanel() {
60
+ const { active, slideId, selected, setSelected, bufferOps, pendingCount, comments, add, remove } =
61
+ useInspector();
62
+ const [snapshot, setSnapshot] = useState<ElementSnapshot | null>(null);
63
+ const reloadCounter = useReloadCounter();
64
+
65
+ useEffect(() => {
66
+ void reloadCounter;
67
+ void pendingCount;
68
+ if (!selected) {
69
+ setSnapshot(null);
70
+ return;
71
+ }
72
+ let anchor = selected.anchor;
73
+ if (!anchor.isConnected) {
74
+ const next = findElementByLine(slideId, selected.line, selected.column);
75
+ if (next) {
76
+ anchor = next;
77
+ setSelected({ ...selected, anchor: next });
78
+ } else {
79
+ return;
80
+ }
81
+ }
82
+ setSnapshot(readSnapshot(anchor));
83
+ }, [selected, setSelected, slideId, reloadCounter, pendingCount]);
84
+
85
+ // Freeze slide animations while editing so commits don't replay motion.
86
+ useEffect(() => {
87
+ if (!active) return;
88
+ const root = document.querySelector<HTMLElement>('[data-inspector-root]');
89
+ if (!root) return;
90
+ const styleEl = document.createElement('style');
91
+ styleEl.textContent = EDITING_FREEZE_CSS;
92
+ document.head.appendChild(styleEl);
93
+ root.dataset.inspectorEditing = 'true';
94
+ return () => {
95
+ let cleaned = false;
96
+ const finish = () => {
97
+ if (cleaned) return;
98
+ cleaned = true;
99
+ styleEl.remove();
100
+ delete root.dataset.inspectorEditing;
101
+ import.meta.hot?.off('vite:afterUpdate', finish);
102
+ clearTimeout(timer);
103
+ };
104
+ const timer = setTimeout(finish, 1500);
105
+ import.meta.hot?.on('vite:afterUpdate', finish);
106
+ };
107
+ }, [active]);
108
+
109
+ const apply = useCallback(
110
+ (ops: EditOp[]) => {
111
+ if (!selected) return;
112
+ bufferOps(selected.line, selected.column, selected.anchor, ops);
113
+ if (selected.anchor.isConnected) setSnapshot(readSnapshot(selected.anchor));
114
+ },
115
+ [selected, bufferOps],
116
+ );
117
+
118
+ // `pinned` keeps the last selection rendered through the close-out
119
+ // animation; `animVisible` lags one frame so the width transition
120
+ // fires on the 0 → PANEL_W flip.
121
+ const targetOpen = active && !!selected && !!snapshot;
122
+ const [pinned, setPinned] = useState<{ s: SelectedTarget; n: ElementSnapshot } | null>(null);
123
+ const [animVisible, setAnimVisible] = useState(false);
124
+
125
+ useEffect(() => {
126
+ if (selected && snapshot) setPinned({ s: selected, n: snapshot });
127
+ }, [selected, snapshot]);
128
+
129
+ useEffect(() => {
130
+ if (targetOpen && pinned) {
131
+ const id = requestAnimationFrame(() => setAnimVisible(true));
132
+ return () => cancelAnimationFrame(id);
133
+ }
134
+ setAnimVisible(false);
135
+ }, [targetOpen, pinned]);
136
+
137
+ useEffect(() => {
138
+ if (!targetOpen && pinned) {
139
+ const t = setTimeout(() => setPinned(null), PANEL_TRANSITION_MS);
140
+ return () => clearTimeout(t);
141
+ }
142
+ }, [targetOpen, pinned]);
143
+
144
+ if (!pinned) return null;
145
+ const { s: pinSelected, n: pinSnapshot } = pinned;
146
+
147
+ return (
148
+ <aside
149
+ data-inspector-ui
150
+ className="flex h-full shrink-0 justify-end overflow-hidden bg-card transition-[width,border-left-width] ease-out"
151
+ style={{
152
+ width: animVisible ? PANEL_W : 0,
153
+ borderLeftWidth: animVisible ? 1 : 0,
154
+ transitionDuration: `${PANEL_TRANSITION_MS}ms`,
155
+ }}
156
+ >
157
+ <div style={{ width: PANEL_W }} className="flex h-full shrink-0 flex-col">
158
+ <header className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-2.5">
159
+ <div className="flex min-w-0 items-center gap-2">
160
+ <span className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px] text-foreground">
161
+ &lt;{pinSelected.anchor.tagName.toLowerCase()}&gt;
162
+ </span>
163
+ </div>
164
+ <Button
165
+ variant="ghost"
166
+ size="icon"
167
+ className="size-7 text-muted-foreground hover:text-foreground"
168
+ onClick={() => setSelected(null)}
169
+ aria-label="Deselect"
170
+ >
171
+ <X className="size-3.5" />
172
+ </Button>
173
+ </header>
174
+
175
+ <ScrollArea className="flex flex-1 flex-col">
176
+ <div className="flex min-h-full flex-col">
177
+ {pinSnapshot.text !== null && (
178
+ <Section title="Content">
179
+ <ContentField snapshot={pinSnapshot} apply={apply} />
180
+ </Section>
181
+ )}
182
+
183
+ <Separator />
184
+
185
+ <Section title="Typography">
186
+ <FontSizeField snapshot={pinSnapshot} apply={apply} />
187
+ <FontWeightField snapshot={pinSnapshot} apply={apply} />
188
+ <StyleToggles snapshot={pinSnapshot} apply={apply} />
189
+ <LineHeightField snapshot={pinSnapshot} apply={apply} />
190
+ <LetterSpacingField snapshot={pinSnapshot} apply={apply} />
191
+ <TextAlignField snapshot={pinSnapshot} apply={apply} />
192
+ </Section>
193
+
194
+ <Separator />
195
+
196
+ <Section title="Color">
197
+ <ColorField
198
+ label="Text"
199
+ value={pinSnapshot.color}
200
+ onChange={(v) => apply([{ kind: 'set-style', key: 'color', value: v }])}
201
+ clearable={false}
202
+ />
203
+ <ColorField
204
+ label="Background"
205
+ value={pinSnapshot.backgroundColor ?? '#ffffff'}
206
+ dim={!pinSnapshot.backgroundColor}
207
+ onChange={(v) => apply([{ kind: 'set-style', key: 'backgroundColor', value: v }])}
208
+ onClear={() => apply([{ kind: 'set-style', key: 'backgroundColor', value: null }])}
209
+ clearable
210
+ />
211
+ </Section>
212
+
213
+ {pinSnapshot.imageSrc !== null && (
214
+ <>
215
+ <Separator />
216
+ <Section title="Image">
217
+ <ImageField slideId={slideId} src={pinSnapshot.imageSrc} apply={apply} />
218
+ </Section>
219
+ </>
220
+ )}
221
+
222
+ <Separator />
223
+
224
+ <div className="mt-auto">
225
+ <CommentsSection
226
+ comments={comments}
227
+ selected={pinSelected}
228
+ onAdd={add}
229
+ onRemove={remove}
230
+ />
231
+ </div>
232
+ </div>
233
+ </ScrollArea>
234
+ </div>
235
+ </aside>
236
+ );
237
+ }
238
+
239
+ function Section({ title, children }: { title: string; children: React.ReactNode }) {
240
+ return (
241
+ <section className="px-4 py-4">
242
+ <div className="mb-3 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
243
+ {title}
244
+ </div>
245
+ <div className="flex flex-col gap-3">{children}</div>
246
+ </section>
247
+ );
248
+ }
249
+
250
+ function Field({ label, children }: { label: string; children: React.ReactNode }) {
251
+ return (
252
+ <div className="grid grid-cols-[80px_1fr] items-center gap-3">
253
+ <Label className="text-[11px] font-normal text-muted-foreground">{label}</Label>
254
+ <div className="flex min-w-0 items-center gap-2">{children}</div>
255
+ </div>
256
+ );
257
+ }
258
+
259
+ const EDITING_FREEZE_CSS = `
260
+ [data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *),
261
+ [data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *)::before,
262
+ [data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *)::after {
263
+ animation-duration: 1ms !important;
264
+ animation-delay: 0s !important;
265
+ animation-iteration-count: 1 !important;
266
+ animation-fill-mode: forwards !important;
267
+ transition: none !important;
268
+ view-transition-name: none !important;
269
+ cursor: pointer !important;
270
+ }
271
+ `;
272
+
273
+ function ContentField({
274
+ snapshot,
275
+ apply,
276
+ }: {
277
+ snapshot: ElementSnapshot;
278
+ apply: (ops: EditOp[]) => void;
279
+ }) {
280
+ // Mirror the value locally and skip syncs during IME composition;
281
+ // a re-render mid-composition would otherwise clobber in-progress
282
+ // candidates (Bopomofo/Pinyin only commit on candidate selection).
283
+ const [local, setLocal] = useState(snapshot.text ?? '');
284
+ const composingRef = useRef(false);
285
+
286
+ useEffect(() => {
287
+ if (!composingRef.current) setLocal(snapshot.text ?? '');
288
+ }, [snapshot.text]);
289
+
290
+ return (
291
+ <Textarea
292
+ value={local}
293
+ onCompositionStart={() => {
294
+ composingRef.current = true;
295
+ }}
296
+ onCompositionEnd={(e) => {
297
+ composingRef.current = false;
298
+ const v = e.currentTarget.value;
299
+ setLocal(v);
300
+ apply([{ kind: 'set-text', value: v }]);
301
+ }}
302
+ onChange={(e) => {
303
+ const v = e.target.value;
304
+ setLocal(v);
305
+ if (!composingRef.current) {
306
+ apply([{ kind: 'set-text', value: v }]);
307
+ }
308
+ }}
309
+ rows={3}
310
+ className="min-h-16 resize-none text-xs"
311
+ placeholder="Element text"
312
+ />
313
+ );
314
+ }
315
+
316
+ function FontSizeField({
317
+ snapshot,
318
+ apply,
319
+ }: {
320
+ snapshot: ElementSnapshot;
321
+ apply: (ops: EditOp[]) => void;
322
+ }) {
323
+ const set = (px: number) => {
324
+ apply([{ kind: 'set-style', key: 'fontSize', value: `${Math.round(px)}px` }]);
325
+ };
326
+ return (
327
+ <Field label="Size">
328
+ <Slider
329
+ min={8}
330
+ max={200}
331
+ step={1}
332
+ value={[snapshot.fontSize]}
333
+ onValueChange={([v]) => set(v ?? snapshot.fontSize)}
334
+ className="flex-1"
335
+ />
336
+ <NumberField
337
+ value={Math.round(snapshot.fontSize)}
338
+ onChange={set}
339
+ min={1}
340
+ max={400}
341
+ suffix="px"
342
+ />
343
+ </Field>
344
+ );
345
+ }
346
+
347
+ const WEIGHT_OPTIONS: { value: string; label: string }[] = [
348
+ { value: '300', label: 'Light · 300' },
349
+ { value: '400', label: 'Regular · 400' },
350
+ { value: '500', label: 'Medium · 500' },
351
+ { value: '600', label: 'Semibold · 600' },
352
+ { value: '700', label: 'Bold · 700' },
353
+ { value: '800', label: 'Extrabold · 800' },
354
+ ];
355
+
356
+ function FontWeightField({
357
+ snapshot,
358
+ apply,
359
+ }: {
360
+ snapshot: ElementSnapshot;
361
+ apply: (ops: EditOp[]) => void;
362
+ }) {
363
+ return (
364
+ <Field label="Weight">
365
+ <Select
366
+ value={String(snapshot.fontWeight)}
367
+ onValueChange={(value) => {
368
+ const n = Number(value);
369
+ apply([
370
+ {
371
+ kind: 'set-style',
372
+ key: 'fontWeight',
373
+ value: n === 400 ? null : value,
374
+ },
375
+ ]);
376
+ }}
377
+ >
378
+ <SelectTrigger size="sm" className="h-8 flex-1 text-xs">
379
+ <SelectValue />
380
+ </SelectTrigger>
381
+ <SelectContent>
382
+ {WEIGHT_OPTIONS.map((opt) => (
383
+ <SelectItem key={opt.value} value={opt.value} className="text-xs">
384
+ {opt.label}
385
+ </SelectItem>
386
+ ))}
387
+ </SelectContent>
388
+ </Select>
389
+ </Field>
390
+ );
391
+ }
392
+
393
+ function StyleToggles({
394
+ snapshot,
395
+ apply,
396
+ }: {
397
+ snapshot: ElementSnapshot;
398
+ apply: (ops: EditOp[]) => void;
399
+ }) {
400
+ return (
401
+ <Field label="Style">
402
+ <Toggle
403
+ size="sm"
404
+ variant="outline"
405
+ pressed={snapshot.fontWeight >= 600}
406
+ onPressedChange={(v) =>
407
+ apply([{ kind: 'set-style', key: 'fontWeight', value: v ? '700' : null }])
408
+ }
409
+ aria-label="Bold"
410
+ >
411
+ <Bold className="size-3.5" />
412
+ </Toggle>
413
+ <Toggle
414
+ size="sm"
415
+ variant="outline"
416
+ pressed={snapshot.fontStyle === 'italic'}
417
+ onPressedChange={(v) =>
418
+ apply([{ kind: 'set-style', key: 'fontStyle', value: v ? 'italic' : null }])
419
+ }
420
+ aria-label="Italic"
421
+ >
422
+ <Italic className="size-3.5" />
423
+ </Toggle>
424
+ </Field>
425
+ );
426
+ }
427
+
428
+ function LineHeightField({
429
+ snapshot,
430
+ apply,
431
+ }: {
432
+ snapshot: ElementSnapshot;
433
+ apply: (ops: EditOp[]) => void;
434
+ }) {
435
+ const v = snapshot.lineHeight ?? 1.4;
436
+ const set = (n: number) => {
437
+ apply([{ kind: 'set-style', key: 'lineHeight', value: String(round2(n)) }]);
438
+ };
439
+ return (
440
+ <Field label="Line height">
441
+ <Slider
442
+ min={0.8}
443
+ max={3}
444
+ step={0.05}
445
+ value={[v]}
446
+ onValueChange={([n]) => set(n ?? v)}
447
+ className="flex-1"
448
+ />
449
+ <NumberField value={round2(v)} onChange={set} step={0.05} min={0.5} max={5} />
450
+ </Field>
451
+ );
452
+ }
453
+
454
+ function LetterSpacingField({
455
+ snapshot,
456
+ apply,
457
+ }: {
458
+ snapshot: ElementSnapshot;
459
+ apply: (ops: EditOp[]) => void;
460
+ }) {
461
+ const set = (n: number) => {
462
+ apply([
463
+ {
464
+ kind: 'set-style',
465
+ key: 'letterSpacing',
466
+ value: n === 0 ? null : `${round2(n)}px`,
467
+ },
468
+ ]);
469
+ };
470
+ return (
471
+ <Field label="Tracking">
472
+ <Slider
473
+ min={-5}
474
+ max={20}
475
+ step={0.1}
476
+ value={[snapshot.letterSpacing]}
477
+ onValueChange={([n]) => set(n ?? snapshot.letterSpacing)}
478
+ className="flex-1"
479
+ />
480
+ <NumberField
481
+ value={round2(snapshot.letterSpacing)}
482
+ onChange={set}
483
+ step={0.1}
484
+ min={-20}
485
+ max={50}
486
+ suffix="px"
487
+ />
488
+ </Field>
489
+ );
490
+ }
491
+
492
+ const ALIGN_OPTIONS = [
493
+ { v: 'left', icon: AlignLeft },
494
+ { v: 'center', icon: AlignCenter },
495
+ { v: 'right', icon: AlignRight },
496
+ { v: 'justify', icon: AlignJustify },
497
+ ] as const;
498
+
499
+ function TextAlignField({
500
+ snapshot,
501
+ apply,
502
+ }: {
503
+ snapshot: ElementSnapshot;
504
+ apply: (ops: EditOp[]) => void;
505
+ }) {
506
+ return (
507
+ <Field label="Align">
508
+ <ToggleGroup
509
+ type="single"
510
+ size="sm"
511
+ variant="outline"
512
+ value={snapshot.textAlign}
513
+ onValueChange={(value) => {
514
+ if (!value) return;
515
+ apply([
516
+ {
517
+ kind: 'set-style',
518
+ key: 'textAlign',
519
+ value: value === 'left' ? null : value,
520
+ },
521
+ ]);
522
+ }}
523
+ >
524
+ {ALIGN_OPTIONS.map(({ v, icon: Icon }) => (
525
+ <ToggleGroupItem key={v} value={v} aria-label={v} className="size-8">
526
+ <Icon className="size-3.5" />
527
+ </ToggleGroupItem>
528
+ ))}
529
+ </ToggleGroup>
530
+ </Field>
531
+ );
532
+ }
533
+
534
+ function ColorField({
535
+ label,
536
+ value,
537
+ dim,
538
+ onChange,
539
+ onClear,
540
+ clearable,
541
+ }: {
542
+ label: string;
543
+ value: string;
544
+ dim?: boolean;
545
+ onChange: (v: string) => void;
546
+ onClear?: () => void;
547
+ clearable: boolean;
548
+ }) {
549
+ // Buffer the text input so intermediate hex like "#a" doesn't
550
+ // commit until it parses as a full color.
551
+ const [draft, setDraft] = useState(value);
552
+ useEffect(() => setDraft(value), [value]);
553
+
554
+ const commitHex = (hex: string) => {
555
+ if (/^#[0-9a-fA-F]{6}$/.test(hex)) onChange(hex);
556
+ };
557
+
558
+ return (
559
+ <Field label={label}>
560
+ <label className="relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md border bg-background shadow-xs">
561
+ <span
562
+ className="size-5 rounded-sm"
563
+ style={{
564
+ backgroundColor: dim ? 'transparent' : value,
565
+ backgroundImage: dim
566
+ ? 'linear-gradient(45deg, #d4d4d4 25%, transparent 25%, transparent 75%, #d4d4d4 75%), linear-gradient(45deg, #d4d4d4 25%, transparent 25%, transparent 75%, #d4d4d4 75%)'
567
+ : undefined,
568
+ backgroundSize: dim ? '8px 8px' : undefined,
569
+ backgroundPosition: dim ? '0 0, 4px 4px' : undefined,
570
+ }}
571
+ />
572
+ <input
573
+ type="color"
574
+ value={value}
575
+ onChange={(e) => {
576
+ setDraft(e.target.value);
577
+ onChange(e.target.value);
578
+ }}
579
+ className="absolute inset-0 cursor-pointer opacity-0"
580
+ />
581
+ </label>
582
+ <Input
583
+ type="text"
584
+ value={draft}
585
+ onChange={(e) => {
586
+ setDraft(e.target.value);
587
+ commitHex(e.target.value);
588
+ }}
589
+ className="h-8 flex-1 font-mono text-[11px] uppercase"
590
+ spellCheck={false}
591
+ />
592
+ {clearable && onClear && (
593
+ <Button
594
+ variant="ghost"
595
+ size="icon"
596
+ className="size-8 text-muted-foreground hover:text-foreground"
597
+ onClick={onClear}
598
+ aria-label="Clear"
599
+ >
600
+ <X className="size-3.5" />
601
+ </Button>
602
+ )}
603
+ </Field>
604
+ );
605
+ }
606
+
607
+ function NumberField({
608
+ value,
609
+ onChange,
610
+ min,
611
+ max,
612
+ step = 1,
613
+ suffix,
614
+ }: {
615
+ value: number;
616
+ onChange: (n: number) => void;
617
+ min?: number;
618
+ max?: number;
619
+ step?: number;
620
+ suffix?: string;
621
+ }) {
622
+ return (
623
+ <div className="flex h-8 shrink-0 items-center rounded-md border bg-background pr-2 shadow-xs focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50">
624
+ <input
625
+ type="number"
626
+ value={value}
627
+ onChange={(e) => {
628
+ const n = Number(e.target.value);
629
+ if (Number.isFinite(n)) onChange(n);
630
+ }}
631
+ min={min}
632
+ max={max}
633
+ step={step}
634
+ className="h-full w-12 bg-transparent px-2 text-right text-[11px] tabular-nums outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
635
+ />
636
+ {suffix && <span className="text-[10px] text-muted-foreground">{suffix}</span>}
637
+ </div>
638
+ );
639
+ }
640
+
641
+ function ImageField({
642
+ slideId,
643
+ src,
644
+ apply,
645
+ }: {
646
+ slideId: string;
647
+ src: string;
648
+ apply: (ops: EditOp[]) => void;
649
+ }) {
650
+ const [open, setOpen] = useState(false);
651
+ return (
652
+ <div className="space-y-2">
653
+ <div className="flex items-center gap-3">
654
+ <div className="flex size-14 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-[repeating-conic-gradient(theme(colors.muted)_0_25%,transparent_0_50%)] bg-[length:8px_8px]">
655
+ <img
656
+ src={src}
657
+ alt=""
658
+ className="size-full object-contain"
659
+ draggable={false}
660
+ onError={(e) => {
661
+ e.currentTarget.style.display = 'none';
662
+ }}
663
+ />
664
+ </div>
665
+ <Button
666
+ type="button"
667
+ variant="outline"
668
+ size="sm"
669
+ className="flex-1"
670
+ onClick={() => setOpen(true)}
671
+ >
672
+ <ImageIcon className="size-3.5" />
673
+ Replace…
674
+ </Button>
675
+ </div>
676
+ {open && (
677
+ <AssetPickerDialog
678
+ slideId={slideId}
679
+ onClose={() => setOpen(false)}
680
+ onPick={(asset) => {
681
+ setOpen(false);
682
+ apply([
683
+ {
684
+ kind: 'set-attr-asset',
685
+ attr: 'src',
686
+ assetPath: `./assets/${asset.name}`,
687
+ previewUrl: asset.url,
688
+ },
689
+ ]);
690
+ }}
691
+ />
692
+ )}
693
+ </div>
694
+ );
695
+ }
696
+
697
+ function AssetPickerDialog({
698
+ slideId,
699
+ onClose,
700
+ onPick,
701
+ }: {
702
+ slideId: string;
703
+ onClose: () => void;
704
+ onPick: (asset: AssetEntry) => void;
705
+ }) {
706
+ const { assets, loading } = useAssets(slideId);
707
+ const images = assets.filter((a) => a.mime.startsWith('image/'));
708
+ return (
709
+ <Dialog open onOpenChange={(o) => !o && onClose()}>
710
+ <DialogContent className="sm:max-w-xl">
711
+ <DialogHeader>
712
+ <DialogTitle>Replace image</DialogTitle>
713
+ <DialogDescription>
714
+ Pick an asset from <span className="font-mono">slides/{slideId}/assets/</span>.
715
+ </DialogDescription>
716
+ </DialogHeader>
717
+ <div className="max-h-[60vh] overflow-y-auto">
718
+ {loading ? (
719
+ <p className="px-1 py-6 text-center text-xs text-muted-foreground">Loading…</p>
720
+ ) : images.length === 0 ? (
721
+ <p className="px-1 py-6 text-center text-xs text-muted-foreground">
722
+ No images in this slide's assets folder yet. Add some from the Assets tab.
723
+ </p>
724
+ ) : (
725
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3">
726
+ {images.map((asset) => (
727
+ <button
728
+ key={asset.name}
729
+ type="button"
730
+ onClick={() => onPick(asset)}
731
+ className={cn(
732
+ 'group flex flex-col overflow-hidden rounded-lg border bg-card text-left shadow-sm transition-all',
733
+ 'hover:-translate-y-0.5 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none',
734
+ )}
735
+ >
736
+ <div className="flex aspect-square w-full items-center justify-center overflow-hidden bg-[repeating-conic-gradient(theme(colors.muted)_0_25%,transparent_0_50%)] bg-[length:12px_12px]">
737
+ <img
738
+ src={asset.url}
739
+ alt=""
740
+ className="size-full object-contain"
741
+ draggable={false}
742
+ />
743
+ </div>
744
+ <div className="border-t px-2 py-1.5">
745
+ <div className="truncate text-[11px] font-medium" title={asset.name}>
746
+ {asset.name}
747
+ </div>
748
+ </div>
749
+ </button>
750
+ ))}
751
+ </div>
752
+ )}
753
+ </div>
754
+ </DialogContent>
755
+ </Dialog>
756
+ );
757
+ }
758
+
759
+ function CommentsSection({
760
+ comments,
761
+ selected,
762
+ onAdd,
763
+ onRemove,
764
+ }: {
765
+ comments: SlideComment[];
766
+ selected: { line: number; column: number };
767
+ onAdd: (line: number, column: number, text: string) => Promise<void>;
768
+ onRemove: (id: string) => Promise<void>;
769
+ }) {
770
+ const [draft, setDraft] = useState('');
771
+ const [submitting, setSubmitting] = useState(false);
772
+
773
+ const submit = async () => {
774
+ const trimmed = draft.trim();
775
+ if (!trimmed) return;
776
+ setSubmitting(true);
777
+ try {
778
+ await onAdd(selected.line, selected.column, trimmed);
779
+ setDraft('');
780
+ } finally {
781
+ setSubmitting(false);
782
+ }
783
+ };
784
+
785
+ return (
786
+ <Section title={comments.length ? `Comments · ${comments.length}` : 'Comments'}>
787
+ <div className="flex flex-col gap-2">
788
+ <Textarea
789
+ value={draft}
790
+ onChange={(e) => setDraft(e.target.value)}
791
+ onKeyDown={(e) => {
792
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
793
+ e.preventDefault();
794
+ submit();
795
+ }
796
+ }}
797
+ placeholder="Describe a change for the agent…"
798
+ className="min-h-16 resize-none text-xs"
799
+ />
800
+ <div className="flex items-center justify-between">
801
+ <span className="text-[10px] text-muted-foreground">⌘/Ctrl + Enter</span>
802
+ <Button
803
+ size="sm"
804
+ disabled={submitting || !draft.trim()}
805
+ onClick={submit}
806
+ className="h-7 px-2.5 text-[11px]"
807
+ >
808
+ Add comment
809
+ </Button>
810
+ </div>
811
+ </div>
812
+
813
+ {comments.length === 0 ? (
814
+ <p className="text-[11px] text-muted-foreground">No comments yet.</p>
815
+ ) : (
816
+ <>
817
+ <ul className="flex flex-col gap-1">
818
+ {comments.map((c) => (
819
+ <li
820
+ key={c.id}
821
+ className="group flex items-start gap-2 rounded-md border bg-background px-2.5 py-2 transition-colors hover:bg-muted/40"
822
+ >
823
+ <div className="min-w-0 flex-1">
824
+ <div className="font-mono text-[10px] text-muted-foreground">line {c.line}</div>
825
+ <div className="mt-0.5 text-xs leading-relaxed break-words">{c.note}</div>
826
+ </div>
827
+ <Button
828
+ variant="ghost"
829
+ size="icon"
830
+ className="size-6 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 hover:text-destructive"
831
+ onClick={() => onRemove(c.id)}
832
+ aria-label="Delete comment"
833
+ >
834
+ <Trash2 className="size-3.5" />
835
+ </Button>
836
+ </li>
837
+ ))}
838
+ </ul>
839
+ <p className="text-[10px] text-muted-foreground">
840
+ Run{' '}
841
+ <code className="rounded bg-muted px-1 py-0.5 font-mono text-foreground">
842
+ /apply-comments
843
+ </code>{' '}
844
+ to apply.
845
+ </p>
846
+ </>
847
+ )}
848
+ </Section>
849
+ );
850
+ }
851
+
852
+ function readSnapshot(el: HTMLElement): ElementSnapshot {
853
+ const cs = getComputedStyle(el);
854
+ const text = isSimpleTextElement(el) ? (el.textContent ?? '') : null;
855
+ const imageSrc =
856
+ el.tagName === 'IMG'
857
+ ? (el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src || null
858
+ : null;
859
+
860
+ return {
861
+ fontSize: parseFloat(cs.fontSize) || 16,
862
+ fontWeight: parseInt(cs.fontWeight, 10) || 400,
863
+ fontStyle: cs.fontStyle === 'italic' ? 'italic' : 'normal',
864
+ color: rgbToHex(cs.color) ?? '#000000',
865
+ backgroundColor: isTransparent(cs.backgroundColor) ? null : rgbToHex(cs.backgroundColor),
866
+ textAlign: normalizeTextAlign(cs.textAlign),
867
+ lineHeight: parseLineHeight(cs.lineHeight, parseFloat(cs.fontSize) || 16),
868
+ letterSpacing: parseLetterSpacing(cs.letterSpacing),
869
+ text,
870
+ imageSrc,
871
+ };
872
+ }
873
+
874
+ function isSimpleTextElement(el: HTMLElement): boolean {
875
+ if (el.childNodes.length === 0) return true;
876
+ if (el.childNodes.length === 1 && el.firstChild?.nodeType === Node.TEXT_NODE) return true;
877
+ return false;
878
+ }
879
+
880
+ function rgbToHex(value: string): string | null {
881
+ const m = value.match(/^rgba?\(([^)]+)\)$/);
882
+ if (!m) return null;
883
+ const parts = m[1].split(',').map((s) => s.trim());
884
+ if (parts.length < 3) return null;
885
+ const r = clampByte(Number(parts[0]));
886
+ const g = clampByte(Number(parts[1]));
887
+ const b = clampByte(Number(parts[2]));
888
+ return `#${[r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')}`;
889
+ }
890
+
891
+ function clampByte(n: number): number {
892
+ return Math.max(0, Math.min(255, Math.round(Number.isFinite(n) ? n : 0)));
893
+ }
894
+
895
+ function isTransparent(value: string): boolean {
896
+ if (!value) return true;
897
+ if (value === 'transparent' || value === 'rgba(0, 0, 0, 0)') return true;
898
+ const m = value.match(/^rgba\([^)]*,\s*0\)$/);
899
+ return Boolean(m);
900
+ }
901
+
902
+ function normalizeTextAlign(v: string): ElementSnapshot['textAlign'] {
903
+ if (v === 'center' || v === 'right' || v === 'justify') return v;
904
+ return 'left';
905
+ }
906
+
907
+ function parseLineHeight(value: string, fontSize: number): number | null {
908
+ if (!value || value === 'normal') return null;
909
+ const n = parseFloat(value);
910
+ if (!Number.isFinite(n) || n === 0) return null;
911
+ return round2(n / fontSize);
912
+ }
913
+
914
+ function parseLetterSpacing(value: string): number {
915
+ if (!value || value === 'normal') return 0;
916
+ const n = parseFloat(value);
917
+ return Number.isFinite(n) ? round2(n) : 0;
918
+ }
919
+
920
+ function round2(n: number): number {
921
+ return Math.round(n * 100) / 100;
922
+ }
923
+
924
+ function findElementByLine(slideId: string, line: number, column: number): HTMLElement | null {
925
+ const root = document.querySelector('[data-inspector-root]');
926
+ if (!root) return null;
927
+ const tagged = root.querySelector<HTMLElement>(`[data-slide-loc="${line}:${column}"]`);
928
+ if (tagged) return tagged;
929
+ const candidates = root.querySelectorAll<HTMLElement>('*');
930
+ for (const el of candidates) {
931
+ const hit = findSlideSource(el, slideId, { hostOnly: true });
932
+ if (hit && hit.line === line) return hit.anchor;
933
+ }
934
+ return null;
935
+ }
936
+
937
+ function useReloadCounter(): number {
938
+ const [n, setN] = useState(0);
939
+ useEffect(() => {
940
+ if (!import.meta.hot) return;
941
+ const handler = () => setN((x) => x + 1);
942
+ import.meta.hot.on('vite:afterUpdate', handler);
943
+ return () => {
944
+ import.meta.hot?.off('vite:afterUpdate', handler);
945
+ };
946
+ }, []);
947
+ return n;
948
+ }