@open-slide/core 1.0.5 → 1.1.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 (40) hide show
  1. package/dist/{build-CoON6kTb.js → build-DSqSio-T.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-D2y1AXaN.d.ts → config-C7vMYzFD.d.ts} +1 -1
  4. package/dist/{config-Bxtztw-H.js → config-KdiYeWtK.js} +114 -1
  5. package/dist/{dev-IezNC17X.js → dev-B_GVbr11.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 +40 -4
  9. package/dist/{preview-BwYjtENY.js → preview-D_mxhj7w.js} +1 -1
  10. package/dist/{types-BVvl_xup.d.ts → types-DYgVpIGo.d.ts} +9 -0
  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/src/app/components/inspector/image-crop-dialog.tsx +168 -0
  15. package/src/app/components/inspector/inspect-overlay.tsx +96 -19
  16. package/src/app/components/inspector/inspector-panel.tsx +46 -13
  17. package/src/app/components/inspector/inspector-provider.tsx +83 -1
  18. package/src/app/components/inspector/save-bar.tsx +0 -3
  19. package/src/app/components/player.tsx +22 -26
  20. package/src/app/components/present/overview-grid.tsx +0 -5
  21. package/src/app/components/present/use-idle.ts +6 -4
  22. package/src/app/components/present/use-presenter-channel.ts +3 -10
  23. package/src/app/components/sidebar/folder-item.tsx +0 -2
  24. package/src/app/components/sidebar/icon-picker.tsx +0 -3
  25. package/src/app/components/slide-canvas.tsx +1 -10
  26. package/src/app/components/style-panel/design-provider.tsx +15 -6
  27. package/src/app/components/style-panel/style-panel.tsx +23 -11
  28. package/src/app/components/thumbnail-rail.tsx +220 -53
  29. package/src/app/lib/design-presets.ts +94 -0
  30. package/src/app/lib/export-html.ts +1 -9
  31. package/src/app/lib/export-pdf.ts +0 -5
  32. package/src/app/lib/print-ready.ts +0 -4
  33. package/src/app/lib/sdk.ts +1 -2
  34. package/src/app/routes/presenter.tsx +27 -24
  35. package/src/app/routes/slide.tsx +53 -1
  36. package/src/locale/en.ts +9 -0
  37. package/src/locale/ja.ts +9 -0
  38. package/src/locale/types.ts +9 -0
  39. package/src/locale/zh-cn.ts +9 -0
  40. package/src/locale/zh-tw.ts +9 -0
@@ -1,16 +1,18 @@
1
- import { useEffect, useLayoutEffect, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import { PANEL_TRANSITION_MS } from '@/components/panel/panel-shell';
2
3
  import { findSlideSource, type SlideSourceHit } from '@/lib/inspector/fiber';
3
4
  import { useInspector } from './inspector-provider';
4
5
 
5
- type Highlight = { rect: DOMRect; hit: SlideSourceHit };
6
+ type Highlight = { hit: SlideSourceHit };
6
7
 
7
8
  type RelRect = { left: number; top: number; width: number; height: number };
8
9
 
9
10
  const FRAME_FADE_MS = 150;
10
11
  const FRAME_MORPH_MS = 180;
12
+ const LAYOUT_TRACK_MS = PANEL_TRANSITION_MS + FRAME_MORPH_MS;
11
13
 
12
14
  export function InspectOverlay() {
13
- const { active, slideId, selected, setSelected, cancel } = useInspector();
15
+ const { active, slideId, selected, setSelected, cancel, openCrop } = useInspector();
14
16
  const overlayRef = useRef<HTMLDivElement>(null);
15
17
  const [hover, setHover] = useState<Highlight | null>(null);
16
18
 
@@ -33,7 +35,7 @@ export function InspectOverlay() {
33
35
  if (!el) return setHover(null);
34
36
  const hit = findSlideSource(el, slideId, { hostOnly: true });
35
37
  if (!hit) return setHover(null);
36
- setHover({ rect: hit.anchor.getBoundingClientRect(), hit });
38
+ setHover({ hit });
37
39
  };
38
40
 
39
41
  const onClick = (e: MouseEvent) => {
@@ -45,18 +47,33 @@ export function InspectOverlay() {
45
47
  e.preventDefault();
46
48
  e.stopPropagation();
47
49
  setSelected({ line: hit.line, column: hit.column, anchor: hit.anchor });
48
- setHover({ rect: hit.anchor.getBoundingClientRect(), hit });
50
+ setHover({ hit });
51
+ };
52
+
53
+ const onDblClick = (e: MouseEvent) => {
54
+ if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) return;
55
+ const el = pickElement(e.clientX, e.clientY);
56
+ if (!el) return;
57
+ const hit = findSlideSource(el, slideId, { hostOnly: true });
58
+ if (!hit) return;
59
+ if (!(hit.anchor instanceof HTMLImageElement)) return;
60
+ e.preventDefault();
61
+ e.stopPropagation();
62
+ setSelected({ line: hit.line, column: hit.column, anchor: hit.anchor });
63
+ openCrop(hit.anchor);
49
64
  };
50
65
 
51
66
  window.addEventListener('pointermove', onMove, true);
52
67
  window.addEventListener('click', onClick, true);
68
+ window.addEventListener('dblclick', onDblClick, true);
53
69
  window.addEventListener('keydown', onKey, true);
54
70
  return () => {
55
71
  window.removeEventListener('pointermove', onMove, true);
56
72
  window.removeEventListener('click', onClick, true);
73
+ window.removeEventListener('dblclick', onDblClick, true);
57
74
  window.removeEventListener('keydown', onKey, true);
58
75
  };
59
- }, [active, slideId, setSelected, cancel]);
76
+ }, [active, slideId, setSelected, cancel, openCrop]);
60
77
 
61
78
  return (
62
79
  <FrameOverlay
@@ -64,7 +81,7 @@ export function InspectOverlay() {
64
81
  overlayRef={overlayRef}
65
82
  // Pin to the selection so the highlight tracks what the panel
66
83
  // is editing even after the cursor moves away.
67
- targetRect={selected?.anchor.getBoundingClientRect() ?? hover?.rect ?? null}
84
+ targetAnchor={selected?.anchor ?? hover?.hit.anchor ?? null}
68
85
  />
69
86
  );
70
87
  }
@@ -72,26 +89,77 @@ export function InspectOverlay() {
72
89
  function FrameOverlay({
73
90
  active,
74
91
  overlayRef,
75
- targetRect,
92
+ targetAnchor,
76
93
  }: {
77
94
  active: boolean;
78
95
  overlayRef: React.RefObject<HTMLDivElement>;
79
- targetRect: DOMRect | null;
96
+ targetAnchor: HTMLElement | null;
80
97
  }) {
81
- const overlayRect = overlayRef.current?.getBoundingClientRect();
82
- const visible = !!(active && targetRect && overlayRect);
83
-
84
- // Hold the last rect so the frame stays put during fade-out, when
85
- // `targetRect` has already gone null.
86
- const lastRectRef = useRef<RelRect | null>(null);
87
- if (visible && targetRect && overlayRect) {
88
- lastRectRef.current = {
98
+ const [rect, setRect] = useState<RelRect | null>(null);
99
+ const [hasTarget, setHasTarget] = useState(false);
100
+
101
+ const measure = useCallback(() => {
102
+ const overlay = overlayRef.current;
103
+ if (!active || !targetAnchor?.isConnected || !overlay) {
104
+ setHasTarget(false);
105
+ return;
106
+ }
107
+
108
+ const targetRect = targetAnchor.getBoundingClientRect();
109
+ const overlayRect = overlay.getBoundingClientRect();
110
+ const next = {
89
111
  left: targetRect.left - overlayRect.left,
90
112
  top: targetRect.top - overlayRect.top,
91
113
  width: targetRect.width,
92
114
  height: targetRect.height,
93
115
  };
94
- }
116
+
117
+ setHasTarget(true);
118
+ setRect((prev) => (sameRect(prev, next) ? prev : next));
119
+ }, [active, overlayRef, targetAnchor]);
120
+
121
+ useLayoutEffect(() => {
122
+ measure();
123
+ }, [measure]);
124
+
125
+ useEffect(() => {
126
+ if (!active) {
127
+ setHasTarget(false);
128
+ return;
129
+ }
130
+
131
+ let scheduled = 0;
132
+ let tracking = 0;
133
+ const scheduleMeasure = () => {
134
+ cancelAnimationFrame(scheduled);
135
+ scheduled = requestAnimationFrame(measure);
136
+ };
137
+
138
+ const resizeObserver = new ResizeObserver(scheduleMeasure);
139
+ const root = document.querySelector<HTMLElement>('[data-inspector-root]');
140
+ if (root) resizeObserver.observe(root);
141
+ if (overlayRef.current) resizeObserver.observe(overlayRef.current);
142
+ if (targetAnchor) resizeObserver.observe(targetAnchor);
143
+
144
+ const stopAt = performance.now() + LAYOUT_TRACK_MS;
145
+ const trackPanelTransition = () => {
146
+ measure();
147
+ if (performance.now() < stopAt) tracking = requestAnimationFrame(trackPanelTransition);
148
+ };
149
+ tracking = requestAnimationFrame(trackPanelTransition);
150
+
151
+ window.addEventListener('resize', scheduleMeasure, true);
152
+ window.addEventListener('scroll', scheduleMeasure, true);
153
+ return () => {
154
+ resizeObserver.disconnect();
155
+ cancelAnimationFrame(scheduled);
156
+ cancelAnimationFrame(tracking);
157
+ window.removeEventListener('resize', scheduleMeasure, true);
158
+ window.removeEventListener('scroll', scheduleMeasure, true);
159
+ };
160
+ }, [active, measure, overlayRef, targetAnchor]);
161
+
162
+ const visible = !!(active && hasTarget && rect);
95
163
 
96
164
  // First render after appearing: snap to the new rect (no transition).
97
165
  // Subsequent rect changes in the same visible session: animate.
@@ -106,7 +174,6 @@ function FrameOverlay({
106
174
  }, [visible]);
107
175
 
108
176
  if (!active) return null;
109
- const rect = lastRectRef.current;
110
177
  const transition = morph
111
178
  ? `left ${FRAME_MORPH_MS}ms ease-out, top ${FRAME_MORPH_MS}ms ease-out, ` +
112
179
  `width ${FRAME_MORPH_MS}ms ease-out, height ${FRAME_MORPH_MS}ms ease-out, ` +
@@ -134,6 +201,16 @@ function FrameOverlay({
134
201
  );
135
202
  }
136
203
 
204
+ function sameRect(a: RelRect | null, b: RelRect): boolean {
205
+ return (
206
+ !!a &&
207
+ Math.abs(a.left - b.left) < 0.5 &&
208
+ Math.abs(a.top - b.top) < 0.5 &&
209
+ Math.abs(a.width - b.width) < 0.5 &&
210
+ Math.abs(a.height - b.height) < 0.5
211
+ );
212
+ }
213
+
137
214
  function pickElement(x: number, y: number): HTMLElement | null {
138
215
  const stack = document.elementsFromPoint(x, y);
139
216
  for (const el of stack) {
@@ -202,7 +202,12 @@ export function InspectorPanel() {
202
202
  <>
203
203
  <Separator />
204
204
  <Section title={t.inspector.imageSection}>
205
- <ImageField slideId={slideId} src={pinSnapshot.imageSrc} apply={apply} />
205
+ <ImageField
206
+ slideId={slideId}
207
+ src={pinSnapshot.imageSrc}
208
+ anchor={pinSelected.anchor}
209
+ apply={apply}
210
+ />
206
211
  </Section>
207
212
  </>
208
213
  )}
@@ -587,14 +592,18 @@ function ColorField({
587
592
  function ImageField({
588
593
  slideId,
589
594
  src,
595
+ anchor,
590
596
  apply,
591
597
  }: {
592
598
  slideId: string;
593
599
  src: string;
600
+ anchor: HTMLElement;
594
601
  apply: (ops: EditOp[]) => void;
595
602
  }) {
596
603
  const [open, setOpen] = useState(false);
597
604
  const t = useLocale();
605
+ const { openCrop } = useInspector();
606
+ const isImage = anchor.tagName === 'IMG';
598
607
  return (
599
608
  <div className="space-y-2">
600
609
  <div className="flex items-center gap-3">
@@ -609,16 +618,29 @@ function ImageField({
609
618
  }}
610
619
  />
611
620
  </div>
612
- <Button
613
- type="button"
614
- variant="outline"
615
- size="sm"
616
- className="flex-1"
617
- onClick={() => setOpen(true)}
618
- >
619
- <ImageIcon className="size-3.5" />
620
- {t.inspector.replace}
621
- </Button>
621
+ <div className="flex flex-1 gap-2">
622
+ <Button
623
+ type="button"
624
+ variant="outline"
625
+ size="sm"
626
+ className="flex-1"
627
+ onClick={() => setOpen(true)}
628
+ >
629
+ <ImageIcon className="size-3.5" />
630
+ {t.inspector.replace}
631
+ </Button>
632
+ {isImage && (
633
+ <Button
634
+ type="button"
635
+ variant="outline"
636
+ size="sm"
637
+ className="flex-1"
638
+ onClick={() => openCrop(anchor as HTMLImageElement)}
639
+ >
640
+ {t.inspector.crop}
641
+ </Button>
642
+ )}
643
+ </div>
622
644
  </div>
623
645
  {open && (
624
646
  <AssetPickerDialog
@@ -626,14 +648,25 @@ function ImageField({
626
648
  onClose={() => setOpen(false)}
627
649
  onPick={(asset) => {
628
650
  setOpen(false);
629
- apply([
651
+ const ops: EditOp[] = [
630
652
  {
631
653
  kind: 'set-attr-asset',
632
654
  attr: 'src',
633
655
  assetPath: `./assets/${asset.name}`,
634
656
  previewUrl: asset.url,
635
657
  },
636
- ]);
658
+ ];
659
+ if (isImage) {
660
+ const cs = window.getComputedStyle(anchor);
661
+ if (cs.objectFit !== 'cover' && cs.objectFit !== 'contain') {
662
+ ops.push({ kind: 'set-style', key: 'objectFit', value: 'cover' });
663
+ }
664
+ const op = cs.objectPosition.trim();
665
+ if (!op || op === '0% 0%' || op === 'auto') {
666
+ ops.push({ kind: 'set-style', key: 'objectPosition', value: '50% 50%' });
667
+ }
668
+ }
669
+ apply(ops);
637
670
  }}
638
671
  />
639
672
  )}
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
15
15
  import { type SlideComment, useComments } from '@/lib/inspector/use-comments';
16
16
  import { type Edit, type EditOp, type EditResult, useEditor } from '@/lib/inspector/use-editor';
17
17
  import { useLocale } from '@/lib/use-locale';
18
+ import { ImageCropDialog } from './image-crop-dialog';
18
19
 
19
20
  export type SelectedTarget = {
20
21
  line: number;
@@ -69,6 +70,7 @@ type InspectorCtx = {
69
70
  commitEdits: () => Promise<void>;
70
71
  cancelEdits: () => void;
71
72
  committing: boolean;
73
+ openCrop: (anchor: HTMLImageElement) => void;
72
74
  };
73
75
 
74
76
  const Ctx = createContext<InspectorCtx | null>(null);
@@ -90,6 +92,16 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
90
92
  const instanceCounterRef = useRef(0);
91
93
  const [pendingCount, setPendingCount] = useState(0);
92
94
  const [committing, setCommitting] = useState(false);
95
+ const [cropTarget, setCropTarget] = useState<{
96
+ line: number;
97
+ column: number;
98
+ anchor: HTMLImageElement;
99
+ src: string;
100
+ targetWidth: number;
101
+ targetHeight: number;
102
+ initialFit: 'cover' | 'contain';
103
+ initialPosition: { x: number; y: number };
104
+ } | null>(null);
93
105
  const t = useLocale();
94
106
 
95
107
  const ensureInstanceId = useCallback((el: HTMLElement): string => {
@@ -529,6 +541,26 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
529
541
  setSelected(null);
530
542
  }, []);
531
543
 
544
+ const openCrop = useCallback((anchor: HTMLImageElement) => {
545
+ const loc = anchor.dataset.slideLoc;
546
+ if (!loc) return;
547
+ const [lineStr, columnStr] = loc.split(':');
548
+ const line = Number(lineStr);
549
+ const column = Number(columnStr);
550
+ if (!Number.isFinite(line) || !Number.isFinite(column)) return;
551
+ const cs = window.getComputedStyle(anchor);
552
+ setCropTarget({
553
+ line,
554
+ column,
555
+ anchor,
556
+ src: anchor.currentSrc || anchor.src,
557
+ targetWidth: anchor.offsetWidth || anchor.getBoundingClientRect().width,
558
+ targetHeight: anchor.offsetHeight || anchor.getBoundingClientRect().height,
559
+ initialFit: cs.objectFit === 'contain' ? 'contain' : 'cover',
560
+ initialPosition: parseObjectPosition(cs.objectPosition),
561
+ });
562
+ }, []);
563
+
532
564
  const value = useMemo<InspectorCtx>(
533
565
  () => ({
534
566
  slideId,
@@ -549,6 +581,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
549
581
  commitEdits,
550
582
  cancelEdits,
551
583
  committing,
584
+ openCrop,
552
585
  }),
553
586
  [
554
587
  slideId,
@@ -568,10 +601,59 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
568
601
  commitEdits,
569
602
  cancelEdits,
570
603
  committing,
604
+ openCrop,
571
605
  ],
572
606
  );
573
607
 
574
- return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
608
+ return (
609
+ <Ctx.Provider value={value}>
610
+ {children}
611
+ {cropTarget && (
612
+ <ImageCropDialog
613
+ src={cropTarget.src}
614
+ targetWidth={cropTarget.targetWidth}
615
+ targetHeight={cropTarget.targetHeight}
616
+ initialFit={cropTarget.initialFit}
617
+ initialPosition={cropTarget.initialPosition}
618
+ onClose={() => setCropTarget(null)}
619
+ onApply={(result) => {
620
+ const { line, column, anchor } = cropTarget;
621
+ if (anchor.isConnected) {
622
+ bufferOps(line, column, anchor, [
623
+ { kind: 'set-style', key: 'objectFit', value: result.fit },
624
+ {
625
+ kind: 'set-style',
626
+ key: 'objectPosition',
627
+ value: `${round2(result.x)}% ${round2(result.y)}%`,
628
+ },
629
+ ]);
630
+ }
631
+ setCropTarget(null);
632
+ }}
633
+ />
634
+ )}
635
+ </Ctx.Provider>
636
+ );
637
+ }
638
+
639
+ function round2(n: number): number {
640
+ return Math.round(n * 100) / 100;
641
+ }
642
+
643
+ function parseObjectPosition(value: string): { x: number; y: number } {
644
+ const parts = value.trim().split(/\s+/);
645
+ const xRaw = parts[0] ?? '50%';
646
+ const yRaw = parts[1] ?? xRaw;
647
+ return { x: parsePercent(xRaw, 50), y: parsePercent(yRaw, 50) };
648
+ }
649
+
650
+ function parsePercent(s: string, fallback: number): number {
651
+ if (s === 'center') return 50;
652
+ if (s === 'left' || s === 'top') return 0;
653
+ if (s === 'right' || s === 'bottom') return 100;
654
+ const m = s.match(/(-?\d+(?:\.\d+)?)%/);
655
+ if (m?.[1]) return Number(m[1]);
656
+ return fallback;
575
657
  }
576
658
 
577
659
  export function InspectToggleButton() {
@@ -4,9 +4,6 @@ import { useDesignPanelState } from '@/components/style-panel/design-provider';
4
4
  import { format, plural, useLocale } from '@/lib/use-locale';
5
5
  import { useInspector } from './inspector-provider';
6
6
 
7
- // Single save card for both inspector edits and design-token edits.
8
- // Counts the design draft as one unit; the user sees one combined
9
- // "N unsaved changes" pill. Save/Discard fan out to both providers.
10
7
  export function SaveBar() {
11
8
  const insp = useInspector();
12
9
  const design = useDesignPanelState();
@@ -21,9 +21,6 @@ import { useTouchSwipe } from './present/use-touch-swipe';
21
21
  import { SlideCanvas } from './slide-canvas';
22
22
 
23
23
  const IDLE_HIDE_MS = 2000;
24
- // Bottom band of the viewport that reveals the control bar + progress bar.
25
- // Generous enough to feel forgiving with a trackpad, tight enough not to
26
- // flash on incidental cursor moves.
27
24
  const BAR_HOTZONE_PX = 160;
28
25
 
29
26
  type Props = {
@@ -33,15 +30,7 @@ type Props = {
33
30
  onIndexChange: (index: number) => void;
34
31
  onExit: () => void;
35
32
  allowExit?: boolean;
36
- /**
37
- * When true, render the full presenter chrome (control bar, progress bar,
38
- * overview, blackout, laser pointer, jump-to-slide, help overlay, and
39
- * the BroadcastChannel sync that powers Presenter View). Defaults to
40
- * false so the static HTML export and any other minimal embeddings stay
41
- * untouched.
42
- */
43
33
  controls?: boolean;
44
- /** Optional id used to namespace the BroadcastChannel for Presenter View. */
45
34
  slideId?: string;
46
35
  };
47
36
 
@@ -56,20 +45,20 @@ export function Player({
56
45
  slideId,
57
46
  }: Props) {
58
47
  const rootRef = useRef<HTMLDivElement>(null);
59
- // Mirrored as state so children that need to portal *into* the player
60
- // (tooltips, popovers — the body is outside the fullscreen subtree and
61
- // therefore invisible) can subscribe and re-render once the node mounts.
48
+ // Mirrored as state so descendants portaling *into* the player subtree
49
+ // (tooltips, popovers — the body is outside the fullscreen tree) re-render
50
+ // once the node mounts.
62
51
  const [rootEl, setRootEl] = useState<HTMLDivElement | null>(null);
63
52
  const setRoot = useCallback((el: HTMLDivElement | null) => {
64
53
  rootRef.current = el;
65
54
  setRootEl(el);
66
55
  }, []);
67
56
 
68
- // ── Overlay state (only meaningful when `controls` is true) ────────────
69
57
  const [overviewOpen, setOverviewOpen] = useState(false);
70
58
  const [helpOpen, setHelpOpen] = useState(false);
71
59
  const [blackout, setBlackout] = useState<'black' | 'white' | null>(null);
72
60
  const [laser, setLaser] = useState(false);
61
+ const [keyboardDriven, setKeyboardDriven] = useState(false);
73
62
  const [startedAt] = useState(() => Date.now());
74
63
 
75
64
  const goPrev = useCallback(() => {
@@ -97,7 +86,6 @@ export function Player({
97
86
  onNext: goNext,
98
87
  });
99
88
 
100
- // ── Fullscreen lifecycle ───────────────────────────────────────────────
101
89
  useEffect(() => {
102
90
  const el = rootRef.current;
103
91
  if (!el) return;
@@ -118,11 +106,9 @@ export function Player({
118
106
  return () => document.removeEventListener('fullscreenchange', onFsChange);
119
107
  }, [onExit, allowExit]);
120
108
 
121
- // ── Presenter View sync ────────────────────────────────────────────────
122
- // Player is the source of truth. It re-publishes state on every change
109
+ // Player is the source of truth: it re-publishes state on every change
123
110
  // and answers `request-state` pings so newly opened presenter windows
124
- // hydrate immediately. Notes are loaded by Presenter View itself from
125
- // the same slide module, so they don't cross the channel.
111
+ // hydrate immediately.
126
112
  const presenterState = useMemo<PresenterState>(
127
113
  () => ({ index, pageCount: pages.length, blackout, startedAt }),
128
114
  [index, pages.length, blackout, startedAt],
@@ -155,7 +141,6 @@ export function Player({
155
141
  channel.send({ type: 'state', state: presenterState });
156
142
  }, [controls, channel, presenterState]);
157
143
 
158
- // ── Keyboard ───────────────────────────────────────────────────────────
159
144
  useEffect(() => {
160
145
  const onKey = (e: KeyboardEvent) => {
161
146
  const tgt = e.target;
@@ -197,19 +182,23 @@ export function Player({
197
182
 
198
183
  if (isNext) {
199
184
  e.preventDefault();
185
+ setKeyboardDriven(true);
200
186
  goNext();
201
187
  return;
202
188
  }
203
189
  if (isPrev) {
204
190
  e.preventDefault();
191
+ setKeyboardDriven(true);
205
192
  goPrev();
206
193
  return;
207
194
  }
208
195
  if (e.key === 'Home') {
196
+ setKeyboardDriven(true);
209
197
  onIndexChange(0);
210
198
  return;
211
199
  }
212
200
  if (e.key === 'End') {
201
+ setKeyboardDriven(true);
213
202
  onIndexChange(pages.length - 1);
214
203
  return;
215
204
  }
@@ -256,14 +245,22 @@ export function Player({
256
245
  slideId,
257
246
  ]);
258
247
 
259
- // ── Chrome visibility / cursor ─────────────────────────────────────────
260
248
  // The control bar + progress strip only surface when the pointer is in
261
- // the bottom hot zone. Keyboard nav (arrows / space / PgDn) never
262
- // reveals them — that's intentional so the deck stays clean.
249
+ // the bottom hot zone. Keyboard nav (arrows / space / PgDn) never reveals
250
+ // them — intentional so the deck stays clean during a talk.
263
251
  const pointerNearBottom = usePointerNearBottom(BAR_HOTZONE_PX, controls && !overlayActive);
264
252
  const chromeVisible = pointerNearBottom || overlayActive;
265
253
  const idle = useIdle(IDLE_HIDE_MS, controls && !overlayActive);
266
- const hideCursor = controls && (laser || (idle && !overlayActive && !pointerNearBottom));
254
+
255
+ useEffect(() => {
256
+ if (!keyboardDriven) return;
257
+ const clear = () => setKeyboardDriven(false);
258
+ window.addEventListener('mousemove', clear, { passive: true });
259
+ return () => window.removeEventListener('mousemove', clear);
260
+ }, [keyboardDriven]);
261
+
262
+ const hideCursor =
263
+ controls && (laser || keyboardDriven || (idle && !overlayActive && !pointerNearBottom));
267
264
 
268
265
  const PageComp = pages[index];
269
266
 
@@ -279,7 +276,6 @@ export function Player({
279
276
  {PageComp ? <PageComp /> : null}
280
277
  </SlideCanvas>
281
278
 
282
- {/* Invisible side click zones — the original mobile-friendly nav. */}
283
279
  <button
284
280
  type="button"
285
281
  aria-label="Previous page"
@@ -18,11 +18,6 @@ type Props = {
18
18
  onSelect: (index: number) => void;
19
19
  };
20
20
 
21
- /**
22
- * Full-screen grid of slide thumbnails. Reuses SlideCanvas at fixed scale
23
- * so each preview is rendered with the slide's design tokens but with
24
- * motion frozen. Arrow keys move focus; Enter/click jumps and closes.
25
- */
26
21
  export function PresentOverviewGrid({ pages, design, open, current, onClose, onSelect }: Props) {
27
22
  const [focused, setFocused] = useState(current);
28
23
  const gridRef = useRef<HTMLDivElement>(null);
@@ -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
  };
@@ -20,16 +20,9 @@ type Handler = (msg: PresenterCommand) => void;
20
20
 
21
21
  const SUPPORTED = typeof window !== 'undefined' && typeof BroadcastChannel !== 'undefined';
22
22
 
23
- /**
24
- * BroadcastChannel wrapper used by both the projection window (Player) and
25
- * the Presenter View. The channel is keyed by slideId so multiple decks
26
- * open in different tabs do not cross-talk. Falls back to no-op when the
27
- * API is missing (older browsers, SSR).
28
- *
29
- * The channel is owned by the effect (not useMemo) so React 18 StrictMode's
30
- * double-invoke creates a fresh channel on the second mount instead of
31
- * leaving a closed one behind that throws on the next `send()`.
32
- */
23
+ // Channel ownership lives in the effect (not useMemo) so StrictMode's
24
+ // double-invoke produces a fresh channel on remount rather than leaving a
25
+ // closed one behind that throws on the next send().
33
26
  export function usePresenterChannel(slideId: string, onMessage?: Handler) {
34
27
  const onMessageRef = useRef(onMessage);
35
28
  onMessageRef.current = onMessage;
@@ -130,8 +130,6 @@ export function FolderItem({
130
130
  <div
131
131
  className={cn(
132
132
  'group relative flex items-center gap-2.5 rounded-[5px] px-2 py-[5px] text-[12.5px] transition-colors',
133
- // Editorial selected state: subtle warm tint + a thin vermillion
134
- // ink-mark on the leading edge. Avoids the heavy "filled pill" look.
135
133
  selected
136
134
  ? 'bg-muted text-foreground before:absolute before:inset-y-1.5 before:-left-0.5 before:w-[2px] before:rounded-full before:bg-brand'
137
135
  : 'text-foreground/70 hover:bg-muted/60 hover:text-foreground',
@@ -3,9 +3,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
3
3
  import type { FolderIcon } from '@/lib/sdk';
4
4
  import { useLocale } from '@/lib/use-locale';
5
5
 
6
- // Editorial palette — restrained warm/earth tones, no shadcn defaults
7
- // (no #8b5cf6 violet, no #3b82f6 blue, etc.). Picked to coexist with the
8
- // vermillion brand accent without shouting over it.
9
6
  export const PRESET_COLORS = [
10
7
  '#c0392b', // vermillion
11
8
  '#b8743e', // ochre
@@ -5,21 +5,12 @@ import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
5
5
 
6
6
  type Props = {
7
7
  children: ReactNode;
8
- /** If set, use this scale directly (e.g., thumbnails). Otherwise fit to container. */
8
+ /** If set, use this scale directly. Otherwise fit to container. */
9
9
  scale?: number;
10
- /** Center the canvas within the container (default true). */
11
10
  center?: boolean;
12
- /** Flat mode: no rounded corners or drop shadow. */
13
11
  flat?: boolean;
14
- /** Freeze descendant animations and transitions, useful for thumbnail previews. */
15
12
  freezeMotion?: boolean;
16
13
  className?: string;
17
- /**
18
- * Per-slide design tokens. When set, the matching CSS custom properties
19
- * are emitted on the canvas root so descendants can use `var(--osd-X)`
20
- * regardless of which surface (editor, player, thumbnail, export) is
21
- * rendering them.
22
- */
23
14
  design?: DesignSystem;
24
15
  };
25
16