@open-slide/core 1.2.0 → 1.4.0

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 (56) hide show
  1. package/dist/{build-6BeQ3cxb.js → build-1Rqivz0d.js} +2 -2
  2. package/dist/cli/bin.js +5 -5
  3. package/dist/{config-AxZ5OE1u.js → config-XZJnC_fu.js} +735 -64
  4. package/dist/{config-CtT8K4VF.d.ts → config-s0YUbmUe.d.ts} +3 -1
  5. package/dist/{dev-C9eLmUEq.js → dev-0W8gYiSa.js} +2 -2
  6. package/dist/en-7GU-DHbJ.js +361 -0
  7. package/dist/index.d.ts +4 -3
  8. package/dist/index.js +229 -39
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +136 -342
  11. package/dist/{preview-Cunm-f4i.js → preview-DT9hJvzM.js} +2 -2
  12. package/dist/sync-j9_QPovT.js +3 -0
  13. package/dist/{types-CRHIeoNq.d.ts → types-QCpkHkiS.d.ts} +42 -2
  14. package/dist/vite/index.d.ts +2 -2
  15. package/dist/vite/index.js +2 -2
  16. package/package.json +9 -1
  17. package/skills/create-slide/SKILL.md +1 -1
  18. package/skills/create-theme/SKILL.md +60 -12
  19. package/skills/slide-authoring/SKILL.md +21 -2
  20. package/src/app/app.tsx +13 -1
  21. package/src/app/components/asset-view.tsx +37 -22
  22. package/src/app/components/image-placeholder.tsx +123 -1
  23. package/src/app/components/inspector/inspect-overlay.tsx +49 -3
  24. package/src/app/components/inspector/inspector-panel.tsx +370 -30
  25. package/src/app/components/inspector/inspector-provider.tsx +390 -49
  26. package/src/app/components/player.tsx +25 -5
  27. package/src/app/components/present/control-bar.tsx +12 -0
  28. package/src/app/components/sidebar/folder-item.tsx +27 -5
  29. package/src/app/components/sidebar/mobile-pill.tsx +34 -0
  30. package/src/app/components/sidebar/sidebar.tsx +20 -0
  31. package/src/app/components/themes/theme-detail.tsx +300 -0
  32. package/src/app/components/themes/themes-gallery.tsx +146 -0
  33. package/src/app/components/thumbnail-rail.tsx +17 -5
  34. package/src/app/lib/assets.ts +55 -2
  35. package/src/app/lib/export-pdf.ts +6 -0
  36. package/src/app/lib/inspector/use-editor.ts +9 -1
  37. package/src/app/lib/sdk.ts +1 -0
  38. package/src/app/lib/slides.ts +17 -1
  39. package/src/app/lib/themes.ts +22 -0
  40. package/src/app/lib/use-agent-socket.ts +18 -0
  41. package/src/app/lib/use-slide-module.ts +48 -0
  42. package/src/app/routes/assets.tsx +9 -0
  43. package/src/app/routes/home-shell.tsx +194 -0
  44. package/src/app/routes/home.tsx +89 -207
  45. package/src/app/routes/presenter.tsx +2 -20
  46. package/src/app/routes/slide.tsx +217 -54
  47. package/src/app/routes/themes.tsx +34 -0
  48. package/src/app/virtual.d.ts +20 -0
  49. package/src/locale/en.ts +49 -7
  50. package/src/locale/ja.ts +50 -7
  51. package/src/locale/types.ts +44 -2
  52. package/src/locale/zh-cn.ts +49 -8
  53. package/src/locale/zh-tw.ts +49 -8
  54. package/dist/sync-B4eLo2H6.js +0 -3
  55. /package/dist/{design-C13iz9_4.js → design-cpzS8aud.js} +0 -0
  56. /package/dist/{sync-3oqN1WyK.js → sync-BCJDRIqo.js} +0 -0
@@ -31,7 +31,7 @@ export function InspectOverlay() {
31
31
  };
32
32
 
33
33
  const onMove = (e: PointerEvent) => {
34
- const el = pickElement(e.clientX, e.clientY);
34
+ const el = pickInspectorTarget(pickElement(e.clientX, e.clientY));
35
35
  if (!el) return setHover(null);
36
36
  const hit = findSlideSource(el, slideId, { hostOnly: true });
37
37
  if (!hit) return setHover(null);
@@ -40,7 +40,7 @@ export function InspectOverlay() {
40
40
 
41
41
  const onClick = (e: MouseEvent) => {
42
42
  if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) return;
43
- const el = pickElement(e.clientX, e.clientY);
43
+ const el = pickInspectorTarget(pickElement(e.clientX, e.clientY));
44
44
  if (!el) return;
45
45
  const hit = findSlideSource(el, slideId, { hostOnly: true });
46
46
  if (!hit) return;
@@ -52,7 +52,7 @@ export function InspectOverlay() {
52
52
 
53
53
  const onDblClick = (e: MouseEvent) => {
54
54
  if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) return;
55
- const el = pickElement(e.clientX, e.clientY);
55
+ const el = pickInspectorTarget(pickElement(e.clientX, e.clientY));
56
56
  if (!el) return;
57
57
  const hit = findSlideSource(el, slideId, { hostOnly: true });
58
58
  if (!hit) return;
@@ -221,3 +221,49 @@ function pickElement(x: number, y: number): HTMLElement | null {
221
221
  }
222
222
  return null;
223
223
  }
224
+
225
+ const INLINE_TEXT_TAGS = new Set([
226
+ 'B',
227
+ 'CODE',
228
+ 'DEL',
229
+ 'EM',
230
+ 'I',
231
+ 'INS',
232
+ 'MARK',
233
+ 'S',
234
+ 'SMALL',
235
+ 'SPAN',
236
+ 'STRONG',
237
+ 'SUB',
238
+ 'SUP',
239
+ 'U',
240
+ ]);
241
+
242
+ function pickInspectorTarget(el: HTMLElement | null): HTMLElement | null {
243
+ if (!el) return null;
244
+ const root = el.closest('[data-inspector-root]');
245
+ const startedOnInlineText = INLINE_TEXT_TAGS.has(el.tagName);
246
+ for (let cur: HTMLElement | null = el; cur && root?.contains(cur); cur = cur.parentElement) {
247
+ if (startedOnInlineText && INLINE_TEXT_TAGS.has(cur.tagName)) continue;
248
+ if (isEditableTextContainer(cur)) return cur;
249
+ }
250
+ return el;
251
+ }
252
+
253
+ function isEditableTextContainer(el: HTMLElement): boolean {
254
+ if (!el.textContent?.trim()) return false;
255
+ return hasOnlyInlineTextChildren(el);
256
+ }
257
+
258
+ function hasOnlyInlineTextChildren(el: HTMLElement): boolean {
259
+ for (const child of Array.from(el.childNodes)) {
260
+ if (child.nodeType === Node.TEXT_NODE) {
261
+ continue;
262
+ } else if (child instanceof HTMLElement) {
263
+ if (child.tagName === 'BR') continue;
264
+ if (INLINE_TEXT_TAGS.has(child.tagName) && hasOnlyInlineTextChildren(child)) continue;
265
+ }
266
+ return false;
267
+ }
268
+ return true;
269
+ }
@@ -3,13 +3,17 @@ import {
3
3
  AlignJustify,
4
4
  AlignLeft,
5
5
  AlignRight,
6
+ ArrowDownToLine,
6
7
  Bold,
7
8
  Crop,
8
9
  ImageIcon,
9
10
  Italic,
11
+ Loader2,
12
+ Upload,
10
13
  X,
11
14
  } from 'lucide-react';
12
- import { useCallback, useEffect, useRef, useState } from 'react';
15
+ import { useCallback, useEffect, useId, useRef, useState } from 'react';
16
+ import { toast } from 'sonner';
13
17
  import { Field, NumberField, Section } from '@/components/panel/panel-fields';
14
18
  import { PANEL_TRANSITION_MS, PanelShell, useAnimatedOpen } from '@/components/panel/panel-shell';
15
19
  import { Button } from '@/components/ui/button';
@@ -30,14 +34,16 @@ import {
30
34
  } from '@/components/ui/select';
31
35
  import { Separator } from '@/components/ui/separator';
32
36
  import { Slider } from '@/components/ui/slider';
37
+ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
33
38
  import { Textarea } from '@/components/ui/textarea';
34
39
  import { Toggle } from '@/components/ui/toggle';
35
40
  import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
36
41
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
37
- import { type AssetEntry, useAssets } from '@/lib/assets';
42
+ import { type AssetEntry, uploadWithAutoRename, useAssets } from '@/lib/assets';
38
43
  import { findSlideSource } from '@/lib/inspector/fiber';
39
44
  import type { EditOp } from '@/lib/inspector/use-editor';
40
- import { useLocale } from '@/lib/use-locale';
45
+ import { useAgentSocketConnected } from '@/lib/use-agent-socket';
46
+ import { format, useLocale } from '@/lib/use-locale';
41
47
  import { cn } from '@/lib/utils';
42
48
  import type { Locale } from '../../../locale/types';
43
49
  import { type SelectedTarget, useInspector } from './inspector-provider';
@@ -56,13 +62,41 @@ type ElementSnapshot = {
56
62
  placeholder: { hint: string; width?: number; height?: number } | null;
57
63
  };
58
64
 
65
+ type ContentSelection = { start: number; end: number };
66
+ type StylePreview = Partial<
67
+ Pick<ElementSnapshot, 'fontSize' | 'fontWeight' | 'fontStyle' | 'color'>
68
+ >;
69
+ type RangeStylePreview = {
70
+ anchor: HTMLElement;
71
+ start: number;
72
+ end: number;
73
+ values: StylePreview;
74
+ };
75
+
76
+ function resolveSelectedTarget(target: SelectedTarget, slideId: string): SelectedTarget {
77
+ const hit = findSlideSource(target.anchor, slideId, { hostOnly: true });
78
+ if (!hit) return target;
79
+ if (hit.line === target.line && hit.column === target.column && hit.anchor === target.anchor) {
80
+ return target;
81
+ }
82
+ return { line: hit.line, column: hit.column, anchor: hit.anchor };
83
+ }
84
+
59
85
  export function InspectorPanel() {
60
86
  const { active, slideId, selected, setSelected, bufferOps, pendingCount, add, applyEdit } =
61
87
  useInspector();
62
88
  const [snapshot, setSnapshot] = useState<ElementSnapshot | null>(null);
89
+ const [contentSelection, setContentSelection] = useState<ContentSelection | null>(null);
90
+ const [rangeStylePreview, setRangeStylePreview] = useState<RangeStylePreview | null>(null);
63
91
  const reloadCounter = useReloadCounter();
64
92
  const t = useLocale();
65
93
 
94
+ useEffect(() => {
95
+ void selected;
96
+ setContentSelection(null);
97
+ setRangeStylePreview(null);
98
+ }, [selected]);
99
+
66
100
  useEffect(() => {
67
101
  void reloadCounter;
68
102
  void pendingCount;
@@ -110,10 +144,12 @@ export function InspectorPanel() {
110
144
  const apply = useCallback(
111
145
  (ops: EditOp[]) => {
112
146
  if (!selected) return;
113
- bufferOps(selected.line, selected.column, selected.anchor, ops);
114
- if (selected.anchor.isConnected) setSnapshot(readSnapshot(selected.anchor));
147
+ const target = resolveSelectedTarget(selected, slideId);
148
+ if (target !== selected) setSelected(target);
149
+ bufferOps(target.line, target.column, target.anchor, ops);
150
+ if (target.anchor.isConnected) setSnapshot(readSnapshot(target.anchor));
115
151
  },
116
- [selected, bufferOps],
152
+ [selected, setSelected, slideId, bufferOps],
117
153
  );
118
154
 
119
155
  // `pinned` keeps the last selection rendered through the close-out
@@ -135,6 +171,76 @@ export function InspectorPanel() {
135
171
 
136
172
  if (!pinned) return null;
137
173
  const { s: pinSelected, n: pinSnapshot } = pinned;
174
+ const contentRange =
175
+ pinSnapshot.text !== null && contentSelection && contentSelection.end > contentSelection.start
176
+ ? contentSelection
177
+ : null;
178
+ const rangePreviewApplies =
179
+ contentRange &&
180
+ rangeStylePreview &&
181
+ rangeStylePreview.anchor === pinSelected.anchor &&
182
+ rangeStylePreview.start === contentRange.start &&
183
+ rangeStylePreview.end === contentRange.end;
184
+ const typographySnapshot = rangePreviewApplies
185
+ ? withStylePreview(pinSnapshot, rangeStylePreview.values)
186
+ : pinSnapshot;
187
+ const applyTextStyle = (ops: EditOp[]) => {
188
+ const styleOps = ops.flatMap((op) => (op.kind === 'set-style' ? [op] : []));
189
+ const target = resolveSelectedTarget(pinSelected, slideId);
190
+ if (target !== pinSelected) setSelected(target);
191
+ if (
192
+ contentRange &&
193
+ pinSnapshot.text !== null &&
194
+ styleOps.length === 1 &&
195
+ styleOps.length === ops.length &&
196
+ styleOps.every((op) => INLINE_CONTENT_STYLE_KEYS.has(op.key))
197
+ ) {
198
+ bufferOps(
199
+ target.line,
200
+ target.column,
201
+ target.anchor,
202
+ styleOps.map((op) => ({
203
+ kind: 'set-text-range-style',
204
+ start: contentRange.start,
205
+ end: contentRange.end,
206
+ key: op.key,
207
+ value: op.value,
208
+ prevText: pinSnapshot.text ?? undefined,
209
+ })),
210
+ );
211
+ setRangeStylePreview((current) => ({
212
+ anchor: target.anchor,
213
+ start: contentRange.start,
214
+ end: contentRange.end,
215
+ values: {
216
+ ...(current?.anchor === target.anchor &&
217
+ current.start === contentRange.start &&
218
+ current.end === contentRange.end
219
+ ? current.values
220
+ : {}),
221
+ ...stylePreviewFromOps(styleOps),
222
+ },
223
+ }));
224
+ if (target.anchor.isConnected) setSnapshot(readSnapshot(target.anchor));
225
+ return;
226
+ }
227
+ if (
228
+ pinSnapshot.text !== null &&
229
+ styleOps.length > 0 &&
230
+ styleOps.length === ops.length &&
231
+ styleOps.every((op) => INLINE_CONTENT_STYLE_KEYS.has(op.key))
232
+ ) {
233
+ bufferOps(
234
+ target.line,
235
+ target.column,
236
+ target.anchor,
237
+ styleOps.map((op) => ({ ...op, prevText: pinSnapshot.text ?? undefined })),
238
+ );
239
+ if (target.anchor.isConnected) setSnapshot(readSnapshot(target.anchor));
240
+ return;
241
+ }
242
+ apply(ops);
243
+ };
138
244
 
139
245
  return (
140
246
  <PanelShell
@@ -169,16 +275,20 @@ export function InspectorPanel() {
169
275
  >
170
276
  {pinSnapshot.text !== null && (
171
277
  <Section title={t.inspector.contentSection}>
172
- <ContentField snapshot={pinSnapshot} apply={apply} />
278
+ <ContentField
279
+ snapshot={pinSnapshot}
280
+ apply={apply}
281
+ onSelectionChange={setContentSelection}
282
+ />
173
283
  </Section>
174
284
  )}
175
285
 
176
286
  <Separator />
177
287
 
178
288
  <Section title={t.inspector.typographySection}>
179
- <FontSizeField snapshot={pinSnapshot} apply={apply} />
180
- <FontWeightField snapshot={pinSnapshot} apply={apply} />
181
- <StyleToggles snapshot={pinSnapshot} apply={apply} />
289
+ <FontSizeField snapshot={typographySnapshot} apply={applyTextStyle} />
290
+ <FontWeightField snapshot={typographySnapshot} apply={applyTextStyle} />
291
+ <StyleToggles snapshot={typographySnapshot} apply={applyTextStyle} />
182
292
  <LineHeightField snapshot={pinSnapshot} apply={apply} />
183
293
  <LetterSpacingField snapshot={pinSnapshot} apply={apply} />
184
294
  <TextAlignField snapshot={pinSnapshot} apply={apply} />
@@ -189,8 +299,8 @@ export function InspectorPanel() {
189
299
  <Section title={t.inspector.colorSection}>
190
300
  <ColorField
191
301
  label={t.inspector.textColor}
192
- value={pinSnapshot.color}
193
- onChange={(v) => apply([{ kind: 'set-style', key: 'color', value: v }])}
302
+ value={typographySnapshot.color}
303
+ onChange={(v) => applyTextStyle([{ kind: 'set-style', key: 'color', value: v }])}
194
304
  clearable={false}
195
305
  />
196
306
  <ColorField
@@ -249,12 +359,43 @@ const EDITING_FREEZE_CSS = `
249
359
  }
250
360
  `;
251
361
 
362
+ const INLINE_CONTENT_STYLE_KEYS = new Set([
363
+ 'fontSize',
364
+ 'fontWeight',
365
+ 'fontStyle',
366
+ 'fontFamily',
367
+ 'color',
368
+ ]);
369
+
370
+ function stylePreviewFromOps(ops: Array<Extract<EditOp, { kind: 'set-style' }>>): StylePreview {
371
+ const preview: StylePreview = {};
372
+ for (const op of ops) {
373
+ if (op.key === 'fontSize' && op.value) {
374
+ const n = parseFloat(op.value);
375
+ if (Number.isFinite(n)) preview.fontSize = n;
376
+ } else if (op.key === 'fontWeight') {
377
+ preview.fontWeight = op.value ? Number(op.value) || 400 : 400;
378
+ } else if (op.key === 'fontStyle') {
379
+ preview.fontStyle = op.value === 'italic' ? 'italic' : 'normal';
380
+ } else if (op.key === 'color' && op.value) {
381
+ preview.color = op.value;
382
+ }
383
+ }
384
+ return preview;
385
+ }
386
+
387
+ function withStylePreview(snapshot: ElementSnapshot, preview: StylePreview): ElementSnapshot {
388
+ return { ...snapshot, ...preview };
389
+ }
390
+
252
391
  function ContentField({
253
392
  snapshot,
254
393
  apply,
394
+ onSelectionChange,
255
395
  }: {
256
396
  snapshot: ElementSnapshot;
257
397
  apply: (ops: EditOp[]) => void;
398
+ onSelectionChange?: (selection: ContentSelection | null) => void;
258
399
  }) {
259
400
  // Mirror the value locally and skip syncs during IME composition;
260
401
  // a re-render mid-composition would otherwise clobber in-progress
@@ -267,6 +408,12 @@ function ContentField({
267
408
  if (!composingRef.current) setLocal(snapshot.text ?? '');
268
409
  }, [snapshot.text]);
269
410
 
411
+ const reportSelection = (el: HTMLTextAreaElement) => {
412
+ const start = el.selectionStart ?? 0;
413
+ const end = el.selectionEnd ?? start;
414
+ onSelectionChange?.(end > start ? { start, end } : null);
415
+ };
416
+
270
417
  return (
271
418
  <Textarea
272
419
  value={local}
@@ -277,17 +424,23 @@ function ContentField({
277
424
  composingRef.current = false;
278
425
  const v = e.currentTarget.value;
279
426
  setLocal(v);
427
+ reportSelection(e.currentTarget);
280
428
  apply([{ kind: 'set-text', value: v }]);
281
429
  }}
282
430
  onChange={(e) => {
283
431
  const v = e.target.value;
284
432
  setLocal(v);
433
+ reportSelection(e.currentTarget);
285
434
  if (!composingRef.current) {
286
435
  apply([{ kind: 'set-text', value: v }]);
287
436
  }
288
437
  }}
438
+ onKeyUp={(e) => reportSelection(e.currentTarget)}
439
+ onMouseUp={(e) => reportSelection(e.currentTarget)}
440
+ onSelect={(e) => reportSelection(e.currentTarget)}
441
+ wrap="off"
289
442
  rows={3}
290
- className="min-h-16 resize-none text-xs"
443
+ className="field-sizing-fixed min-h-16 w-full resize-none overflow-x-auto whitespace-pre text-xs"
291
444
  placeholder={t.inspector.elementTextPlaceholder}
292
445
  />
293
446
  );
@@ -652,13 +805,15 @@ function ImageField({
652
805
  <AssetPickerDialog
653
806
  slideId={slideId}
654
807
  onClose={() => setOpen(false)}
655
- onPick={(asset) => {
808
+ onPick={(asset, scope) => {
656
809
  setOpen(false);
810
+ const assetPath =
811
+ scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
657
812
  const ops: EditOp[] = [
658
813
  {
659
814
  kind: 'set-attr-asset',
660
815
  attr: 'src',
661
- assetPath: `./assets/${asset.name}`,
816
+ assetPath,
662
817
  previewUrl: asset.url,
663
818
  },
664
819
  ];
@@ -717,14 +872,16 @@ function PlaceholderField({
717
872
  <AssetPickerDialog
718
873
  slideId={slideId}
719
874
  onClose={() => setOpen(false)}
720
- onPick={async (asset) => {
875
+ onPick={async (asset, scope) => {
721
876
  setOpen(false);
722
877
  setSubmitting(true);
723
878
  try {
879
+ const assetPath =
880
+ scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
724
881
  await applyEdit(line, column, [
725
882
  {
726
883
  kind: 'replace-placeholder-with-image',
727
- assetPath: `./assets/${asset.name}`,
884
+ assetPath,
728
885
  },
729
886
  ]);
730
887
  } finally {
@@ -737,6 +894,9 @@ function PlaceholderField({
737
894
  );
738
895
  }
739
896
 
897
+ type PickerScope = 'slide' | 'global';
898
+ const GLOBAL_PICKER_SLIDE_ID = '@global';
899
+
740
900
  function AssetPickerDialog({
741
901
  slideId,
742
902
  onClose,
@@ -744,13 +904,39 @@ function AssetPickerDialog({
744
904
  }: {
745
905
  slideId: string;
746
906
  onClose: () => void;
747
- onPick: (asset: AssetEntry) => void;
907
+ onPick: (asset: AssetEntry, scope: PickerScope) => void;
748
908
  }) {
749
- const { assets, loading } = useAssets(slideId);
909
+ const [scope, setScope] = useState<PickerScope>('slide');
910
+ const effectiveSlideId = scope === 'global' ? GLOBAL_PICKER_SLIDE_ID : slideId;
911
+ const { assets, loading, refresh } = useAssets(effectiveSlideId);
750
912
  const images = assets.filter((a) => a.mime.startsWith('image/'));
751
913
  const t = useLocale();
752
- const path = `slides/${slideId}/assets/`;
914
+ const path = scope === 'global' ? 'assets/' : `slides/${slideId}/assets/`;
753
915
  const [descPrefix, descSuffix] = t.inspector.replaceImageDescription.split('{path}');
916
+ const [uploading, setUploading] = useState(false);
917
+ const [dragActive, setDragActive] = useState(false);
918
+ const dragDepth = useRef(0);
919
+ const inputId = useId();
920
+
921
+ const handleFile = useCallback(
922
+ async (file: File) => {
923
+ if (!file.type.startsWith('image/')) return;
924
+ setUploading(true);
925
+ try {
926
+ const { ok, status, entry } = await uploadWithAutoRename(effectiveSlideId, file);
927
+ if (!ok || !entry) {
928
+ toast.error(format(t.asset.toastUploadFailed, { status }));
929
+ return;
930
+ }
931
+ await refresh().catch(() => {});
932
+ onPick(entry, scope);
933
+ } finally {
934
+ setUploading(false);
935
+ }
936
+ },
937
+ [effectiveSlideId, scope, refresh, onPick, t],
938
+ );
939
+
754
940
  return (
755
941
  <Dialog open onOpenChange={(o) => !o && onClose()}>
756
942
  <DialogContent className="sm:max-w-xl">
@@ -762,7 +948,66 @@ function AssetPickerDialog({
762
948
  {descSuffix}
763
949
  </DialogDescription>
764
950
  </DialogHeader>
765
- <div className="max-h-[60vh] overflow-y-auto">
951
+ <Tabs value={scope} onValueChange={(next) => setScope(next as PickerScope)}>
952
+ <TabsList>
953
+ <TabsTrigger value="slide">{t.asset.scopeSlide}</TabsTrigger>
954
+ <TabsTrigger value="global">{t.asset.scopeGlobal}</TabsTrigger>
955
+ </TabsList>
956
+ </Tabs>
957
+ <label
958
+ htmlFor={inputId}
959
+ className={cn(
960
+ 'absolute right-12 top-3.5 inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-[5px] border border-border bg-card px-2 text-[12px] font-medium transition-colors',
961
+ 'hover:bg-muted/60 hover:border-foreground/20 active:translate-y-px',
962
+ uploading && 'pointer-events-none opacity-60',
963
+ )}
964
+ >
965
+ {uploading ? (
966
+ <Loader2 className="size-3.5 animate-spin" />
967
+ ) : (
968
+ <Upload className="size-3.5" />
969
+ )}
970
+ <span>{t.asset.upload}</span>
971
+ </label>
972
+ <input
973
+ id={inputId}
974
+ type="file"
975
+ accept="image/*"
976
+ className="sr-only"
977
+ disabled={uploading}
978
+ onChange={(e) => {
979
+ const file = e.target.files?.[0];
980
+ e.target.value = '';
981
+ if (file) handleFile(file).catch(() => {});
982
+ }}
983
+ />
984
+ <section
985
+ aria-label={t.inspector.replaceImageDialogTitle}
986
+ className="relative max-h-[60vh] overflow-y-auto"
987
+ onDragEnter={(e) => {
988
+ if (uploading || !hasFiles(e)) return;
989
+ e.preventDefault();
990
+ dragDepth.current += 1;
991
+ setDragActive(true);
992
+ }}
993
+ onDragOver={(e) => {
994
+ if (uploading || !hasFiles(e)) return;
995
+ e.preventDefault();
996
+ e.dataTransfer.dropEffect = 'copy';
997
+ }}
998
+ onDragLeave={() => {
999
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
1000
+ if (dragDepth.current === 0) setDragActive(false);
1001
+ }}
1002
+ onDrop={(e) => {
1003
+ if (uploading || !hasFiles(e)) return;
1004
+ e.preventDefault();
1005
+ dragDepth.current = 0;
1006
+ setDragActive(false);
1007
+ const file = e.dataTransfer.files?.[0];
1008
+ if (file) handleFile(file).catch(() => {});
1009
+ }}
1010
+ >
766
1011
  {loading ? (
767
1012
  <p className="px-1 py-6 text-center text-xs text-muted-foreground">
768
1013
  {t.inspector.pickerLoading}
@@ -777,7 +1022,7 @@ function AssetPickerDialog({
777
1022
  <button
778
1023
  key={asset.name}
779
1024
  type="button"
780
- onClick={() => onPick(asset)}
1025
+ onClick={() => onPick(asset, scope)}
781
1026
  className={cn(
782
1027
  'group flex flex-col overflow-hidden rounded-lg border bg-card text-left shadow-sm transition-all',
783
1028
  'hover:-translate-y-0.5 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none',
@@ -800,14 +1045,39 @@ function AssetPickerDialog({
800
1045
  ))}
801
1046
  </div>
802
1047
  )}
803
- </div>
1048
+ {dragActive && (
1049
+ <div
1050
+ className="pointer-events-none absolute inset-0 z-10 animate-in fade-in-0 duration-200"
1051
+ aria-hidden
1052
+ >
1053
+ <div className="absolute inset-0 bg-brand/5" />
1054
+ <div className="absolute inset-1 rounded-[8px] border border-dashed border-brand/40" />
1055
+ <div className="absolute inset-x-0 bottom-4 flex justify-center">
1056
+ <div className="flex items-center gap-2 rounded-[6px] border border-border bg-card px-3 py-1.5 text-[12px] font-medium shadow-floating">
1057
+ <ArrowDownToLine className="size-3.5 text-brand" />
1058
+ <span>{t.asset.dropToUpload}</span>
1059
+ </div>
1060
+ </div>
1061
+ </div>
1062
+ )}
1063
+ </section>
804
1064
  </DialogContent>
805
1065
  </Dialog>
806
1066
  );
807
1067
  }
808
1068
 
1069
+ function hasFiles(e: React.DragEvent): boolean {
1070
+ const types = e.dataTransfer?.types;
1071
+ if (!types) return false;
1072
+ for (let i = 0; i < types.length; i++) {
1073
+ if (types[i] === 'Files') return true;
1074
+ }
1075
+ return false;
1076
+ }
1077
+
809
1078
  function AgentWatchingBadge() {
810
1079
  const t = useLocale();
1080
+ const connected = useAgentSocketConnected();
811
1081
  return (
812
1082
  <TooltipProvider delayDuration={200}>
813
1083
  <Tooltip>
@@ -817,14 +1087,20 @@ function AgentWatchingBadge() {
817
1087
  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"
818
1088
  >
819
1089
  <span aria-hidden className="relative flex size-1.5 items-center justify-center">
820
- <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />
821
- <span className="relative inline-flex size-1.5 rounded-full bg-emerald-500" />
1090
+ {connected ? (
1091
+ <>
1092
+ <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />
1093
+ <span className="relative inline-flex size-1.5 rounded-full bg-emerald-500" />
1094
+ </>
1095
+ ) : (
1096
+ <span className="relative inline-flex size-1.5 rounded-full bg-rose-500" />
1097
+ )}
822
1098
  </span>
823
- {t.inspector.agentWatching}
1099
+ {connected ? t.inspector.agentWatching : t.inspector.agentNotWatching}
824
1100
  </button>
825
1101
  </TooltipTrigger>
826
1102
  <TooltipContent side="bottom" align="end" className="max-w-[260px] leading-relaxed">
827
- {t.inspector.agentWatchingTooltip}
1103
+ {connected ? t.inspector.agentWatchingTooltip : t.inspector.agentNotWatchingTooltip}
828
1104
  </TooltipContent>
829
1105
  </Tooltip>
830
1106
  </TooltipProvider>
@@ -886,7 +1162,7 @@ function CommentsSection({
886
1162
 
887
1163
  function readSnapshot(el: HTMLElement): ElementSnapshot {
888
1164
  const cs = getComputedStyle(el);
889
- const text = isSimpleTextElement(el) ? (el.textContent ?? '') : null;
1165
+ const text = isSimpleTextElement(el) ? readEditableText(el) : null;
890
1166
  const imageSrc =
891
1167
  el.tagName === 'IMG'
892
1168
  ? (el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src || null
@@ -918,8 +1194,72 @@ function readSnapshot(el: HTMLElement): ElementSnapshot {
918
1194
 
919
1195
  function isSimpleTextElement(el: HTMLElement): boolean {
920
1196
  if (el.childNodes.length === 0) return true;
921
- if (el.childNodes.length === 1 && el.firstChild?.nodeType === Node.TEXT_NODE) return true;
922
- return false;
1197
+ return hasOnlyInlineTextChildren(el);
1198
+ }
1199
+
1200
+ const INLINE_TEXT_TAGS = new Set([
1201
+ 'B',
1202
+ 'CODE',
1203
+ 'DEL',
1204
+ 'EM',
1205
+ 'I',
1206
+ 'INS',
1207
+ 'MARK',
1208
+ 'S',
1209
+ 'SMALL',
1210
+ 'SPAN',
1211
+ 'STRONG',
1212
+ 'SUB',
1213
+ 'SUP',
1214
+ 'U',
1215
+ ]);
1216
+
1217
+ function hasOnlyInlineTextChildren(el: HTMLElement): boolean {
1218
+ for (const child of Array.from(el.childNodes)) {
1219
+ if (child.nodeType === Node.TEXT_NODE) {
1220
+ continue;
1221
+ } else if (child instanceof HTMLElement) {
1222
+ if (child.tagName === 'BR') continue;
1223
+ if (INLINE_TEXT_TAGS.has(child.tagName) && hasOnlyInlineTextChildren(child)) continue;
1224
+ }
1225
+ return false;
1226
+ }
1227
+ return true;
1228
+ }
1229
+
1230
+ function readEditableText(el: HTMLElement): string {
1231
+ const parts: string[] = [];
1232
+ for (const child of Array.from(el.childNodes)) {
1233
+ if (child.nodeType === Node.TEXT_NODE) {
1234
+ parts.push(renderedTextNodeValue(child as Text));
1235
+ } else if (child instanceof HTMLBRElement) {
1236
+ parts.push('\n');
1237
+ } else if (child instanceof HTMLElement) {
1238
+ parts.push(readEditableText(child));
1239
+ }
1240
+ }
1241
+ return normalizeRenderedText(parts);
1242
+ }
1243
+
1244
+ function normalizeRenderedText(parts: string[]): string {
1245
+ return parts
1246
+ .map((part, index) => {
1247
+ if (part === '\n') return part;
1248
+ let next = part;
1249
+ if (parts[index - 1] === '\n') next = next.replace(/^\s+/, '');
1250
+ if (parts[index + 1] === '\n') next = next.replace(/\s+$/, '');
1251
+ return next;
1252
+ })
1253
+ .join('');
1254
+ }
1255
+
1256
+ function renderedTextNodeValue(node: Text): string {
1257
+ const value = node.textContent ?? '';
1258
+ const whiteSpace = node.parentElement ? getComputedStyle(node.parentElement).whiteSpace : '';
1259
+ if (whiteSpace === 'pre' || whiteSpace === 'pre-wrap' || whiteSpace === 'break-spaces') {
1260
+ return value;
1261
+ }
1262
+ return value.replace(/\s+/g, ' ');
923
1263
  }
924
1264
 
925
1265
  function rgbToHex(value: string): string | null {