@open-slide/core 1.6.0 → 1.8.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-tLrkKUHr.js → build-CCZDC8eF.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-PwUHqZ_X.js → config-C7sZtiY2.js} +45 -18
  4. package/dist/{config-CfMThYN9.d.ts → config-D1bANimZ.d.ts} +1 -1
  5. package/dist/{dev-DpCIRbhT.js → dev-kLS_4CAI.js} +1 -1
  6. package/dist/{en-BDnM5zKJ.js → en-hyGpmL1O.js} +1 -4
  7. package/dist/index.d.ts +22 -4
  8. package/dist/index.js +1 -1
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +4 -13
  11. package/dist/{preview-BSGlM6Se.js → preview-DUkOjOx8.js} +1 -1
  12. package/dist/{types-B-KrjgX8.d.ts → types-Bvk1pM70.d.ts} +1 -4
  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/create-theme/SKILL.md +1 -1
  17. package/skills/slide-authoring/SKILL.md +169 -0
  18. package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
  19. package/src/app/components/inspector/comment-widget.tsx +16 -2
  20. package/src/app/components/inspector/inspect-overlay.tsx +132 -35
  21. package/src/app/components/inspector/inspector-panel.tsx +19 -256
  22. package/src/app/components/inspector/inspector-provider.tsx +102 -1
  23. package/src/app/components/panel/save-card.tsx +4 -4
  24. package/src/app/components/player.tsx +25 -25
  25. package/src/app/components/sidebar/folder-item.tsx +7 -2
  26. package/src/app/components/sidebar/sidebar.tsx +87 -16
  27. package/src/app/components/slide-transition-layer.tsx +154 -0
  28. package/src/app/components/style-panel/style-panel.tsx +3 -0
  29. package/src/app/lib/folders.ts +28 -0
  30. package/src/app/lib/inspector/fiber.test.ts +154 -0
  31. package/src/app/lib/inspector/fiber.ts +12 -1
  32. package/src/app/lib/sdk.ts +3 -1
  33. package/src/app/lib/transition.ts +23 -0
  34. package/src/app/lib/use-click-page-navigation.ts +52 -0
  35. package/src/app/lib/use-is-mobile.ts +21 -0
  36. package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
  37. package/src/app/routes/home-shell.tsx +8 -0
  38. package/src/app/routes/home.tsx +1 -1
  39. package/src/app/routes/slide.tsx +92 -60
  40. package/src/locale/en.ts +1 -5
  41. package/src/locale/ja.ts +1 -5
  42. package/src/locale/types.ts +1 -5
  43. package/src/locale/zh-cn.ts +1 -5
  44. package/src/locale/zh-tw.ts +1 -5
  45. package/src/app/components/click-nav-zones.tsx +0 -36
@@ -0,0 +1,196 @@
1
+ import { ArrowDownToLine, Loader2, Upload } from 'lucide-react';
2
+ import type React from 'react';
3
+ import { useCallback, useId, useRef, useState } from 'react';
4
+ import { toast } from 'sonner';
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from '@/components/ui/dialog';
12
+ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
13
+ import { type AssetEntry, uploadWithAutoRename, useAssets } from '@/lib/assets';
14
+ import { format, useLocale } from '@/lib/use-locale';
15
+ import { cn } from '@/lib/utils';
16
+
17
+ export type PickerScope = 'slide' | 'global';
18
+ const GLOBAL_PICKER_SLIDE_ID = '@global';
19
+
20
+ export function AssetPickerDialog({
21
+ slideId,
22
+ onClose,
23
+ onPick,
24
+ }: {
25
+ slideId: string;
26
+ onClose: () => void;
27
+ onPick: (asset: AssetEntry, scope: PickerScope) => void;
28
+ }) {
29
+ const [scope, setScope] = useState<PickerScope>('slide');
30
+ const effectiveSlideId = scope === 'global' ? GLOBAL_PICKER_SLIDE_ID : slideId;
31
+ const { assets, loading, refresh } = useAssets(effectiveSlideId);
32
+ const images = assets.filter((a) => a.mime.startsWith('image/'));
33
+ const t = useLocale();
34
+ const path = scope === 'global' ? 'assets/' : `slides/${slideId}/assets/`;
35
+ const [descPrefix, descSuffix] = t.inspector.replaceImageDescription.split('{path}');
36
+ const [uploading, setUploading] = useState(false);
37
+ const [dragActive, setDragActive] = useState(false);
38
+ const dragDepth = useRef(0);
39
+ const inputId = useId();
40
+
41
+ const handleFile = useCallback(
42
+ async (file: File) => {
43
+ if (!file.type.startsWith('image/')) return;
44
+ setUploading(true);
45
+ try {
46
+ const { ok, status, entry } = await uploadWithAutoRename(effectiveSlideId, file);
47
+ if (!ok || !entry) {
48
+ toast.error(format(t.asset.toastUploadFailed, { status }));
49
+ return;
50
+ }
51
+ await refresh().catch(() => {});
52
+ onPick(entry, scope);
53
+ } finally {
54
+ setUploading(false);
55
+ }
56
+ },
57
+ [effectiveSlideId, scope, refresh, onPick, t],
58
+ );
59
+
60
+ return (
61
+ <Dialog open onOpenChange={(o) => !o && onClose()}>
62
+ <DialogContent className="sm:max-w-xl">
63
+ <DialogHeader>
64
+ <DialogTitle>{t.inspector.replaceImageDialogTitle}</DialogTitle>
65
+ <DialogDescription>
66
+ {descPrefix}
67
+ <span className="font-mono">{path}</span>
68
+ {descSuffix}
69
+ </DialogDescription>
70
+ </DialogHeader>
71
+ <Tabs value={scope} onValueChange={(next) => setScope(next as PickerScope)}>
72
+ <TabsList>
73
+ <TabsTrigger value="slide">{t.asset.scopeSlide}</TabsTrigger>
74
+ <TabsTrigger value="global">{t.asset.scopeGlobal}</TabsTrigger>
75
+ </TabsList>
76
+ </Tabs>
77
+ <label
78
+ htmlFor={inputId}
79
+ className={cn(
80
+ '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',
81
+ 'hover:bg-muted/60 hover:border-foreground/20 active:translate-y-px',
82
+ uploading && 'pointer-events-none opacity-60',
83
+ )}
84
+ >
85
+ {uploading ? (
86
+ <Loader2 className="size-3.5 animate-spin" />
87
+ ) : (
88
+ <Upload className="size-3.5" />
89
+ )}
90
+ <span>{t.asset.upload}</span>
91
+ </label>
92
+ <input
93
+ id={inputId}
94
+ type="file"
95
+ accept="image/*"
96
+ className="sr-only"
97
+ disabled={uploading}
98
+ onChange={(e) => {
99
+ const file = e.target.files?.[0];
100
+ e.target.value = '';
101
+ if (file) handleFile(file).catch(() => {});
102
+ }}
103
+ />
104
+ <section
105
+ aria-label={t.inspector.replaceImageDialogTitle}
106
+ className="relative max-h-[60vh] overflow-y-auto"
107
+ onDragEnter={(e) => {
108
+ if (uploading || !hasFiles(e)) return;
109
+ e.preventDefault();
110
+ dragDepth.current += 1;
111
+ setDragActive(true);
112
+ }}
113
+ onDragOver={(e) => {
114
+ if (uploading || !hasFiles(e)) return;
115
+ e.preventDefault();
116
+ e.dataTransfer.dropEffect = 'copy';
117
+ }}
118
+ onDragLeave={() => {
119
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
120
+ if (dragDepth.current === 0) setDragActive(false);
121
+ }}
122
+ onDrop={(e) => {
123
+ if (uploading || !hasFiles(e)) return;
124
+ e.preventDefault();
125
+ dragDepth.current = 0;
126
+ setDragActive(false);
127
+ const file = e.dataTransfer.files?.[0];
128
+ if (file) handleFile(file).catch(() => {});
129
+ }}
130
+ >
131
+ {loading ? (
132
+ <p className="px-1 py-6 text-center text-xs text-muted-foreground">
133
+ {t.inspector.pickerLoading}
134
+ </p>
135
+ ) : images.length === 0 ? (
136
+ <p className="px-1 py-6 text-center text-xs text-muted-foreground">
137
+ {t.inspector.pickerEmpty}
138
+ </p>
139
+ ) : (
140
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3">
141
+ {images.map((asset) => (
142
+ <button
143
+ key={asset.name}
144
+ type="button"
145
+ onClick={() => onPick(asset, scope)}
146
+ className={cn(
147
+ 'group flex flex-col overflow-hidden rounded-lg border bg-card text-left shadow-sm transition-all',
148
+ 'hover:-translate-y-0.5 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none',
149
+ )}
150
+ >
151
+ <div className="flex aspect-square w-full items-center justify-center overflow-hidden bg-[repeating-conic-gradient(theme(colors.muted)_0_25%,transparent_0_50%)] bg-[length:12px_12px]">
152
+ <img
153
+ src={asset.url}
154
+ alt=""
155
+ className="size-full object-contain"
156
+ draggable={false}
157
+ />
158
+ </div>
159
+ <div className="border-t px-2 py-1.5">
160
+ <div className="truncate text-[11px] font-medium" title={asset.name}>
161
+ {asset.name}
162
+ </div>
163
+ </div>
164
+ </button>
165
+ ))}
166
+ </div>
167
+ )}
168
+ {dragActive && (
169
+ <div
170
+ className="pointer-events-none absolute inset-0 z-10 animate-in fade-in-0 duration-200"
171
+ aria-hidden
172
+ >
173
+ <div className="absolute inset-0 bg-brand/5" />
174
+ <div className="absolute inset-1 rounded-[8px] border border-dashed border-brand/40" />
175
+ <div className="absolute inset-x-0 bottom-4 flex justify-center">
176
+ <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">
177
+ <ArrowDownToLine className="size-3.5 text-brand" />
178
+ <span>{t.asset.dropToUpload}</span>
179
+ </div>
180
+ </div>
181
+ </div>
182
+ )}
183
+ </section>
184
+ </DialogContent>
185
+ </Dialog>
186
+ );
187
+ }
188
+
189
+ function hasFiles(e: React.DragEvent): boolean {
190
+ const types = e.dataTransfer?.types;
191
+ if (!types) return false;
192
+ for (let i = 0; i < types.length; i++) {
193
+ if (types[i] === 'Files') return true;
194
+ }
195
+ return false;
196
+ }
@@ -1,5 +1,5 @@
1
1
  import { MessageSquare, Trash2, X } from 'lucide-react';
2
- import { useState } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
3
  import { format, plural, useLocale } from '@/lib/use-locale';
4
4
  import { useInspector } from './inspector-provider';
5
5
 
@@ -8,9 +8,23 @@ export function CommentWidget() {
8
8
  const { comments, remove, error } = useInspector();
9
9
  const [open, setOpen] = useState(false);
10
10
  const count = comments.length;
11
+ const ref = useRef<HTMLDivElement>(null);
12
+
13
+ useEffect(() => {
14
+ if (!open) return;
15
+ const onPointerDown = (e: PointerEvent) => {
16
+ if (!ref.current?.contains(e.target as Node)) setOpen(false);
17
+ };
18
+ document.addEventListener('pointerdown', onPointerDown);
19
+ return () => document.removeEventListener('pointerdown', onPointerDown);
20
+ }, [open]);
11
21
 
12
22
  return (
13
- <div data-inspector-ui className="absolute right-4 bottom-4 z-20 flex flex-col items-end gap-2">
23
+ <div
24
+ ref={ref}
25
+ data-inspector-ui
26
+ className="absolute right-4 bottom-4 z-20 flex flex-col items-end gap-2"
27
+ >
14
28
  {open && (
15
29
  <div className="w-80 rounded-md border bg-card shadow-xl animate-in fade-in-0 slide-in-from-bottom-2 duration-200">
16
30
  <div className="flex items-center justify-between border-b px-3 py-2">
@@ -1,6 +1,10 @@
1
+ import { Crop, ImageIcon } from 'lucide-react';
1
2
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
3
  import { PANEL_TRANSITION_MS } from '@/components/panel/panel-shell';
4
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
3
5
  import { findSlideSource, type SlideSourceHit } from '@/lib/inspector/fiber';
6
+ import { useLocale } from '@/lib/use-locale';
7
+ import { cn } from '@/lib/utils';
4
8
  import { useInspector } from './inspector-provider';
5
9
 
6
10
  type Highlight = { hit: SlideSourceHit };
@@ -31,6 +35,9 @@ export function InspectOverlay() {
31
35
  };
32
36
 
33
37
  const onMove = (e: PointerEvent) => {
38
+ if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) {
39
+ return setHover(null);
40
+ }
34
41
  const el = pickInspectorTarget(pickElement(e.clientX, e.clientY));
35
42
  if (!el) return setHover(null);
36
43
  const hit = findSlideSource(el, slideId, { hostOnly: true });
@@ -75,37 +82,48 @@ export function InspectOverlay() {
75
82
  };
76
83
  }, [active, slideId, setSelected, cancel, openCrop]);
77
84
 
85
+ const hoverAnchor = hover?.hit.anchor.isConnected ? hover.hit.anchor : null;
86
+ const selectedAnchor = selected?.anchor.isConnected ? selected.anchor : null;
87
+ const dedupedHover = hoverAnchor && hoverAnchor !== selectedAnchor ? hoverAnchor : null;
88
+
89
+ if (!active) return null;
78
90
  return (
79
- <FrameOverlay
80
- active={active}
81
- overlayRef={overlayRef}
82
- // Pin to the selection so the highlight tracks what the panel
83
- // is editing even after the cursor moves away.
84
- targetAnchor={selected?.anchor ?? hover?.hit.anchor ?? null}
85
- />
91
+ <div ref={overlayRef} data-inspector-ui className="pointer-events-none absolute inset-0 z-30">
92
+ <Frame anchor={selectedAnchor} overlayRef={overlayRef} variant="selected" showImageActions />
93
+ <Frame anchor={dedupedHover} overlayRef={overlayRef} variant="hover" />
94
+ </div>
86
95
  );
87
96
  }
88
97
 
89
- function FrameOverlay({
90
- active,
98
+ type FrameVariant = 'selected' | 'hover';
99
+
100
+ const FRAME_STYLES: Record<FrameVariant, React.CSSProperties> = {
101
+ selected: { outline: '2px solid #3b82f6', background: 'rgba(59,130,246,0.1)' },
102
+ hover: { outline: '1.5px dashed #3b82f6', background: 'rgba(59,130,246,0.05)' },
103
+ };
104
+
105
+ function Frame({
106
+ anchor,
91
107
  overlayRef,
92
- targetAnchor,
108
+ variant,
109
+ showImageActions = false,
93
110
  }: {
94
- active: boolean;
111
+ anchor: HTMLElement | null;
95
112
  overlayRef: React.RefObject<HTMLDivElement>;
96
- targetAnchor: HTMLElement | null;
113
+ variant: FrameVariant;
114
+ showImageActions?: boolean;
97
115
  }) {
98
116
  const [rect, setRect] = useState<RelRect | null>(null);
99
117
  const [hasTarget, setHasTarget] = useState(false);
100
118
 
101
119
  const measure = useCallback(() => {
102
120
  const overlay = overlayRef.current;
103
- if (!active || !targetAnchor?.isConnected || !overlay) {
121
+ if (!anchor?.isConnected || !overlay) {
104
122
  setHasTarget(false);
105
123
  return;
106
124
  }
107
125
 
108
- const targetRect = targetAnchor.getBoundingClientRect();
126
+ const targetRect = anchor.getBoundingClientRect();
109
127
  const overlayRect = overlay.getBoundingClientRect();
110
128
  const next = {
111
129
  left: targetRect.left - overlayRect.left,
@@ -116,14 +134,14 @@ function FrameOverlay({
116
134
 
117
135
  setHasTarget(true);
118
136
  setRect((prev) => (sameRect(prev, next) ? prev : next));
119
- }, [active, overlayRef, targetAnchor]);
137
+ }, [overlayRef, anchor]);
120
138
 
121
139
  useLayoutEffect(() => {
122
140
  measure();
123
141
  }, [measure]);
124
142
 
125
143
  useEffect(() => {
126
- if (!active) {
144
+ if (!anchor) {
127
145
  setHasTarget(false);
128
146
  return;
129
147
  }
@@ -139,7 +157,7 @@ function FrameOverlay({
139
157
  const root = document.querySelector<HTMLElement>('[data-inspector-root]');
140
158
  if (root) resizeObserver.observe(root);
141
159
  if (overlayRef.current) resizeObserver.observe(overlayRef.current);
142
- if (targetAnchor) resizeObserver.observe(targetAnchor);
160
+ resizeObserver.observe(anchor);
143
161
 
144
162
  const stopAt = performance.now() + LAYOUT_TRACK_MS;
145
163
  const trackPanelTransition = () => {
@@ -157,9 +175,9 @@ function FrameOverlay({
157
175
  window.removeEventListener('resize', scheduleMeasure, true);
158
176
  window.removeEventListener('scroll', scheduleMeasure, true);
159
177
  };
160
- }, [active, measure, overlayRef, targetAnchor]);
178
+ }, [measure, overlayRef, anchor]);
161
179
 
162
- const visible = !!(active && hasTarget && rect);
180
+ const visible = !!(hasTarget && rect);
163
181
 
164
182
  // First render after appearing: snap to the new rect (no transition).
165
183
  // Subsequent rect changes in the same visible session: animate.
@@ -173,31 +191,110 @@ function FrameOverlay({
173
191
  return () => clearTimeout(t);
174
192
  }, [visible]);
175
193
 
176
- if (!active) return null;
194
+ if (!rect) return null;
177
195
  const transition = morph
178
196
  ? `left ${FRAME_MORPH_MS}ms ease-out, top ${FRAME_MORPH_MS}ms ease-out, ` +
179
197
  `width ${FRAME_MORPH_MS}ms ease-out, height ${FRAME_MORPH_MS}ms ease-out, ` +
180
198
  `opacity ${FRAME_FADE_MS}ms ease-out`
181
199
  : `opacity ${FRAME_FADE_MS}ms ease-out`;
182
200
 
201
+ const imageAnchor = anchor instanceof HTMLImageElement ? anchor : null;
202
+ const actionsVisible = showImageActions && visible && !!imageAnchor;
203
+
183
204
  return (
184
- <div ref={overlayRef} data-inspector-ui className="pointer-events-none absolute inset-0 z-30">
185
- {rect && (
186
- <div
187
- className="absolute"
188
- style={{
189
- left: rect.left,
190
- top: rect.top,
191
- width: rect.width,
192
- height: rect.height,
193
- opacity: visible ? 1 : 0,
194
- transition,
195
- outline: '2px solid #3b82f6',
196
- background: 'rgba(59,130,246,0.1)',
197
- }}
205
+ <>
206
+ <div
207
+ className="absolute"
208
+ style={{
209
+ left: rect.left,
210
+ top: rect.top,
211
+ width: rect.width,
212
+ height: rect.height,
213
+ opacity: visible ? 1 : 0,
214
+ transition,
215
+ ...FRAME_STYLES[variant],
216
+ }}
217
+ />
218
+ {showImageActions && imageAnchor && (
219
+ <ImageActionPanel
220
+ anchor={imageAnchor}
221
+ rect={rect}
222
+ visible={actionsVisible}
223
+ transition={transition}
198
224
  />
199
225
  )}
200
- </div>
226
+ </>
227
+ );
228
+ }
229
+
230
+ const FLOATING_PANEL_GAP = 8;
231
+
232
+ function ImageActionPanel({
233
+ anchor,
234
+ rect,
235
+ visible,
236
+ transition,
237
+ }: {
238
+ anchor: HTMLElement;
239
+ rect: RelRect;
240
+ visible: boolean;
241
+ transition: string;
242
+ }) {
243
+ const { openCrop, openReplace } = useInspector();
244
+ const t = useLocale();
245
+ return (
246
+ <TooltipProvider delayDuration={200}>
247
+ <div
248
+ className={cn(
249
+ 'absolute flex items-center gap-0.5 rounded-[8px] border border-border bg-popover p-1 text-popover-foreground shadow-floating',
250
+ visible ? 'pointer-events-auto' : 'pointer-events-none',
251
+ )}
252
+ style={{
253
+ left: rect.left + rect.width / 2,
254
+ top: rect.top + rect.height + FLOATING_PANEL_GAP,
255
+ transform: 'translateX(-50%)',
256
+ opacity: visible ? 1 : 0,
257
+ transition,
258
+ }}
259
+ >
260
+ <Tooltip>
261
+ <TooltipTrigger asChild>
262
+ <button
263
+ type="button"
264
+ aria-label={t.inspector.replace}
265
+ onClick={(e) => {
266
+ e.stopPropagation();
267
+ openReplace(anchor);
268
+ }}
269
+ className="inline-flex size-7 items-center justify-center rounded-[5px] text-foreground/85 transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
270
+ >
271
+ <ImageIcon className="size-3.5" />
272
+ </button>
273
+ </TooltipTrigger>
274
+ <TooltipContent side="bottom" data-inspector-ui>
275
+ {t.inspector.replace}
276
+ </TooltipContent>
277
+ </Tooltip>
278
+ <Tooltip>
279
+ <TooltipTrigger asChild>
280
+ <button
281
+ type="button"
282
+ aria-label={t.inspector.crop}
283
+ onClick={(e) => {
284
+ e.stopPropagation();
285
+ openCrop(anchor as HTMLImageElement);
286
+ }}
287
+ className="inline-flex size-7 items-center justify-center rounded-[5px] text-foreground/85 transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
288
+ >
289
+ <Crop className="size-3.5" />
290
+ </button>
291
+ </TooltipTrigger>
292
+ <TooltipContent side="bottom" data-inspector-ui>
293
+ {t.inspector.crop}
294
+ </TooltipContent>
295
+ </Tooltip>
296
+ </div>
297
+ </TooltipProvider>
201
298
  );
202
299
  }
203
300