@open-slide/core 1.1.0 → 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 (31) hide show
  1. package/dist/{build-DSqSio-T.js → build-6BeQ3cxb.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-KdiYeWtK.js → config-AxZ5OE1u.js} +673 -211
  4. package/dist/{config-C7vMYzFD.d.ts → config-CtT8K4VF.d.ts} +1 -1
  5. package/dist/{dev-B_GVbr11.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 +96 -20
  9. package/dist/{preview-D_mxhj7w.js → preview-Cunm-f4i.js} +1 -1
  10. package/dist/{types-DYgVpIGo.d.ts → types-CRHIeoNq.d.ts} +28 -4
  11. package/dist/vite/index.d.ts +2 -2
  12. package/dist/vite/index.js +1 -1
  13. package/package.json +1 -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 +64 -20
  17. package/src/app/components/inspector/inspector-panel.tsx +44 -13
  18. package/src/app/components/inspector/inspector-provider.tsx +60 -7
  19. package/src/app/components/notes-drawer.tsx +117 -0
  20. package/src/app/components/player.tsx +11 -7
  21. package/src/app/components/present/overview-grid.tsx +2 -2
  22. package/src/app/components/thumbnail-rail.tsx +119 -24
  23. package/src/app/components/ui/context-menu.tsx +237 -0
  24. package/src/app/lib/inspector/use-notes.ts +134 -0
  25. package/src/app/routes/home.tsx +34 -12
  26. package/src/app/routes/slide.tsx +209 -74
  27. package/src/locale/en.ts +26 -4
  28. package/src/locale/ja.ts +26 -4
  29. package/src/locale/types.ts +29 -4
  30. package/src/locale/zh-cn.ts +26 -4
  31. package/src/locale/zh-tw.ts +26 -4
@@ -13,11 +13,9 @@ import {
13
13
  import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
14
14
  import { useLocale } from '@/lib/use-locale';
15
15
 
16
- export type ImageCropResult = {
17
- fit: 'cover' | 'contain';
18
- x: number;
19
- y: number;
20
- };
16
+ export type ImageCropRect = { x: number; y: number; width: number; height: number };
17
+
18
+ export type ImageCropResult = { fit: 'contain' } | { fit: 'cover'; rect: ImageCropRect };
21
19
 
22
20
  export function ImageCropDialog({
23
21
  src,
@@ -25,6 +23,7 @@ export function ImageCropDialog({
25
23
  targetHeight,
26
24
  initialFit,
27
25
  initialPosition,
26
+ initialRect,
28
27
  onClose,
29
28
  onApply,
30
29
  }: {
@@ -33,6 +32,7 @@ export function ImageCropDialog({
33
32
  targetHeight: number;
34
33
  initialFit: 'cover' | 'contain';
35
34
  initialPosition: { x: number; y: number };
35
+ initialRect: ImageCropRect | null;
36
36
  onClose: () => void;
37
37
  onApply: (result: ImageCropResult) => void;
38
38
  }) {
@@ -44,26 +44,30 @@ export function ImageCropDialog({
44
44
 
45
45
  const onImageLoad = (e: SyntheticEvent<HTMLImageElement>) => {
46
46
  const im = e.currentTarget;
47
- setCrop(makeMaxSizeCrop(im.naturalWidth, im.naturalHeight, aspect, initialPosition));
47
+ setCrop(initialCrop(im.naturalWidth, im.naturalHeight, aspect, initialRect, initialPosition));
48
48
  };
49
49
 
50
50
  useEffect(() => {
51
51
  const im = imgRef.current;
52
- if (!im || !im.complete || !im.naturalWidth || !im.naturalHeight) return;
52
+ if (!im?.complete || !im.naturalWidth || !im.naturalHeight) return;
53
53
  setCrop((prev) => {
54
- const pos = prev ? deriveObjectPosition(prev as PercentCrop) : initialPosition;
55
- return makeMaxSizeCrop(im.naturalWidth, im.naturalHeight, aspect, pos);
54
+ if (prev && prev.unit === '%') {
55
+ return clampToAspect(prev as PercentCrop, aspect, im.naturalWidth, im.naturalHeight);
56
+ }
57
+ return initialCrop(im.naturalWidth, im.naturalHeight, aspect, initialRect, initialPosition);
56
58
  });
57
- }, [aspect, initialPosition]);
59
+ }, [aspect, initialPosition, initialRect]);
58
60
 
59
61
  const onApplyClick = () => {
60
62
  if (fit === 'contain') {
61
- onApply({ fit, x: 50, y: 50 });
63
+ onApply({ fit });
62
64
  return;
63
65
  }
64
- const pos =
65
- crop && crop.unit === '%' ? deriveObjectPosition(crop as PercentCrop) : { x: 50, y: 50 };
66
- onApply({ fit, x: round2(pos.x), y: round2(pos.y) });
66
+ const rect =
67
+ crop && crop.unit === '%'
68
+ ? roundRect(crop as PercentCrop)
69
+ : { x: 0, y: 0, width: 100, height: 100 };
70
+ onApply({ fit, rect });
67
71
  };
68
72
 
69
73
  return (
@@ -98,7 +102,6 @@ export function ImageCropDialog({
98
102
  onChange={(_, percentCrop) => setCrop(percentCrop)}
99
103
  aspect={aspect}
100
104
  keepSelection
101
- locked
102
105
  className="max-h-full"
103
106
  >
104
107
  <img
@@ -124,6 +127,19 @@ export function ImageCropDialog({
124
127
  );
125
128
  }
126
129
 
130
+ function initialCrop(
131
+ naturalW: number,
132
+ naturalH: number,
133
+ aspect: number,
134
+ rect: ImageCropRect | null,
135
+ position: { x: number; y: number },
136
+ ): PercentCrop {
137
+ if (rect) {
138
+ return clampToAspect({ unit: '%', ...rect }, aspect, naturalW, naturalH);
139
+ }
140
+ return makeMaxSizeCrop(naturalW, naturalH, aspect, position);
141
+ }
142
+
127
143
  function makeMaxSizeCrop(
128
144
  naturalW: number,
129
145
  naturalH: number,
@@ -150,12 +166,40 @@ function makeMaxSizeCrop(
150
166
  return { unit: '%', x, y, width, height };
151
167
  }
152
168
 
153
- function deriveObjectPosition(crop: PercentCrop): { x: number; y: number } {
154
- const slackX = 100 - crop.width;
155
- const slackY = 100 - crop.height;
169
+ function clampToAspect(
170
+ crop: PercentCrop,
171
+ aspect: number,
172
+ naturalW: number,
173
+ naturalH: number,
174
+ ): PercentCrop {
175
+ const cx = crop.x + crop.width / 2;
176
+ const cy = crop.y + crop.height / 2;
177
+ let width = crop.width;
178
+ let height = crop.height;
179
+ const targetPctRatio = naturalW > 0 && naturalH > 0 ? (aspect * naturalH) / naturalW : aspect;
180
+ const currentRatio = height > 0 ? width / height : targetPctRatio;
181
+ if (Math.abs(currentRatio - targetPctRatio) > 0.0001) {
182
+ if (currentRatio > targetPctRatio) {
183
+ height = width / targetPctRatio;
184
+ } else {
185
+ width = height * targetPctRatio;
186
+ }
187
+ }
188
+ width = clamp(width, 1, 100);
189
+ height = clamp(height, 1, 100);
190
+ let x = cx - width / 2;
191
+ let y = cy - height / 2;
192
+ x = clamp(x, 0, 100 - width);
193
+ y = clamp(y, 0, 100 - height);
194
+ return { unit: '%', x, y, width, height };
195
+ }
196
+
197
+ function roundRect(crop: PercentCrop): ImageCropRect {
156
198
  return {
157
- x: slackX > 0 ? clamp((crop.x / slackX) * 100, 0, 100) : 50,
158
- y: slackY > 0 ? clamp((crop.y / slackY) * 100, 0, 100) : 50,
199
+ x: round2(crop.x),
200
+ y: round2(crop.y),
201
+ width: round2(crop.width),
202
+ height: round2(crop.height),
159
203
  };
160
204
  }
161
205
 
@@ -4,6 +4,7 @@ import {
4
4
  AlignLeft,
5
5
  AlignRight,
6
6
  Bold,
7
+ Crop,
7
8
  ImageIcon,
8
9
  Italic,
9
10
  X,
@@ -32,6 +33,7 @@ import { Slider } from '@/components/ui/slider';
32
33
  import { Textarea } from '@/components/ui/textarea';
33
34
  import { Toggle } from '@/components/ui/toggle';
34
35
  import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
36
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
35
37
  import { type AssetEntry, useAssets } from '@/lib/assets';
36
38
  import { findSlideSource } from '@/lib/inspector/fiber';
37
39
  import type { EditOp } from '@/lib/inspector/use-editor';
@@ -149,15 +151,18 @@ export function InspectorPanel() {
149
151
  &lt;{pinSelected.anchor.tagName.toLowerCase()}&gt;
150
152
  </span>
151
153
  </div>
152
- <Button
153
- variant="ghost"
154
- size="icon-sm"
155
- className="text-muted-foreground hover:text-foreground"
156
- onClick={() => setSelected(null)}
157
- aria-label={t.inspector.deselect}
158
- >
159
- <X className="size-3.5" />
160
- </Button>
154
+ <div className="flex items-center gap-1.5">
155
+ <AgentWatchingBadge />
156
+ <Button
157
+ variant="ghost"
158
+ size="icon-sm"
159
+ className="text-muted-foreground hover:text-foreground"
160
+ onClick={() => setSelected(null)}
161
+ aria-label={t.inspector.deselect}
162
+ >
163
+ <X className="size-3.5" />
164
+ </Button>
165
+ </div>
161
166
  </>
162
167
  }
163
168
  footer={<CommentsSection selected={pinSelected} onAdd={add} />}
@@ -637,6 +642,7 @@ function ImageField({
637
642
  className="flex-1"
638
643
  onClick={() => openCrop(anchor as HTMLImageElement)}
639
644
  >
645
+ <Crop className="size-3.5" />
640
646
  {t.inspector.crop}
641
647
  </Button>
642
648
  )}
@@ -800,6 +806,31 @@ function AssetPickerDialog({
800
806
  );
801
807
  }
802
808
 
809
+ function AgentWatchingBadge() {
810
+ const t = useLocale();
811
+ return (
812
+ <TooltipProvider delayDuration={200}>
813
+ <Tooltip>
814
+ <TooltipTrigger asChild>
815
+ <button
816
+ type="button"
817
+ 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
+ >
819
+ <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" />
822
+ </span>
823
+ {t.inspector.agentWatching}
824
+ </button>
825
+ </TooltipTrigger>
826
+ <TooltipContent side="bottom" align="end" className="max-w-[260px] leading-relaxed">
827
+ {t.inspector.agentWatchingTooltip}
828
+ </TooltipContent>
829
+ </Tooltip>
830
+ </TooltipProvider>
831
+ );
832
+ }
833
+
803
834
  function CommentsSection({
804
835
  selected,
805
836
  onAdd,
@@ -824,7 +855,7 @@ function CommentsSection({
824
855
  };
825
856
 
826
857
  return (
827
- <Section title={t.inspector.noteForAgent}>
858
+ <Section title={t.inspector.leaveComment}>
828
859
  <div className="flex flex-col gap-2">
829
860
  <div className="comment-cue rounded-[6px]">
830
861
  <Textarea
@@ -836,16 +867,16 @@ function CommentsSection({
836
867
  submit();
837
868
  }
838
869
  }}
839
- placeholder={t.inspector.noteAgentPlaceholder}
870
+ placeholder={t.inspector.commentPlaceholder}
840
871
  className="min-h-16 resize-none text-[12px]"
841
872
  />
842
873
  </div>
843
874
  <div className="flex items-center justify-between gap-2">
844
875
  <span className="font-mono text-[10.5px] text-muted-foreground/70">
845
- {t.inspector.noteShortcutHint}
876
+ {t.inspector.commentShortcutHint}
846
877
  </span>
847
878
  <Button size="sm" variant="brand" disabled={submitting || !draft.trim()} onClick={submit}>
848
- {t.inspector.addNote}
879
+ {t.inspector.addComment}
849
880
  </Button>
850
881
  </div>
851
882
  </div>
@@ -15,7 +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
+ import { ImageCropDialog, type ImageCropRect } from './image-crop-dialog';
19
19
 
20
20
  export type SelectedTarget = {
21
21
  line: number;
@@ -101,6 +101,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
101
101
  targetHeight: number;
102
102
  initialFit: 'cover' | 'contain';
103
103
  initialPosition: { x: number; y: number };
104
+ initialRect: ImageCropRect | null;
104
105
  } | null>(null);
105
106
  const t = useLocale();
106
107
 
@@ -558,6 +559,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
558
559
  targetHeight: anchor.offsetHeight || anchor.getBoundingClientRect().height,
559
560
  initialFit: cs.objectFit === 'contain' ? 'contain' : 'cover',
560
561
  initialPosition: parseObjectPosition(cs.objectPosition),
562
+ initialRect: parseObjectViewBox(cs.getPropertyValue('object-view-box')),
561
563
  });
562
564
  }, []);
563
565
 
@@ -615,18 +617,30 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
615
617
  targetHeight={cropTarget.targetHeight}
616
618
  initialFit={cropTarget.initialFit}
617
619
  initialPosition={cropTarget.initialPosition}
620
+ initialRect={cropTarget.initialRect}
618
621
  onClose={() => setCropTarget(null)}
619
622
  onApply={(result) => {
620
623
  const { line, column, anchor } = cropTarget;
621
624
  if (anchor.isConnected) {
622
- bufferOps(line, column, anchor, [
625
+ const ops: EditOp[] = [
623
626
  { kind: 'set-style', key: 'objectFit', value: result.fit },
624
- {
627
+ { kind: 'set-style', key: 'objectPosition', value: '50% 50%' },
628
+ ];
629
+ if (result.fit === 'cover') {
630
+ const { x, y, width, height } = result.rect;
631
+ const top = round2(y);
632
+ const left = round2(x);
633
+ const right = round2(100 - x - width);
634
+ const bottom = round2(100 - y - height);
635
+ ops.push({
625
636
  kind: 'set-style',
626
- key: 'objectPosition',
627
- value: `${round2(result.x)}% ${round2(result.y)}%`,
628
- },
629
- ]);
637
+ key: 'objectViewBox',
638
+ value: `inset(${top}% ${right}% ${bottom}% ${left}%)`,
639
+ });
640
+ } else {
641
+ ops.push({ kind: 'set-style', key: 'objectViewBox', value: null });
642
+ }
643
+ bufferOps(line, column, anchor, ops);
630
644
  }
631
645
  setCropTarget(null);
632
646
  }}
@@ -640,6 +654,45 @@ function round2(n: number): number {
640
654
  return Math.round(n * 100) / 100;
641
655
  }
642
656
 
657
+ function parseObjectViewBox(value: string): ImageCropRect | null {
658
+ const v = value?.trim();
659
+ if (!v || v === 'none') return null;
660
+ const m = v.match(/^inset\(([^)]+)\)$/);
661
+ if (!m?.[1]) return null;
662
+ const nums = m[1]
663
+ .trim()
664
+ .split(/\s+/)
665
+ .map((p) => {
666
+ const n = p.match(/^(-?\d+(?:\.\d+)?)%$/);
667
+ return n?.[1] ? Number(n[1]) : null;
668
+ });
669
+ if (nums.some((n) => n === null)) return null;
670
+ let top: number, right: number, bottom: number, left: number;
671
+ if (nums.length === 1) {
672
+ top = right = bottom = left = nums[0] as number;
673
+ } else if (nums.length === 2) {
674
+ top = bottom = nums[0] as number;
675
+ right = left = nums[1] as number;
676
+ } else if (nums.length === 3) {
677
+ top = nums[0] as number;
678
+ right = left = nums[1] as number;
679
+ bottom = nums[2] as number;
680
+ } else if (nums.length === 4) {
681
+ top = nums[0] as number;
682
+ right = nums[1] as number;
683
+ bottom = nums[2] as number;
684
+ left = nums[3] as number;
685
+ } else {
686
+ return null;
687
+ }
688
+ const x = left;
689
+ const y = top;
690
+ const width = 100 - left - right;
691
+ const height = 100 - top - bottom;
692
+ if (width <= 0 || height <= 0) return null;
693
+ return { x, y, width, height };
694
+ }
695
+
643
696
  function parseObjectPosition(value: string): { x: number; y: number } {
644
697
  const parts = value.trim().split(/\s+/);
645
698
  const xRaw = parts[0] ?? '50%';
@@ -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
+ }
@@ -61,6 +61,9 @@ export function Player({
61
61
  const [keyboardDriven, setKeyboardDriven] = useState(false);
62
62
  const [startedAt] = useState(() => Date.now());
63
63
 
64
+ const canPrev = index > 0;
65
+ const canNext = index < pages.length - 1;
66
+
64
67
  const goPrev = useCallback(() => {
65
68
  if (index > 0) onIndexChange(index - 1);
66
69
  }, [index, onIndexChange]);
@@ -73,8 +76,8 @@ export function Player({
73
76
  useWheelPageNavigation({
74
77
  ref: rootRef,
75
78
  enabled: !overlayActive,
76
- canPrev: index > 0,
77
- canNext: index < pages.length - 1,
79
+ canPrev,
80
+ canNext,
78
81
  onPrev: goPrev,
79
82
  onNext: goNext,
80
83
  });
@@ -269,7 +272,8 @@ export function Player({
269
272
  ref={setRoot}
270
273
  className={cn(
271
274
  'relative flex h-dvh w-screen items-center justify-center bg-black',
272
- hideCursor && 'cursor-none',
275
+ controls && 'select-none',
276
+ controls && (hideCursor ? 'cursor-none' : 'cursor-default'),
273
277
  )}
274
278
  >
275
279
  <SlideCanvas flat design={design}>
@@ -280,15 +284,15 @@ export function Player({
280
284
  type="button"
281
285
  aria-label="Previous page"
282
286
  onClick={goPrev}
283
- disabled={index === 0}
284
- 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')}
285
289
  />
286
290
  <button
287
291
  type="button"
288
292
  aria-label="Next page"
289
293
  onClick={goNext}
290
- disabled={index === pages.length - 1}
291
- 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')}
292
296
  />
293
297
 
294
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) => {