@open-slide/core 1.3.0 → 1.5.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 (45) hide show
  1. package/dist/{build-_276DMmJ.js → build-DZhbjQpQ.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-D9cZ1A0X.d.ts → config-BQdTMho4.d.ts} +2 -1
  4. package/dist/{config-BAwKWNtW.js → config-iKjqaX08.js} +2528 -1640
  5. package/dist/{dev-BoqeVXVq.js → dev-BjLGk5nN.js} +1 -1
  6. package/dist/{en-CDKzoZvf.js → en-DDGqyNaW.js} +27 -4
  7. package/dist/index.d.ts +4 -2
  8. package/dist/index.js +1 -1
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +82 -13
  11. package/dist/{preview-BLPxspc9.js → preview-jwLWHWkQ.js} +1 -1
  12. package/dist/{types-JYG1cmwC.d.ts → types-Dpr8nbih.d.ts} +27 -1
  13. package/dist/vite/index.d.ts +2 -2
  14. package/dist/vite/index.js +1 -1
  15. package/package.json +1 -1
  16. package/skills/slide-authoring/SKILL.md +19 -4
  17. package/src/app/app.tsx +2 -0
  18. package/src/app/components/asset-view.tsx +111 -18
  19. package/src/app/components/inspector/inspect-overlay.tsx +49 -3
  20. package/src/app/components/inspector/inspector-panel.tsx +267 -25
  21. package/src/app/components/inspector/inspector-provider.tsx +390 -49
  22. package/src/app/components/panel/panel-shell.tsx +5 -3
  23. package/src/app/components/player.tsx +25 -5
  24. package/src/app/components/present/control-bar.tsx +12 -0
  25. package/src/app/components/present/laser-pointer.tsx +3 -4
  26. package/src/app/components/present/progress-bar.tsx +4 -4
  27. package/src/app/components/sidebar/folder-item.tsx +14 -3
  28. package/src/app/components/sidebar/sidebar.tsx +10 -0
  29. package/src/app/lib/assets.ts +21 -0
  30. package/src/app/lib/export-pdf.ts +6 -0
  31. package/src/app/lib/inspector/use-editor.ts +9 -1
  32. package/src/app/lib/sdk.ts +2 -0
  33. package/src/app/lib/slides.ts +9 -0
  34. package/src/app/lib/use-slide-module.ts +48 -0
  35. package/src/app/routes/assets.tsx +9 -0
  36. package/src/app/routes/home-shell.tsx +23 -2
  37. package/src/app/routes/home.tsx +101 -3
  38. package/src/app/routes/presenter.tsx +2 -20
  39. package/src/app/routes/slide.tsx +117 -39
  40. package/src/app/virtual.d.ts +1 -0
  41. package/src/locale/en.ts +28 -5
  42. package/src/locale/ja.ts +28 -5
  43. package/src/locale/types.ts +27 -1
  44. package/src/locale/zh-cn.ts +28 -6
  45. package/src/locale/zh-tw.ts +28 -6
@@ -30,10 +30,14 @@ import {
30
30
  DropdownMenuItem,
31
31
  DropdownMenuTrigger,
32
32
  } from '@/components/ui/dropdown-menu';
33
+ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
33
34
  import {
34
35
  type AssetEntry,
36
+ type AssetUsage,
35
37
  fetchSvgAsFile,
38
+ listAssetUsages,
36
39
  renamedCopy,
40
+ revertAssetUsage,
37
41
  type SvglItem,
38
42
  searchSvgl,
39
43
  useAssets,
@@ -41,7 +45,11 @@ import {
41
45
  import { format, useLocale } from '@/lib/use-locale';
42
46
  import { cn } from '@/lib/utils';
43
47
 
44
- type Props = { slideId: string };
48
+ type Props = { slideId: string | null };
49
+
50
+ type Scope = 'slide' | 'global';
51
+
52
+ const GLOBAL_SLIDE_ID = '@global';
45
53
 
46
54
  type ConflictState = {
47
55
  file: File;
@@ -49,11 +57,15 @@ type ConflictState = {
49
57
  };
50
58
 
51
59
  export function AssetView({ slideId }: Props) {
52
- const { assets, loading, available, upload, rename, remove } = useAssets(slideId);
60
+ const lockedToGlobal = slideId === null;
61
+ const [scope, setScope] = useState<Scope>(lockedToGlobal ? 'global' : 'slide');
62
+ const effectiveSlideId = scope === 'global' || slideId === null ? GLOBAL_SLIDE_ID : slideId;
63
+ const { assets, loading, available, upload, rename, remove } = useAssets(effectiveSlideId);
53
64
  const [dragActive, setDragActive] = useState(false);
54
65
  const [conflict, setConflict] = useState<ConflictState | null>(null);
55
66
  const [preview, setPreview] = useState<AssetEntry | null>(null);
56
67
  const [confirmDelete, setConfirmDelete] = useState<AssetEntry | null>(null);
68
+ const [confirmDeleteUsages, setConfirmDeleteUsages] = useState<AssetUsage[] | null>(null);
57
69
  const [renaming, setRenaming] = useState<string | null>(null);
58
70
  const [logoSearchOpen, setLogoSearchOpen] = useState(false);
59
71
  const dragDepth = useRef(0);
@@ -133,10 +145,21 @@ export function AssetView({ slideId }: Props) {
133
145
  }}
134
146
  >
135
147
  <div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline bg-sidebar px-6 py-3">
136
- <div className="min-w-0">
137
- <span className="eyebrow">{t.asset.eyebrow}</span>
138
- <p className="mt-0.5 truncate text-[12px] text-muted-foreground">
139
- <span className="font-mono text-[11.5px]">slides/{slideId}/assets/</span>
148
+ <div className="flex min-w-0 items-center gap-3">
149
+ {lockedToGlobal ? (
150
+ <span className="eyebrow">{t.asset.eyebrow}</span>
151
+ ) : (
152
+ <Tabs value={scope} onValueChange={(next) => setScope(next as Scope)}>
153
+ <TabsList>
154
+ <TabsTrigger value="slide">{t.asset.scopeSlide}</TabsTrigger>
155
+ <TabsTrigger value="global">{t.asset.scopeGlobal}</TabsTrigger>
156
+ </TabsList>
157
+ </Tabs>
158
+ )}
159
+ <p className="min-w-0 truncate text-[12px] text-muted-foreground">
160
+ <span className="font-mono text-[11.5px]">
161
+ {scope === 'global' ? 'assets/' : `slides/${slideId}/assets/`}
162
+ </span>
140
163
  {!loading && (
141
164
  <>
142
165
  <span className="mx-2 opacity-50">·</span>
@@ -226,7 +249,13 @@ export function AssetView({ slideId }: Props) {
226
249
  asset={asset}
227
250
  onPreview={() => setPreview(asset)}
228
251
  onRename={() => setRenaming(asset.name)}
229
- onDelete={() => setConfirmDelete(asset)}
252
+ onDelete={() => {
253
+ setConfirmDelete(asset);
254
+ setConfirmDeleteUsages(null);
255
+ listAssetUsages(effectiveSlideId, asset.name)
256
+ .then((u) => setConfirmDeleteUsages(u))
257
+ .catch(() => setConfirmDeleteUsages([]));
258
+ }}
230
259
  />
231
260
  ),
232
261
  )}
@@ -263,18 +292,46 @@ export function AssetView({ slideId }: Props) {
263
292
  {confirmDelete && (
264
293
  <DeleteDialog
265
294
  asset={confirmDelete}
266
- onCancel={() => setConfirmDelete(null)}
295
+ usages={confirmDeleteUsages}
296
+ onCancel={() => {
297
+ setConfirmDelete(null);
298
+ setConfirmDeleteUsages(null);
299
+ }}
267
300
  onConfirm={async () => {
268
301
  const target = confirmDelete;
302
+ const usages = confirmDeleteUsages ?? [];
269
303
  setConfirmDelete(null);
304
+ setConfirmDeleteUsages(null);
305
+ const assetPath =
306
+ scope === 'global' ? `@assets/${target.name}` : `./assets/${target.name}`;
307
+ for (const u of usages) {
308
+ const rev = await revertAssetUsage(u.slideId, assetPath);
309
+ if (!rev.ok) {
310
+ toast.error(format(t.asset.toastRevertFailed, { slideId: u.slideId }));
311
+ return;
312
+ }
313
+ }
270
314
  const res = await remove(target.name);
271
- if (!res.ok) toast.error(format(t.asset.toastDeleteFailed, { status: res.status }));
272
- else toast.success(format(t.asset.toastDeleted, { name: target.name }));
315
+ if (!res.ok) {
316
+ toast.error(format(t.asset.toastDeleteFailed, { status: res.status }));
317
+ return;
318
+ }
319
+ const totalUsages = usages.reduce((acc, u) => acc + u.count, 0);
320
+ if (totalUsages > 0) {
321
+ toast.success(
322
+ format(t.asset.toastDeletedWithRevert, {
323
+ name: target.name,
324
+ count: totalUsages,
325
+ }),
326
+ );
327
+ } else {
328
+ toast.success(format(t.asset.toastDeleted, { name: target.name }));
329
+ }
273
330
  }}
274
331
  />
275
332
  )}
276
333
 
277
- {preview && <PreviewDialog asset={preview} onClose={() => setPreview(null)} />}
334
+ {preview && <PreviewDialog asset={preview} scope={scope} onClose={() => setPreview(null)} />}
278
335
 
279
336
  {logoSearchOpen && (
280
337
  <LogoSearchDialog
@@ -498,14 +555,19 @@ function ConflictDialog({
498
555
 
499
556
  function DeleteDialog({
500
557
  asset,
558
+ usages,
501
559
  onCancel,
502
560
  onConfirm,
503
561
  }: {
504
562
  asset: AssetEntry;
563
+ usages: AssetUsage[] | null;
505
564
  onCancel: () => void;
506
565
  onConfirm: () => void;
507
566
  }) {
508
567
  const t = useLocale();
568
+ const inUse = (usages?.length ?? 0) > 0;
569
+ const totalUses = usages?.reduce((acc, u) => acc + u.count, 0) ?? 0;
570
+ const slideCount = usages?.length ?? 0;
509
571
  const [descPrefix, descSuffix] = t.asset.deleteAssetDescription.split('{name}');
510
572
  return (
511
573
  <Dialog open onOpenChange={(open) => !open && onCancel()}>
@@ -513,17 +575,40 @@ function DeleteDialog({
513
575
  <DialogHeader>
514
576
  <DialogTitle>{t.asset.deleteAssetTitle}</DialogTitle>
515
577
  <DialogDescription>
516
- {descPrefix}
517
- <span className="font-mono">{asset.name}</span>
518
- {descSuffix}
578
+ {inUse ? (
579
+ <>
580
+ {format(t.asset.deleteAssetInUseDescription, {
581
+ name: asset.name,
582
+ count: totalUses,
583
+ slides: slideCount,
584
+ })}{' '}
585
+ {t.asset.deleteAssetInUseHint}
586
+ </>
587
+ ) : (
588
+ <>
589
+ {descPrefix}
590
+ <span className="font-mono">{asset.name}</span>
591
+ {descSuffix}
592
+ </>
593
+ )}
519
594
  </DialogDescription>
520
595
  </DialogHeader>
596
+ {inUse && usages && (
597
+ <ul className="max-h-40 overflow-y-auto rounded-[5px] border border-hairline bg-muted/40 px-3 py-2 font-mono text-[11.5px] leading-relaxed">
598
+ {usages.map((u) => (
599
+ <li key={u.slideId} className="flex items-center justify-between gap-3">
600
+ <span className="truncate">{u.slideId}</span>
601
+ <span className="text-muted-foreground">×{u.count}</span>
602
+ </li>
603
+ ))}
604
+ </ul>
605
+ )}
521
606
  <DialogFooter>
522
607
  <Button variant="outline" onClick={onCancel}>
523
608
  {t.common.cancel}
524
609
  </Button>
525
- <Button variant="destructive" onClick={onConfirm}>
526
- {t.common.delete}
610
+ <Button variant="destructive" onClick={onConfirm} disabled={usages === null}>
611
+ {inUse ? t.asset.deleteAndRevert : t.common.delete}
527
612
  </Button>
528
613
  </DialogFooter>
529
614
  </DialogContent>
@@ -542,9 +627,17 @@ function NoResultsMessage({ query, t }: { query: string; t: ReturnType<typeof us
542
627
  );
543
628
  }
544
629
 
545
- function PreviewDialog({ asset, onClose }: { asset: AssetEntry; onClose: () => void }) {
630
+ function PreviewDialog({
631
+ asset,
632
+ scope,
633
+ onClose,
634
+ }: {
635
+ asset: AssetEntry;
636
+ scope: Scope;
637
+ onClose: () => void;
638
+ }) {
546
639
  const isImage = asset.mime.startsWith('image/');
547
- const importPath = `./assets/${asset.name}`;
640
+ const importPath = scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
548
641
  const t = useLocale();
549
642
  return (
550
643
  <Dialog open onOpenChange={(open) => !open && onClose()}>
@@ -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
+ }