@open-slide/core 1.0.6 → 1.2.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-4wOJF1l4.js → build-6BeQ3cxb.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-evLWCV1-.js → config-AxZ5OE1u.js} +772 -201
- package/dist/{config-D2y1AXaN.d.ts → config-CtT8K4VF.d.ts} +1 -1
- package/dist/{dev-BUr0S-Ij.js → dev-C9eLmUEq.js} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +136 -24
- package/dist/{preview-DP_gIphz.js → preview-Cunm-f4i.js} +1 -1
- package/dist/{types-BVvl_xup.d.ts → types-CRHIeoNq.d.ts} +37 -4
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +5 -1
- package/skills/current-slide/SKILL.md +110 -0
- package/skills/slide-authoring/SKILL.md +48 -1
- package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
- package/src/app/components/inspector/inspect-overlay.tsx +17 -2
- package/src/app/components/inspector/inspector-panel.tsx +90 -26
- package/src/app/components/inspector/inspector-provider.tsx +136 -1
- package/src/app/components/notes-drawer.tsx +117 -0
- package/src/app/components/player.tsx +26 -8
- package/src/app/components/present/overview-grid.tsx +2 -2
- package/src/app/components/present/use-idle.ts +6 -4
- package/src/app/components/style-panel/design-provider.tsx +13 -0
- package/src/app/components/style-panel/style-panel.tsx +23 -11
- package/src/app/components/thumbnail-rail.tsx +317 -55
- package/src/app/components/ui/context-menu.tsx +237 -0
- package/src/app/lib/design-presets.ts +94 -0
- package/src/app/lib/inspector/use-notes.ts +134 -0
- package/src/app/routes/home.tsx +34 -12
- package/src/app/routes/presenter.tsx +27 -24
- package/src/app/routes/slide.tsx +238 -51
- package/src/locale/en.ts +35 -4
- package/src/locale/ja.ts +35 -4
- package/src/locale/types.ts +38 -4
- package/src/locale/zh-cn.ts +35 -4
- package/src/locale/zh-tw.ts +35 -4
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { ChevronDown, ChevronUp, NotebookPen } from 'lucide-react';
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { PANEL_TRANSITION_MS, usePanelMount } from '@/components/panel/panel-shell';
|
|
4
|
+
import { useNotes } from '@/lib/inspector/use-notes';
|
|
5
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = 'open-slide:notes-drawer-open';
|
|
9
|
+
const DRAWER_CONTENT_H = 166;
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
slideId: string;
|
|
13
|
+
index: number;
|
|
14
|
+
total: number;
|
|
15
|
+
initial: string | undefined;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function NotesDrawer({ slideId, index, total, initial }: Props) {
|
|
19
|
+
const t = useLocale();
|
|
20
|
+
const [open, setOpen] = useState(() => {
|
|
21
|
+
if (typeof window === 'undefined') return false;
|
|
22
|
+
return window.localStorage.getItem(STORAGE_KEY) === '1';
|
|
23
|
+
});
|
|
24
|
+
const { value, setValue, status, flush } = useNotes(slideId, index, initial);
|
|
25
|
+
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
26
|
+
const { mounted, animVisible } = usePanelMount(open);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (typeof window === 'undefined') return;
|
|
30
|
+
window.localStorage.setItem(STORAGE_KEY, open ? '1' : '0');
|
|
31
|
+
}, [open]);
|
|
32
|
+
|
|
33
|
+
const statusLabel = (() => {
|
|
34
|
+
switch (status.kind) {
|
|
35
|
+
case 'saving':
|
|
36
|
+
return t.notesDrawer.statusSaving;
|
|
37
|
+
case 'saved':
|
|
38
|
+
return t.notesDrawer.statusSaved;
|
|
39
|
+
case 'error':
|
|
40
|
+
return format(t.notesDrawer.statusError, { msg: status.message });
|
|
41
|
+
default:
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
})();
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<aside
|
|
48
|
+
data-notes-drawer
|
|
49
|
+
className="hidden shrink-0 border-t border-hairline bg-sidebar/85 backdrop-blur md:block"
|
|
50
|
+
>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
onClick={() => {
|
|
54
|
+
setOpen((o) => {
|
|
55
|
+
if (o) void flush();
|
|
56
|
+
return !o;
|
|
57
|
+
});
|
|
58
|
+
}}
|
|
59
|
+
className="flex h-9 w-full items-center gap-2 px-3 text-[12px] text-foreground/80 hover:bg-muted/40"
|
|
60
|
+
aria-expanded={open}
|
|
61
|
+
>
|
|
62
|
+
<NotebookPen className="size-3.5 text-muted-foreground" />
|
|
63
|
+
<span className="font-medium">{t.notesDrawer.toggle}</span>
|
|
64
|
+
<span className="font-mono text-[11px] text-muted-foreground">
|
|
65
|
+
{format(t.notesDrawer.pageLabel, { n: index + 1, total })}
|
|
66
|
+
</span>
|
|
67
|
+
<span
|
|
68
|
+
className={cn(
|
|
69
|
+
'ml-auto truncate text-[11px]',
|
|
70
|
+
status.kind === 'error' ? 'text-destructive' : 'text-muted-foreground',
|
|
71
|
+
)}
|
|
72
|
+
aria-live="polite"
|
|
73
|
+
>
|
|
74
|
+
{statusLabel}
|
|
75
|
+
</span>
|
|
76
|
+
{open ? (
|
|
77
|
+
<ChevronDown className="size-3.5 text-muted-foreground" />
|
|
78
|
+
) : (
|
|
79
|
+
<ChevronUp className="size-3.5 text-muted-foreground" />
|
|
80
|
+
)}
|
|
81
|
+
</button>
|
|
82
|
+
{mounted && (
|
|
83
|
+
<div
|
|
84
|
+
className="overflow-hidden border-t border-hairline transition-[height] ease-out"
|
|
85
|
+
style={{
|
|
86
|
+
height: animVisible ? DRAWER_CONTENT_H : 0,
|
|
87
|
+
transitionDuration: `${PANEL_TRANSITION_MS}ms`,
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<div className="px-3 py-2">
|
|
91
|
+
<textarea
|
|
92
|
+
ref={textareaRef}
|
|
93
|
+
value={value}
|
|
94
|
+
onChange={(e) => setValue(e.target.value)}
|
|
95
|
+
onBlur={() => {
|
|
96
|
+
void flush();
|
|
97
|
+
}}
|
|
98
|
+
onKeyDown={(e) => {
|
|
99
|
+
if (e.key === 'Escape') {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
textareaRef.current?.blur();
|
|
102
|
+
} else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
void flush();
|
|
105
|
+
}
|
|
106
|
+
}}
|
|
107
|
+
placeholder={t.notesDrawer.placeholder}
|
|
108
|
+
rows={6}
|
|
109
|
+
spellCheck
|
|
110
|
+
className="block h-[150px] w-full resize-none rounded-[6px] border border-border bg-card px-3 py-2 text-[13px] leading-relaxed text-card-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
</aside>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -58,8 +58,12 @@ export function Player({
|
|
|
58
58
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
59
59
|
const [blackout, setBlackout] = useState<'black' | 'white' | null>(null);
|
|
60
60
|
const [laser, setLaser] = useState(false);
|
|
61
|
+
const [keyboardDriven, setKeyboardDriven] = useState(false);
|
|
61
62
|
const [startedAt] = useState(() => Date.now());
|
|
62
63
|
|
|
64
|
+
const canPrev = index > 0;
|
|
65
|
+
const canNext = index < pages.length - 1;
|
|
66
|
+
|
|
63
67
|
const goPrev = useCallback(() => {
|
|
64
68
|
if (index > 0) onIndexChange(index - 1);
|
|
65
69
|
}, [index, onIndexChange]);
|
|
@@ -72,8 +76,8 @@ export function Player({
|
|
|
72
76
|
useWheelPageNavigation({
|
|
73
77
|
ref: rootRef,
|
|
74
78
|
enabled: !overlayActive,
|
|
75
|
-
canPrev
|
|
76
|
-
canNext
|
|
79
|
+
canPrev,
|
|
80
|
+
canNext,
|
|
77
81
|
onPrev: goPrev,
|
|
78
82
|
onNext: goNext,
|
|
79
83
|
});
|
|
@@ -181,19 +185,23 @@ export function Player({
|
|
|
181
185
|
|
|
182
186
|
if (isNext) {
|
|
183
187
|
e.preventDefault();
|
|
188
|
+
setKeyboardDriven(true);
|
|
184
189
|
goNext();
|
|
185
190
|
return;
|
|
186
191
|
}
|
|
187
192
|
if (isPrev) {
|
|
188
193
|
e.preventDefault();
|
|
194
|
+
setKeyboardDriven(true);
|
|
189
195
|
goPrev();
|
|
190
196
|
return;
|
|
191
197
|
}
|
|
192
198
|
if (e.key === 'Home') {
|
|
199
|
+
setKeyboardDriven(true);
|
|
193
200
|
onIndexChange(0);
|
|
194
201
|
return;
|
|
195
202
|
}
|
|
196
203
|
if (e.key === 'End') {
|
|
204
|
+
setKeyboardDriven(true);
|
|
197
205
|
onIndexChange(pages.length - 1);
|
|
198
206
|
return;
|
|
199
207
|
}
|
|
@@ -246,7 +254,16 @@ export function Player({
|
|
|
246
254
|
const pointerNearBottom = usePointerNearBottom(BAR_HOTZONE_PX, controls && !overlayActive);
|
|
247
255
|
const chromeVisible = pointerNearBottom || overlayActive;
|
|
248
256
|
const idle = useIdle(IDLE_HIDE_MS, controls && !overlayActive);
|
|
249
|
-
|
|
257
|
+
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
if (!keyboardDriven) return;
|
|
260
|
+
const clear = () => setKeyboardDriven(false);
|
|
261
|
+
window.addEventListener('mousemove', clear, { passive: true });
|
|
262
|
+
return () => window.removeEventListener('mousemove', clear);
|
|
263
|
+
}, [keyboardDriven]);
|
|
264
|
+
|
|
265
|
+
const hideCursor =
|
|
266
|
+
controls && (laser || keyboardDriven || (idle && !overlayActive && !pointerNearBottom));
|
|
250
267
|
|
|
251
268
|
const PageComp = pages[index];
|
|
252
269
|
|
|
@@ -255,7 +272,8 @@ export function Player({
|
|
|
255
272
|
ref={setRoot}
|
|
256
273
|
className={cn(
|
|
257
274
|
'relative flex h-dvh w-screen items-center justify-center bg-black',
|
|
258
|
-
|
|
275
|
+
controls && 'select-none',
|
|
276
|
+
controls && (hideCursor ? 'cursor-none' : 'cursor-default'),
|
|
259
277
|
)}
|
|
260
278
|
>
|
|
261
279
|
<SlideCanvas flat design={design}>
|
|
@@ -266,15 +284,15 @@ export function Player({
|
|
|
266
284
|
type="button"
|
|
267
285
|
aria-label="Previous page"
|
|
268
286
|
onClick={goPrev}
|
|
269
|
-
disabled={
|
|
270
|
-
className=
|
|
287
|
+
disabled={!canPrev}
|
|
288
|
+
className={cn('absolute inset-y-0 left-0 z-10 w-[30%]', hideCursor && 'cursor-none')}
|
|
271
289
|
/>
|
|
272
290
|
<button
|
|
273
291
|
type="button"
|
|
274
292
|
aria-label="Next page"
|
|
275
293
|
onClick={goNext}
|
|
276
|
-
disabled={
|
|
277
|
-
className=
|
|
294
|
+
disabled={!canNext}
|
|
295
|
+
className={cn('absolute inset-y-0 right-0 z-10 w-[30%]', hideCursor && 'cursor-none')}
|
|
278
296
|
/>
|
|
279
297
|
|
|
280
298
|
{controls && (
|
|
@@ -96,9 +96,9 @@ export function PresentOverviewGrid({ pages, design, open, current, onClose, onS
|
|
|
96
96
|
</div>
|
|
97
97
|
<div ref={gridRef} className="min-h-0 flex-1 overflow-auto px-8 pb-8">
|
|
98
98
|
<div
|
|
99
|
-
className="grid gap-5"
|
|
99
|
+
className="grid justify-center gap-5"
|
|
100
100
|
style={{
|
|
101
|
-
gridTemplateColumns: `repeat(auto-fill,
|
|
101
|
+
gridTemplateColumns: `repeat(auto-fill, ${THUMB_W}px)`,
|
|
102
102
|
}}
|
|
103
103
|
>
|
|
104
104
|
{pages.map((PageComp, i) => {
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Reports whether the user has been idle (no pointer /
|
|
5
|
-
*
|
|
4
|
+
* Reports whether the user has been idle (no pointer / touch input) for at
|
|
5
|
+
* least `delayMs`. Resets on any pointer-related event. The hook starts in
|
|
6
6
|
* the non-idle state so freshly-mounted UI is visible while the user
|
|
7
7
|
* orients themselves.
|
|
8
8
|
*
|
|
9
|
+
* Keyboard input is intentionally excluded — during a talk the presenter
|
|
10
|
+
* drives slides with arrow keys, and we want the cursor to stay hidden
|
|
11
|
+
* while they do.
|
|
12
|
+
*
|
|
9
13
|
* Pass `enabled = false` to short-circuit (useful when the player is
|
|
10
14
|
* paused on an overlay and we don't want to hide chrome behind it).
|
|
11
15
|
*/
|
|
@@ -27,14 +31,12 @@ export function useIdle(delayMs: number, enabled = true) {
|
|
|
27
31
|
const opts = { passive: true } as const;
|
|
28
32
|
window.addEventListener('mousemove', reset, opts);
|
|
29
33
|
window.addEventListener('mousedown', reset, opts);
|
|
30
|
-
window.addEventListener('keydown', reset);
|
|
31
34
|
window.addEventListener('touchstart', reset, opts);
|
|
32
35
|
window.addEventListener('wheel', reset, opts);
|
|
33
36
|
return () => {
|
|
34
37
|
if (timer) clearTimeout(timer);
|
|
35
38
|
window.removeEventListener('mousemove', reset);
|
|
36
39
|
window.removeEventListener('mousedown', reset);
|
|
37
|
-
window.removeEventListener('keydown', reset);
|
|
38
40
|
window.removeEventListener('touchstart', reset);
|
|
39
41
|
window.removeEventListener('wheel', reset);
|
|
40
42
|
};
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { toast } from 'sonner';
|
|
12
12
|
import { useHistory } from '@/components/history-provider';
|
|
13
13
|
import { type DesignSystem, defaultDesign, designToCssVars } from '../../lib/design';
|
|
14
|
+
import { shuffleDesign } from '../../lib/design-presets';
|
|
14
15
|
import { useDesign as useDesignFetch } from './use-design';
|
|
15
16
|
|
|
16
17
|
type DesignCtx = {
|
|
@@ -26,6 +27,7 @@ type DesignCtx = {
|
|
|
26
27
|
commit: () => Promise<void>;
|
|
27
28
|
discard: () => void;
|
|
28
29
|
resetToDefaults: () => void;
|
|
30
|
+
shuffle: () => void;
|
|
29
31
|
};
|
|
30
32
|
|
|
31
33
|
const Ctx = createContext<DesignCtx | null>(null);
|
|
@@ -98,6 +100,16 @@ export function DesignProvider({ slideId, children }: { slideId: string; childre
|
|
|
98
100
|
});
|
|
99
101
|
}, [history]);
|
|
100
102
|
|
|
103
|
+
const shuffle = useCallback(() => {
|
|
104
|
+
const prev = draftRef.current;
|
|
105
|
+
const next = clone(shuffleDesign(prev));
|
|
106
|
+
setDraft(next);
|
|
107
|
+
history.record({
|
|
108
|
+
undo: () => setDraft(prev),
|
|
109
|
+
redo: () => setDraft(next),
|
|
110
|
+
});
|
|
111
|
+
}, [history]);
|
|
112
|
+
|
|
101
113
|
// SlideCanvas emits its design vars inline on the canvas root, so a draft
|
|
102
114
|
// overlay must use `!important` to outrank those inline styles.
|
|
103
115
|
const previewCss = useMemo(() => {
|
|
@@ -121,6 +133,7 @@ export function DesignProvider({ slideId, children }: { slideId: string; childre
|
|
|
121
133
|
commit,
|
|
122
134
|
discard,
|
|
123
135
|
resetToDefaults,
|
|
136
|
+
shuffle,
|
|
124
137
|
};
|
|
125
138
|
|
|
126
139
|
return (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Palette, X } from 'lucide-react';
|
|
1
|
+
import { Palette, Shuffle, X } from 'lucide-react';
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
3
|
import { Field, NumberField, Section } from '@/components/panel/panel-fields';
|
|
4
4
|
import { PanelShell, usePanelMount } from '@/components/panel/panel-shell';
|
|
@@ -28,7 +28,7 @@ type DesignPanelProps = {
|
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
31
|
-
const { draft, exists, warning, loaded, dirty, update } = useDesignPanelState();
|
|
31
|
+
const { draft, exists, warning, loaded, dirty, update, shuffle } = useDesignPanelState();
|
|
32
32
|
const { mounted, animVisible } = usePanelMount(open);
|
|
33
33
|
const t = useLocale();
|
|
34
34
|
|
|
@@ -60,15 +60,27 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
60
60
|
/>
|
|
61
61
|
)}
|
|
62
62
|
</div>
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
63
|
+
<div className="flex items-center gap-0.5">
|
|
64
|
+
<Button
|
|
65
|
+
variant="ghost"
|
|
66
|
+
size="icon-sm"
|
|
67
|
+
className="text-muted-foreground hover:text-foreground"
|
|
68
|
+
onClick={shuffle}
|
|
69
|
+
aria-label={t.stylePanel.shuffleAria}
|
|
70
|
+
title={t.stylePanel.shuffleTitle}
|
|
71
|
+
>
|
|
72
|
+
<Shuffle className="size-3.5" />
|
|
73
|
+
</Button>
|
|
74
|
+
<Button
|
|
75
|
+
variant="ghost"
|
|
76
|
+
size="icon-sm"
|
|
77
|
+
className="text-muted-foreground hover:text-foreground"
|
|
78
|
+
onClick={onClose}
|
|
79
|
+
aria-label={t.stylePanel.closePanelAria}
|
|
80
|
+
>
|
|
81
|
+
<X className="size-3.5" />
|
|
82
|
+
</Button>
|
|
83
|
+
</div>
|
|
72
84
|
</>
|
|
73
85
|
}
|
|
74
86
|
banner={
|