@open-slide/core 1.8.0 → 1.10.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-CCZDC8eF.js → build-ZM7IfDO-.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-C7sZtiY2.js → config-BAZeaz2P.js} +248 -232
- package/dist/{config-D1bANimZ.d.ts → config-mwmC1XI1.d.ts} +6 -1
- package/dist/{dev-kLS_4CAI.js → dev-BQkNTG_t.js} +1 -1
- package/dist/format-BvBmqbNW.js +1581 -0
- package/dist/index.d.ts +24 -4
- package/dist/index.js +120 -10
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +1 -1135
- package/dist/{preview-DUkOjOx8.js → preview-D8hUtbRA.js} +1 -1
- package/dist/{types-Bvk1pM70.d.ts → types-D_q_ylIe.d.ts} +19 -0
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +2 -1
- package/skills/slide-authoring/SKILL.md +42 -0
- package/src/app/components/language-toggle.tsx +39 -0
- package/src/app/components/player.tsx +30 -11
- package/src/app/components/pptx-progress-toast.tsx +32 -0
- package/src/app/components/sidebar/sidebar-footer.tsx +51 -0
- package/src/app/components/sidebar/sidebar.tsx +8 -1
- package/src/app/components/slide-transition-layer.tsx +36 -4
- package/src/app/components/thumbnail-rail.tsx +77 -15
- package/src/app/lib/design-presets.ts +1 -1
- package/src/app/lib/export-pptx.ts +284 -0
- package/src/app/lib/locale-store.ts +67 -0
- package/src/app/lib/step-context.tsx +169 -0
- package/src/app/lib/use-locale.ts +4 -16
- package/src/app/routes/slide.tsx +70 -0
- package/src/app/virtual.d.ts +1 -0
- package/src/locale/en.ts +21 -0
- package/src/locale/ja.ts +22 -0
- package/src/locale/types.ts +21 -0
- package/src/locale/zh-cn.ts +20 -0
- package/src/locale/zh-tw.ts +20 -0
- package/dist/en-hyGpmL1O.js +0 -375
|
@@ -44,6 +44,7 @@ type Locale = {
|
|
|
44
44
|
folders: string;
|
|
45
45
|
newFolder: string;
|
|
46
46
|
folderName: string;
|
|
47
|
+
updateAvailable: string;
|
|
47
48
|
changeIcon: string;
|
|
48
49
|
iconEmojiTab: string;
|
|
49
50
|
iconColorTab: string;
|
|
@@ -106,7 +107,12 @@ type Locale = {
|
|
|
106
107
|
toastCopyLinkFailed: string;
|
|
107
108
|
exportAsHtml: string;
|
|
108
109
|
exportAsPdf: string;
|
|
110
|
+
exportAsImagePptx: string;
|
|
111
|
+
exportAsPptx: string;
|
|
112
|
+
comingSoon: string;
|
|
113
|
+
pptxComingSoonTooltip: string;
|
|
109
114
|
pdfExportFailed: string;
|
|
115
|
+
imagePptxExportFailed: string;
|
|
110
116
|
pdfExportSafariUnsupported: string;
|
|
111
117
|
present: string;
|
|
112
118
|
presentMenuAria: string;
|
|
@@ -351,6 +357,8 @@ type Locale = {
|
|
|
351
357
|
toastDuplicateFailed: string;
|
|
352
358
|
toastDeleteFailed: string;
|
|
353
359
|
resizeRail: string;
|
|
360
|
+
transitionIndicator: string;
|
|
361
|
+
stepsIndicator: string;
|
|
354
362
|
};
|
|
355
363
|
pdfToast: {
|
|
356
364
|
title: string;
|
|
@@ -359,6 +367,13 @@ type Locale = {
|
|
|
359
367
|
printing: string;
|
|
360
368
|
done: string;
|
|
361
369
|
};
|
|
370
|
+
pptxToast: {
|
|
371
|
+
title: string;
|
|
372
|
+
/** template: "Rendering page {current} of {total}" */
|
|
373
|
+
processing: string;
|
|
374
|
+
generating: string;
|
|
375
|
+
done: string;
|
|
376
|
+
};
|
|
362
377
|
themeToggle: {
|
|
363
378
|
toggleAria: string;
|
|
364
379
|
title: string;
|
|
@@ -366,6 +381,10 @@ type Locale = {
|
|
|
366
381
|
dark: string;
|
|
367
382
|
system: string;
|
|
368
383
|
};
|
|
384
|
+
languageToggle: {
|
|
385
|
+
toggleAria: string;
|
|
386
|
+
title: string;
|
|
387
|
+
};
|
|
369
388
|
imagePlaceholder: {
|
|
370
389
|
dropOverlay: string;
|
|
371
390
|
uploading: string;
|
package/dist/vite/index.d.ts
CHANGED
package/dist/vite/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-slide/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Runtime and CLI for open-slide — write slides in slides/, we handle the rest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
"emoji-picker-react": "^4.18.0",
|
|
75
75
|
"fast-glob": "^3.3.2",
|
|
76
76
|
"fflate": "^0.8.2",
|
|
77
|
+
"html-to-image": "^1.11.13",
|
|
77
78
|
"lucide-react": "^1.8.0",
|
|
78
79
|
"next-themes": "^0.4.6",
|
|
79
80
|
"radix-ui": "^1.4.3",
|
|
@@ -308,6 +308,45 @@ const Footer = () => {
|
|
|
308
308
|
|
|
309
309
|
`current` is 1-indexed (matches what readers see) and `total` is the slide's page count. The hook works in every render context (main viewer, thumbnails, overview grid, present mode, presenter window, HTML/PDF export) — the same `<Footer />` JSX is correct everywhere. Call the hook inside a component that's used **per page**; don't try to call it at module top level.
|
|
310
310
|
|
|
311
|
+
## Stepped reveals (`<Steps>` / `<Step>`)
|
|
312
|
+
|
|
313
|
+
Reveal a page one beat at a time instead of showing everything at once. Wrap the deferred parts in `<Step>`, wrap the group in `<Steps>`. Each `→` reveals the next `<Step>`; `→` after the last one advances to the next page. `←` peels the last reveal back. Use it to stage attention — show framing first, then the consequence, then the turn — so the audience reads at the speaker's pace, not ahead.
|
|
314
|
+
|
|
315
|
+
`slides/build-on-reveal/` is the canonical worked example; study it before authoring a stepped page.
|
|
316
|
+
|
|
317
|
+
```tsx
|
|
318
|
+
import { Step, Steps } from '@open-slide/core';
|
|
319
|
+
|
|
320
|
+
<Steps>
|
|
321
|
+
<Step><div style={BULLET_ROW}>An audience reads faster than a presenter speaks.</div></Step>
|
|
322
|
+
<Step><div style={BULLET_ROW}>Showing every bullet at once invites pre-reading.</div></Step>
|
|
323
|
+
<Step><div style={BULLET_ROW}>Revealing in time stages attention.</div></Step>
|
|
324
|
+
</Steps>
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Rules
|
|
328
|
+
|
|
329
|
+
- **`<Step>` must be a *direct* child of `<Steps>`.** A `<Step>` nested deeper (or used without a `<Steps>` parent) renders fully revealed and defers nothing.
|
|
330
|
+
- **Non-`Step` children render immediately.** Put a headline or intro paragraph *inside* `<Steps>` as a plain element and it shows from the start; only the `<Step>` blocks wait. This is the "headline always, body in turn" pattern:
|
|
331
|
+
```tsx
|
|
332
|
+
<Steps>
|
|
333
|
+
<h2>Not everything has to wait.</h2>{/* visible immediately */}
|
|
334
|
+
<Step><p>First, set the stage…</p></Step>
|
|
335
|
+
<Step><p>Then, layer the consequence…</p></Step>
|
|
336
|
+
</Steps>
|
|
337
|
+
```
|
|
338
|
+
- **Multiple `<Steps>` blocks on one page compose in document order.** The first block reveals all its steps before the second begins; `←` unwinds in reverse. Use this for two columns that build left-then-right, each column owning its own `<Steps>`:
|
|
339
|
+
```tsx
|
|
340
|
+
<div style={COL}><Steps><Step>…</Step><Step>…</Step></Steps></div>{/* finishes first */}
|
|
341
|
+
<div style={COL}><Steps><Step>…</Step><Step>…</Step></Steps></div>{/* then this */}
|
|
342
|
+
```
|
|
343
|
+
- **Entry direction decides the starting state — same content, two rhythms.** Entering forward (`→` from the previous page) starts empty and builds up. Jumping in via the overview grid, or arriving backward from a later page, shows the page **fully composed** with every step already revealed. Design the page to read well both ways: a thumbnail or overview jump should look complete, not blank.
|
|
344
|
+
- **`<Step>` fades in over `duration` ms (default 180).** Pass `<Step duration={...}>` to adjust. `prefers-reduced-motion: reduce` collapses it to an instant cut automatically — don't write a fallback.
|
|
345
|
+
|
|
346
|
+
### When to reach for it
|
|
347
|
+
|
|
348
|
+
Use stepped reveals when the *order* of ideas is the point — a list whose payoff is the last item, a build-up to a conclusion, a before/after. Don't wrap every page's content in `<Step>` reflexively: a page the audience should take in at a glance (a hero title, a single quote, a diagram) is stronger shown whole. Reveals are timing, not decoration — same restraint as transitions.
|
|
349
|
+
|
|
311
350
|
## Page transitions
|
|
312
351
|
|
|
313
352
|
The framework can run an enter/exit animation between every slide change. There's **no default** — pages snap unless you declare a `SlideTransition`. Snap-swap is a perfectly tasteful default; only opt in when motion adds something.
|
|
@@ -541,6 +580,7 @@ This applies whenever the *visual element* repeats, not whenever the *data* does
|
|
|
541
580
|
- [ ] Visually repeated elements (cards, tiles, logo rows) are rendered as explicit `<Component />` instances, not via `array.map` over a data list.
|
|
542
581
|
- [ ] All imported assets exist on disk — slide-local under `slides/<id>/assets/`, or global under `assets/` (imported via `@assets/...`).
|
|
543
582
|
- [ ] Every `<ImagePlaceholder>` corresponds to a real image the user must supply — not decorative filler. If it could be replaced by typography or layout, it should be.
|
|
583
|
+
- [ ] If a page uses `<Steps>`/`<Step>`, every `<Step>` is a direct child of a `<Steps>`, and the page still reads as complete when jumped to via the overview grid (entering forward builds up; jumping in shows it fully revealed).
|
|
544
584
|
- [ ] If a `SlideTransition` is declared, every page sits in one family — same duration band (140–280 ms), same easing pair, same out-then-in stagger, magnitude under 12 px / 3%. No six-different-vocabularies decks. When in doubt, omit transitions entirely.
|
|
545
585
|
- [ ] Nothing outside `slides/<id>/` was edited.
|
|
546
586
|
|
|
@@ -561,3 +601,5 @@ This applies whenever the *visual element* repeats, not whenever the *data* does
|
|
|
561
601
|
- ❌ Sprinkling `<ImagePlaceholder>` across pages "for visual interest". Placeholders are for content the user owns; they're not stock-photo slots.
|
|
562
602
|
- ❌ Using a placeholder for an icon or decorative shape — those are typography/SVG problems, not asset problems.
|
|
563
603
|
- ❌ Rendering visually repeated elements with `array.map(...)` over a data array. Define a component and instantiate it explicitly per item (`<Card />`, `<Card />`, `<Card />`) so the inspector can edit each independently — a shared `map` body mutates every instance at once.
|
|
604
|
+
- ❌ Wrapping every page's content in `<Step>` reflexively. Stepped reveals are for content whose *order* is the point; a glance-and-get-it page (hero title, single quote, diagram) is stronger shown whole.
|
|
605
|
+
- ❌ A `<Step>` that isn't a direct child of `<Steps>` (nested deeper, or with no `<Steps>` parent). It renders fully revealed and defers nothing — the reveal silently does nothing.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Languages } from 'lucide-react';
|
|
2
|
+
import { buttonVariants } from '@/components/ui/button';
|
|
3
|
+
import {
|
|
4
|
+
DropdownMenu,
|
|
5
|
+
DropdownMenuContent,
|
|
6
|
+
DropdownMenuItem,
|
|
7
|
+
DropdownMenuTrigger,
|
|
8
|
+
} from '@/components/ui/dropdown-menu';
|
|
9
|
+
import { LOCALE_OPTIONS, setLocale } from '@/lib/locale-store';
|
|
10
|
+
import { useLocale } from '@/lib/use-locale';
|
|
11
|
+
import { cn } from '@/lib/utils';
|
|
12
|
+
|
|
13
|
+
export function LanguageToggle() {
|
|
14
|
+
const t = useLocale();
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<DropdownMenu>
|
|
18
|
+
<DropdownMenuTrigger
|
|
19
|
+
type="button"
|
|
20
|
+
aria-label={t.languageToggle.toggleAria}
|
|
21
|
+
title={t.languageToggle.title}
|
|
22
|
+
className={cn(buttonVariants({ variant: 'ghost', size: 'icon-sm' }))}
|
|
23
|
+
>
|
|
24
|
+
<Languages className="size-3.5" />
|
|
25
|
+
</DropdownMenuTrigger>
|
|
26
|
+
<DropdownMenuContent align="end" className="min-w-[140px]">
|
|
27
|
+
{LOCALE_OPTIONS.map((option) => (
|
|
28
|
+
<DropdownMenuItem
|
|
29
|
+
key={option.id}
|
|
30
|
+
onSelect={() => setLocale(option.id)}
|
|
31
|
+
data-active={t.id === option.id}
|
|
32
|
+
>
|
|
33
|
+
{option.label}
|
|
34
|
+
</DropdownMenuItem>
|
|
35
|
+
))}
|
|
36
|
+
</DropdownMenuContent>
|
|
37
|
+
</DropdownMenu>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -4,6 +4,7 @@ import { useWheelPageNavigation } from '@/lib/use-wheel-page-navigation';
|
|
|
4
4
|
import { cn } from '@/lib/utils';
|
|
5
5
|
import type { DesignSystem } from '../lib/design';
|
|
6
6
|
import type { Page } from '../lib/sdk';
|
|
7
|
+
import type { EntryDirection, StepController } from '../lib/step-context';
|
|
7
8
|
import type { SlideTransition } from '../lib/transition';
|
|
8
9
|
import { usePrefersReducedMotion } from '../lib/use-prefers-reduced-motion';
|
|
9
10
|
import { PresentBlackoutOverlay } from './present/blackout-overlay';
|
|
@@ -84,12 +85,28 @@ export function Player({
|
|
|
84
85
|
const canPrev = index > 0;
|
|
85
86
|
const canNext = index < pages.length - 1;
|
|
86
87
|
|
|
88
|
+
const stepControllerRef = useRef<StepController | null>(null);
|
|
89
|
+
const [entryDirection, setEntryDirection] = useState<EntryDirection>('jump');
|
|
90
|
+
|
|
91
|
+
// Every navigation funnels through here so entryDirection is settled
|
|
92
|
+
// synchronously, before the incoming page's <Steps> reads it on mount.
|
|
93
|
+
const handleIndexChange = useCallback(
|
|
94
|
+
(next: number) => {
|
|
95
|
+
const delta = next - index;
|
|
96
|
+
setEntryDirection(delta === 1 ? 'forward' : delta === -1 ? 'backward' : 'jump');
|
|
97
|
+
onIndexChange(next);
|
|
98
|
+
},
|
|
99
|
+
[index, onIndexChange],
|
|
100
|
+
);
|
|
101
|
+
|
|
87
102
|
const goPrev = useCallback(() => {
|
|
88
|
-
if (
|
|
89
|
-
|
|
103
|
+
if (stepControllerRef.current?.retreat()) return;
|
|
104
|
+
if (index > 0) handleIndexChange(index - 1);
|
|
105
|
+
}, [index, handleIndexChange]);
|
|
90
106
|
const goNext = useCallback(() => {
|
|
91
|
-
if (
|
|
92
|
-
|
|
107
|
+
if (stepControllerRef.current?.advance()) return;
|
|
108
|
+
if (index < pages.length - 1) handleIndexChange(index + 1);
|
|
109
|
+
}, [index, pages.length, handleIndexChange]);
|
|
93
110
|
|
|
94
111
|
const overlayActive = controls && (overviewOpen || helpOpen);
|
|
95
112
|
|
|
@@ -158,14 +175,14 @@ export function Player({
|
|
|
158
175
|
if (msg.type === 'next') goNext();
|
|
159
176
|
else if (msg.type === 'prev') goPrev();
|
|
160
177
|
else if (msg.type === 'goto') {
|
|
161
|
-
|
|
178
|
+
handleIndexChange(Math.max(0, Math.min(pages.length - 1, msg.index)));
|
|
162
179
|
} else if (msg.type === 'toggle-blackout') {
|
|
163
180
|
setBlackout((cur) => (cur === msg.mode ? null : msg.mode));
|
|
164
181
|
} else if (msg.type === 'request-state') {
|
|
165
182
|
send({ type: 'state', state: presenterStateRef.current });
|
|
166
183
|
}
|
|
167
184
|
},
|
|
168
|
-
[goNext, goPrev,
|
|
185
|
+
[goNext, goPrev, handleIndexChange, pages.length],
|
|
169
186
|
);
|
|
170
187
|
|
|
171
188
|
const channel = usePresenterChannel(slideId ?? '__none__', (msg) => {
|
|
@@ -231,12 +248,12 @@ export function Player({
|
|
|
231
248
|
}
|
|
232
249
|
if (e.key === 'Home') {
|
|
233
250
|
setKeyboardDriven(true);
|
|
234
|
-
|
|
251
|
+
handleIndexChange(0);
|
|
235
252
|
return;
|
|
236
253
|
}
|
|
237
254
|
if (e.key === 'End') {
|
|
238
255
|
setKeyboardDriven(true);
|
|
239
|
-
|
|
256
|
+
handleIndexChange(pages.length - 1);
|
|
240
257
|
return;
|
|
241
258
|
}
|
|
242
259
|
|
|
@@ -277,7 +294,7 @@ export function Player({
|
|
|
277
294
|
onExit,
|
|
278
295
|
goNext,
|
|
279
296
|
goPrev,
|
|
280
|
-
|
|
297
|
+
handleIndexChange,
|
|
281
298
|
pages.length,
|
|
282
299
|
slideId,
|
|
283
300
|
]);
|
|
@@ -315,6 +332,8 @@ export function Player({
|
|
|
315
332
|
total={pages.length}
|
|
316
333
|
moduleTransition={transition}
|
|
317
334
|
disabled={prefersReducedMotion}
|
|
335
|
+
stepControllerRef={stepControllerRef}
|
|
336
|
+
entryDirection={entryDirection}
|
|
318
337
|
/>
|
|
319
338
|
</SlideCanvas>
|
|
320
339
|
|
|
@@ -322,7 +341,7 @@ export function Player({
|
|
|
322
341
|
<div data-osd-chrome style={{ display: 'contents' }}>
|
|
323
342
|
<PresentProgressBar index={index} total={pages.length} visible={chromeVisible} />
|
|
324
343
|
<PresentBlackoutOverlay mode={blackout} />
|
|
325
|
-
<PresentJumpInput pageCount={pages.length} onJump={
|
|
344
|
+
<PresentJumpInput pageCount={pages.length} onJump={handleIndexChange} />
|
|
326
345
|
<PresentLaserPointer enabled={laser} />
|
|
327
346
|
<PresentControlBar
|
|
328
347
|
tooltipContainer={rootEl}
|
|
@@ -350,7 +369,7 @@ export function Player({
|
|
|
350
369
|
open={overviewOpen}
|
|
351
370
|
current={index}
|
|
352
371
|
onClose={() => setOverviewOpen(false)}
|
|
353
|
-
onSelect={
|
|
372
|
+
onSelect={handleIndexChange}
|
|
354
373
|
/>
|
|
355
374
|
<PresentHelpOverlay open={helpOpen} onOpenChange={setHelpOpen} container={rootEl} />
|
|
356
375
|
</div>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Loader2 } from 'lucide-react';
|
|
2
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
3
|
+
import type { PptxExportProgress } from '../lib/export-pptx';
|
|
4
|
+
import { Progress } from './ui/progress';
|
|
5
|
+
|
|
6
|
+
export function PptxProgressToast({ progress }: { progress: PptxExportProgress }) {
|
|
7
|
+
const t = useLocale();
|
|
8
|
+
const text =
|
|
9
|
+
progress.phase === 'processing'
|
|
10
|
+
? format(t.pptxToast.processing, {
|
|
11
|
+
current: progress.current.toString().padStart(2, '0'),
|
|
12
|
+
total: progress.total.toString().padStart(2, '0'),
|
|
13
|
+
})
|
|
14
|
+
: progress.phase === 'generating'
|
|
15
|
+
? t.pptxToast.generating
|
|
16
|
+
: t.pptxToast.done;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="flex w-80 items-start gap-3 rounded-[8px] border border-border bg-popover px-3.5 py-3 text-popover-foreground shadow-floating">
|
|
20
|
+
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin text-brand" />
|
|
21
|
+
<div className="min-w-0 flex-1">
|
|
22
|
+
<p className="font-heading text-[12.5px] font-semibold tracking-tight">
|
|
23
|
+
{t.pptxToast.title}
|
|
24
|
+
</p>
|
|
25
|
+
<p className="truncate font-mono text-[10.5px] tracking-[0.04em] text-muted-foreground">
|
|
26
|
+
{text}
|
|
27
|
+
</p>
|
|
28
|
+
<Progress value={Math.round(progress.percent)} className="mt-2 h-[3px]" />
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import config from 'virtual:open-slide/config';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
4
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
5
|
+
|
|
6
|
+
type UpdateCheck = { current: string; latest: string | null; outdated: boolean };
|
|
7
|
+
|
|
8
|
+
export function SidebarFooter() {
|
|
9
|
+
const t = useLocale();
|
|
10
|
+
const [update, setUpdate] = useState<UpdateCheck | null>(null);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!import.meta.env.DEV) return;
|
|
14
|
+
let cancelled = false;
|
|
15
|
+
fetch('/__update-check')
|
|
16
|
+
.then((res) => (res.ok ? (res.json() as Promise<UpdateCheck>) : null))
|
|
17
|
+
.then((data) => {
|
|
18
|
+
if (!cancelled && data?.outdated) setUpdate(data);
|
|
19
|
+
})
|
|
20
|
+
.catch(() => {});
|
|
21
|
+
return () => {
|
|
22
|
+
cancelled = true;
|
|
23
|
+
};
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
const label = `v${config.version}`;
|
|
27
|
+
|
|
28
|
+
const versionRow = (
|
|
29
|
+
<span className="inline-flex items-center gap-1.5">
|
|
30
|
+
{update?.latest && <span className="size-1.5 rounded-full bg-brand" aria-hidden />}
|
|
31
|
+
{label}
|
|
32
|
+
</span>
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="px-4 py-3 text-[11px] text-muted-foreground/70 tabular-nums">
|
|
37
|
+
{update?.latest ? (
|
|
38
|
+
<TooltipProvider delayDuration={200}>
|
|
39
|
+
<Tooltip>
|
|
40
|
+
<TooltipTrigger asChild>{versionRow}</TooltipTrigger>
|
|
41
|
+
<TooltipContent side="top" sideOffset={6} className="max-w-56">
|
|
42
|
+
{format(t.home.updateAvailable, { version: update.latest })}
|
|
43
|
+
</TooltipContent>
|
|
44
|
+
</Tooltip>
|
|
45
|
+
</TooltipProvider>
|
|
46
|
+
) : (
|
|
47
|
+
versionRow
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Plus } from 'lucide-react';
|
|
2
2
|
import { useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { toast } from 'sonner';
|
|
4
|
+
import { LanguageToggle } from '@/components/language-toggle';
|
|
4
5
|
import { ThemeToggle } from '@/components/theme-toggle';
|
|
5
6
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
6
7
|
import type { Folder, FolderIcon } from '@/lib/sdk';
|
|
@@ -8,6 +9,7 @@ import { format, useLocale } from '@/lib/use-locale';
|
|
|
8
9
|
import { cn } from '@/lib/utils';
|
|
9
10
|
import { FolderIconChip, FolderItem } from './folder-item';
|
|
10
11
|
import { IconPicker, PRESET_COLORS } from './icon-picker';
|
|
12
|
+
import { SidebarFooter } from './sidebar-footer';
|
|
11
13
|
|
|
12
14
|
export const DRAFT_ID = 'draft';
|
|
13
15
|
export const THEMES_ID = '__themes__';
|
|
@@ -124,7 +126,8 @@ export function Sidebar({
|
|
|
124
126
|
<aside className="paper relative flex h-full w-[16.5rem] shrink-0 flex-col border-r border-hairline bg-sidebar text-sidebar-foreground">
|
|
125
127
|
<div className="flex items-center justify-between px-4 pt-5 pb-4">
|
|
126
128
|
<h1 className="font-heading text-lg font-bold tracking-tight">{t.home.appTitle}</h1>
|
|
127
|
-
<div className="-mr-1.5">
|
|
129
|
+
<div className="-mr-1.5 flex items-center">
|
|
130
|
+
<LanguageToggle />
|
|
128
131
|
<ThemeToggle />
|
|
129
132
|
</div>
|
|
130
133
|
</div>
|
|
@@ -271,6 +274,10 @@ export function Sidebar({
|
|
|
271
274
|
</button>
|
|
272
275
|
))}
|
|
273
276
|
</div>
|
|
277
|
+
|
|
278
|
+
<div className="border-t border-hairline">
|
|
279
|
+
<SidebarFooter />
|
|
280
|
+
</div>
|
|
274
281
|
</aside>
|
|
275
282
|
);
|
|
276
283
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react';
|
|
1
|
+
import { type MutableRefObject, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { SlidePageProvider } from '../lib/page-context';
|
|
3
3
|
import type { Page } from '../lib/sdk';
|
|
4
|
+
import { type EntryDirection, type StepController, StepHost } from '../lib/step-context';
|
|
4
5
|
import { resolveTransition, type SlideTransition, type TransitionPhase } from '../lib/transition';
|
|
5
6
|
|
|
6
7
|
type Props = {
|
|
@@ -9,6 +10,8 @@ type Props = {
|
|
|
9
10
|
total: number;
|
|
10
11
|
moduleTransition?: SlideTransition;
|
|
11
12
|
disabled?: boolean;
|
|
13
|
+
stepControllerRef?: MutableRefObject<StepController | null>;
|
|
14
|
+
entryDirection?: EntryDirection;
|
|
12
15
|
};
|
|
13
16
|
|
|
14
17
|
type Direction = 'forward' | 'backward';
|
|
@@ -30,7 +33,15 @@ function runPhase(
|
|
|
30
33
|
});
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
export function SlideTransitionLayer({
|
|
36
|
+
export function SlideTransitionLayer({
|
|
37
|
+
pages,
|
|
38
|
+
index,
|
|
39
|
+
total,
|
|
40
|
+
moduleTransition,
|
|
41
|
+
disabled,
|
|
42
|
+
stepControllerRef,
|
|
43
|
+
entryDirection = 'jump',
|
|
44
|
+
}: Props) {
|
|
34
45
|
const [current, setCurrent] = useState(index);
|
|
35
46
|
const [outgoing, setOutgoing] = useState<number | null>(null);
|
|
36
47
|
const [direction, setDirection] = useState<Direction>('forward');
|
|
@@ -129,6 +140,15 @@ export function SlideTransitionLayer({ pages, index, total, moduleTransition, di
|
|
|
129
140
|
const CurrentPage = pages[current];
|
|
130
141
|
const OutgoingPage = outgoing !== null ? pages[outgoing] : null;
|
|
131
142
|
|
|
143
|
+
// Outgoing layer mirrors the direction we just navigated so its <Steps>
|
|
144
|
+
// re-mounts in the state the audience just saw: forward nav → outgoing was
|
|
145
|
+
// fully revealed; backward nav → outgoing was at zero reveals.
|
|
146
|
+
const outgoingEntryDirection: EntryDirection =
|
|
147
|
+
entryDirection === 'backward' ? 'forward' : 'backward';
|
|
148
|
+
|
|
149
|
+
const noopControllerRef = useRef<StepController | null>(null);
|
|
150
|
+
const activeControllerRef = stepControllerRef ?? noopControllerRef;
|
|
151
|
+
|
|
132
152
|
return (
|
|
133
153
|
<div
|
|
134
154
|
ref={wrapperRef}
|
|
@@ -138,14 +158,26 @@ export function SlideTransitionLayer({ pages, index, total, moduleTransition, di
|
|
|
138
158
|
{OutgoingPage && outgoing !== null ? (
|
|
139
159
|
<div ref={outgoingLayerRef} className="absolute inset-0">
|
|
140
160
|
<SlidePageProvider index={outgoing} total={total}>
|
|
141
|
-
<
|
|
161
|
+
<StepHost
|
|
162
|
+
isActivePage={false}
|
|
163
|
+
entryDirection={outgoingEntryDirection}
|
|
164
|
+
controllerRef={activeControllerRef}
|
|
165
|
+
>
|
|
166
|
+
<OutgoingPage />
|
|
167
|
+
</StepHost>
|
|
142
168
|
</SlidePageProvider>
|
|
143
169
|
</div>
|
|
144
170
|
) : null}
|
|
145
171
|
{CurrentPage ? (
|
|
146
172
|
<div ref={incomingLayerRef} className="absolute inset-0">
|
|
147
173
|
<SlidePageProvider index={current} total={total}>
|
|
148
|
-
<
|
|
174
|
+
<StepHost
|
|
175
|
+
isActivePage
|
|
176
|
+
entryDirection={entryDirection}
|
|
177
|
+
controllerRef={activeControllerRef}
|
|
178
|
+
>
|
|
179
|
+
<CurrentPage />
|
|
180
|
+
</StepHost>
|
|
149
181
|
</SlidePageProvider>
|
|
150
182
|
</div>
|
|
151
183
|
) : null}
|
|
@@ -15,8 +15,8 @@ import {
|
|
|
15
15
|
verticalListSortingStrategy,
|
|
16
16
|
} from '@dnd-kit/sortable';
|
|
17
17
|
import { CSS } from '@dnd-kit/utilities';
|
|
18
|
-
import { Copy, Trash2 } from 'lucide-react';
|
|
19
|
-
import { Fragment, useEffect, useRef } from 'react';
|
|
18
|
+
import { Copy, ListOrdered, type LucideIcon, Sparkles, Trash2 } from 'lucide-react';
|
|
19
|
+
import { Fragment, useEffect, useRef, useState } from 'react';
|
|
20
20
|
import {
|
|
21
21
|
ContextMenu,
|
|
22
22
|
ContextMenuContent,
|
|
@@ -25,12 +25,14 @@ import {
|
|
|
25
25
|
ContextMenuTrigger,
|
|
26
26
|
} from '@/components/ui/context-menu';
|
|
27
27
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
28
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
28
29
|
import { format, useLocale } from '@/lib/use-locale';
|
|
29
30
|
import { cn } from '@/lib/utils';
|
|
30
31
|
import type { DesignSystem } from '../lib/design';
|
|
31
32
|
import { SlidePageProvider } from '../lib/page-context';
|
|
32
33
|
import type { Page } from '../lib/sdk';
|
|
33
34
|
import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
|
|
35
|
+
import type { SlideTransition } from '../lib/transition';
|
|
34
36
|
import { SlideCanvas } from './slide-canvas';
|
|
35
37
|
|
|
36
38
|
type Orientation = 'vertical' | 'horizontal';
|
|
@@ -50,6 +52,8 @@ type Props = {
|
|
|
50
52
|
orientation?: Orientation;
|
|
51
53
|
/** Vertical-only: total rail width in px. Thumbnails scale to fit. */
|
|
52
54
|
width?: number;
|
|
55
|
+
/** Deck-level transition default; used to flag pages that inherit a transition. */
|
|
56
|
+
moduleTransition?: SlideTransition;
|
|
53
57
|
};
|
|
54
58
|
|
|
55
59
|
const DEFAULT_VERTICAL_THUMB_WIDTH = 184;
|
|
@@ -66,6 +70,7 @@ export function ThumbnailRail({
|
|
|
66
70
|
actions,
|
|
67
71
|
orientation = 'vertical',
|
|
68
72
|
width,
|
|
73
|
+
moduleTransition,
|
|
69
74
|
}: Props) {
|
|
70
75
|
const activeRef = useRef<HTMLButtonElement | null>(null);
|
|
71
76
|
const t = useLocale();
|
|
@@ -165,6 +170,7 @@ export function ThumbnailRail({
|
|
|
165
170
|
scale={scale}
|
|
166
171
|
thumbWidth={thumbWidth}
|
|
167
172
|
height={height}
|
|
173
|
+
moduleTransition={moduleTransition}
|
|
168
174
|
/>
|
|
169
175
|
);
|
|
170
176
|
|
|
@@ -218,15 +224,21 @@ export function ThumbnailRail({
|
|
|
218
224
|
);
|
|
219
225
|
|
|
220
226
|
if (!onReorder) {
|
|
221
|
-
return
|
|
227
|
+
return (
|
|
228
|
+
<TooltipProvider delayDuration={200}>
|
|
229
|
+
<ScrollArea className="h-full border-r border-hairline bg-sidebar">{list}</ScrollArea>
|
|
230
|
+
</TooltipProvider>
|
|
231
|
+
);
|
|
222
232
|
}
|
|
223
233
|
|
|
224
234
|
return (
|
|
225
|
-
<
|
|
226
|
-
<
|
|
227
|
-
{
|
|
228
|
-
|
|
229
|
-
|
|
235
|
+
<TooltipProvider delayDuration={200}>
|
|
236
|
+
<ScrollArea className="h-full border-r border-hairline bg-sidebar">
|
|
237
|
+
<SortableRail pages={pages} onReorder={onReorder} onSelect={onSelect}>
|
|
238
|
+
{list}
|
|
239
|
+
</SortableRail>
|
|
240
|
+
</ScrollArea>
|
|
241
|
+
</TooltipProvider>
|
|
230
242
|
);
|
|
231
243
|
}
|
|
232
244
|
|
|
@@ -247,6 +259,7 @@ function ThumbContents({
|
|
|
247
259
|
scale,
|
|
248
260
|
thumbWidth,
|
|
249
261
|
height,
|
|
262
|
+
moduleTransition,
|
|
250
263
|
}: {
|
|
251
264
|
index: number;
|
|
252
265
|
total: number;
|
|
@@ -256,18 +269,45 @@ function ThumbContents({
|
|
|
256
269
|
scale: number;
|
|
257
270
|
thumbWidth: number;
|
|
258
271
|
height: number;
|
|
272
|
+
moduleTransition?: SlideTransition;
|
|
259
273
|
}) {
|
|
274
|
+
const t = useLocale();
|
|
275
|
+
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
276
|
+
const [hasSteps, setHasSteps] = useState(false);
|
|
277
|
+
|
|
278
|
+
// Steps live in JSX and can't be introspected statically — detect them from
|
|
279
|
+
// the already-rendered thumbnail DOM, where each Step emits `data-osd-step`.
|
|
280
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-detect when the page at this slot changes (reorder/edit reuses the index)
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
setHasSteps(boxRef.current?.querySelector('[data-osd-step]') != null);
|
|
283
|
+
}, [PageComp]);
|
|
284
|
+
|
|
285
|
+
const hasTransition = Boolean(PageComp.transition ?? moduleTransition);
|
|
286
|
+
|
|
260
287
|
return (
|
|
261
288
|
<>
|
|
262
|
-
<
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
289
|
+
<div className="mt-1.5 flex w-7 shrink-0 flex-col items-end gap-1">
|
|
290
|
+
<span
|
|
291
|
+
className={cn(
|
|
292
|
+
'font-mono text-[10px] font-medium tracking-[0.06em] tabular-nums uppercase',
|
|
293
|
+
active ? 'text-brand' : 'text-muted-foreground/70',
|
|
294
|
+
)}
|
|
295
|
+
>
|
|
296
|
+
{(index + 1).toString().padStart(2, '0')}
|
|
297
|
+
</span>
|
|
298
|
+
{(hasTransition || hasSteps) && (
|
|
299
|
+
<div className="flex flex-col items-end gap-0.5">
|
|
300
|
+
{hasTransition && (
|
|
301
|
+
<ThumbIndicator icon={Sparkles} label={t.thumbnailRail.transitionIndicator} />
|
|
302
|
+
)}
|
|
303
|
+
{hasSteps && (
|
|
304
|
+
<ThumbIndicator icon={ListOrdered} label={t.thumbnailRail.stepsIndicator} />
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
266
307
|
)}
|
|
267
|
-
>
|
|
268
|
-
{(index + 1).toString().padStart(2, '0')}
|
|
269
|
-
</span>
|
|
308
|
+
</div>
|
|
270
309
|
<div
|
|
310
|
+
ref={boxRef}
|
|
271
311
|
className={cn(
|
|
272
312
|
'relative shrink-0 overflow-hidden rounded-[4px] border bg-card motion-safe:transition-[border-color,box-shadow]',
|
|
273
313
|
active
|
|
@@ -292,6 +332,28 @@ function ThumbContents({
|
|
|
292
332
|
);
|
|
293
333
|
}
|
|
294
334
|
|
|
335
|
+
function ThumbIndicator({ icon: Icon, label }: { icon: LucideIcon; label: string }) {
|
|
336
|
+
return (
|
|
337
|
+
<Tooltip>
|
|
338
|
+
<TooltipTrigger asChild>
|
|
339
|
+
<span
|
|
340
|
+
role="img"
|
|
341
|
+
aria-label={label}
|
|
342
|
+
className={cn(
|
|
343
|
+
'flex size-3.5 items-center justify-center text-muted-foreground/55',
|
|
344
|
+
'motion-safe:transition-colors group-hover/thumb:text-muted-foreground/80',
|
|
345
|
+
)}
|
|
346
|
+
>
|
|
347
|
+
<Icon className="size-3" strokeWidth={2} />
|
|
348
|
+
</span>
|
|
349
|
+
</TooltipTrigger>
|
|
350
|
+
<TooltipContent side="right" sideOffset={6}>
|
|
351
|
+
{label}
|
|
352
|
+
</TooltipContent>
|
|
353
|
+
</Tooltip>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
295
357
|
function ThumbContextMenu({
|
|
296
358
|
index,
|
|
297
359
|
actions,
|