@open-slide/core 1.5.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/{build-DZhbjQpQ.js → build-tLrkKUHr.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-BQdTMho4.d.ts → config-CfMThYN9.d.ts} +1 -1
- package/dist/{config-iKjqaX08.js → config-PwUHqZ_X.js} +246 -2
- package/dist/{dev-BjLGk5nN.js → dev-DpCIRbhT.js} +1 -1
- package/dist/{en-DDGqyNaW.js → en-BDnM5zKJ.js} +4 -0
- package/dist/index.d.ts +29 -4
- package/dist/index.js +20 -4
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +13 -1
- package/dist/{preview-jwLWHWkQ.js → preview-BSGlM6Se.js} +1 -1
- package/dist/{types-Dpr8nbih.d.ts → types-B-KrjgX8.d.ts} +5 -0
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/create-theme/SKILL.md +30 -22
- package/skills/slide-authoring/SKILL.md +186 -0
- package/src/app/components/asset-view.tsx +8 -1
- 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 -3
- package/src/app/components/present/overview-grid.tsx +4 -1
- package/src/app/components/slide-transition-layer.tsx +154 -0
- package/src/app/components/style-panel/style-panel.tsx +3 -0
- package/src/app/components/themes/theme-detail.tsx +7 -2
- package/src/app/components/themes/themes-gallery.tsx +4 -1
- package/src/app/components/thumbnail-rail.tsx +10 -2
- package/src/app/lib/assets.ts +2 -0
- package/src/app/lib/export-html.ts +7 -2
- package/src/app/lib/export-pdf.ts +34 -2
- package/src/app/lib/folders.ts +35 -1
- package/src/app/lib/page-context.tsx +38 -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/lib/use-wheel-page-navigation.ts +7 -0
- package/src/app/routes/home-shell.tsx +13 -2
- package/src/app/routes/home.tsx +28 -2
- package/src/app/routes/presenter.tsx +7 -2
- package/src/app/routes/slide.tsx +19 -8
- package/src/locale/en.ts +4 -0
- package/src/locale/ja.ts +4 -0
- package/src/locale/types.ts +5 -0
- package/src/locale/zh-cn.ts +4 -0
- package/src/locale/zh-tw.ts +4 -0
package/src/app/lib/folders.ts
CHANGED
|
@@ -33,6 +33,19 @@ async function patchSlideName(slideId: string, name: string): Promise<void> {
|
|
|
33
33
|
if (!res.ok) throw new Error(`PATCH /__slides/${slideId} ${res.status}`);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
async function duplicateSlideReq(slideId: string, newId?: string): Promise<string> {
|
|
37
|
+
const init: RequestInit = { method: 'POST' };
|
|
38
|
+
if (newId !== undefined) {
|
|
39
|
+
init.headers = { 'content-type': 'application/json' };
|
|
40
|
+
init.body = JSON.stringify({ newId });
|
|
41
|
+
}
|
|
42
|
+
const res = await fetch(`/__slides/${slideId}/duplicate`, init);
|
|
43
|
+
if (!res.ok) throw new Error(`POST /__slides/${slideId}/duplicate ${res.status}`);
|
|
44
|
+
const body = (await res.json()) as { slideId?: unknown };
|
|
45
|
+
if (typeof body.slideId !== 'string') throw new Error('duplicate response missing slideId');
|
|
46
|
+
return body.slideId;
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
async function deleteSlideReq(slideId: string): Promise<void> {
|
|
37
50
|
const res = await fetch(`/__slides/${slideId}`, { method: 'DELETE' });
|
|
38
51
|
if (!res.ok) throw new Error(`DELETE /__slides/${slideId} ${res.status}`);
|
|
@@ -83,6 +96,7 @@ export type UseFoldersResult = {
|
|
|
83
96
|
remove: (id: string) => Promise<void>;
|
|
84
97
|
assign: (slideId: string, folderId: string | null) => Promise<void>;
|
|
85
98
|
renameSlide: (slideId: string, name: string) => Promise<void>;
|
|
99
|
+
duplicateSlide: (slideId: string, newId?: string) => Promise<string>;
|
|
86
100
|
deleteSlide: (slideId: string) => Promise<void>;
|
|
87
101
|
refresh: () => Promise<void>;
|
|
88
102
|
};
|
|
@@ -165,6 +179,15 @@ export function useFolders(): UseFoldersResult {
|
|
|
165
179
|
[refresh],
|
|
166
180
|
);
|
|
167
181
|
|
|
182
|
+
const duplicateSlide = useCallback(
|
|
183
|
+
async (slideId: string, newId?: string) => {
|
|
184
|
+
const duplicatedId = await duplicateSlideReq(slideId, newId);
|
|
185
|
+
await refresh();
|
|
186
|
+
return duplicatedId;
|
|
187
|
+
},
|
|
188
|
+
[refresh],
|
|
189
|
+
);
|
|
190
|
+
|
|
168
191
|
const deleteSlide = useCallback(
|
|
169
192
|
async (slideId: string) => {
|
|
170
193
|
await deleteSlideReq(slideId);
|
|
@@ -173,5 +196,16 @@ export function useFolders(): UseFoldersResult {
|
|
|
173
196
|
[refresh],
|
|
174
197
|
);
|
|
175
198
|
|
|
176
|
-
return {
|
|
199
|
+
return {
|
|
200
|
+
manifest,
|
|
201
|
+
loading,
|
|
202
|
+
create,
|
|
203
|
+
update,
|
|
204
|
+
remove,
|
|
205
|
+
assign,
|
|
206
|
+
renameSlide,
|
|
207
|
+
duplicateSlide,
|
|
208
|
+
deleteSlide,
|
|
209
|
+
refresh,
|
|
210
|
+
};
|
|
177
211
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Context, createContext, type PropsWithChildren, useContext, useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
type SlidePageContextValue = {
|
|
4
|
+
index: number;
|
|
5
|
+
total: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// Stored on globalThis so dev (src) and published (dist) copies of this module
|
|
9
|
+
// share one context instance — otherwise the provider writes to one context and
|
|
10
|
+
// the hook reads from another, and `useSlidePageNumber` always sees null.
|
|
11
|
+
const GLOBAL_KEY = '__open_slide_page_context__';
|
|
12
|
+
type GlobalWithCtx = typeof globalThis & {
|
|
13
|
+
[GLOBAL_KEY]?: Context<SlidePageContextValue | null>;
|
|
14
|
+
};
|
|
15
|
+
const g = globalThis as GlobalWithCtx;
|
|
16
|
+
if (!g[GLOBAL_KEY]) {
|
|
17
|
+
g[GLOBAL_KEY] = createContext<SlidePageContextValue | null>(null);
|
|
18
|
+
}
|
|
19
|
+
const SlidePageContext = g[GLOBAL_KEY];
|
|
20
|
+
|
|
21
|
+
export function SlidePageProvider({
|
|
22
|
+
index,
|
|
23
|
+
total,
|
|
24
|
+
children,
|
|
25
|
+
}: PropsWithChildren<{ index: number; total: number }>) {
|
|
26
|
+
const value = useMemo(() => ({ index, total }), [index, total]);
|
|
27
|
+
return <SlidePageContext.Provider value={value}>{children}</SlidePageContext.Provider>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useSlidePageNumber(): { current: number; total: number } {
|
|
31
|
+
const ctx = useContext(SlidePageContext);
|
|
32
|
+
if (!ctx) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'useSlidePageNumber must be called from a slide page rendered by @open-slide/core',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return { current: ctx.index + 1, total: ctx.total };
|
|
38
|
+
}
|
package/src/app/lib/sdk.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ComponentType } from 'react';
|
|
2
2
|
import type { DesignSystem } from './design.ts';
|
|
3
|
+
import type { SlideTransition } from './transition.ts';
|
|
3
4
|
|
|
4
|
-
export type Page = ComponentType;
|
|
5
|
+
export type Page = ComponentType & { transition?: SlideTransition };
|
|
5
6
|
|
|
6
7
|
export type SlideMeta = {
|
|
7
8
|
title?: string;
|
|
@@ -16,6 +17,7 @@ export type SlideModule = {
|
|
|
16
17
|
design?: DesignSystem;
|
|
17
18
|
// Index-aligned with `default`.
|
|
18
19
|
notes?: (string | undefined)[];
|
|
20
|
+
transition?: SlideTransition;
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
export type FolderIcon = { type: 'emoji'; value: string } | { type: 'color'; value: string };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Page } from './sdk';
|
|
2
|
+
|
|
3
|
+
export type TransitionPhase = {
|
|
4
|
+
keyframes: Keyframe[] | PropertyIndexedKeyframes;
|
|
5
|
+
easing?: string;
|
|
6
|
+
duration?: number;
|
|
7
|
+
delay?: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type SlideTransition = {
|
|
11
|
+
duration: number;
|
|
12
|
+
easing?: string;
|
|
13
|
+
enter?: TransitionPhase;
|
|
14
|
+
exit?: TransitionPhase;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function resolveTransition(
|
|
18
|
+
pages: Page[],
|
|
19
|
+
index: number,
|
|
20
|
+
moduleDefault?: SlideTransition,
|
|
21
|
+
): SlideTransition | undefined {
|
|
22
|
+
return pages[index]?.transition ?? moduleDefault;
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
const QUERY = '(prefers-reduced-motion: reduce)';
|
|
4
|
+
|
|
5
|
+
export function usePrefersReducedMotion(): boolean {
|
|
6
|
+
const [reduce, setReduce] = useState(() => {
|
|
7
|
+
if (typeof window === 'undefined') return false;
|
|
8
|
+
return window.matchMedia(QUERY).matches;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const mql = window.matchMedia(QUERY);
|
|
13
|
+
const onChange = (e: MediaQueryListEvent) => setReduce(e.matches);
|
|
14
|
+
mql.addEventListener('change', onChange);
|
|
15
|
+
return () => mql.removeEventListener('change', onChange);
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
return reduce;
|
|
19
|
+
}
|
|
@@ -31,6 +31,7 @@ export function useWheelPageNavigation<T extends HTMLElement>({
|
|
|
31
31
|
|
|
32
32
|
const onWheel = (event: WheelEvent) => {
|
|
33
33
|
if (event.defaultPrevented || event.ctrlKey || shouldIgnoreWheelTarget(event.target)) return;
|
|
34
|
+
if (isVisualViewportZoomed()) return;
|
|
34
35
|
|
|
35
36
|
const deltaY = normalizeDeltaY(event);
|
|
36
37
|
if (Math.abs(deltaY) <= Math.abs(normalizeDeltaX(event))) return;
|
|
@@ -84,6 +85,12 @@ function normalizeWheelDelta(delta: number, deltaMode: number) {
|
|
|
84
85
|
return delta;
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
function isVisualViewportZoomed() {
|
|
89
|
+
if (typeof window === 'undefined') return false;
|
|
90
|
+
const vv = window.visualViewport;
|
|
91
|
+
return vv != null && vv.scale > 1.01;
|
|
92
|
+
}
|
|
93
|
+
|
|
87
94
|
function shouldIgnoreWheelTarget(target: EventTarget | null) {
|
|
88
95
|
if (!(target instanceof HTMLElement)) return false;
|
|
89
96
|
return Boolean(
|
|
@@ -22,6 +22,7 @@ export type HomeOutletContext = {
|
|
|
22
22
|
titleMap: Record<string, string>;
|
|
23
23
|
assign: (slideId: string, folderId: string | null) => Promise<void>;
|
|
24
24
|
renameSlide: (slideId: string, name: string) => Promise<void>;
|
|
25
|
+
duplicateSlide: (slideId: string, newId?: string) => Promise<string>;
|
|
25
26
|
deleteSlide: (slideId: string) => Promise<void>;
|
|
26
27
|
};
|
|
27
28
|
|
|
@@ -32,8 +33,17 @@ function pathToSelectedId(pathname: string, search: URLSearchParams): string {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export function HomeShell() {
|
|
35
|
-
const {
|
|
36
|
-
|
|
36
|
+
const {
|
|
37
|
+
manifest,
|
|
38
|
+
loading,
|
|
39
|
+
create,
|
|
40
|
+
update,
|
|
41
|
+
remove,
|
|
42
|
+
assign,
|
|
43
|
+
renameSlide,
|
|
44
|
+
duplicateSlide,
|
|
45
|
+
deleteSlide,
|
|
46
|
+
} = useFolders();
|
|
37
47
|
const navigate = useNavigate();
|
|
38
48
|
const location = useLocation();
|
|
39
49
|
const [searchParams] = useSearchParams();
|
|
@@ -108,6 +118,7 @@ export function HomeShell() {
|
|
|
108
118
|
titleMap,
|
|
109
119
|
assign,
|
|
110
120
|
renameSlide,
|
|
121
|
+
duplicateSlide,
|
|
111
122
|
deleteSlide,
|
|
112
123
|
};
|
|
113
124
|
|
package/src/app/routes/home.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
ArrowDownAZ,
|
|
3
3
|
ChevronDown,
|
|
4
4
|
Clock,
|
|
5
|
+
Copy,
|
|
5
6
|
FolderInput,
|
|
6
7
|
FolderPlus,
|
|
7
8
|
MoreHorizontal,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
} from 'lucide-react';
|
|
14
15
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
15
16
|
import { Link, useOutletContext } from 'react-router-dom';
|
|
17
|
+
import { toast } from 'sonner';
|
|
16
18
|
import { Button } from '@/components/ui/button';
|
|
17
19
|
import {
|
|
18
20
|
Dialog,
|
|
@@ -28,11 +30,12 @@ import {
|
|
|
28
30
|
DropdownMenuItem,
|
|
29
31
|
DropdownMenuTrigger,
|
|
30
32
|
} from '@/components/ui/dropdown-menu';
|
|
31
|
-
import { useLocale } from '@/lib/use-locale';
|
|
33
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
32
34
|
import { cn } from '@/lib/utils';
|
|
33
35
|
import { FolderIconChip, SLIDE_DND_MIME } from '../components/sidebar/folder-item';
|
|
34
36
|
import { DRAFT_ID } from '../components/sidebar/sidebar';
|
|
35
37
|
import { SlideCanvas } from '../components/slide-canvas';
|
|
38
|
+
import { SlidePageProvider } from '../lib/page-context';
|
|
36
39
|
import type { Folder, FolderIcon, SlideModule } from '../lib/sdk';
|
|
37
40
|
import { loadSlide, slideCreatedAt } from '../lib/slides';
|
|
38
41
|
import type { HomeOutletContext } from './home-shell';
|
|
@@ -77,6 +80,7 @@ export function Home() {
|
|
|
77
80
|
titleMap,
|
|
78
81
|
assign,
|
|
79
82
|
renameSlide,
|
|
83
|
+
duplicateSlide,
|
|
80
84
|
deleteSlide,
|
|
81
85
|
} = useOutletContext<HomeOutletContext>();
|
|
82
86
|
const t = useLocale();
|
|
@@ -163,6 +167,20 @@ export function Home() {
|
|
|
163
167
|
folders={manifest.folders}
|
|
164
168
|
currentFolderId={manifest.assignments[id] ?? null}
|
|
165
169
|
onRename={(name) => renameSlide(id, name)}
|
|
170
|
+
onDuplicate={async () => {
|
|
171
|
+
const slideName = titleMap[id] ?? id;
|
|
172
|
+
try {
|
|
173
|
+
const newSlideId = await duplicateSlide(id);
|
|
174
|
+
toast.success(
|
|
175
|
+
format(t.home.toastSlideDuplicated, {
|
|
176
|
+
slide: slideName,
|
|
177
|
+
newSlide: newSlideId,
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
} catch {
|
|
181
|
+
toast.error(t.home.toastSlideDuplicateFailed);
|
|
182
|
+
}
|
|
183
|
+
}}
|
|
166
184
|
onMove={(folderId) => assign(id, folderId)}
|
|
167
185
|
onDelete={() => deleteSlide(id)}
|
|
168
186
|
onTitleResolved={reportTitle}
|
|
@@ -381,6 +399,7 @@ function SlideCard({
|
|
|
381
399
|
folders,
|
|
382
400
|
currentFolderId,
|
|
383
401
|
onRename,
|
|
402
|
+
onDuplicate,
|
|
384
403
|
onMove,
|
|
385
404
|
onDelete,
|
|
386
405
|
onTitleResolved,
|
|
@@ -389,6 +408,7 @@ function SlideCard({
|
|
|
389
408
|
folders: Folder[];
|
|
390
409
|
currentFolderId: string | null;
|
|
391
410
|
onRename: (name: string) => Promise<void> | void;
|
|
411
|
+
onDuplicate: () => Promise<void> | void;
|
|
392
412
|
onMove: (folderId: string | null) => Promise<void> | void;
|
|
393
413
|
onDelete: () => Promise<void> | void;
|
|
394
414
|
onTitleResolved?: (id: string, title: string) => void;
|
|
@@ -441,7 +461,9 @@ function SlideCard({
|
|
|
441
461
|
{FirstPage ? (
|
|
442
462
|
<div className="h-full w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]">
|
|
443
463
|
<SlideCanvas flat freezeMotion design={slide?.design}>
|
|
444
|
-
<
|
|
464
|
+
<SlidePageProvider index={0} total={slide?.default.length ?? 1}>
|
|
465
|
+
<FirstPage />
|
|
466
|
+
</SlidePageProvider>
|
|
445
467
|
</SlideCanvas>
|
|
446
468
|
</div>
|
|
447
469
|
) : (
|
|
@@ -489,6 +511,10 @@ function SlideCard({
|
|
|
489
511
|
<Pencil />
|
|
490
512
|
{tCard.common.rename}
|
|
491
513
|
</DropdownMenuItem>
|
|
514
|
+
<DropdownMenuItem onSelect={() => onDuplicate()}>
|
|
515
|
+
<Copy />
|
|
516
|
+
{tCard.home.duplicate}
|
|
517
|
+
</DropdownMenuItem>
|
|
492
518
|
<DropdownMenuItem onSelect={() => setDialog('move')}>
|
|
493
519
|
<FolderInput />
|
|
494
520
|
{tCard.home.moveToFolder}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
usePresenterChannel,
|
|
10
10
|
} from '../components/present/use-presenter-channel';
|
|
11
11
|
import { SlideCanvas } from '../components/slide-canvas';
|
|
12
|
+
import { SlidePageProvider } from '../lib/page-context';
|
|
12
13
|
import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
|
|
13
14
|
import { useSlideModule } from '../lib/use-slide-module';
|
|
14
15
|
|
|
@@ -138,7 +139,9 @@ export function Presenter() {
|
|
|
138
139
|
<SectionLabel>{t.presenter.nowShowing}</SectionLabel>
|
|
139
140
|
<div className="relative min-h-0 flex-1 overflow-hidden rounded-[8px] bg-black ring-1 ring-border">
|
|
140
141
|
<SlideCanvas flat design={slide.design}>
|
|
141
|
-
<
|
|
142
|
+
<SlidePageProvider index={index} total={total}>
|
|
143
|
+
<CurrentPage />
|
|
144
|
+
</SlidePageProvider>
|
|
142
145
|
</SlideCanvas>
|
|
143
146
|
{blackout && (
|
|
144
147
|
<div
|
|
@@ -164,7 +167,9 @@ export function Presenter() {
|
|
|
164
167
|
>
|
|
165
168
|
{NextPage ? (
|
|
166
169
|
<SlideCanvas flat freezeMotion design={slide.design}>
|
|
167
|
-
<
|
|
170
|
+
<SlidePageProvider index={nextIndex} total={total}>
|
|
171
|
+
<NextPage />
|
|
172
|
+
</SlidePageProvider>
|
|
168
173
|
</SlideCanvas>
|
|
169
174
|
) : (
|
|
170
175
|
<div className="grid h-full place-items-center text-[11.5px] text-muted-foreground">
|
package/src/app/routes/slide.tsx
CHANGED
|
@@ -48,11 +48,13 @@ import { NotesDrawer } from '../components/notes-drawer';
|
|
|
48
48
|
import { PdfProgressToast } from '../components/pdf-progress-toast';
|
|
49
49
|
import { openPresenterWindow, Player } from '../components/player';
|
|
50
50
|
import { SlideCanvas } from '../components/slide-canvas';
|
|
51
|
+
import { SlideTransitionLayer } from '../components/slide-transition-layer';
|
|
51
52
|
import { type ThumbnailActions, ThumbnailRail } from '../components/thumbnail-rail';
|
|
52
53
|
import { exportSlideAsHtml } from '../lib/export-html';
|
|
53
54
|
import { exportSlideAsPdf, isSafari } from '../lib/export-pdf';
|
|
54
55
|
import { remapNotesSessionCacheAfterReorder } from '../lib/inspector/use-notes';
|
|
55
56
|
import type { SlideModule } from '../lib/sdk';
|
|
57
|
+
import { usePrefersReducedMotion } from '../lib/use-prefers-reduced-motion';
|
|
56
58
|
import { useSlideModule } from '../lib/use-slide-module';
|
|
57
59
|
|
|
58
60
|
const { showSlideUi, showSlideBrowser, allowHtmlDownload } = config.build;
|
|
@@ -75,6 +77,7 @@ export function Slide() {
|
|
|
75
77
|
const { renameSlide } = useFolders();
|
|
76
78
|
const slideViewportRef = useRef<HTMLElement>(null);
|
|
77
79
|
const t = useLocale();
|
|
80
|
+
const prefersReducedMotion = usePrefersReducedMotion();
|
|
78
81
|
|
|
79
82
|
const modulePages = useMemo(() => slide?.default ?? [], [slide]);
|
|
80
83
|
const [pages, setPages] = useState<typeof modulePages>(modulePages);
|
|
@@ -236,6 +239,8 @@ export function Slide() {
|
|
|
236
239
|
goTo(index - 1);
|
|
237
240
|
} else if (e.key === 'f' || e.key === 'F') {
|
|
238
241
|
setPlayMode('fullscreen');
|
|
242
|
+
} else if (import.meta.env.DEV && (e.key === 'd' || e.key === 'D')) {
|
|
243
|
+
setDesignOpen((v) => !v);
|
|
239
244
|
}
|
|
240
245
|
};
|
|
241
246
|
window.addEventListener('keydown', onKey);
|
|
@@ -324,6 +329,7 @@ export function Slide() {
|
|
|
324
329
|
<Player
|
|
325
330
|
pages={pages}
|
|
326
331
|
design={slide.design}
|
|
332
|
+
transition={slide.transition}
|
|
327
333
|
index={index}
|
|
328
334
|
onIndexChange={goTo}
|
|
329
335
|
onExit={() => setPlayMode(null)}
|
|
@@ -334,17 +340,16 @@ export function Slide() {
|
|
|
334
340
|
);
|
|
335
341
|
}
|
|
336
342
|
|
|
337
|
-
const CurrentPage = pages[index];
|
|
338
343
|
const title = slide.meta?.title ?? slideId;
|
|
339
344
|
|
|
340
345
|
return (
|
|
341
346
|
<HistoryProvider>
|
|
342
|
-
<InspectorProvider slideId={slideId}>
|
|
347
|
+
<InspectorProvider slideId={slideId} pageIndex={index}>
|
|
343
348
|
<SelectionReporter />
|
|
344
349
|
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
|
|
345
350
|
{/* Editorial toolbar — three zones, hairline separators, mono-folio center */}
|
|
346
|
-
<header className="
|
|
347
|
-
<div className="flex items-center gap-1.5 md:gap-2">
|
|
351
|
+
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-hairline bg-sidebar/85 px-2 backdrop-blur-md md:px-3">
|
|
352
|
+
<div className="flex shrink-0 items-center gap-1.5 md:gap-2">
|
|
348
353
|
{showSlideBrowser && (
|
|
349
354
|
<Button asChild variant="ghost" size="icon-sm" title={t.slide.home}>
|
|
350
355
|
<Link to="/" aria-label={t.slide.backToHome}>
|
|
@@ -378,13 +383,13 @@ export function Slide() {
|
|
|
378
383
|
</div>
|
|
379
384
|
|
|
380
385
|
{/* Centered title — the rail and mobile pill carry the page count. */}
|
|
381
|
-
<div className="
|
|
382
|
-
<div className="
|
|
386
|
+
<div className="flex min-w-0 flex-1 justify-center px-2">
|
|
387
|
+
<div className="min-w-0 max-w-[34rem]">
|
|
383
388
|
<InlineTitleEditor title={title} onSubmit={(next) => renameSlide(slideId, next)} />
|
|
384
389
|
</div>
|
|
385
390
|
</div>
|
|
386
391
|
|
|
387
|
-
<div className="flex items-center gap-1">
|
|
392
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
388
393
|
{view === 'slides' && (
|
|
389
394
|
<button
|
|
390
395
|
type="button"
|
|
@@ -584,7 +589,13 @@ export function Slide() {
|
|
|
584
589
|
canNext={index < pageCount - 1}
|
|
585
590
|
/>
|
|
586
591
|
<SlideCanvas design={slide.design}>
|
|
587
|
-
<
|
|
592
|
+
<SlideTransitionLayer
|
|
593
|
+
pages={pages}
|
|
594
|
+
index={index}
|
|
595
|
+
total={pageCount}
|
|
596
|
+
moduleTransition={slide.transition}
|
|
597
|
+
disabled={prefersReducedMotion}
|
|
598
|
+
/>
|
|
588
599
|
</SlideCanvas>
|
|
589
600
|
<ClickNavZones
|
|
590
601
|
onPrev={() => goTo(index - 1)}
|
package/src/locale/en.ts
CHANGED
|
@@ -38,6 +38,7 @@ export const en: Locale = {
|
|
|
38
38
|
home: {
|
|
39
39
|
appTitle: 'open-slide',
|
|
40
40
|
draft: 'Draft',
|
|
41
|
+
duplicate: 'Duplicate',
|
|
41
42
|
themes: 'Themes',
|
|
42
43
|
assets: 'Assets',
|
|
43
44
|
folders: 'Folders',
|
|
@@ -79,6 +80,8 @@ export const en: Locale = {
|
|
|
79
80
|
deleteDialogDescriptionSuffix: 'This action cannot be undone.',
|
|
80
81
|
toastFolderCreated: 'Created folder “{name}”',
|
|
81
82
|
toastFolderCreateFailed: 'Failed to create folder',
|
|
83
|
+
toastSlideDuplicated: 'Duplicated “{slide}” as {newSlide}',
|
|
84
|
+
toastSlideDuplicateFailed: 'Could not duplicate slide',
|
|
82
85
|
toastSlideMoved: 'Moved “{slide}” to {folder}',
|
|
83
86
|
toastSlideMoveFailed: 'Failed to move slide',
|
|
84
87
|
toastFolderDeleted: 'Deleted folder “{name}”',
|
|
@@ -270,6 +273,7 @@ export const en: Locale = {
|
|
|
270
273
|
scopeSlide: 'This slide',
|
|
271
274
|
scopeGlobal: 'Global',
|
|
272
275
|
fileCount: { one: '{count} file', other: '{count} files' },
|
|
276
|
+
usageUnused: 'Unused',
|
|
273
277
|
searchLogos: 'Search logos',
|
|
274
278
|
upload: 'Upload',
|
|
275
279
|
dropToUpload: 'Drop to upload',
|
package/src/locale/ja.ts
CHANGED
|
@@ -38,6 +38,7 @@ export const ja: Locale = {
|
|
|
38
38
|
home: {
|
|
39
39
|
appTitle: 'open-slide',
|
|
40
40
|
draft: '下書き',
|
|
41
|
+
duplicate: '複製',
|
|
41
42
|
themes: 'テーマ',
|
|
42
43
|
assets: 'アセット',
|
|
43
44
|
folders: 'フォルダ',
|
|
@@ -79,6 +80,8 @@ export const ja: Locale = {
|
|
|
79
80
|
deleteDialogDescriptionSuffix: 'この操作は元に戻せません。',
|
|
80
81
|
toastFolderCreated: 'フォルダ「{name}」を作成しました',
|
|
81
82
|
toastFolderCreateFailed: 'フォルダの作成に失敗しました',
|
|
83
|
+
toastSlideDuplicated: '「{slide}」を {newSlide} として複製しました',
|
|
84
|
+
toastSlideDuplicateFailed: 'スライドを複製できませんでした',
|
|
82
85
|
toastSlideMoved: '「{slide}」を {folder} に移動しました',
|
|
83
86
|
toastSlideMoveFailed: 'スライドの移動に失敗しました',
|
|
84
87
|
toastFolderDeleted: 'フォルダ「{name}」を削除しました',
|
|
@@ -272,6 +275,7 @@ export const ja: Locale = {
|
|
|
272
275
|
scopeSlide: 'このスライド',
|
|
273
276
|
scopeGlobal: 'グローバル',
|
|
274
277
|
fileCount: { one: 'ファイル {count} 件', other: 'ファイル {count} 件' },
|
|
278
|
+
usageUnused: '未使用',
|
|
275
279
|
searchLogos: 'ロゴを検索',
|
|
276
280
|
upload: 'アップロード',
|
|
277
281
|
dropToUpload: 'ドロップでアップロード',
|
package/src/locale/types.ts
CHANGED
|
@@ -38,6 +38,7 @@ export type Locale = {
|
|
|
38
38
|
home: {
|
|
39
39
|
appTitle: string;
|
|
40
40
|
draft: string;
|
|
41
|
+
duplicate: string;
|
|
41
42
|
themes: string;
|
|
42
43
|
assets: string;
|
|
43
44
|
folders: string;
|
|
@@ -80,6 +81,9 @@ export type Locale = {
|
|
|
80
81
|
/** template: "Created folder “{name}”" */
|
|
81
82
|
toastFolderCreated: string;
|
|
82
83
|
toastFolderCreateFailed: string;
|
|
84
|
+
/** template: "Duplicated “{slide}” as {newSlide}" */
|
|
85
|
+
toastSlideDuplicated: string;
|
|
86
|
+
toastSlideDuplicateFailed: string;
|
|
83
87
|
/** template: "Moved “{slide}” to {folder}" */
|
|
84
88
|
toastSlideMoved: string;
|
|
85
89
|
toastSlideMoveFailed: string;
|
|
@@ -271,6 +275,7 @@ export type Locale = {
|
|
|
271
275
|
scopeGlobal: string;
|
|
272
276
|
/** templates: "{count} file" / "{count} files" */
|
|
273
277
|
fileCount: Plural;
|
|
278
|
+
usageUnused: string;
|
|
274
279
|
searchLogos: string;
|
|
275
280
|
upload: string;
|
|
276
281
|
dropToUpload: string;
|
package/src/locale/zh-cn.ts
CHANGED
|
@@ -38,6 +38,7 @@ export const zhCN: Locale = {
|
|
|
38
38
|
home: {
|
|
39
39
|
appTitle: 'open-slide',
|
|
40
40
|
draft: '草稿',
|
|
41
|
+
duplicate: '复制',
|
|
41
42
|
themes: '主题',
|
|
42
43
|
assets: '素材',
|
|
43
44
|
folders: '文件夹',
|
|
@@ -79,6 +80,8 @@ export const zhCN: Locale = {
|
|
|
79
80
|
deleteDialogDescriptionSuffix: '此操作无法撤销。',
|
|
80
81
|
toastFolderCreated: '已创建文件夹"{name}"',
|
|
81
82
|
toastFolderCreateFailed: '创建文件夹失败',
|
|
83
|
+
toastSlideDuplicated: '已将"{slide}"复制为 {newSlide}',
|
|
84
|
+
toastSlideDuplicateFailed: '无法复制幻灯片',
|
|
82
85
|
toastSlideMoved: '已将"{slide}"移至 {folder}',
|
|
83
86
|
toastSlideMoveFailed: '移动幻灯片失败',
|
|
84
87
|
toastFolderDeleted: '已删除文件夹"{name}"',
|
|
@@ -269,6 +272,7 @@ export const zhCN: Locale = {
|
|
|
269
272
|
scopeSlide: '当前幻灯片',
|
|
270
273
|
scopeGlobal: '全局',
|
|
271
274
|
fileCount: { one: '{count} 个文件', other: '{count} 个文件' },
|
|
275
|
+
usageUnused: '未使用',
|
|
272
276
|
searchLogos: '搜索 Logo',
|
|
273
277
|
upload: '上传',
|
|
274
278
|
dropToUpload: '拖入即可上传',
|
package/src/locale/zh-tw.ts
CHANGED
|
@@ -38,6 +38,7 @@ export const zhTW: Locale = {
|
|
|
38
38
|
home: {
|
|
39
39
|
appTitle: 'open-slide',
|
|
40
40
|
draft: '草稿',
|
|
41
|
+
duplicate: '複製',
|
|
41
42
|
themes: '主題',
|
|
42
43
|
assets: '素材',
|
|
43
44
|
folders: '資料夾',
|
|
@@ -79,6 +80,8 @@ export const zhTW: Locale = {
|
|
|
79
80
|
deleteDialogDescriptionSuffix: '此操作無法復原。',
|
|
80
81
|
toastFolderCreated: '已建立資料夾「{name}」',
|
|
81
82
|
toastFolderCreateFailed: '建立資料夾失敗',
|
|
83
|
+
toastSlideDuplicated: '已將「{slide}」複製為 {newSlide}',
|
|
84
|
+
toastSlideDuplicateFailed: '無法複製投影片',
|
|
82
85
|
toastSlideMoved: '已將「{slide}」移至 {folder}',
|
|
83
86
|
toastSlideMoveFailed: '移動投影片失敗',
|
|
84
87
|
toastFolderDeleted: '已刪除資料夾「{name}」',
|
|
@@ -269,6 +272,7 @@ export const zhTW: Locale = {
|
|
|
269
272
|
scopeSlide: '此投影片',
|
|
270
273
|
scopeGlobal: '全域',
|
|
271
274
|
fileCount: { one: '{count} 個檔案', other: '{count} 個檔案' },
|
|
275
|
+
usageUnused: '未使用',
|
|
272
276
|
searchLogos: '搜尋 Logo',
|
|
273
277
|
upload: '上傳',
|
|
274
278
|
dropToUpload: '拖入即可上傳',
|