@open-slide/core 1.0.6 → 1.2.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 (37) hide show
  1. package/dist/{build-4wOJF1l4.js → build-6BeQ3cxb.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-evLWCV1-.js → config-AxZ5OE1u.js} +772 -201
  4. package/dist/{config-D2y1AXaN.d.ts → config-CtT8K4VF.d.ts} +1 -1
  5. package/dist/{dev-BUr0S-Ij.js → dev-C9eLmUEq.js} +1 -1
  6. package/dist/index.d.ts +2 -2
  7. package/dist/locale/index.d.ts +1 -1
  8. package/dist/locale/index.js +136 -24
  9. package/dist/{preview-DP_gIphz.js → preview-Cunm-f4i.js} +1 -1
  10. package/dist/{types-BVvl_xup.d.ts → types-CRHIeoNq.d.ts} +37 -4
  11. package/dist/vite/index.d.ts +2 -2
  12. package/dist/vite/index.js +1 -1
  13. package/package.json +5 -1
  14. package/skills/current-slide/SKILL.md +110 -0
  15. package/skills/slide-authoring/SKILL.md +48 -1
  16. package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
  17. package/src/app/components/inspector/inspect-overlay.tsx +17 -2
  18. package/src/app/components/inspector/inspector-panel.tsx +90 -26
  19. package/src/app/components/inspector/inspector-provider.tsx +136 -1
  20. package/src/app/components/notes-drawer.tsx +117 -0
  21. package/src/app/components/player.tsx +26 -8
  22. package/src/app/components/present/overview-grid.tsx +2 -2
  23. package/src/app/components/present/use-idle.ts +6 -4
  24. package/src/app/components/style-panel/design-provider.tsx +13 -0
  25. package/src/app/components/style-panel/style-panel.tsx +23 -11
  26. package/src/app/components/thumbnail-rail.tsx +317 -55
  27. package/src/app/components/ui/context-menu.tsx +237 -0
  28. package/src/app/lib/design-presets.ts +94 -0
  29. package/src/app/lib/inspector/use-notes.ts +134 -0
  30. package/src/app/routes/home.tsx +34 -12
  31. package/src/app/routes/presenter.tsx +27 -24
  32. package/src/app/routes/slide.tsx +238 -51
  33. package/src/locale/en.ts +35 -4
  34. package/src/locale/ja.ts +35 -4
  35. package/src/locale/types.ts +38 -4
  36. package/src/locale/zh-cn.ts +35 -4
  37. package/src/locale/zh-tw.ts +35 -4
@@ -0,0 +1,117 @@
1
+ import { ChevronDown, ChevronUp, NotebookPen } from 'lucide-react';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { PANEL_TRANSITION_MS, usePanelMount } from '@/components/panel/panel-shell';
4
+ import { useNotes } from '@/lib/inspector/use-notes';
5
+ import { format, useLocale } from '@/lib/use-locale';
6
+ import { cn } from '@/lib/utils';
7
+
8
+ const STORAGE_KEY = 'open-slide:notes-drawer-open';
9
+ const DRAWER_CONTENT_H = 166;
10
+
11
+ type Props = {
12
+ slideId: string;
13
+ index: number;
14
+ total: number;
15
+ initial: string | undefined;
16
+ };
17
+
18
+ export function NotesDrawer({ slideId, index, total, initial }: Props) {
19
+ const t = useLocale();
20
+ const [open, setOpen] = useState(() => {
21
+ if (typeof window === 'undefined') return false;
22
+ return window.localStorage.getItem(STORAGE_KEY) === '1';
23
+ });
24
+ const { value, setValue, status, flush } = useNotes(slideId, index, initial);
25
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
26
+ const { mounted, animVisible } = usePanelMount(open);
27
+
28
+ useEffect(() => {
29
+ if (typeof window === 'undefined') return;
30
+ window.localStorage.setItem(STORAGE_KEY, open ? '1' : '0');
31
+ }, [open]);
32
+
33
+ const statusLabel = (() => {
34
+ switch (status.kind) {
35
+ case 'saving':
36
+ return t.notesDrawer.statusSaving;
37
+ case 'saved':
38
+ return t.notesDrawer.statusSaved;
39
+ case 'error':
40
+ return format(t.notesDrawer.statusError, { msg: status.message });
41
+ default:
42
+ return '';
43
+ }
44
+ })();
45
+
46
+ return (
47
+ <aside
48
+ data-notes-drawer
49
+ className="hidden shrink-0 border-t border-hairline bg-sidebar/85 backdrop-blur md:block"
50
+ >
51
+ <button
52
+ type="button"
53
+ onClick={() => {
54
+ setOpen((o) => {
55
+ if (o) void flush();
56
+ return !o;
57
+ });
58
+ }}
59
+ className="flex h-9 w-full items-center gap-2 px-3 text-[12px] text-foreground/80 hover:bg-muted/40"
60
+ aria-expanded={open}
61
+ >
62
+ <NotebookPen className="size-3.5 text-muted-foreground" />
63
+ <span className="font-medium">{t.notesDrawer.toggle}</span>
64
+ <span className="font-mono text-[11px] text-muted-foreground">
65
+ {format(t.notesDrawer.pageLabel, { n: index + 1, total })}
66
+ </span>
67
+ <span
68
+ className={cn(
69
+ 'ml-auto truncate text-[11px]',
70
+ status.kind === 'error' ? 'text-destructive' : 'text-muted-foreground',
71
+ )}
72
+ aria-live="polite"
73
+ >
74
+ {statusLabel}
75
+ </span>
76
+ {open ? (
77
+ <ChevronDown className="size-3.5 text-muted-foreground" />
78
+ ) : (
79
+ <ChevronUp className="size-3.5 text-muted-foreground" />
80
+ )}
81
+ </button>
82
+ {mounted && (
83
+ <div
84
+ className="overflow-hidden border-t border-hairline transition-[height] ease-out"
85
+ style={{
86
+ height: animVisible ? DRAWER_CONTENT_H : 0,
87
+ transitionDuration: `${PANEL_TRANSITION_MS}ms`,
88
+ }}
89
+ >
90
+ <div className="px-3 py-2">
91
+ <textarea
92
+ ref={textareaRef}
93
+ value={value}
94
+ onChange={(e) => setValue(e.target.value)}
95
+ onBlur={() => {
96
+ void flush();
97
+ }}
98
+ onKeyDown={(e) => {
99
+ if (e.key === 'Escape') {
100
+ e.preventDefault();
101
+ textareaRef.current?.blur();
102
+ } else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
103
+ e.preventDefault();
104
+ void flush();
105
+ }
106
+ }}
107
+ placeholder={t.notesDrawer.placeholder}
108
+ rows={6}
109
+ spellCheck
110
+ className="block h-[150px] w-full resize-none rounded-[6px] border border-border bg-card px-3 py-2 text-[13px] leading-relaxed text-card-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
111
+ />
112
+ </div>
113
+ </div>
114
+ )}
115
+ </aside>
116
+ );
117
+ }
@@ -58,8 +58,12 @@ export function Player({
58
58
  const [helpOpen, setHelpOpen] = useState(false);
59
59
  const [blackout, setBlackout] = useState<'black' | 'white' | null>(null);
60
60
  const [laser, setLaser] = useState(false);
61
+ const [keyboardDriven, setKeyboardDriven] = useState(false);
61
62
  const [startedAt] = useState(() => Date.now());
62
63
 
64
+ const canPrev = index > 0;
65
+ const canNext = index < pages.length - 1;
66
+
63
67
  const goPrev = useCallback(() => {
64
68
  if (index > 0) onIndexChange(index - 1);
65
69
  }, [index, onIndexChange]);
@@ -72,8 +76,8 @@ export function Player({
72
76
  useWheelPageNavigation({
73
77
  ref: rootRef,
74
78
  enabled: !overlayActive,
75
- canPrev: index > 0,
76
- canNext: index < pages.length - 1,
79
+ canPrev,
80
+ canNext,
77
81
  onPrev: goPrev,
78
82
  onNext: goNext,
79
83
  });
@@ -181,19 +185,23 @@ export function Player({
181
185
 
182
186
  if (isNext) {
183
187
  e.preventDefault();
188
+ setKeyboardDriven(true);
184
189
  goNext();
185
190
  return;
186
191
  }
187
192
  if (isPrev) {
188
193
  e.preventDefault();
194
+ setKeyboardDriven(true);
189
195
  goPrev();
190
196
  return;
191
197
  }
192
198
  if (e.key === 'Home') {
199
+ setKeyboardDriven(true);
193
200
  onIndexChange(0);
194
201
  return;
195
202
  }
196
203
  if (e.key === 'End') {
204
+ setKeyboardDriven(true);
197
205
  onIndexChange(pages.length - 1);
198
206
  return;
199
207
  }
@@ -246,7 +254,16 @@ export function Player({
246
254
  const pointerNearBottom = usePointerNearBottom(BAR_HOTZONE_PX, controls && !overlayActive);
247
255
  const chromeVisible = pointerNearBottom || overlayActive;
248
256
  const idle = useIdle(IDLE_HIDE_MS, controls && !overlayActive);
249
- const hideCursor = controls && (laser || (idle && !overlayActive && !pointerNearBottom));
257
+
258
+ useEffect(() => {
259
+ if (!keyboardDriven) return;
260
+ const clear = () => setKeyboardDriven(false);
261
+ window.addEventListener('mousemove', clear, { passive: true });
262
+ return () => window.removeEventListener('mousemove', clear);
263
+ }, [keyboardDriven]);
264
+
265
+ const hideCursor =
266
+ controls && (laser || keyboardDriven || (idle && !overlayActive && !pointerNearBottom));
250
267
 
251
268
  const PageComp = pages[index];
252
269
 
@@ -255,7 +272,8 @@ export function Player({
255
272
  ref={setRoot}
256
273
  className={cn(
257
274
  'relative flex h-dvh w-screen items-center justify-center bg-black',
258
- hideCursor && 'cursor-none',
275
+ controls && 'select-none',
276
+ controls && (hideCursor ? 'cursor-none' : 'cursor-default'),
259
277
  )}
260
278
  >
261
279
  <SlideCanvas flat design={design}>
@@ -266,15 +284,15 @@ export function Player({
266
284
  type="button"
267
285
  aria-label="Previous page"
268
286
  onClick={goPrev}
269
- disabled={index === 0}
270
- className="absolute inset-y-0 left-0 z-10 w-[30%]"
287
+ disabled={!canPrev}
288
+ className={cn('absolute inset-y-0 left-0 z-10 w-[30%]', hideCursor && 'cursor-none')}
271
289
  />
272
290
  <button
273
291
  type="button"
274
292
  aria-label="Next page"
275
293
  onClick={goNext}
276
- disabled={index === pages.length - 1}
277
- className="absolute inset-y-0 right-0 z-10 w-[30%]"
294
+ disabled={!canNext}
295
+ className={cn('absolute inset-y-0 right-0 z-10 w-[30%]', hideCursor && 'cursor-none')}
278
296
  />
279
297
 
280
298
  {controls && (
@@ -96,9 +96,9 @@ export function PresentOverviewGrid({ pages, design, open, current, onClose, onS
96
96
  </div>
97
97
  <div ref={gridRef} className="min-h-0 flex-1 overflow-auto px-8 pb-8">
98
98
  <div
99
- className="grid gap-5"
99
+ className="grid justify-center gap-5"
100
100
  style={{
101
- gridTemplateColumns: `repeat(auto-fill, minmax(${THUMB_W}px, 1fr))`,
101
+ gridTemplateColumns: `repeat(auto-fill, ${THUMB_W}px)`,
102
102
  }}
103
103
  >
104
104
  {pages.map((PageComp, i) => {
@@ -1,11 +1,15 @@
1
1
  import { useEffect, useState } from 'react';
2
2
 
3
3
  /**
4
- * Reports whether the user has been idle (no pointer / key / touch input)
5
- * for at least `delayMs`. Resets on any input event. The hook starts in
4
+ * Reports whether the user has been idle (no pointer / touch input) for at
5
+ * least `delayMs`. Resets on any pointer-related event. The hook starts in
6
6
  * the non-idle state so freshly-mounted UI is visible while the user
7
7
  * orients themselves.
8
8
  *
9
+ * Keyboard input is intentionally excluded — during a talk the presenter
10
+ * drives slides with arrow keys, and we want the cursor to stay hidden
11
+ * while they do.
12
+ *
9
13
  * Pass `enabled = false` to short-circuit (useful when the player is
10
14
  * paused on an overlay and we don't want to hide chrome behind it).
11
15
  */
@@ -27,14 +31,12 @@ export function useIdle(delayMs: number, enabled = true) {
27
31
  const opts = { passive: true } as const;
28
32
  window.addEventListener('mousemove', reset, opts);
29
33
  window.addEventListener('mousedown', reset, opts);
30
- window.addEventListener('keydown', reset);
31
34
  window.addEventListener('touchstart', reset, opts);
32
35
  window.addEventListener('wheel', reset, opts);
33
36
  return () => {
34
37
  if (timer) clearTimeout(timer);
35
38
  window.removeEventListener('mousemove', reset);
36
39
  window.removeEventListener('mousedown', reset);
37
- window.removeEventListener('keydown', reset);
38
40
  window.removeEventListener('touchstart', reset);
39
41
  window.removeEventListener('wheel', reset);
40
42
  };
@@ -11,6 +11,7 @@ import {
11
11
  import { toast } from 'sonner';
12
12
  import { useHistory } from '@/components/history-provider';
13
13
  import { type DesignSystem, defaultDesign, designToCssVars } from '../../lib/design';
14
+ import { shuffleDesign } from '../../lib/design-presets';
14
15
  import { useDesign as useDesignFetch } from './use-design';
15
16
 
16
17
  type DesignCtx = {
@@ -26,6 +27,7 @@ type DesignCtx = {
26
27
  commit: () => Promise<void>;
27
28
  discard: () => void;
28
29
  resetToDefaults: () => void;
30
+ shuffle: () => void;
29
31
  };
30
32
 
31
33
  const Ctx = createContext<DesignCtx | null>(null);
@@ -98,6 +100,16 @@ export function DesignProvider({ slideId, children }: { slideId: string; childre
98
100
  });
99
101
  }, [history]);
100
102
 
103
+ const shuffle = useCallback(() => {
104
+ const prev = draftRef.current;
105
+ const next = clone(shuffleDesign(prev));
106
+ setDraft(next);
107
+ history.record({
108
+ undo: () => setDraft(prev),
109
+ redo: () => setDraft(next),
110
+ });
111
+ }, [history]);
112
+
101
113
  // SlideCanvas emits its design vars inline on the canvas root, so a draft
102
114
  // overlay must use `!important` to outrank those inline styles.
103
115
  const previewCss = useMemo(() => {
@@ -121,6 +133,7 @@ export function DesignProvider({ slideId, children }: { slideId: string; childre
121
133
  commit,
122
134
  discard,
123
135
  resetToDefaults,
136
+ shuffle,
124
137
  };
125
138
 
126
139
  return (
@@ -1,4 +1,4 @@
1
- import { Palette, X } from 'lucide-react';
1
+ import { Palette, Shuffle, X } from 'lucide-react';
2
2
  import { useEffect, useState } from 'react';
3
3
  import { Field, NumberField, Section } from '@/components/panel/panel-fields';
4
4
  import { PanelShell, usePanelMount } from '@/components/panel/panel-shell';
@@ -28,7 +28,7 @@ type DesignPanelProps = {
28
28
  };
29
29
 
30
30
  export function DesignPanel({ open, onClose }: DesignPanelProps) {
31
- const { draft, exists, warning, loaded, dirty, update } = useDesignPanelState();
31
+ const { draft, exists, warning, loaded, dirty, update, shuffle } = useDesignPanelState();
32
32
  const { mounted, animVisible } = usePanelMount(open);
33
33
  const t = useLocale();
34
34
 
@@ -60,15 +60,27 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
60
60
  />
61
61
  )}
62
62
  </div>
63
- <Button
64
- variant="ghost"
65
- size="icon-sm"
66
- className="text-muted-foreground hover:text-foreground"
67
- onClick={onClose}
68
- aria-label={t.stylePanel.closePanelAria}
69
- >
70
- <X className="size-3.5" />
71
- </Button>
63
+ <div className="flex items-center gap-0.5">
64
+ <Button
65
+ variant="ghost"
66
+ size="icon-sm"
67
+ className="text-muted-foreground hover:text-foreground"
68
+ onClick={shuffle}
69
+ aria-label={t.stylePanel.shuffleAria}
70
+ title={t.stylePanel.shuffleTitle}
71
+ >
72
+ <Shuffle className="size-3.5" />
73
+ </Button>
74
+ <Button
75
+ variant="ghost"
76
+ size="icon-sm"
77
+ className="text-muted-foreground hover:text-foreground"
78
+ onClick={onClose}
79
+ aria-label={t.stylePanel.closePanelAria}
80
+ >
81
+ <X className="size-3.5" />
82
+ </Button>
83
+ </div>
72
84
  </>
73
85
  }
74
86
  banner={