@open-slide/core 1.6.0 → 1.7.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.
@@ -3,27 +3,17 @@ import {
3
3
  AlignJustify,
4
4
  AlignLeft,
5
5
  AlignRight,
6
- ArrowDownToLine,
7
6
  Bold,
8
7
  Crop,
8
+ Crosshair,
9
9
  ImageIcon,
10
10
  Italic,
11
- Loader2,
12
- Upload,
13
11
  X,
14
12
  } from 'lucide-react';
15
- import { useCallback, useEffect, useId, useRef, useState } from 'react';
16
- import { toast } from 'sonner';
13
+ import { useCallback, useEffect, useRef, useState } from 'react';
17
14
  import { Field, NumberField, Section } from '@/components/panel/panel-fields';
18
15
  import { PANEL_TRANSITION_MS, PanelShell, useAnimatedOpen } from '@/components/panel/panel-shell';
19
16
  import { Button } from '@/components/ui/button';
20
- import {
21
- Dialog,
22
- DialogContent,
23
- DialogDescription,
24
- DialogHeader,
25
- DialogTitle,
26
- } from '@/components/ui/dialog';
27
17
  import { Input } from '@/components/ui/input';
28
18
  import {
29
19
  Select,
@@ -34,18 +24,16 @@ import {
34
24
  } from '@/components/ui/select';
35
25
  import { Separator } from '@/components/ui/separator';
36
26
  import { Slider } from '@/components/ui/slider';
37
- import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
38
27
  import { Textarea } from '@/components/ui/textarea';
39
28
  import { Toggle } from '@/components/ui/toggle';
40
29
  import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
41
30
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
42
- import { type AssetEntry, uploadWithAutoRename, useAssets } from '@/lib/assets';
43
31
  import { findSlideSource } from '@/lib/inspector/fiber';
44
32
  import type { EditOp } from '@/lib/inspector/use-editor';
45
33
  import { useAgentSocketConnected } from '@/lib/use-agent-socket';
46
- import { format, useLocale } from '@/lib/use-locale';
47
- import { cn } from '@/lib/utils';
34
+ import { useLocale } from '@/lib/use-locale';
48
35
  import type { Locale } from '../../../locale/types';
36
+ import { AssetPickerDialog } from './asset-picker-dialog';
49
37
  import { type SelectedTarget, useInspector } from './inspector-provider';
50
38
 
51
39
  type ElementSnapshot = {
@@ -249,6 +237,7 @@ export function InspectorPanel() {
249
237
  header={
250
238
  <>
251
239
  <div className="flex min-w-0 items-center gap-2">
240
+ <Crosshair className="size-3.5 text-muted-foreground" />
252
241
  <span className="font-heading text-[12px] font-semibold tracking-tight">
253
242
  {t.inspector.inspect}
254
243
  </span>
@@ -274,17 +263,18 @@ export function InspectorPanel() {
274
263
  footer={<CommentsSection selected={pinSelected} onAdd={add} />}
275
264
  >
276
265
  {pinSnapshot.text !== null && (
277
- <Section title={t.inspector.contentSection}>
278
- <ContentField
279
- snapshot={pinSnapshot}
280
- apply={apply}
281
- onSelectionChange={setContentSelection}
282
- />
283
- </Section>
266
+ <>
267
+ <Section title={t.inspector.contentSection}>
268
+ <ContentField
269
+ snapshot={pinSnapshot}
270
+ apply={apply}
271
+ onSelectionChange={setContentSelection}
272
+ />
273
+ </Section>
274
+ <Separator />
275
+ </>
284
276
  )}
285
277
 
286
- <Separator />
287
-
288
278
  <Section title={t.inspector.typographySection}>
289
279
  <FontSizeField snapshot={typographySnapshot} apply={applyTextStyle} />
290
280
  <FontWeightField snapshot={typographySnapshot} apply={applyTextStyle} />
@@ -317,12 +307,7 @@ export function InspectorPanel() {
317
307
  <>
318
308
  <Separator />
319
309
  <Section title={t.inspector.imageSection}>
320
- <ImageField
321
- slideId={slideId}
322
- src={pinSnapshot.imageSrc}
323
- anchor={pinSelected.anchor}
324
- apply={apply}
325
- />
310
+ <ImageField src={pinSnapshot.imageSrc} anchor={pinSelected.anchor} />
326
311
  </Section>
327
312
  </>
328
313
  )}
@@ -747,20 +732,9 @@ function ColorField({
747
732
  );
748
733
  }
749
734
 
750
- function ImageField({
751
- slideId,
752
- src,
753
- anchor,
754
- apply,
755
- }: {
756
- slideId: string;
757
- src: string;
758
- anchor: HTMLElement;
759
- apply: (ops: EditOp[]) => void;
760
- }) {
761
- const [open, setOpen] = useState(false);
735
+ function ImageField({ src, anchor }: { src: string; anchor: HTMLElement }) {
762
736
  const t = useLocale();
763
- const { openCrop } = useInspector();
737
+ const { openCrop, openReplace } = useInspector();
764
738
  const isImage = anchor.tagName === 'IMG';
765
739
  return (
766
740
  <div className="space-y-2">
@@ -782,7 +756,7 @@ function ImageField({
782
756
  variant="outline"
783
757
  size="sm"
784
758
  className="flex-1"
785
- onClick={() => setOpen(true)}
759
+ onClick={() => openReplace(anchor)}
786
760
  >
787
761
  <ImageIcon className="size-3.5" />
788
762
  {t.inspector.replace}
@@ -801,36 +775,6 @@ function ImageField({
801
775
  )}
802
776
  </div>
803
777
  </div>
804
- {open && (
805
- <AssetPickerDialog
806
- slideId={slideId}
807
- onClose={() => setOpen(false)}
808
- onPick={(asset, scope) => {
809
- setOpen(false);
810
- const assetPath =
811
- scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
812
- const ops: EditOp[] = [
813
- {
814
- kind: 'set-attr-asset',
815
- attr: 'src',
816
- assetPath,
817
- previewUrl: asset.url,
818
- },
819
- ];
820
- if (isImage) {
821
- const cs = window.getComputedStyle(anchor);
822
- if (cs.objectFit !== 'cover' && cs.objectFit !== 'contain') {
823
- ops.push({ kind: 'set-style', key: 'objectFit', value: 'cover' });
824
- }
825
- const op = cs.objectPosition.trim();
826
- if (!op || op === '0% 0%' || op === 'auto') {
827
- ops.push({ kind: 'set-style', key: 'objectPosition', value: '50% 50%' });
828
- }
829
- }
830
- apply(ops);
831
- }}
832
- />
833
- )}
834
778
  </div>
835
779
  );
836
780
  }
@@ -894,187 +838,6 @@ function PlaceholderField({
894
838
  );
895
839
  }
896
840
 
897
- type PickerScope = 'slide' | 'global';
898
- const GLOBAL_PICKER_SLIDE_ID = '@global';
899
-
900
- function AssetPickerDialog({
901
- slideId,
902
- onClose,
903
- onPick,
904
- }: {
905
- slideId: string;
906
- onClose: () => void;
907
- onPick: (asset: AssetEntry, scope: PickerScope) => void;
908
- }) {
909
- const [scope, setScope] = useState<PickerScope>('slide');
910
- const effectiveSlideId = scope === 'global' ? GLOBAL_PICKER_SLIDE_ID : slideId;
911
- const { assets, loading, refresh } = useAssets(effectiveSlideId);
912
- const images = assets.filter((a) => a.mime.startsWith('image/'));
913
- const t = useLocale();
914
- const path = scope === 'global' ? 'assets/' : `slides/${slideId}/assets/`;
915
- const [descPrefix, descSuffix] = t.inspector.replaceImageDescription.split('{path}');
916
- const [uploading, setUploading] = useState(false);
917
- const [dragActive, setDragActive] = useState(false);
918
- const dragDepth = useRef(0);
919
- const inputId = useId();
920
-
921
- const handleFile = useCallback(
922
- async (file: File) => {
923
- if (!file.type.startsWith('image/')) return;
924
- setUploading(true);
925
- try {
926
- const { ok, status, entry } = await uploadWithAutoRename(effectiveSlideId, file);
927
- if (!ok || !entry) {
928
- toast.error(format(t.asset.toastUploadFailed, { status }));
929
- return;
930
- }
931
- await refresh().catch(() => {});
932
- onPick(entry, scope);
933
- } finally {
934
- setUploading(false);
935
- }
936
- },
937
- [effectiveSlideId, scope, refresh, onPick, t],
938
- );
939
-
940
- return (
941
- <Dialog open onOpenChange={(o) => !o && onClose()}>
942
- <DialogContent className="sm:max-w-xl">
943
- <DialogHeader>
944
- <DialogTitle>{t.inspector.replaceImageDialogTitle}</DialogTitle>
945
- <DialogDescription>
946
- {descPrefix}
947
- <span className="font-mono">{path}</span>
948
- {descSuffix}
949
- </DialogDescription>
950
- </DialogHeader>
951
- <Tabs value={scope} onValueChange={(next) => setScope(next as PickerScope)}>
952
- <TabsList>
953
- <TabsTrigger value="slide">{t.asset.scopeSlide}</TabsTrigger>
954
- <TabsTrigger value="global">{t.asset.scopeGlobal}</TabsTrigger>
955
- </TabsList>
956
- </Tabs>
957
- <label
958
- htmlFor={inputId}
959
- className={cn(
960
- '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',
961
- 'hover:bg-muted/60 hover:border-foreground/20 active:translate-y-px',
962
- uploading && 'pointer-events-none opacity-60',
963
- )}
964
- >
965
- {uploading ? (
966
- <Loader2 className="size-3.5 animate-spin" />
967
- ) : (
968
- <Upload className="size-3.5" />
969
- )}
970
- <span>{t.asset.upload}</span>
971
- </label>
972
- <input
973
- id={inputId}
974
- type="file"
975
- accept="image/*"
976
- className="sr-only"
977
- disabled={uploading}
978
- onChange={(e) => {
979
- const file = e.target.files?.[0];
980
- e.target.value = '';
981
- if (file) handleFile(file).catch(() => {});
982
- }}
983
- />
984
- <section
985
- aria-label={t.inspector.replaceImageDialogTitle}
986
- className="relative max-h-[60vh] overflow-y-auto"
987
- onDragEnter={(e) => {
988
- if (uploading || !hasFiles(e)) return;
989
- e.preventDefault();
990
- dragDepth.current += 1;
991
- setDragActive(true);
992
- }}
993
- onDragOver={(e) => {
994
- if (uploading || !hasFiles(e)) return;
995
- e.preventDefault();
996
- e.dataTransfer.dropEffect = 'copy';
997
- }}
998
- onDragLeave={() => {
999
- dragDepth.current = Math.max(0, dragDepth.current - 1);
1000
- if (dragDepth.current === 0) setDragActive(false);
1001
- }}
1002
- onDrop={(e) => {
1003
- if (uploading || !hasFiles(e)) return;
1004
- e.preventDefault();
1005
- dragDepth.current = 0;
1006
- setDragActive(false);
1007
- const file = e.dataTransfer.files?.[0];
1008
- if (file) handleFile(file).catch(() => {});
1009
- }}
1010
- >
1011
- {loading ? (
1012
- <p className="px-1 py-6 text-center text-xs text-muted-foreground">
1013
- {t.inspector.pickerLoading}
1014
- </p>
1015
- ) : images.length === 0 ? (
1016
- <p className="px-1 py-6 text-center text-xs text-muted-foreground">
1017
- {t.inspector.pickerEmpty}
1018
- </p>
1019
- ) : (
1020
- <div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3">
1021
- {images.map((asset) => (
1022
- <button
1023
- key={asset.name}
1024
- type="button"
1025
- onClick={() => onPick(asset, scope)}
1026
- className={cn(
1027
- 'group flex flex-col overflow-hidden rounded-lg border bg-card text-left shadow-sm transition-all',
1028
- 'hover:-translate-y-0.5 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none',
1029
- )}
1030
- >
1031
- <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]">
1032
- <img
1033
- src={asset.url}
1034
- alt=""
1035
- className="size-full object-contain"
1036
- draggable={false}
1037
- />
1038
- </div>
1039
- <div className="border-t px-2 py-1.5">
1040
- <div className="truncate text-[11px] font-medium" title={asset.name}>
1041
- {asset.name}
1042
- </div>
1043
- </div>
1044
- </button>
1045
- ))}
1046
- </div>
1047
- )}
1048
- {dragActive && (
1049
- <div
1050
- className="pointer-events-none absolute inset-0 z-10 animate-in fade-in-0 duration-200"
1051
- aria-hidden
1052
- >
1053
- <div className="absolute inset-0 bg-brand/5" />
1054
- <div className="absolute inset-1 rounded-[8px] border border-dashed border-brand/40" />
1055
- <div className="absolute inset-x-0 bottom-4 flex justify-center">
1056
- <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">
1057
- <ArrowDownToLine className="size-3.5 text-brand" />
1058
- <span>{t.asset.dropToUpload}</span>
1059
- </div>
1060
- </div>
1061
- </div>
1062
- )}
1063
- </section>
1064
- </DialogContent>
1065
- </Dialog>
1066
- );
1067
- }
1068
-
1069
- function hasFiles(e: React.DragEvent): boolean {
1070
- const types = e.dataTransfer?.types;
1071
- if (!types) return false;
1072
- for (let i = 0; i < types.length; i++) {
1073
- if (types[i] === 'Files') return true;
1074
- }
1075
- return false;
1076
- }
1077
-
1078
841
  function AgentWatchingBadge() {
1079
842
  const t = useLocale();
1080
843
  const connected = useAgentSocketConnected();
@@ -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 { AssetPickerDialog } from './asset-picker-dialog';
18
19
  import { ImageCropDialog, type ImageCropRect } from './image-crop-dialog';
19
20
 
20
21
  export type SelectedTarget = {
@@ -263,6 +264,7 @@ type InspectorCtx = {
263
264
  cancelEdits: () => void;
264
265
  committing: boolean;
265
266
  openCrop: (anchor: HTMLImageElement) => void;
267
+ openReplace: (anchor: HTMLElement) => void;
266
268
  };
267
269
 
268
270
  const Ctx = createContext<InspectorCtx | null>(null);
@@ -273,7 +275,15 @@ export function useInspector(): InspectorCtx {
273
275
  return v;
274
276
  }
275
277
 
276
- export function InspectorProvider({ slideId, children }: { slideId: string; children: ReactNode }) {
278
+ export function InspectorProvider({
279
+ slideId,
280
+ pageIndex,
281
+ children,
282
+ }: {
283
+ slideId: string;
284
+ pageIndex: number;
285
+ children: ReactNode;
286
+ }) {
277
287
  const [active, setActive] = useState(false);
278
288
  const [selected, setSelected] = useState<SelectedTarget | null>(null);
279
289
  const { comments, error, refetch, add, remove } = useComments(slideId);
@@ -296,6 +306,11 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
296
306
  initialPosition: { x: number; y: number };
297
307
  initialRect: ImageCropRect | null;
298
308
  } | null>(null);
309
+ const [replaceTarget, setReplaceTarget] = useState<{
310
+ line: number;
311
+ column: number;
312
+ anchor: HTMLElement;
313
+ } | null>(null);
299
314
  const t = useLocale();
300
315
 
301
316
  const ensureInstanceId = useCallback((el: HTMLElement): string => {
@@ -871,6 +886,35 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
871
886
  return () => observer?.disconnect();
872
887
  }, []);
873
888
 
889
+ useEffect(() => {
890
+ void pageIndex;
891
+ setSelected(null);
892
+ }, [pageIndex]);
893
+
894
+ // Never clear `selected` on a miss: the observer can fire between an
895
+ // "old removed" and "new added" mutation batch, and clearing then would
896
+ // drop a selection that's about to reattach on the next fire.
897
+ useEffect(() => {
898
+ if (!selected) return;
899
+ const root = document.querySelector<HTMLElement>('[data-inspector-root]');
900
+ if (!root) return;
901
+
902
+ const revalidate = () => {
903
+ if (selected.anchor.isConnected) return;
904
+ const next = root.querySelector<HTMLElement>(
905
+ `[data-slide-loc="${selected.line}:${selected.column}"]`,
906
+ );
907
+ if (next && next !== selected.anchor) {
908
+ setSelected({ ...selected, anchor: next });
909
+ }
910
+ };
911
+
912
+ revalidate();
913
+ const observer = new MutationObserver(revalidate);
914
+ observer.observe(root, { childList: true, subtree: true });
915
+ return () => observer.disconnect();
916
+ }, [selected]);
917
+
874
918
  const toggle = useCallback(() => {
875
919
  setActive((a) => {
876
920
  if (a) setSelected(null);
@@ -883,6 +927,27 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
883
927
  setSelected(null);
884
928
  }, []);
885
929
 
930
+ const openReplace = useCallback((anchor: HTMLElement) => {
931
+ const loc = anchor.dataset.slideLoc;
932
+ if (!loc) return;
933
+ const [lineStr, columnStr] = loc.split(':');
934
+ const line = Number(lineStr);
935
+ const column = Number(columnStr);
936
+ if (!Number.isFinite(line) || !Number.isFinite(column)) return;
937
+ setReplaceTarget({ line, column, anchor });
938
+ }, []);
939
+
940
+ useEffect(() => {
941
+ if (import.meta.env.PROD) return;
942
+ const onKey = (e: KeyboardEvent) => {
943
+ if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
944
+ if (e.key !== 'i' && e.key !== 'I') return;
945
+ toggle();
946
+ };
947
+ window.addEventListener('keydown', onKey);
948
+ return () => window.removeEventListener('keydown', onKey);
949
+ }, [toggle]);
950
+
886
951
  const openCrop = useCallback((anchor: HTMLImageElement) => {
887
952
  const loc = anchor.dataset.slideLoc;
888
953
  if (!loc) return;
@@ -925,6 +990,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
925
990
  cancelEdits,
926
991
  committing,
927
992
  openCrop,
993
+ openReplace,
928
994
  }),
929
995
  [
930
996
  slideId,
@@ -945,12 +1011,44 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
945
1011
  cancelEdits,
946
1012
  committing,
947
1013
  openCrop,
1014
+ openReplace,
948
1015
  ],
949
1016
  );
950
1017
 
951
1018
  return (
952
1019
  <Ctx.Provider value={value}>
953
1020
  {children}
1021
+ {replaceTarget && (
1022
+ <AssetPickerDialog
1023
+ slideId={slideId}
1024
+ onClose={() => setReplaceTarget(null)}
1025
+ onPick={(asset, scope) => {
1026
+ const { line, column, anchor } = replaceTarget;
1027
+ const assetPath =
1028
+ scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
1029
+ const ops: EditOp[] = [
1030
+ {
1031
+ kind: 'set-attr-asset',
1032
+ attr: 'src',
1033
+ assetPath,
1034
+ previewUrl: asset.url,
1035
+ },
1036
+ ];
1037
+ if (anchor.tagName === 'IMG' && anchor.isConnected) {
1038
+ const cs = window.getComputedStyle(anchor);
1039
+ if (cs.objectFit !== 'cover' && cs.objectFit !== 'contain') {
1040
+ ops.push({ kind: 'set-style', key: 'objectFit', value: 'cover' });
1041
+ }
1042
+ const op = cs.objectPosition.trim();
1043
+ if (!op || op === '0% 0%' || op === 'auto') {
1044
+ ops.push({ kind: 'set-style', key: 'objectPosition', value: '50% 50%' });
1045
+ }
1046
+ }
1047
+ bufferOps(line, column, anchor, ops);
1048
+ setReplaceTarget(null);
1049
+ }}
1050
+ />
1051
+ )}
954
1052
  {cropTarget && (
955
1053
  <ImageCropDialog
956
1054
  src={cropTarget.src}
@@ -1064,6 +1162,9 @@ export function InspectToggleButton() {
1064
1162
  >
1065
1163
  <Crosshair className="size-3.5" />
1066
1164
  <span className="hidden md:inline">{t.inspector.inspect}</span>
1165
+ <kbd className="ml-1 hidden rounded-[3px] bg-foreground/10 px-1 font-mono text-[9.5px] tracking-[0.04em] md:inline">
1166
+ I
1167
+ </kbd>
1067
1168
  </Button>
1068
1169
  );
1069
1170
  }
@@ -91,15 +91,15 @@ export function SaveCard({
91
91
  </div>
92
92
  )}
93
93
  {justSaved ? (
94
- <span className="flex items-center gap-1.5 px-2.5 text-[12px] font-medium text-foreground">
95
- <Check className="size-3.5 text-[oklch(0.55_0.13_165)]" strokeWidth={2.5} />
94
+ <span className="flex items-center gap-1.5 whitespace-nowrap px-2.5 text-[12px] font-medium text-foreground">
95
+ <Check className="size-3.5 shrink-0 text-[oklch(0.55_0.13_165)]" strokeWidth={2.5} />
96
96
  {resolvedSavedLabel}
97
97
  </span>
98
98
  ) : dirty || committing ? (
99
- <span className="inline-flex items-center gap-1.5 px-2.5 text-[12px] font-medium text-foreground">
99
+ <span className="inline-flex items-center gap-1.5 whitespace-nowrap px-2.5 text-[12px] font-medium text-foreground">
100
100
  <span
101
101
  aria-hidden
102
- className="size-1.5 rounded-full bg-brand shadow-[0_0_0_3px_var(--brand-soft)]"
102
+ className="size-1.5 shrink-0 rounded-full bg-brand shadow-[0_0_0_3px_var(--brand-soft)]"
103
103
  />
104
104
  <span className="nums">{unsavedLabel}</span>
105
105
  </span>
@@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { useWheelPageNavigation } from '@/lib/use-wheel-page-navigation';
3
3
  import { cn } from '@/lib/utils';
4
4
  import type { DesignSystem } from '../lib/design';
5
- import { SlidePageProvider } from '../lib/page-context';
6
5
  import type { Page } from '../lib/sdk';
6
+ import type { SlideTransition } from '../lib/transition';
7
+ import { usePrefersReducedMotion } from '../lib/use-prefers-reduced-motion';
7
8
  import { PresentBlackoutOverlay } from './present/blackout-overlay';
8
9
  import { PresentControlBar } from './present/control-bar';
9
10
  import { PresentHelpOverlay } from './present/help-overlay';
@@ -20,6 +21,7 @@ import {
20
21
  } from './present/use-presenter-channel';
21
22
  import { useTouchSwipe } from './present/use-touch-swipe';
22
23
  import { SlideCanvas } from './slide-canvas';
24
+ import { SlideTransitionLayer } from './slide-transition-layer';
23
25
 
24
26
  const IDLE_HIDE_MS = 2000;
25
27
  const BAR_HOTZONE_PX = 160;
@@ -27,6 +29,7 @@ const BAR_HOTZONE_PX = 160;
27
29
  type Props = {
28
30
  pages: Page[];
29
31
  design?: DesignSystem;
32
+ transition?: SlideTransition;
30
33
  index: number;
31
34
  onIndexChange: (index: number) => void;
32
35
  onExit: () => void;
@@ -44,6 +47,7 @@ type Props = {
44
47
  export function Player({
45
48
  pages,
46
49
  design,
50
+ transition,
47
51
  index,
48
52
  onIndexChange,
49
53
  onExit,
@@ -52,6 +56,7 @@ export function Player({
52
56
  slideId,
53
57
  fullscreen = true,
54
58
  }: Props) {
59
+ const prefersReducedMotion = usePrefersReducedMotion();
55
60
  const rootRef = useRef<HTMLDivElement | null>(null);
56
61
  // Mirrored as state so descendants portaling *into* the player subtree
57
62
  // (tooltips, popovers — the body is outside the fullscreen tree) re-render
@@ -284,8 +289,6 @@ export function Player({
284
289
  const hideCursor =
285
290
  controls && (laser || keyboardDriven || (idle && !overlayActive && !pointerNearBottom));
286
291
 
287
- const PageComp = pages[index];
288
-
289
292
  return (
290
293
  <div
291
294
  ref={setRoot}
@@ -296,11 +299,13 @@ export function Player({
296
299
  )}
297
300
  >
298
301
  <SlideCanvas flat design={design}>
299
- {PageComp ? (
300
- <SlidePageProvider index={index} total={pages.length}>
301
- <PageComp />
302
- </SlidePageProvider>
303
- ) : null}
302
+ <SlideTransitionLayer
303
+ pages={pages}
304
+ index={index}
305
+ total={pages.length}
306
+ moduleTransition={transition}
307
+ disabled={prefersReducedMotion}
308
+ />
304
309
  </SlideCanvas>
305
310
 
306
311
  <button