@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.
- package/dist/index.d.ts +20 -2
- package/package.json +1 -1
- package/skills/slide-authoring/SKILL.md +169 -0
- package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
- package/src/app/components/inspector/inspect-overlay.tsx +132 -35
- package/src/app/components/inspector/inspector-panel.tsx +19 -256
- package/src/app/components/inspector/inspector-provider.tsx +102 -1
- package/src/app/components/panel/save-card.tsx +4 -4
- package/src/app/components/player.tsx +13 -8
- package/src/app/components/slide-transition-layer.tsx +154 -0
- package/src/app/components/style-panel/style-panel.tsx +3 -0
- package/src/app/lib/sdk.ts +3 -1
- package/src/app/lib/transition.ts +23 -0
- package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
- package/src/app/routes/slide.tsx +19 -11
|
@@ -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,
|
|
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 {
|
|
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
|
-
|
|
278
|
-
<
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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={() =>
|
|
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({
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|