@open-slide/core 0.0.7 → 0.0.9

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