@open-slide/core 1.0.4 → 1.0.6
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-DqfKmw9h.js → build-4wOJF1l4.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-DweCbRkQ.d.ts → config-D2y1AXaN.d.ts} +3 -0
- package/dist/{config-CN7J0RDO.js → config-evLWCV1-.js} +378 -222
- package/dist/{dev-jWxtWHAG.js → dev-BUr0S-Ij.js} +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/locale/index.d.ts +24 -0
- package/dist/locale/index.js +1189 -0
- package/dist/{preview-CSA05Gfm.js → preview-DP_gIphz.js} +1 -1
- package/dist/types-BVvl_xup.d.ts +314 -0
- package/dist/vite/index.d.ts +2 -1
- package/dist/vite/index.js +1 -1
- package/package.json +7 -1
- package/src/app/app.tsx +6 -2
- package/src/app/components/asset-view.tsx +87 -64
- package/src/app/components/click-nav-zones.tsx +4 -2
- package/src/app/components/inspector/comment-widget.tsx +9 -7
- package/src/app/components/inspector/inspect-overlay.tsx +79 -17
- package/src/app/components/inspector/inspector-panel.tsx +68 -39
- package/src/app/components/inspector/inspector-provider.tsx +185 -58
- package/src/app/components/inspector/save-bar.tsx +6 -5
- package/src/app/components/panel/save-card.tsx +12 -9
- package/src/app/components/pdf-progress-toast.tsx +11 -4
- package/src/app/components/player.tsx +7 -25
- package/src/app/components/present/control-bar.tsx +17 -10
- package/src/app/components/present/help-overlay.tsx +18 -17
- package/src/app/components/present/overview-grid.tsx +6 -9
- package/src/app/components/present/use-presenter-channel.ts +3 -10
- package/src/app/components/sidebar/folder-item.tsx +16 -9
- package/src/app/components/sidebar/icon-picker.tsx +4 -5
- package/src/app/components/sidebar/sidebar.tsx +87 -25
- package/src/app/components/slide-canvas.tsx +1 -10
- package/src/app/components/style-panel/design-provider.tsx +2 -6
- package/src/app/components/style-panel/style-panel.tsx +26 -18
- package/src/app/components/theme-toggle.tsx +7 -5
- package/src/app/components/thumbnail-rail.tsx +4 -2
- package/src/app/favicon.ico +0 -0
- package/src/app/lib/export-html.ts +1 -9
- package/src/app/lib/export-pdf.ts +0 -5
- package/src/app/lib/inspector/use-editor.ts +9 -7
- package/src/app/lib/print-ready.ts +0 -4
- package/src/app/lib/sdk.ts +1 -2
- package/src/app/lib/use-locale.ts +20 -0
- package/src/app/routes/home.tsx +90 -45
- package/src/app/routes/presenter.tsx +45 -25
- package/src/app/routes/slide.tsx +37 -24
- package/src/app/styles.css +28 -0
- package/src/app/virtual.d.ts +4 -0
- package/src/locale/en.ts +303 -0
- package/src/locale/format.ts +12 -0
- package/src/locale/index.ts +6 -0
- package/src/locale/ja.ts +307 -0
- package/src/locale/types.ts +323 -0
- package/src/locale/zh-cn.ts +303 -0
- package/src/locale/zh-tw.ts +303 -0
|
@@ -21,9 +21,6 @@ import { useTouchSwipe } from './present/use-touch-swipe';
|
|
|
21
21
|
import { SlideCanvas } from './slide-canvas';
|
|
22
22
|
|
|
23
23
|
const IDLE_HIDE_MS = 2000;
|
|
24
|
-
// Bottom band of the viewport that reveals the control bar + progress bar.
|
|
25
|
-
// Generous enough to feel forgiving with a trackpad, tight enough not to
|
|
26
|
-
// flash on incidental cursor moves.
|
|
27
24
|
const BAR_HOTZONE_PX = 160;
|
|
28
25
|
|
|
29
26
|
type Props = {
|
|
@@ -33,15 +30,7 @@ type Props = {
|
|
|
33
30
|
onIndexChange: (index: number) => void;
|
|
34
31
|
onExit: () => void;
|
|
35
32
|
allowExit?: boolean;
|
|
36
|
-
/**
|
|
37
|
-
* When true, render the full presenter chrome (control bar, progress bar,
|
|
38
|
-
* overview, blackout, laser pointer, jump-to-slide, help overlay, and
|
|
39
|
-
* the BroadcastChannel sync that powers Presenter View). Defaults to
|
|
40
|
-
* false so the static HTML export and any other minimal embeddings stay
|
|
41
|
-
* untouched.
|
|
42
|
-
*/
|
|
43
33
|
controls?: boolean;
|
|
44
|
-
/** Optional id used to namespace the BroadcastChannel for Presenter View. */
|
|
45
34
|
slideId?: string;
|
|
46
35
|
};
|
|
47
36
|
|
|
@@ -56,16 +45,15 @@ export function Player({
|
|
|
56
45
|
slideId,
|
|
57
46
|
}: Props) {
|
|
58
47
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
59
|
-
// Mirrored as state so
|
|
60
|
-
// (tooltips, popovers — the body is outside the fullscreen
|
|
61
|
-
//
|
|
48
|
+
// Mirrored as state so descendants portaling *into* the player subtree
|
|
49
|
+
// (tooltips, popovers — the body is outside the fullscreen tree) re-render
|
|
50
|
+
// once the node mounts.
|
|
62
51
|
const [rootEl, setRootEl] = useState<HTMLDivElement | null>(null);
|
|
63
52
|
const setRoot = useCallback((el: HTMLDivElement | null) => {
|
|
64
53
|
rootRef.current = el;
|
|
65
54
|
setRootEl(el);
|
|
66
55
|
}, []);
|
|
67
56
|
|
|
68
|
-
// ── Overlay state (only meaningful when `controls` is true) ────────────
|
|
69
57
|
const [overviewOpen, setOverviewOpen] = useState(false);
|
|
70
58
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
71
59
|
const [blackout, setBlackout] = useState<'black' | 'white' | null>(null);
|
|
@@ -97,7 +85,6 @@ export function Player({
|
|
|
97
85
|
onNext: goNext,
|
|
98
86
|
});
|
|
99
87
|
|
|
100
|
-
// ── Fullscreen lifecycle ───────────────────────────────────────────────
|
|
101
88
|
useEffect(() => {
|
|
102
89
|
const el = rootRef.current;
|
|
103
90
|
if (!el) return;
|
|
@@ -118,11 +105,9 @@ export function Player({
|
|
|
118
105
|
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
|
119
106
|
}, [onExit, allowExit]);
|
|
120
107
|
|
|
121
|
-
//
|
|
122
|
-
// Player is the source of truth. It re-publishes state on every change
|
|
108
|
+
// Player is the source of truth: it re-publishes state on every change
|
|
123
109
|
// and answers `request-state` pings so newly opened presenter windows
|
|
124
|
-
// hydrate immediately.
|
|
125
|
-
// the same slide module, so they don't cross the channel.
|
|
110
|
+
// hydrate immediately.
|
|
126
111
|
const presenterState = useMemo<PresenterState>(
|
|
127
112
|
() => ({ index, pageCount: pages.length, blackout, startedAt }),
|
|
128
113
|
[index, pages.length, blackout, startedAt],
|
|
@@ -155,7 +140,6 @@ export function Player({
|
|
|
155
140
|
channel.send({ type: 'state', state: presenterState });
|
|
156
141
|
}, [controls, channel, presenterState]);
|
|
157
142
|
|
|
158
|
-
// ── Keyboard ───────────────────────────────────────────────────────────
|
|
159
143
|
useEffect(() => {
|
|
160
144
|
const onKey = (e: KeyboardEvent) => {
|
|
161
145
|
const tgt = e.target;
|
|
@@ -256,10 +240,9 @@ export function Player({
|
|
|
256
240
|
slideId,
|
|
257
241
|
]);
|
|
258
242
|
|
|
259
|
-
// ── Chrome visibility / cursor ─────────────────────────────────────────
|
|
260
243
|
// The control bar + progress strip only surface when the pointer is in
|
|
261
|
-
// the bottom hot zone. Keyboard nav (arrows / space / PgDn) never
|
|
262
|
-
//
|
|
244
|
+
// the bottom hot zone. Keyboard nav (arrows / space / PgDn) never reveals
|
|
245
|
+
// them — intentional so the deck stays clean during a talk.
|
|
263
246
|
const pointerNearBottom = usePointerNearBottom(BAR_HOTZONE_PX, controls && !overlayActive);
|
|
264
247
|
const chromeVisible = pointerNearBottom || overlayActive;
|
|
265
248
|
const idle = useIdle(IDLE_HIDE_MS, controls && !overlayActive);
|
|
@@ -279,7 +262,6 @@ export function Player({
|
|
|
279
262
|
{PageComp ? <PageComp /> : null}
|
|
280
263
|
</SlideCanvas>
|
|
281
264
|
|
|
282
|
-
{/* Invisible side click zones — the original mobile-friendly nav. */}
|
|
283
265
|
<button
|
|
284
266
|
type="button"
|
|
285
267
|
aria-label="Previous page"
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from 'lucide-react';
|
|
12
12
|
import { createContext, useContext, useEffect, useState } from 'react';
|
|
13
13
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
14
|
+
import { useLocale } from '@/lib/use-locale';
|
|
14
15
|
import { cn } from '@/lib/utils';
|
|
15
16
|
|
|
16
17
|
const TooltipContainerCtx = createContext<HTMLElement | null>(null);
|
|
@@ -57,6 +58,7 @@ export function PresentControlBar({
|
|
|
57
58
|
onExit,
|
|
58
59
|
tooltipContainer,
|
|
59
60
|
}: Props) {
|
|
61
|
+
const t = useLocale();
|
|
60
62
|
return (
|
|
61
63
|
<div
|
|
62
64
|
data-state={visible ? 'visible' : 'hidden'}
|
|
@@ -73,10 +75,14 @@ export function PresentControlBar({
|
|
|
73
75
|
<TooltipProvider delayDuration={300}>
|
|
74
76
|
<TooltipContainerCtx.Provider value={tooltipContainer ?? null}>
|
|
75
77
|
<div className="pointer-events-auto flex h-11 items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 text-white/85 shadow-[0_8px_30px_-8px_oklch(0_0_0/0.6)] backdrop-blur-md">
|
|
76
|
-
<BarButton label=
|
|
78
|
+
<BarButton label={t.present.prevSlideAria} onClick={onPrev} disabled={index === 0}>
|
|
77
79
|
<ChevronLeft className="size-4" />
|
|
78
80
|
</BarButton>
|
|
79
|
-
<BarButton
|
|
81
|
+
<BarButton
|
|
82
|
+
label={t.present.nextSlideAria}
|
|
83
|
+
onClick={onNext}
|
|
84
|
+
disabled={index >= total - 1}
|
|
85
|
+
>
|
|
80
86
|
<ChevronRight className="size-4" />
|
|
81
87
|
</BarButton>
|
|
82
88
|
|
|
@@ -94,37 +100,37 @@ export function PresentControlBar({
|
|
|
94
100
|
|
|
95
101
|
<Divider />
|
|
96
102
|
|
|
97
|
-
<BarButton label=
|
|
103
|
+
<BarButton label={t.present.overviewAria} onClick={onOverview}>
|
|
98
104
|
<Grid2x2 className="size-4" />
|
|
99
105
|
</BarButton>
|
|
100
106
|
<BarButton
|
|
101
|
-
label=
|
|
107
|
+
label={t.present.blackoutAria}
|
|
102
108
|
onClick={() => onBlackout('black')}
|
|
103
109
|
active={blackout === 'black'}
|
|
104
110
|
>
|
|
105
111
|
<Square className="size-4 fill-current" />
|
|
106
112
|
</BarButton>
|
|
107
113
|
<BarButton
|
|
108
|
-
label=
|
|
114
|
+
label={t.present.whiteoutAria}
|
|
109
115
|
onClick={() => onBlackout('white')}
|
|
110
116
|
active={blackout === 'white'}
|
|
111
117
|
>
|
|
112
118
|
<Sun className="size-4" />
|
|
113
119
|
</BarButton>
|
|
114
|
-
<BarButton label=
|
|
120
|
+
<BarButton label={t.present.laserAria} onClick={onLaser} active={laser}>
|
|
115
121
|
<Crosshair className="size-4" />
|
|
116
122
|
</BarButton>
|
|
117
|
-
<BarButton label=
|
|
123
|
+
<BarButton label={t.present.presenterAria} onClick={onPresenter}>
|
|
118
124
|
<MonitorSpeaker className="size-4" />
|
|
119
125
|
</BarButton>
|
|
120
|
-
<BarButton label=
|
|
126
|
+
<BarButton label={t.present.helpAria} onClick={onHelp}>
|
|
121
127
|
<Keyboard className="size-4" />
|
|
122
128
|
</BarButton>
|
|
123
129
|
|
|
124
130
|
{allowExit && (
|
|
125
131
|
<>
|
|
126
132
|
<Divider />
|
|
127
|
-
<BarButton label=
|
|
133
|
+
<BarButton label={t.present.exitAria} onClick={onExit}>
|
|
128
134
|
<LogOut className="size-4" />
|
|
129
135
|
</BarButton>
|
|
130
136
|
</>
|
|
@@ -186,6 +192,7 @@ function Divider() {
|
|
|
186
192
|
|
|
187
193
|
function ElapsedClock({ startedAt }: { startedAt: number }) {
|
|
188
194
|
const [now, setNow] = useState(() => Date.now());
|
|
195
|
+
const t = useLocale();
|
|
189
196
|
useEffect(() => {
|
|
190
197
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
191
198
|
return () => clearInterval(id);
|
|
@@ -195,7 +202,7 @@ function ElapsedClock({ startedAt }: { startedAt: number }) {
|
|
|
195
202
|
const s = elapsed % 60;
|
|
196
203
|
return (
|
|
197
204
|
<time
|
|
198
|
-
title=
|
|
205
|
+
title={t.present.elapsedTime}
|
|
199
206
|
className="px-2 font-mono text-[11.5px] tracking-[0.08em] tabular-nums uppercase select-none text-white/70"
|
|
200
207
|
>
|
|
201
208
|
{m.toString().padStart(2, '0')}:{s.toString().padStart(2, '0')}
|
|
@@ -1,18 +1,5 @@
|
|
|
1
1
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
2
|
-
|
|
3
|
-
const SHORTCUTS: Array<{ keys: string[]; label: string }> = [
|
|
4
|
-
{ keys: ['→', '↓', 'Space', 'PgDn'], label: 'Next slide' },
|
|
5
|
-
{ keys: ['←', '↑', 'PgUp'], label: 'Previous slide' },
|
|
6
|
-
{ keys: ['Home', 'End'], label: 'First / last slide' },
|
|
7
|
-
{ keys: ['1–9', 'Enter'], label: 'Jump to slide' },
|
|
8
|
-
{ keys: ['O'], label: 'Slide overview' },
|
|
9
|
-
{ keys: ['B'], label: 'Black screen' },
|
|
10
|
-
{ keys: ['W'], label: 'White screen' },
|
|
11
|
-
{ keys: ['L'], label: 'Laser pointer' },
|
|
12
|
-
{ keys: ['P'], label: 'Open Presenter View' },
|
|
13
|
-
{ keys: ['?', 'H'], label: 'Toggle this help' },
|
|
14
|
-
{ keys: ['Esc'], label: 'Close overlay / exit' },
|
|
15
|
-
];
|
|
2
|
+
import { useLocale } from '@/lib/use-locale';
|
|
16
3
|
|
|
17
4
|
type Props = {
|
|
18
5
|
open: boolean;
|
|
@@ -23,15 +10,29 @@ type Props = {
|
|
|
23
10
|
};
|
|
24
11
|
|
|
25
12
|
export function PresentHelpOverlay({ open, onOpenChange, container }: Props) {
|
|
13
|
+
const t = useLocale();
|
|
14
|
+
const shortcuts: Array<{ keys: string[]; label: string }> = [
|
|
15
|
+
{ keys: ['→', '↓', 'Space', 'PgDn'], label: t.present.shortcutNext },
|
|
16
|
+
{ keys: ['←', '↑', 'PgUp'], label: t.present.shortcutPrev },
|
|
17
|
+
{ keys: ['Home', 'End'], label: t.present.shortcutFirstLast },
|
|
18
|
+
{ keys: ['1–9', 'Enter'], label: t.present.shortcutJump },
|
|
19
|
+
{ keys: ['O'], label: t.present.shortcutOverview },
|
|
20
|
+
{ keys: ['B'], label: t.present.shortcutBlack },
|
|
21
|
+
{ keys: ['W'], label: t.present.shortcutWhite },
|
|
22
|
+
{ keys: ['L'], label: t.present.shortcutLaser },
|
|
23
|
+
{ keys: ['P'], label: t.present.shortcutPresenter },
|
|
24
|
+
{ keys: ['?', 'H'], label: t.present.shortcutToggleHelp },
|
|
25
|
+
{ keys: ['Esc'], label: t.present.shortcutCloseExit },
|
|
26
|
+
];
|
|
26
27
|
return (
|
|
27
28
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
28
29
|
<DialogContent container={container ?? undefined} className="max-w-lg sm:max-w-lg">
|
|
29
30
|
<DialogHeader>
|
|
30
|
-
<span className="eyebrow">
|
|
31
|
-
<DialogTitle>
|
|
31
|
+
<span className="eyebrow">{t.present.helpEyebrow}</span>
|
|
32
|
+
<DialogTitle>{t.present.helpTitle}</DialogTitle>
|
|
32
33
|
</DialogHeader>
|
|
33
34
|
<div className="grid grid-cols-1 gap-x-8 gap-y-2 sm:grid-cols-2">
|
|
34
|
-
{
|
|
35
|
+
{shortcuts.map((row) => (
|
|
35
36
|
<div
|
|
36
37
|
key={row.label}
|
|
37
38
|
className="flex items-center justify-between gap-3 border-b border-hairline py-1.5 last:border-0"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
2
3
|
import { cn } from '@/lib/utils';
|
|
3
4
|
import type { DesignSystem } from '../../lib/design';
|
|
4
5
|
import type { Page } from '../../lib/sdk';
|
|
@@ -17,15 +18,11 @@ type Props = {
|
|
|
17
18
|
onSelect: (index: number) => void;
|
|
18
19
|
};
|
|
19
20
|
|
|
20
|
-
/**
|
|
21
|
-
* Full-screen grid of slide thumbnails. Reuses SlideCanvas at fixed scale
|
|
22
|
-
* so each preview is rendered with the slide's design tokens but with
|
|
23
|
-
* motion frozen. Arrow keys move focus; Enter/click jumps and closes.
|
|
24
|
-
*/
|
|
25
21
|
export function PresentOverviewGrid({ pages, design, open, current, onClose, onSelect }: Props) {
|
|
26
22
|
const [focused, setFocused] = useState(current);
|
|
27
23
|
const gridRef = useRef<HTMLDivElement>(null);
|
|
28
24
|
const focusedRef = useRef<HTMLButtonElement | null>(null);
|
|
25
|
+
const t = useLocale();
|
|
29
26
|
|
|
30
27
|
// biome-ignore lint/correctness/useExhaustiveDependencies: only re-sync on open transition
|
|
31
28
|
useEffect(() => {
|
|
@@ -88,11 +85,11 @@ export function PresentOverviewGrid({ pages, design, open, current, onClose, onS
|
|
|
88
85
|
<div
|
|
89
86
|
role="dialog"
|
|
90
87
|
aria-modal="true"
|
|
91
|
-
aria-label=
|
|
88
|
+
aria-label={t.present.overviewDialogAria}
|
|
92
89
|
className="absolute inset-0 z-50 flex flex-col bg-black/95 backdrop-blur-sm"
|
|
93
90
|
>
|
|
94
91
|
<div className="flex shrink-0 items-baseline justify-between px-8 pt-6 pb-3">
|
|
95
|
-
<span className="eyebrow text-white/55">
|
|
92
|
+
<span className="eyebrow text-white/55">{t.present.overviewEyebrow}</span>
|
|
96
93
|
<span className="font-mono text-[11px] text-white/55 tabular-nums">
|
|
97
94
|
{(focused + 1).toString().padStart(2, '0')} · {pages.length.toString().padStart(2, '0')}
|
|
98
95
|
</span>
|
|
@@ -118,7 +115,7 @@ export function PresentOverviewGrid({ pages, design, open, current, onClose, onS
|
|
|
118
115
|
onClose();
|
|
119
116
|
}}
|
|
120
117
|
onMouseEnter={() => setFocused(i)}
|
|
121
|
-
aria-label={
|
|
118
|
+
aria-label={format(t.present.overviewGoToAria, { n: i + 1 })}
|
|
122
119
|
aria-current={isCurrent ? 'true' : undefined}
|
|
123
120
|
className={cn(
|
|
124
121
|
'group/thumb flex flex-col items-start gap-2 rounded-[6px] p-1.5 outline-none transition-colors',
|
|
@@ -146,7 +143,7 @@ export function PresentOverviewGrid({ pages, design, open, current, onClose, onS
|
|
|
146
143
|
aria-hidden
|
|
147
144
|
className="pointer-events-none absolute top-1.5 right-1.5 rounded-[3px] bg-[var(--brand,#ef4444)] px-1.5 py-0.5 font-mono text-[9.5px] tracking-[0.06em] uppercase text-white"
|
|
148
145
|
>
|
|
149
|
-
|
|
146
|
+
{t.present.nowBadge}
|
|
150
147
|
</span>
|
|
151
148
|
)}
|
|
152
149
|
</div>
|
|
@@ -20,16 +20,9 @@ type Handler = (msg: PresenterCommand) => void;
|
|
|
20
20
|
|
|
21
21
|
const SUPPORTED = typeof window !== 'undefined' && typeof BroadcastChannel !== 'undefined';
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
* open in different tabs do not cross-talk. Falls back to no-op when the
|
|
27
|
-
* API is missing (older browsers, SSR).
|
|
28
|
-
*
|
|
29
|
-
* The channel is owned by the effect (not useMemo) so React 18 StrictMode's
|
|
30
|
-
* double-invoke creates a fresh channel on the second mount instead of
|
|
31
|
-
* leaving a closed one behind that throws on the next `send()`.
|
|
32
|
-
*/
|
|
23
|
+
// Channel ownership lives in the effect (not useMemo) so StrictMode's
|
|
24
|
+
// double-invoke produces a fresh channel on remount rather than leaving a
|
|
25
|
+
// closed one behind that throws on the next send().
|
|
33
26
|
export function usePresenterChannel(slideId: string, onMessage?: Handler) {
|
|
34
27
|
const onMessageRef = useRef(onMessage);
|
|
35
28
|
onMessageRef.current = onMessage;
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from '@/components/ui/dropdown-menu';
|
|
9
9
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
10
10
|
import type { Folder, FolderIcon } from '@/lib/sdk';
|
|
11
|
+
import { useLocale } from '@/lib/use-locale';
|
|
11
12
|
import { cn } from '@/lib/utils';
|
|
12
13
|
import { IconPicker } from './icon-picker';
|
|
13
14
|
|
|
@@ -86,6 +87,7 @@ export function FolderItem({
|
|
|
86
87
|
const dragDepth = useRef(0);
|
|
87
88
|
const [draftName, setDraftName] = useState(row.kind === 'folder' ? row.folder.name : '');
|
|
88
89
|
const slideDragActive = useSlideDragActive();
|
|
90
|
+
const t = useLocale();
|
|
89
91
|
|
|
90
92
|
const isSlideDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(SLIDE_DND_MIME);
|
|
91
93
|
const handleDragEnter = (e: React.DragEvent) => {
|
|
@@ -114,7 +116,7 @@ export function FolderItem({
|
|
|
114
116
|
|
|
115
117
|
const icon =
|
|
116
118
|
row.kind === 'draft' ? ({ type: 'emoji', value: '📝' } satisfies FolderIcon) : row.folder.icon;
|
|
117
|
-
const label = row.kind === 'draft' ?
|
|
119
|
+
const label = row.kind === 'draft' ? t.home.draft : row.folder.name;
|
|
118
120
|
|
|
119
121
|
const commitRename = () => {
|
|
120
122
|
if (row.kind !== 'folder') return;
|
|
@@ -128,8 +130,6 @@ export function FolderItem({
|
|
|
128
130
|
<div
|
|
129
131
|
className={cn(
|
|
130
132
|
'group relative flex items-center gap-2.5 rounded-[5px] px-2 py-[5px] text-[12.5px] transition-colors',
|
|
131
|
-
// Editorial selected state: subtle warm tint + a thin vermillion
|
|
132
|
-
// ink-mark on the leading edge. Avoids the heavy "filled pill" look.
|
|
133
133
|
selected
|
|
134
134
|
? 'bg-muted text-foreground before:absolute before:inset-y-1.5 before:-left-0.5 before:w-[2px] before:rounded-full before:bg-brand'
|
|
135
135
|
: 'text-foreground/70 hover:bg-muted/60 hover:text-foreground',
|
|
@@ -148,7 +148,7 @@ export function FolderItem({
|
|
|
148
148
|
<button
|
|
149
149
|
type="button"
|
|
150
150
|
className="flex size-5 shrink-0 items-center justify-center rounded transition-transform hover:scale-110"
|
|
151
|
-
aria-label=
|
|
151
|
+
aria-label={t.home.changeIcon}
|
|
152
152
|
onClick={(e) => e.stopPropagation()}
|
|
153
153
|
>
|
|
154
154
|
<FolderIconChip icon={icon} />
|
|
@@ -185,7 +185,14 @@ export function FolderItem({
|
|
|
185
185
|
</button>
|
|
186
186
|
)}
|
|
187
187
|
|
|
188
|
-
<span
|
|
188
|
+
<span
|
|
189
|
+
className={cn(
|
|
190
|
+
'folio ml-auto shrink-0 transition-opacity',
|
|
191
|
+
row.kind === 'folder' &&
|
|
192
|
+
import.meta.env.DEV &&
|
|
193
|
+
'group-hover:opacity-0 group-has-[[aria-expanded=true]]:opacity-0',
|
|
194
|
+
)}
|
|
195
|
+
>
|
|
189
196
|
{count.toString().padStart(2, '0')}
|
|
190
197
|
</span>
|
|
191
198
|
|
|
@@ -195,8 +202,8 @@ export function FolderItem({
|
|
|
195
202
|
<button
|
|
196
203
|
type="button"
|
|
197
204
|
onClick={(e) => e.stopPropagation()}
|
|
198
|
-
className="size-5
|
|
199
|
-
aria-label=
|
|
205
|
+
className="absolute right-2 top-1/2 size-5 -translate-y-1/2 rounded opacity-0 transition-opacity hover:bg-foreground/10 group-hover:opacity-100 aria-expanded:opacity-100"
|
|
206
|
+
aria-label={t.home.folderActions}
|
|
200
207
|
>
|
|
201
208
|
<MoreHorizontal className="mx-auto size-3.5" />
|
|
202
209
|
</button>
|
|
@@ -209,11 +216,11 @@ export function FolderItem({
|
|
|
209
216
|
}}
|
|
210
217
|
>
|
|
211
218
|
<Pencil />
|
|
212
|
-
|
|
219
|
+
{t.common.rename}
|
|
213
220
|
</DropdownMenuItem>
|
|
214
221
|
<DropdownMenuItem variant="destructive" onSelect={() => row.onDelete()}>
|
|
215
222
|
<Trash2 />
|
|
216
|
-
|
|
223
|
+
{t.common.delete}
|
|
217
224
|
</DropdownMenuItem>
|
|
218
225
|
</DropdownMenuContent>
|
|
219
226
|
</DropdownMenu>
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import EmojiPicker, { EmojiStyle, Theme } from 'emoji-picker-react';
|
|
2
2
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
3
3
|
import type { FolderIcon } from '@/lib/sdk';
|
|
4
|
+
import { useLocale } from '@/lib/use-locale';
|
|
4
5
|
|
|
5
|
-
// Editorial palette — restrained warm/earth tones, no shadcn defaults
|
|
6
|
-
// (no #8b5cf6 violet, no #3b82f6 blue, etc.). Picked to coexist with the
|
|
7
|
-
// vermillion brand accent without shouting over it.
|
|
8
6
|
export const PRESET_COLORS = [
|
|
9
7
|
'#c0392b', // vermillion
|
|
10
8
|
'#b8743e', // ochre
|
|
@@ -23,11 +21,12 @@ export function IconPicker({
|
|
|
23
21
|
value: FolderIcon;
|
|
24
22
|
onChange: (icon: FolderIcon) => void;
|
|
25
23
|
}) {
|
|
24
|
+
const t = useLocale();
|
|
26
25
|
return (
|
|
27
26
|
<Tabs defaultValue={value.type} className="w-[320px]">
|
|
28
27
|
<TabsList className="w-full">
|
|
29
|
-
<TabsTrigger value="emoji">
|
|
30
|
-
<TabsTrigger value="color">
|
|
28
|
+
<TabsTrigger value="emoji">{t.home.iconEmojiTab}</TabsTrigger>
|
|
29
|
+
<TabsTrigger value="color">{t.home.iconColorTab}</TabsTrigger>
|
|
31
30
|
</TabsList>
|
|
32
31
|
|
|
33
32
|
<TabsContent value="emoji">
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { Plus } from 'lucide-react';
|
|
2
|
-
import { useState } from 'react';
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { toast } from 'sonner';
|
|
3
4
|
import { ThemeToggle } from '@/components/theme-toggle';
|
|
5
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
4
6
|
import type { Folder, FolderIcon } from '@/lib/sdk';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
8
|
+
import { FolderIconChip, FolderItem } from './folder-item';
|
|
9
|
+
import { IconPicker, PRESET_COLORS } from './icon-picker';
|
|
7
10
|
|
|
8
11
|
export const DRAFT_ID = 'draft';
|
|
9
12
|
|
|
@@ -32,25 +35,72 @@ export function Sidebar({
|
|
|
32
35
|
}) {
|
|
33
36
|
const [creating, setCreating] = useState(false);
|
|
34
37
|
const [newName, setNewName] = useState('');
|
|
38
|
+
const [newIcon, setNewIcon] = useState<FolderIcon>(() => ({
|
|
39
|
+
type: 'color',
|
|
40
|
+
value: PRESET_COLORS[0],
|
|
41
|
+
}));
|
|
42
|
+
const [iconOpen, setIconOpen] = useState(false);
|
|
43
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
44
|
+
const t = useLocale();
|
|
35
45
|
|
|
36
|
-
const
|
|
37
|
-
const trimmed = newName.trim();
|
|
38
|
-
if (!trimmed) {
|
|
39
|
-
setCreating(false);
|
|
40
|
-
setNewName('');
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
46
|
+
const startCreating = () => {
|
|
43
47
|
const color = PRESET_COLORS[folders.length % PRESET_COLORS.length];
|
|
44
|
-
|
|
48
|
+
setNewIcon({ type: 'color', value: color });
|
|
45
49
|
setNewName('');
|
|
50
|
+
setCreating(true);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (creating) inputRef.current?.focus();
|
|
55
|
+
}, [creating]);
|
|
56
|
+
|
|
57
|
+
const stateRef = useRef({ name: newName, icon: newIcon, iconOpen });
|
|
58
|
+
stateRef.current = { name: newName, icon: newIcon, iconOpen };
|
|
59
|
+
|
|
60
|
+
const exitCreate = () => {
|
|
46
61
|
setCreating(false);
|
|
62
|
+
setNewName('');
|
|
63
|
+
setIconOpen(false);
|
|
47
64
|
};
|
|
48
65
|
|
|
66
|
+
const commitCreate = async () => {
|
|
67
|
+
const trimmed = stateRef.current.name.trim();
|
|
68
|
+
const icon = stateRef.current.icon;
|
|
69
|
+
if (!trimmed) {
|
|
70
|
+
exitCreate();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
exitCreate();
|
|
74
|
+
try {
|
|
75
|
+
const folder = await onCreate(trimmed, icon);
|
|
76
|
+
toast.success(format(t.home.toastFolderCreated, { name: folder?.name ?? trimmed }));
|
|
77
|
+
} catch {
|
|
78
|
+
toast.error(t.home.toastFolderCreateFailed);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: commitCreate reads latest state via stateRef
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!creating) return;
|
|
85
|
+
const onDown = (e: MouseEvent) => {
|
|
86
|
+
if (stateRef.current.iconOpen) return;
|
|
87
|
+
const target = e.target as HTMLElement | null;
|
|
88
|
+
if (!target) return;
|
|
89
|
+
if (target.closest('[data-folder-create]')) return;
|
|
90
|
+
if (target.closest('[data-radix-popper-content-wrapper]')) return;
|
|
91
|
+
commitCreate();
|
|
92
|
+
};
|
|
93
|
+
document.addEventListener('mousedown', onDown);
|
|
94
|
+
return () => document.removeEventListener('mousedown', onDown);
|
|
95
|
+
}, [creating]);
|
|
96
|
+
|
|
49
97
|
return (
|
|
50
98
|
<aside className="paper relative flex h-full w-[16.5rem] shrink-0 flex-col border-r border-hairline bg-sidebar text-sidebar-foreground">
|
|
51
99
|
<div className="flex items-center justify-between px-4 pt-5 pb-4">
|
|
52
|
-
<h1 className="font-heading text-lg font-bold tracking-tight">
|
|
53
|
-
<
|
|
100
|
+
<h1 className="font-heading text-lg font-bold tracking-tight">{t.home.appTitle}</h1>
|
|
101
|
+
<div className="-mr-1.5">
|
|
102
|
+
<ThemeToggle />
|
|
103
|
+
</div>
|
|
54
104
|
</div>
|
|
55
105
|
|
|
56
106
|
<div className="px-2">
|
|
@@ -64,9 +114,8 @@ export function Sidebar({
|
|
|
64
114
|
</div>
|
|
65
115
|
|
|
66
116
|
<div className="mt-5 flex items-center gap-2 px-4 pb-1.5">
|
|
67
|
-
<span className="eyebrow">
|
|
117
|
+
<span className="eyebrow">{t.home.folders}</span>
|
|
68
118
|
<span className="h-px flex-1 bg-hairline" aria-hidden />
|
|
69
|
-
<span className="folio">{folders.length.toString().padStart(2, '0')}</span>
|
|
70
119
|
</div>
|
|
71
120
|
|
|
72
121
|
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
|
@@ -89,20 +138,33 @@ export function Sidebar({
|
|
|
89
138
|
|
|
90
139
|
{import.meta.env.DEV &&
|
|
91
140
|
(creating ? (
|
|
92
|
-
<div
|
|
93
|
-
|
|
141
|
+
<div
|
|
142
|
+
data-folder-create
|
|
143
|
+
className="mt-1 flex items-center gap-2.5 rounded-[5px] border border-dashed border-foreground/30 bg-card px-2 py-[5px]"
|
|
144
|
+
>
|
|
145
|
+
<Popover open={iconOpen} onOpenChange={setIconOpen}>
|
|
146
|
+
<PopoverTrigger asChild>
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
className="flex size-5 shrink-0 items-center justify-center rounded transition-transform hover:scale-110"
|
|
150
|
+
aria-label={t.home.pickIcon}
|
|
151
|
+
>
|
|
152
|
+
<FolderIconChip icon={newIcon} />
|
|
153
|
+
</button>
|
|
154
|
+
</PopoverTrigger>
|
|
155
|
+
<PopoverContent side="right" align="start" className="w-auto p-2">
|
|
156
|
+
<IconPicker value={newIcon} onChange={setNewIcon} />
|
|
157
|
+
</PopoverContent>
|
|
158
|
+
</Popover>
|
|
94
159
|
<input
|
|
160
|
+
ref={inputRef}
|
|
95
161
|
value={newName}
|
|
96
162
|
onChange={(e) => setNewName(e.target.value)}
|
|
97
|
-
onBlur={commitCreate}
|
|
98
163
|
onKeyDown={(e) => {
|
|
99
164
|
if (e.key === 'Enter') commitCreate();
|
|
100
|
-
if (e.key === 'Escape')
|
|
101
|
-
setCreating(false);
|
|
102
|
-
setNewName('');
|
|
103
|
-
}
|
|
165
|
+
if (e.key === 'Escape') exitCreate();
|
|
104
166
|
}}
|
|
105
|
-
placeholder=
|
|
167
|
+
placeholder={t.home.folderName}
|
|
106
168
|
maxLength={40}
|
|
107
169
|
className="min-w-0 flex-1 bg-transparent text-[12.5px] outline-none placeholder:text-muted-foreground/60"
|
|
108
170
|
/>
|
|
@@ -110,11 +172,11 @@ export function Sidebar({
|
|
|
110
172
|
) : (
|
|
111
173
|
<button
|
|
112
174
|
type="button"
|
|
113
|
-
onClick={
|
|
175
|
+
onClick={startCreating}
|
|
114
176
|
className="mt-1 flex w-full items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12px] text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
|
|
115
177
|
>
|
|
116
178
|
<Plus className="size-3.5" />
|
|
117
|
-
<span>
|
|
179
|
+
<span>{t.home.newFolder}</span>
|
|
118
180
|
</button>
|
|
119
181
|
))}
|
|
120
182
|
</div>
|
|
@@ -5,21 +5,12 @@ import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
|
|
|
5
5
|
|
|
6
6
|
type Props = {
|
|
7
7
|
children: ReactNode;
|
|
8
|
-
/** If set, use this scale directly
|
|
8
|
+
/** If set, use this scale directly. Otherwise fit to container. */
|
|
9
9
|
scale?: number;
|
|
10
|
-
/** Center the canvas within the container (default true). */
|
|
11
10
|
center?: boolean;
|
|
12
|
-
/** Flat mode: no rounded corners or drop shadow. */
|
|
13
11
|
flat?: boolean;
|
|
14
|
-
/** Freeze descendant animations and transitions, useful for thumbnail previews. */
|
|
15
12
|
freezeMotion?: boolean;
|
|
16
13
|
className?: string;
|
|
17
|
-
/**
|
|
18
|
-
* Per-slide design tokens. When set, the matching CSS custom properties
|
|
19
|
-
* are emitted on the canvas root so descendants can use `var(--osd-X)`
|
|
20
|
-
* regardless of which surface (editor, player, thumbnail, export) is
|
|
21
|
-
* rendering them.
|
|
22
|
-
*/
|
|
23
14
|
design?: DesignSystem;
|
|
24
15
|
};
|
|
25
16
|
|
|
@@ -48,7 +48,6 @@ export function DesignProvider({ slideId, children }: { slideId: string; childre
|
|
|
48
48
|
const draftRef = useRef<DesignSystem | null>(null);
|
|
49
49
|
draftRef.current = draft;
|
|
50
50
|
|
|
51
|
-
// Re-seed draft whenever the saved design changes (slide switch, post-save HMR).
|
|
52
51
|
useEffect(() => {
|
|
53
52
|
if (design) setDraft(clone(design));
|
|
54
53
|
}, [design]);
|
|
@@ -99,11 +98,8 @@ export function DesignProvider({ slideId, children }: { slideId: string; childre
|
|
|
99
98
|
});
|
|
100
99
|
}, [history]);
|
|
101
100
|
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
// emits its own CSS variables inline on the canvas root (so home thumbnails,
|
|
105
|
-
// player, and exports work without any extra plumbing). Inline styles win
|
|
106
|
-
// against external rules, so the overlay must use `!important` to override.
|
|
101
|
+
// SlideCanvas emits its design vars inline on the canvas root, so a draft
|
|
102
|
+
// overlay must use `!important` to outrank those inline styles.
|
|
107
103
|
const previewCss = useMemo(() => {
|
|
108
104
|
if (!dirty || !draft) return '';
|
|
109
105
|
const lines = Object.entries(designToCssVars(draft))
|