@open-aippt/core 1.13.2

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 (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/bin.js +2 -0
  4. package/dist/build-DxTqmvsO.js +17 -0
  5. package/dist/cli/bin.d.ts +1 -0
  6. package/dist/cli/bin.js +86 -0
  7. package/dist/config-CjzqjrEA.js +4280 -0
  8. package/dist/config-DIC-yVPp.d.ts +23 -0
  9. package/dist/design-cpzS8aud.js +35 -0
  10. package/dist/dev-BYuTeJbA.js +20 -0
  11. package/dist/format-BCeKbTOM.js +1605 -0
  12. package/dist/index.d.ts +134 -0
  13. package/dist/index.js +467 -0
  14. package/dist/locale/index.d.ts +24 -0
  15. package/dist/locale/index.js +3 -0
  16. package/dist/preview-DlQvnJPq.js +18 -0
  17. package/dist/sync-BPZ0m27m.js +139 -0
  18. package/dist/sync-EsYusbbL.js +3 -0
  19. package/dist/types-CHmFPIG_.d.ts +430 -0
  20. package/dist/vite/index.d.ts +14 -0
  21. package/dist/vite/index.js +4 -0
  22. package/env.d.ts +59 -0
  23. package/package.json +103 -0
  24. package/skills/apply-comments/SKILL.md +83 -0
  25. package/skills/create-slide/SKILL.md +91 -0
  26. package/skills/create-theme/SKILL.md +250 -0
  27. package/skills/current-slide/SKILL.md +110 -0
  28. package/skills/slide-authoring/SKILL.md +625 -0
  29. package/src/app/app.tsx +47 -0
  30. package/src/app/components/asset-view.tsx +966 -0
  31. package/src/app/components/history-provider.tsx +120 -0
  32. package/src/app/components/image-placeholder.tsx +243 -0
  33. package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
  34. package/src/app/components/inspector/comment-widget.tsx +93 -0
  35. package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
  36. package/src/app/components/inspector/inspect-overlay.tsx +387 -0
  37. package/src/app/components/inspector/inspector-panel.tsx +1115 -0
  38. package/src/app/components/inspector/inspector-provider.tsx +1218 -0
  39. package/src/app/components/inspector/save-bar.tsx +48 -0
  40. package/src/app/components/language-toggle.tsx +39 -0
  41. package/src/app/components/notes-drawer.tsx +120 -0
  42. package/src/app/components/overview-grid.tsx +363 -0
  43. package/src/app/components/panel/panel-fields.tsx +60 -0
  44. package/src/app/components/panel/panel-shell.tsx +80 -0
  45. package/src/app/components/panel/save-card.tsx +142 -0
  46. package/src/app/components/pdf-progress-toast.tsx +32 -0
  47. package/src/app/components/player.tsx +466 -0
  48. package/src/app/components/pptx-progress-toast.tsx +32 -0
  49. package/src/app/components/present/blackout-overlay.tsx +18 -0
  50. package/src/app/components/present/control-bar.tsx +315 -0
  51. package/src/app/components/present/help-overlay.tsx +57 -0
  52. package/src/app/components/present/jump-input.tsx +74 -0
  53. package/src/app/components/present/laser-pointer.tsx +39 -0
  54. package/src/app/components/present/progress-bar.tsx +26 -0
  55. package/src/app/components/present/use-idle.ts +46 -0
  56. package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
  57. package/src/app/components/present/use-presenter-channel.ts +66 -0
  58. package/src/app/components/present/use-touch-swipe.ts +66 -0
  59. package/src/app/components/shared-element.tsx +48 -0
  60. package/src/app/components/sidebar/folder-item.tsx +258 -0
  61. package/src/app/components/sidebar/icon-picker.tsx +61 -0
  62. package/src/app/components/sidebar/mobile-pill.tsx +34 -0
  63. package/src/app/components/sidebar/sidebar-footer.tsx +105 -0
  64. package/src/app/components/sidebar/sidebar.tsx +284 -0
  65. package/src/app/components/slide-canvas.tsx +102 -0
  66. package/src/app/components/slide-transition-layer.tsx +844 -0
  67. package/src/app/components/style-panel/design-provider.tsx +148 -0
  68. package/src/app/components/style-panel/style-panel.tsx +349 -0
  69. package/src/app/components/style-panel/use-design.ts +112 -0
  70. package/src/app/components/theme-toggle.tsx +59 -0
  71. package/src/app/components/themes/theme-detail.tsx +305 -0
  72. package/src/app/components/themes/themes-gallery.tsx +149 -0
  73. package/src/app/components/thumbnail-rail.tsx +805 -0
  74. package/src/app/components/ui/badge.tsx +45 -0
  75. package/src/app/components/ui/button.tsx +99 -0
  76. package/src/app/components/ui/card.tsx +92 -0
  77. package/src/app/components/ui/context-menu.tsx +237 -0
  78. package/src/app/components/ui/dialog.tsx +157 -0
  79. package/src/app/components/ui/dropdown-menu.tsx +245 -0
  80. package/src/app/components/ui/input.tsx +25 -0
  81. package/src/app/components/ui/label.tsx +24 -0
  82. package/src/app/components/ui/popover.tsx +75 -0
  83. package/src/app/components/ui/progress.tsx +31 -0
  84. package/src/app/components/ui/scroll-area.tsx +53 -0
  85. package/src/app/components/ui/select.tsx +196 -0
  86. package/src/app/components/ui/separator.tsx +28 -0
  87. package/src/app/components/ui/slider.tsx +61 -0
  88. package/src/app/components/ui/sonner.tsx +48 -0
  89. package/src/app/components/ui/tabs.tsx +79 -0
  90. package/src/app/components/ui/textarea.tsx +22 -0
  91. package/src/app/components/ui/toggle-group.tsx +83 -0
  92. package/src/app/components/ui/toggle.tsx +45 -0
  93. package/src/app/components/ui/tooltip.tsx +58 -0
  94. package/src/app/favicon.ico +0 -0
  95. package/src/app/index.html +13 -0
  96. package/src/app/lib/assets.ts +242 -0
  97. package/src/app/lib/design-presets.ts +94 -0
  98. package/src/app/lib/design.ts +58 -0
  99. package/src/app/lib/export-html.ts +326 -0
  100. package/src/app/lib/export-pdf.ts +298 -0
  101. package/src/app/lib/export-pptx.ts +284 -0
  102. package/src/app/lib/folders.ts +239 -0
  103. package/src/app/lib/inspector/fiber.test.ts +154 -0
  104. package/src/app/lib/inspector/fiber.ts +85 -0
  105. package/src/app/lib/inspector/use-comments.ts +74 -0
  106. package/src/app/lib/inspector/use-editor.ts +73 -0
  107. package/src/app/lib/inspector/use-notes.ts +134 -0
  108. package/src/app/lib/locale-store.ts +67 -0
  109. package/src/app/lib/page-context.tsx +38 -0
  110. package/src/app/lib/print-ready.test.ts +32 -0
  111. package/src/app/lib/print-ready.ts +51 -0
  112. package/src/app/lib/sdk.test.ts +13 -0
  113. package/src/app/lib/sdk.ts +37 -0
  114. package/src/app/lib/slides.ts +26 -0
  115. package/src/app/lib/step-context.tsx +261 -0
  116. package/src/app/lib/themes.ts +22 -0
  117. package/src/app/lib/transition.ts +30 -0
  118. package/src/app/lib/use-agent-socket.ts +18 -0
  119. package/src/app/lib/use-click-page-navigation.ts +60 -0
  120. package/src/app/lib/use-is-mobile.ts +21 -0
  121. package/src/app/lib/use-locale.ts +8 -0
  122. package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
  123. package/src/app/lib/use-slide-module.ts +48 -0
  124. package/src/app/lib/use-wheel-page-navigation.ts +99 -0
  125. package/src/app/lib/utils.test.ts +25 -0
  126. package/src/app/lib/utils.ts +6 -0
  127. package/src/app/main.tsx +14 -0
  128. package/src/app/routes/assets.tsx +9 -0
  129. package/src/app/routes/home-shell.tsx +213 -0
  130. package/src/app/routes/home.tsx +807 -0
  131. package/src/app/routes/presenter.tsx +418 -0
  132. package/src/app/routes/slide.tsx +1108 -0
  133. package/src/app/routes/themes.tsx +34 -0
  134. package/src/app/styles.css +429 -0
  135. package/src/app/virtual.d.ts +51 -0
  136. package/src/locale/en.ts +416 -0
  137. package/src/locale/format.ts +12 -0
  138. package/src/locale/index.ts +6 -0
  139. package/src/locale/ja.ts +422 -0
  140. package/src/locale/types.ts +443 -0
  141. package/src/locale/zh-cn.ts +414 -0
  142. package/src/locale/zh-tw.ts +414 -0
@@ -0,0 +1,1115 @@
1
+ import {
2
+ AlignCenter,
3
+ AlignJustify,
4
+ AlignLeft,
5
+ AlignRight,
6
+ Bold,
7
+ Crop,
8
+ Crosshair,
9
+ ImageIcon,
10
+ Italic,
11
+ X,
12
+ } from 'lucide-react';
13
+ import { useCallback, useEffect, useRef, useState } from 'react';
14
+ import { Field, NumberField, Section } from '@/components/panel/panel-fields';
15
+ import { PANEL_TRANSITION_MS, PanelShell, useAnimatedOpen } from '@/components/panel/panel-shell';
16
+ import { Button } from '@/components/ui/button';
17
+ import { Input } from '@/components/ui/input';
18
+ import {
19
+ Select,
20
+ SelectContent,
21
+ SelectItem,
22
+ SelectTrigger,
23
+ SelectValue,
24
+ } from '@/components/ui/select';
25
+ import { Separator } from '@/components/ui/separator';
26
+ import { Slider } from '@/components/ui/slider';
27
+ import { Textarea } from '@/components/ui/textarea';
28
+ import { Toggle } from '@/components/ui/toggle';
29
+ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
30
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
31
+ import { findSlideSource } from '@/lib/inspector/fiber';
32
+ import type { EditOp } from '@/lib/inspector/use-editor';
33
+ import { useAgentSocketConnected } from '@/lib/use-agent-socket';
34
+ import { useLocale } from '@/lib/use-locale';
35
+ import type { Locale } from '../../../locale/types';
36
+ import { AssetPickerDialog } from './asset-picker-dialog';
37
+ import { type SelectedTarget, useInspector } from './inspector-provider';
38
+
39
+ type ElementSnapshot = {
40
+ fontSize: number;
41
+ fontWeight: number;
42
+ fontStyle: 'normal' | 'italic';
43
+ color: string;
44
+ backgroundColor: string | null;
45
+ textAlign: 'left' | 'center' | 'right' | 'justify';
46
+ lineHeight: number | null;
47
+ letterSpacing: number;
48
+ text: string | null;
49
+ imageSrc: string | null;
50
+ placeholder: { hint: string; width?: number; height?: number } | null;
51
+ };
52
+
53
+ type ContentSelection = { start: number; end: number };
54
+ type StylePreview = Partial<
55
+ Pick<ElementSnapshot, 'fontSize' | 'fontWeight' | 'fontStyle' | 'color'>
56
+ >;
57
+ type RangeStylePreview = {
58
+ anchor: HTMLElement;
59
+ start: number;
60
+ end: number;
61
+ values: StylePreview;
62
+ };
63
+
64
+ function resolveSelectedTarget(target: SelectedTarget, slideId: string): SelectedTarget {
65
+ const hit = findSlideSource(target.anchor, slideId, { hostOnly: true });
66
+ if (!hit) return target;
67
+ if (hit.line === target.line && hit.column === target.column && hit.anchor === target.anchor) {
68
+ return target;
69
+ }
70
+ return { line: hit.line, column: hit.column, anchor: hit.anchor };
71
+ }
72
+
73
+ export function InspectorPanel() {
74
+ const { active, slideId, selected, setSelected, bufferOps, pendingCount, add, applyEdit } =
75
+ useInspector();
76
+ const [snapshot, setSnapshot] = useState<ElementSnapshot | null>(null);
77
+ const [contentSelection, setContentSelection] = useState<ContentSelection | null>(null);
78
+ const [rangeStylePreview, setRangeStylePreview] = useState<RangeStylePreview | null>(null);
79
+ const reloadCounter = useReloadCounter();
80
+ const t = useLocale();
81
+
82
+ useEffect(() => {
83
+ void selected;
84
+ setContentSelection(null);
85
+ setRangeStylePreview(null);
86
+ }, [selected]);
87
+
88
+ useEffect(() => {
89
+ void reloadCounter;
90
+ void pendingCount;
91
+ if (!selected) {
92
+ setSnapshot(null);
93
+ return;
94
+ }
95
+ let anchor = selected.anchor;
96
+ if (!anchor.isConnected) {
97
+ const next = findElementByLine(slideId, selected.line, selected.column);
98
+ if (next) {
99
+ anchor = next;
100
+ setSelected({ ...selected, anchor: next });
101
+ } else {
102
+ return;
103
+ }
104
+ }
105
+ setSnapshot(readSnapshot(anchor));
106
+ }, [selected, setSelected, slideId, reloadCounter, pendingCount]);
107
+
108
+ // Freeze slide animations while editing so commits don't replay motion.
109
+ useEffect(() => {
110
+ if (!active) return;
111
+ const root = document.querySelector<HTMLElement>('[data-inspector-root]');
112
+ if (!root) return;
113
+ const styleEl = document.createElement('style');
114
+ styleEl.textContent = EDITING_FREEZE_CSS;
115
+ document.head.appendChild(styleEl);
116
+ root.dataset.inspectorEditing = 'true';
117
+ return () => {
118
+ let cleaned = false;
119
+ const finish = () => {
120
+ if (cleaned) return;
121
+ cleaned = true;
122
+ styleEl.remove();
123
+ delete root.dataset.inspectorEditing;
124
+ import.meta.hot?.off('vite:afterUpdate', finish);
125
+ clearTimeout(timer);
126
+ };
127
+ const timer = setTimeout(finish, 1500);
128
+ import.meta.hot?.on('vite:afterUpdate', finish);
129
+ };
130
+ }, [active]);
131
+
132
+ const apply = useCallback(
133
+ (ops: EditOp[]) => {
134
+ if (!selected) return;
135
+ const target = resolveSelectedTarget(selected, slideId);
136
+ if (target !== selected) setSelected(target);
137
+ bufferOps(target.line, target.column, target.anchor, ops);
138
+ if (target.anchor.isConnected) setSnapshot(readSnapshot(target.anchor));
139
+ },
140
+ [selected, setSelected, slideId, bufferOps],
141
+ );
142
+
143
+ // `pinned` keeps the last selection rendered through the close-out
144
+ // animation so the panel's contents don't blank out before it collapses.
145
+ const targetOpen = active && !!selected && !!snapshot;
146
+ const [pinned, setPinned] = useState<{ s: SelectedTarget; n: ElementSnapshot } | null>(null);
147
+ const animVisible = useAnimatedOpen(targetOpen && !!pinned);
148
+
149
+ useEffect(() => {
150
+ if (selected && snapshot) setPinned({ s: selected, n: snapshot });
151
+ }, [selected, snapshot]);
152
+
153
+ useEffect(() => {
154
+ if (!targetOpen && pinned) {
155
+ const t = setTimeout(() => setPinned(null), PANEL_TRANSITION_MS);
156
+ return () => clearTimeout(t);
157
+ }
158
+ }, [targetOpen, pinned]);
159
+
160
+ if (!pinned) return null;
161
+ const { s: pinSelected, n: pinSnapshot } = pinned;
162
+ const contentRange =
163
+ pinSnapshot.text !== null && contentSelection && contentSelection.end > contentSelection.start
164
+ ? contentSelection
165
+ : null;
166
+ const rangePreviewApplies =
167
+ contentRange &&
168
+ rangeStylePreview &&
169
+ rangeStylePreview.anchor === pinSelected.anchor &&
170
+ rangeStylePreview.start === contentRange.start &&
171
+ rangeStylePreview.end === contentRange.end;
172
+ const typographySnapshot = rangePreviewApplies
173
+ ? withStylePreview(pinSnapshot, rangeStylePreview.values)
174
+ : pinSnapshot;
175
+ const applyTextStyle = (ops: EditOp[]) => {
176
+ const styleOps = ops.flatMap((op) => (op.kind === 'set-style' ? [op] : []));
177
+ const target = resolveSelectedTarget(pinSelected, slideId);
178
+ if (target !== pinSelected) setSelected(target);
179
+ if (
180
+ contentRange &&
181
+ pinSnapshot.text !== null &&
182
+ styleOps.length === 1 &&
183
+ styleOps.length === ops.length &&
184
+ styleOps.every((op) => INLINE_CONTENT_STYLE_KEYS.has(op.key))
185
+ ) {
186
+ bufferOps(
187
+ target.line,
188
+ target.column,
189
+ target.anchor,
190
+ styleOps.map((op) => ({
191
+ kind: 'set-text-range-style',
192
+ start: contentRange.start,
193
+ end: contentRange.end,
194
+ key: op.key,
195
+ value: op.value,
196
+ prevText: pinSnapshot.text ?? undefined,
197
+ })),
198
+ );
199
+ setRangeStylePreview((current) => ({
200
+ anchor: target.anchor,
201
+ start: contentRange.start,
202
+ end: contentRange.end,
203
+ values: {
204
+ ...(current?.anchor === target.anchor &&
205
+ current.start === contentRange.start &&
206
+ current.end === contentRange.end
207
+ ? current.values
208
+ : {}),
209
+ ...stylePreviewFromOps(styleOps),
210
+ },
211
+ }));
212
+ if (target.anchor.isConnected) setSnapshot(readSnapshot(target.anchor));
213
+ return;
214
+ }
215
+ if (
216
+ pinSnapshot.text !== null &&
217
+ styleOps.length > 0 &&
218
+ styleOps.length === ops.length &&
219
+ styleOps.every((op) => INLINE_CONTENT_STYLE_KEYS.has(op.key))
220
+ ) {
221
+ bufferOps(
222
+ target.line,
223
+ target.column,
224
+ target.anchor,
225
+ styleOps.map((op) => ({ ...op, prevText: pinSnapshot.text ?? undefined })),
226
+ );
227
+ if (target.anchor.isConnected) setSnapshot(readSnapshot(target.anchor));
228
+ return;
229
+ }
230
+ apply(ops);
231
+ };
232
+
233
+ return (
234
+ <PanelShell
235
+ uiAttr="inspector"
236
+ animVisible={animVisible}
237
+ header={
238
+ <>
239
+ <div className="flex min-w-0 items-center gap-2">
240
+ <Crosshair className="size-3.5 text-muted-foreground" />
241
+ <span className="font-heading text-[12px] font-semibold tracking-tight">
242
+ {t.inspector.inspect}
243
+ </span>
244
+ <span aria-hidden className="h-3 w-px bg-hairline" />
245
+ <span className="rounded-[3px] border border-hairline bg-card px-1.5 py-px font-mono text-[10.5px] text-foreground/85">
246
+ &lt;{pinSelected.anchor.tagName.toLowerCase()}&gt;
247
+ </span>
248
+ </div>
249
+ <div className="flex items-center gap-1.5">
250
+ <AgentWatchingBadge />
251
+ <Button
252
+ variant="ghost"
253
+ size="icon-sm"
254
+ className="text-muted-foreground hover:text-foreground"
255
+ onClick={() => setSelected(null)}
256
+ aria-label={t.inspector.deselect}
257
+ >
258
+ <X className="size-3.5" />
259
+ </Button>
260
+ </div>
261
+ </>
262
+ }
263
+ footer={<CommentsSection selected={pinSelected} onAdd={add} />}
264
+ >
265
+ {pinSnapshot.text !== null && (
266
+ <>
267
+ <Section title={t.inspector.contentSection}>
268
+ <ContentField
269
+ snapshot={pinSnapshot}
270
+ apply={apply}
271
+ onSelectionChange={setContentSelection}
272
+ />
273
+ </Section>
274
+ <Separator />
275
+ </>
276
+ )}
277
+
278
+ <Section title={t.inspector.typographySection}>
279
+ <FontSizeField snapshot={typographySnapshot} apply={applyTextStyle} />
280
+ <FontWeightField snapshot={typographySnapshot} apply={applyTextStyle} />
281
+ <StyleToggles snapshot={typographySnapshot} apply={applyTextStyle} />
282
+ <LineHeightField snapshot={pinSnapshot} apply={apply} />
283
+ <LetterSpacingField snapshot={pinSnapshot} apply={apply} />
284
+ <TextAlignField snapshot={pinSnapshot} apply={apply} />
285
+ </Section>
286
+
287
+ <Separator />
288
+
289
+ <Section title={t.inspector.colorSection}>
290
+ <ColorField
291
+ label={t.inspector.textColor}
292
+ value={typographySnapshot.color}
293
+ onChange={(v) => applyTextStyle([{ kind: 'set-style', key: 'color', value: v }])}
294
+ clearable={false}
295
+ />
296
+ <ColorField
297
+ label={t.inspector.backgroundColor}
298
+ value={pinSnapshot.backgroundColor ?? '#ffffff'}
299
+ dim={!pinSnapshot.backgroundColor}
300
+ onChange={(v) => apply([{ kind: 'set-style', key: 'backgroundColor', value: v }])}
301
+ onClear={() => apply([{ kind: 'set-style', key: 'backgroundColor', value: null }])}
302
+ clearable
303
+ />
304
+ </Section>
305
+
306
+ {pinSnapshot.imageSrc !== null && (
307
+ <>
308
+ <Separator />
309
+ <Section title={t.inspector.imageSection}>
310
+ <ImageField src={pinSnapshot.imageSrc} anchor={pinSelected.anchor} />
311
+ </Section>
312
+ </>
313
+ )}
314
+
315
+ {pinSnapshot.placeholder && (
316
+ <>
317
+ <Separator />
318
+ <Section title={t.inspector.imagePlaceholderSection}>
319
+ <PlaceholderField
320
+ slideId={slideId}
321
+ hint={pinSnapshot.placeholder.hint}
322
+ line={pinSelected.line}
323
+ column={pinSelected.column}
324
+ applyEdit={applyEdit}
325
+ />
326
+ </Section>
327
+ </>
328
+ )}
329
+ </PanelShell>
330
+ );
331
+ }
332
+
333
+ const EDITING_FREEZE_CSS = `
334
+ [data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *),
335
+ [data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *)::before,
336
+ [data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *)::after {
337
+ animation-duration: 1ms !important;
338
+ animation-delay: 0s !important;
339
+ animation-iteration-count: 1 !important;
340
+ animation-fill-mode: forwards !important;
341
+ transition: none !important;
342
+ view-transition-name: none !important;
343
+ cursor: pointer !important;
344
+ }
345
+ `;
346
+
347
+ const INLINE_CONTENT_STYLE_KEYS = new Set([
348
+ 'fontSize',
349
+ 'fontWeight',
350
+ 'fontStyle',
351
+ 'fontFamily',
352
+ 'color',
353
+ ]);
354
+
355
+ function stylePreviewFromOps(ops: Array<Extract<EditOp, { kind: 'set-style' }>>): StylePreview {
356
+ const preview: StylePreview = {};
357
+ for (const op of ops) {
358
+ if (op.key === 'fontSize' && op.value) {
359
+ const n = parseFloat(op.value);
360
+ if (Number.isFinite(n)) preview.fontSize = n;
361
+ } else if (op.key === 'fontWeight') {
362
+ preview.fontWeight = op.value ? Number(op.value) || 400 : 400;
363
+ } else if (op.key === 'fontStyle') {
364
+ preview.fontStyle = op.value === 'italic' ? 'italic' : 'normal';
365
+ } else if (op.key === 'color' && op.value) {
366
+ preview.color = op.value;
367
+ }
368
+ }
369
+ return preview;
370
+ }
371
+
372
+ function withStylePreview(snapshot: ElementSnapshot, preview: StylePreview): ElementSnapshot {
373
+ return { ...snapshot, ...preview };
374
+ }
375
+
376
+ function ContentField({
377
+ snapshot,
378
+ apply,
379
+ onSelectionChange,
380
+ }: {
381
+ snapshot: ElementSnapshot;
382
+ apply: (ops: EditOp[]) => void;
383
+ onSelectionChange?: (selection: ContentSelection | null) => void;
384
+ }) {
385
+ // Mirror the value locally and skip syncs during IME composition;
386
+ // a re-render mid-composition would otherwise clobber in-progress
387
+ // candidates (Bopomofo/Pinyin only commit on candidate selection).
388
+ const [local, setLocal] = useState(snapshot.text ?? '');
389
+ const composingRef = useRef(false);
390
+ const t = useLocale();
391
+
392
+ useEffect(() => {
393
+ if (!composingRef.current) setLocal(snapshot.text ?? '');
394
+ }, [snapshot.text]);
395
+
396
+ const reportSelection = (el: HTMLTextAreaElement) => {
397
+ const start = el.selectionStart ?? 0;
398
+ const end = el.selectionEnd ?? start;
399
+ onSelectionChange?.(end > start ? { start, end } : null);
400
+ };
401
+
402
+ return (
403
+ <Textarea
404
+ value={local}
405
+ onCompositionStart={() => {
406
+ composingRef.current = true;
407
+ }}
408
+ onCompositionEnd={(e) => {
409
+ composingRef.current = false;
410
+ const v = e.currentTarget.value;
411
+ setLocal(v);
412
+ reportSelection(e.currentTarget);
413
+ apply([{ kind: 'set-text', value: v }]);
414
+ }}
415
+ onChange={(e) => {
416
+ const v = e.target.value;
417
+ setLocal(v);
418
+ reportSelection(e.currentTarget);
419
+ if (!composingRef.current) {
420
+ apply([{ kind: 'set-text', value: v }]);
421
+ }
422
+ }}
423
+ onKeyUp={(e) => reportSelection(e.currentTarget)}
424
+ onMouseUp={(e) => reportSelection(e.currentTarget)}
425
+ onSelect={(e) => reportSelection(e.currentTarget)}
426
+ wrap="off"
427
+ rows={3}
428
+ className="field-sizing-fixed min-h-16 w-full resize-none overflow-x-auto whitespace-pre text-xs"
429
+ placeholder={t.inspector.elementTextPlaceholder}
430
+ />
431
+ );
432
+ }
433
+
434
+ function FontSizeField({
435
+ snapshot,
436
+ apply,
437
+ }: {
438
+ snapshot: ElementSnapshot;
439
+ apply: (ops: EditOp[]) => void;
440
+ }) {
441
+ const set = (px: number) => {
442
+ apply([{ kind: 'set-style', key: 'fontSize', value: `${Math.round(px)}px` }]);
443
+ };
444
+ const t = useLocale();
445
+ return (
446
+ <Field label={t.inspector.sizeLabel}>
447
+ <Slider
448
+ min={8}
449
+ max={200}
450
+ step={1}
451
+ value={[snapshot.fontSize]}
452
+ onValueChange={([v]) => set(v ?? snapshot.fontSize)}
453
+ className="flex-1"
454
+ />
455
+ <NumberField
456
+ value={Math.round(snapshot.fontSize)}
457
+ onChange={set}
458
+ min={1}
459
+ max={400}
460
+ suffix="px"
461
+ />
462
+ </Field>
463
+ );
464
+ }
465
+
466
+ function getWeightOptions(t: Locale): { value: string; label: string }[] {
467
+ return [
468
+ { value: '300', label: t.inspector.weightLight },
469
+ { value: '400', label: t.inspector.weightRegular },
470
+ { value: '500', label: t.inspector.weightMedium },
471
+ { value: '600', label: t.inspector.weightSemibold },
472
+ { value: '700', label: t.inspector.weightBold },
473
+ { value: '800', label: t.inspector.weightExtrabold },
474
+ ];
475
+ }
476
+
477
+ function FontWeightField({
478
+ snapshot,
479
+ apply,
480
+ }: {
481
+ snapshot: ElementSnapshot;
482
+ apply: (ops: EditOp[]) => void;
483
+ }) {
484
+ const t = useLocale();
485
+ const weightOptions = getWeightOptions(t);
486
+ return (
487
+ <Field label={t.inspector.weightLabel}>
488
+ <Select
489
+ value={String(snapshot.fontWeight)}
490
+ onValueChange={(value) => {
491
+ const n = Number(value);
492
+ apply([
493
+ {
494
+ kind: 'set-style',
495
+ key: 'fontWeight',
496
+ value: n === 400 ? null : value,
497
+ },
498
+ ]);
499
+ }}
500
+ >
501
+ <SelectTrigger size="sm" className="h-8 flex-1 text-xs">
502
+ <SelectValue />
503
+ </SelectTrigger>
504
+ <SelectContent>
505
+ {weightOptions.map((opt) => (
506
+ <SelectItem key={opt.value} value={opt.value} className="text-xs">
507
+ {opt.label}
508
+ </SelectItem>
509
+ ))}
510
+ </SelectContent>
511
+ </Select>
512
+ </Field>
513
+ );
514
+ }
515
+
516
+ function StyleToggles({
517
+ snapshot,
518
+ apply,
519
+ }: {
520
+ snapshot: ElementSnapshot;
521
+ apply: (ops: EditOp[]) => void;
522
+ }) {
523
+ const t = useLocale();
524
+ return (
525
+ <Field label={t.inspector.styleLabel}>
526
+ <Toggle
527
+ size="sm"
528
+ variant="outline"
529
+ pressed={snapshot.fontWeight >= 600}
530
+ onPressedChange={(v) =>
531
+ apply([{ kind: 'set-style', key: 'fontWeight', value: v ? '700' : null }])
532
+ }
533
+ aria-label={t.inspector.boldAria}
534
+ >
535
+ <Bold className="size-3.5" />
536
+ </Toggle>
537
+ <Toggle
538
+ size="sm"
539
+ variant="outline"
540
+ pressed={snapshot.fontStyle === 'italic'}
541
+ onPressedChange={(v) =>
542
+ apply([{ kind: 'set-style', key: 'fontStyle', value: v ? 'italic' : null }])
543
+ }
544
+ aria-label={t.inspector.italicAria}
545
+ >
546
+ <Italic className="size-3.5" />
547
+ </Toggle>
548
+ </Field>
549
+ );
550
+ }
551
+
552
+ function LineHeightField({
553
+ snapshot,
554
+ apply,
555
+ }: {
556
+ snapshot: ElementSnapshot;
557
+ apply: (ops: EditOp[]) => void;
558
+ }) {
559
+ const v = snapshot.lineHeight ?? 1.4;
560
+ const set = (n: number) => {
561
+ apply([{ kind: 'set-style', key: 'lineHeight', value: String(round2(n)) }]);
562
+ };
563
+ const t = useLocale();
564
+ return (
565
+ <Field label={t.inspector.lineHeightLabel}>
566
+ <Slider
567
+ min={0.8}
568
+ max={3}
569
+ step={0.05}
570
+ value={[v]}
571
+ onValueChange={([n]) => set(n ?? v)}
572
+ className="flex-1"
573
+ />
574
+ <NumberField value={round2(v)} onChange={set} step={0.05} min={0.5} max={5} />
575
+ </Field>
576
+ );
577
+ }
578
+
579
+ function LetterSpacingField({
580
+ snapshot,
581
+ apply,
582
+ }: {
583
+ snapshot: ElementSnapshot;
584
+ apply: (ops: EditOp[]) => void;
585
+ }) {
586
+ const set = (n: number) => {
587
+ apply([
588
+ {
589
+ kind: 'set-style',
590
+ key: 'letterSpacing',
591
+ value: n === 0 ? null : `${round2(n)}px`,
592
+ },
593
+ ]);
594
+ };
595
+ const t = useLocale();
596
+ return (
597
+ <Field label={t.inspector.trackingLabel}>
598
+ <Slider
599
+ min={-5}
600
+ max={20}
601
+ step={0.1}
602
+ value={[snapshot.letterSpacing]}
603
+ onValueChange={([n]) => set(n ?? snapshot.letterSpacing)}
604
+ className="flex-1"
605
+ />
606
+ <NumberField
607
+ value={round2(snapshot.letterSpacing)}
608
+ onChange={set}
609
+ step={0.1}
610
+ min={-20}
611
+ max={50}
612
+ suffix="px"
613
+ />
614
+ </Field>
615
+ );
616
+ }
617
+
618
+ const ALIGN_OPTIONS = [
619
+ { v: 'left', icon: AlignLeft },
620
+ { v: 'center', icon: AlignCenter },
621
+ { v: 'right', icon: AlignRight },
622
+ { v: 'justify', icon: AlignJustify },
623
+ ] as const;
624
+
625
+ function TextAlignField({
626
+ snapshot,
627
+ apply,
628
+ }: {
629
+ snapshot: ElementSnapshot;
630
+ apply: (ops: EditOp[]) => void;
631
+ }) {
632
+ const t = useLocale();
633
+ return (
634
+ <Field label={t.inspector.alignLabel}>
635
+ <ToggleGroup
636
+ type="single"
637
+ size="sm"
638
+ variant="outline"
639
+ value={snapshot.textAlign}
640
+ onValueChange={(value) => {
641
+ if (!value) return;
642
+ apply([
643
+ {
644
+ kind: 'set-style',
645
+ key: 'textAlign',
646
+ value: value === 'left' ? null : value,
647
+ },
648
+ ]);
649
+ }}
650
+ >
651
+ {ALIGN_OPTIONS.map(({ v, icon: Icon }) => (
652
+ <ToggleGroupItem key={v} value={v} aria-label={v} className="size-8">
653
+ <Icon className="size-3.5" />
654
+ </ToggleGroupItem>
655
+ ))}
656
+ </ToggleGroup>
657
+ </Field>
658
+ );
659
+ }
660
+
661
+ function ColorField({
662
+ label,
663
+ value,
664
+ dim,
665
+ onChange,
666
+ onClear,
667
+ clearable,
668
+ }: {
669
+ label: string;
670
+ value: string;
671
+ dim?: boolean;
672
+ onChange: (v: string) => void;
673
+ onClear?: () => void;
674
+ clearable: boolean;
675
+ }) {
676
+ // Buffer the text input so intermediate hex like "#a" doesn't
677
+ // commit until it parses as a full color.
678
+ const [draft, setDraft] = useState(value);
679
+ const tColor = useLocale();
680
+ useEffect(() => setDraft(value), [value]);
681
+
682
+ const commitHex = (hex: string) => {
683
+ if (/^#[0-9a-fA-F]{6}$/.test(hex)) onChange(hex);
684
+ };
685
+
686
+ return (
687
+ <Field label={label}>
688
+ <label className="relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md border bg-background shadow-xs">
689
+ <span
690
+ className="size-5 rounded-sm"
691
+ style={{
692
+ backgroundColor: dim ? 'transparent' : value,
693
+ backgroundImage: dim
694
+ ? 'linear-gradient(45deg, #d4d4d4 25%, transparent 25%, transparent 75%, #d4d4d4 75%), linear-gradient(45deg, #d4d4d4 25%, transparent 25%, transparent 75%, #d4d4d4 75%)'
695
+ : undefined,
696
+ backgroundSize: dim ? '8px 8px' : undefined,
697
+ backgroundPosition: dim ? '0 0, 4px 4px' : undefined,
698
+ }}
699
+ />
700
+ <input
701
+ type="color"
702
+ value={value}
703
+ onChange={(e) => {
704
+ setDraft(e.target.value);
705
+ onChange(e.target.value);
706
+ }}
707
+ className="absolute inset-0 cursor-pointer opacity-0"
708
+ />
709
+ </label>
710
+ <Input
711
+ type="text"
712
+ value={draft}
713
+ onChange={(e) => {
714
+ setDraft(e.target.value);
715
+ commitHex(e.target.value);
716
+ }}
717
+ className="h-8 flex-1 font-mono text-[11px] uppercase"
718
+ spellCheck={false}
719
+ />
720
+ {clearable && onClear && (
721
+ <Button
722
+ variant="ghost"
723
+ size="icon"
724
+ className="size-8 text-muted-foreground hover:text-foreground"
725
+ onClick={onClear}
726
+ aria-label={tColor.inspector.clearAria}
727
+ >
728
+ <X className="size-3.5" />
729
+ </Button>
730
+ )}
731
+ </Field>
732
+ );
733
+ }
734
+
735
+ function ImageField({ src, anchor }: { src: string; anchor: HTMLElement }) {
736
+ const t = useLocale();
737
+ const { openCrop, openReplace } = useInspector();
738
+ const isImage = anchor.tagName === 'IMG';
739
+ return (
740
+ <div className="space-y-2">
741
+ <div className="flex items-center gap-3">
742
+ <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]">
743
+ <img
744
+ src={src}
745
+ alt=""
746
+ className="size-full object-contain"
747
+ draggable={false}
748
+ onError={(e) => {
749
+ e.currentTarget.style.display = 'none';
750
+ }}
751
+ />
752
+ </div>
753
+ <div className="flex flex-1 gap-2">
754
+ <Button
755
+ type="button"
756
+ variant="outline"
757
+ size="sm"
758
+ className="flex-1"
759
+ onClick={() => openReplace(anchor)}
760
+ >
761
+ <ImageIcon className="size-3.5" />
762
+ {t.inspector.replace}
763
+ </Button>
764
+ {isImage && (
765
+ <Button
766
+ type="button"
767
+ variant="outline"
768
+ size="sm"
769
+ className="flex-1"
770
+ onClick={() => openCrop(anchor as HTMLImageElement)}
771
+ >
772
+ <Crop className="size-3.5" />
773
+ {t.inspector.crop}
774
+ </Button>
775
+ )}
776
+ </div>
777
+ </div>
778
+ </div>
779
+ );
780
+ }
781
+
782
+ function PlaceholderField({
783
+ slideId,
784
+ hint,
785
+ line,
786
+ column,
787
+ applyEdit,
788
+ }: {
789
+ slideId: string;
790
+ hint: string;
791
+ line: number;
792
+ column: number;
793
+ applyEdit: (line: number, column: number, ops: EditOp[]) => Promise<void>;
794
+ }) {
795
+ const [open, setOpen] = useState(false);
796
+ const [submitting, setSubmitting] = useState(false);
797
+ const t = useLocale();
798
+ return (
799
+ <div className="space-y-2">
800
+ <p className="text-[11px] leading-relaxed text-muted-foreground">
801
+ {t.inspector.placeholderHintLabel}{' '}
802
+ <span className="font-medium text-foreground">{hint}</span>
803
+ </p>
804
+ <Button
805
+ type="button"
806
+ variant="outline"
807
+ size="sm"
808
+ className="w-full"
809
+ disabled={submitting}
810
+ onClick={() => setOpen(true)}
811
+ >
812
+ <ImageIcon className="size-3.5" />
813
+ {t.inspector.replace}
814
+ </Button>
815
+ {open && (
816
+ <AssetPickerDialog
817
+ slideId={slideId}
818
+ onClose={() => setOpen(false)}
819
+ onPick={async (asset, scope) => {
820
+ setOpen(false);
821
+ setSubmitting(true);
822
+ try {
823
+ const assetPath =
824
+ scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
825
+ await applyEdit(line, column, [
826
+ {
827
+ kind: 'replace-placeholder-with-image',
828
+ assetPath,
829
+ },
830
+ ]);
831
+ } finally {
832
+ setSubmitting(false);
833
+ }
834
+ }}
835
+ />
836
+ )}
837
+ </div>
838
+ );
839
+ }
840
+
841
+ function AgentWatchingBadge() {
842
+ const t = useLocale();
843
+ const connected = useAgentSocketConnected();
844
+ return (
845
+ <TooltipProvider delayDuration={200}>
846
+ <Tooltip>
847
+ <TooltipTrigger asChild>
848
+ <button
849
+ type="button"
850
+ className="flex shrink-0 cursor-help items-center gap-1.5 rounded-[3px] border border-hairline bg-card px-1.5 py-px text-[10.5px] text-foreground/85 outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
851
+ >
852
+ <span aria-hidden className="relative flex size-1.5 items-center justify-center">
853
+ {connected ? (
854
+ <>
855
+ <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />
856
+ <span className="relative inline-flex size-1.5 rounded-full bg-emerald-500" />
857
+ </>
858
+ ) : (
859
+ <span className="relative inline-flex size-1.5 rounded-full bg-rose-500" />
860
+ )}
861
+ </span>
862
+ {connected ? t.inspector.agentWatching : t.inspector.agentNotWatching}
863
+ </button>
864
+ </TooltipTrigger>
865
+ <TooltipContent
866
+ side="bottom"
867
+ align="end"
868
+ className="w-max max-w-[min(520px,calc(100vw-2rem))] text-center leading-relaxed"
869
+ >
870
+ {connected ? t.inspector.agentWatchingTooltip : t.inspector.agentNotWatchingTooltip}
871
+ </TooltipContent>
872
+ </Tooltip>
873
+ </TooltipProvider>
874
+ );
875
+ }
876
+
877
+ function CommentsSection({
878
+ selected,
879
+ onAdd,
880
+ }: {
881
+ selected: { line: number; column: number };
882
+ onAdd: (line: number, column: number, text: string) => Promise<void>;
883
+ }) {
884
+ const [draft, setDraft] = useState('');
885
+ const [submitting, setSubmitting] = useState(false);
886
+ const wrapRef = useRef<HTMLDivElement>(null);
887
+ const t = useLocale();
888
+
889
+ useEffect(() => {
890
+ const onKey = (e: KeyboardEvent) => {
891
+ if (e.key !== '/') return;
892
+ if (!(e.metaKey || e.ctrlKey)) return;
893
+ if (e.altKey || e.shiftKey) return;
894
+ const ta = wrapRef.current?.querySelector('textarea');
895
+ if (!ta) return;
896
+ e.preventDefault();
897
+ ta.focus({ preventScroll: true });
898
+ };
899
+ window.addEventListener('keydown', onKey);
900
+ return () => window.removeEventListener('keydown', onKey);
901
+ }, []);
902
+
903
+ const submit = async () => {
904
+ const trimmed = draft.trim();
905
+ if (!trimmed) return;
906
+ setSubmitting(true);
907
+ try {
908
+ await onAdd(selected.line, selected.column, trimmed);
909
+ setDraft('');
910
+ } finally {
911
+ setSubmitting(false);
912
+ }
913
+ };
914
+
915
+ return (
916
+ <Section title={t.inspector.leaveComment}>
917
+ <div className="flex flex-col gap-2">
918
+ <div ref={wrapRef} className="comment-cue rounded-[6px]">
919
+ <Textarea
920
+ value={draft}
921
+ onChange={(e) => setDraft(e.target.value)}
922
+ onKeyDown={(e) => {
923
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
924
+ e.preventDefault();
925
+ submit();
926
+ }
927
+ }}
928
+ placeholder={t.inspector.commentPlaceholder}
929
+ className="min-h-16 resize-none text-[12px]"
930
+ />
931
+ </div>
932
+ <div className="flex items-center justify-between gap-2">
933
+ <span className="font-mono text-[10.5px] text-muted-foreground/70">
934
+ {t.inspector.commentShortcutHint}
935
+ </span>
936
+ <Button size="sm" variant="brand" disabled={submitting || !draft.trim()} onClick={submit}>
937
+ {t.inspector.addComment}
938
+ </Button>
939
+ </div>
940
+ </div>
941
+ </Section>
942
+ );
943
+ }
944
+
945
+ function readSnapshot(el: HTMLElement): ElementSnapshot {
946
+ const cs = getComputedStyle(el);
947
+ const text = isSimpleTextElement(el) ? readEditableText(el) : null;
948
+ const imageSrc =
949
+ el.tagName === 'IMG'
950
+ ? (el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src || null
951
+ : null;
952
+ const ph = el.dataset.slidePlaceholder ?? null;
953
+ const placeholder =
954
+ ph !== null
955
+ ? {
956
+ hint: ph,
957
+ width: el.dataset.placeholderW ? Number(el.dataset.placeholderW) : undefined,
958
+ height: el.dataset.placeholderH ? Number(el.dataset.placeholderH) : undefined,
959
+ }
960
+ : null;
961
+
962
+ return {
963
+ fontSize: parseFloat(cs.fontSize) || 16,
964
+ fontWeight: parseInt(cs.fontWeight, 10) || 400,
965
+ fontStyle: cs.fontStyle === 'italic' ? 'italic' : 'normal',
966
+ color: rgbToHex(cs.color) ?? '#000000',
967
+ backgroundColor: isTransparent(cs.backgroundColor) ? null : rgbToHex(cs.backgroundColor),
968
+ textAlign: normalizeTextAlign(cs.textAlign),
969
+ lineHeight: parseLineHeight(cs.lineHeight, parseFloat(cs.fontSize) || 16),
970
+ letterSpacing: parseLetterSpacing(cs.letterSpacing),
971
+ text,
972
+ imageSrc,
973
+ placeholder,
974
+ };
975
+ }
976
+
977
+ function isSimpleTextElement(el: HTMLElement): boolean {
978
+ if (el.childNodes.length === 0) return true;
979
+ return hasOnlyInlineTextChildren(el);
980
+ }
981
+
982
+ const INLINE_TEXT_TAGS = new Set([
983
+ 'B',
984
+ 'CODE',
985
+ 'DEL',
986
+ 'EM',
987
+ 'I',
988
+ 'INS',
989
+ 'MARK',
990
+ 'S',
991
+ 'SMALL',
992
+ 'SPAN',
993
+ 'STRONG',
994
+ 'SUB',
995
+ 'SUP',
996
+ 'U',
997
+ ]);
998
+
999
+ function hasOnlyInlineTextChildren(el: HTMLElement): boolean {
1000
+ for (const child of Array.from(el.childNodes)) {
1001
+ if (child.nodeType === Node.TEXT_NODE) {
1002
+ continue;
1003
+ } else if (child instanceof HTMLElement) {
1004
+ if (child.tagName === 'BR') continue;
1005
+ if (INLINE_TEXT_TAGS.has(child.tagName) && hasOnlyInlineTextChildren(child)) continue;
1006
+ }
1007
+ return false;
1008
+ }
1009
+ return true;
1010
+ }
1011
+
1012
+ function readEditableText(el: HTMLElement): string {
1013
+ const parts: string[] = [];
1014
+ for (const child of Array.from(el.childNodes)) {
1015
+ if (child.nodeType === Node.TEXT_NODE) {
1016
+ parts.push(renderedTextNodeValue(child as Text));
1017
+ } else if (child instanceof HTMLBRElement) {
1018
+ parts.push('\n');
1019
+ } else if (child instanceof HTMLElement) {
1020
+ parts.push(readEditableText(child));
1021
+ }
1022
+ }
1023
+ return normalizeRenderedText(parts);
1024
+ }
1025
+
1026
+ function normalizeRenderedText(parts: string[]): string {
1027
+ return parts
1028
+ .map((part, index) => {
1029
+ if (part === '\n') return part;
1030
+ let next = part;
1031
+ if (parts[index - 1] === '\n') next = next.replace(/^\s+/, '');
1032
+ if (parts[index + 1] === '\n') next = next.replace(/\s+$/, '');
1033
+ return next;
1034
+ })
1035
+ .join('');
1036
+ }
1037
+
1038
+ function renderedTextNodeValue(node: Text): string {
1039
+ const value = node.textContent ?? '';
1040
+ const whiteSpace = node.parentElement ? getComputedStyle(node.parentElement).whiteSpace : '';
1041
+ if (whiteSpace === 'pre' || whiteSpace === 'pre-wrap' || whiteSpace === 'break-spaces') {
1042
+ return value;
1043
+ }
1044
+ return value.replace(/\s+/g, ' ');
1045
+ }
1046
+
1047
+ function rgbToHex(value: string): string | null {
1048
+ const m = value.match(/^rgba?\(([^)]+)\)$/);
1049
+ if (!m) return null;
1050
+ const parts = m[1].split(',').map((s) => s.trim());
1051
+ if (parts.length < 3) return null;
1052
+ const r = clampByte(Number(parts[0]));
1053
+ const g = clampByte(Number(parts[1]));
1054
+ const b = clampByte(Number(parts[2]));
1055
+ return `#${[r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')}`;
1056
+ }
1057
+
1058
+ function clampByte(n: number): number {
1059
+ return Math.max(0, Math.min(255, Math.round(Number.isFinite(n) ? n : 0)));
1060
+ }
1061
+
1062
+ function isTransparent(value: string): boolean {
1063
+ if (!value) return true;
1064
+ if (value === 'transparent' || value === 'rgba(0, 0, 0, 0)') return true;
1065
+ const m = value.match(/^rgba\([^)]*,\s*0\)$/);
1066
+ return Boolean(m);
1067
+ }
1068
+
1069
+ function normalizeTextAlign(v: string): ElementSnapshot['textAlign'] {
1070
+ if (v === 'center' || v === 'right' || v === 'justify') return v;
1071
+ return 'left';
1072
+ }
1073
+
1074
+ function parseLineHeight(value: string, fontSize: number): number | null {
1075
+ if (!value || value === 'normal') return null;
1076
+ const n = parseFloat(value);
1077
+ if (!Number.isFinite(n) || n === 0) return null;
1078
+ return round2(n / fontSize);
1079
+ }
1080
+
1081
+ function parseLetterSpacing(value: string): number {
1082
+ if (!value || value === 'normal') return 0;
1083
+ const n = parseFloat(value);
1084
+ return Number.isFinite(n) ? round2(n) : 0;
1085
+ }
1086
+
1087
+ function round2(n: number): number {
1088
+ return Math.round(n * 100) / 100;
1089
+ }
1090
+
1091
+ function findElementByLine(slideId: string, line: number, column: number): HTMLElement | null {
1092
+ const root = document.querySelector('[data-inspector-root]');
1093
+ if (!root) return null;
1094
+ const tagged = root.querySelector<HTMLElement>(`[data-slide-loc="${line}:${column}"]`);
1095
+ if (tagged) return tagged;
1096
+ const candidates = root.querySelectorAll<HTMLElement>('*');
1097
+ for (const el of candidates) {
1098
+ const hit = findSlideSource(el, slideId, { hostOnly: true });
1099
+ if (hit && hit.line === line) return hit.anchor;
1100
+ }
1101
+ return null;
1102
+ }
1103
+
1104
+ function useReloadCounter(): number {
1105
+ const [n, setN] = useState(0);
1106
+ useEffect(() => {
1107
+ if (!import.meta.hot) return;
1108
+ const handler = () => setN((x) => x + 1);
1109
+ import.meta.hot.on('vite:afterUpdate', handler);
1110
+ return () => {
1111
+ import.meta.hot?.off('vite:afterUpdate', handler);
1112
+ };
1113
+ }, []);
1114
+ return n;
1115
+ }