@open-slide/core 0.0.10 → 0.0.12
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-DHiRlpjn.js → build-aiY_8kwE.js} +2 -1
- package/dist/cli/bin.js +43 -4
- package/dist/{config-LZM903FE.js → config-CVqRAagl.js} +592 -63
- package/dist/design-CROQh0AA.js +35 -0
- package/dist/{dev-B3JzCYn7.js → dev-R2we2iaF.js} +2 -1
- package/dist/index.d.ts +55 -4
- package/dist/index.js +110 -1
- package/dist/{preview-UikovHEt.js → preview-CU4zSyGp.js} +2 -1
- package/dist/sync-3oqN1WyK.js +139 -0
- package/dist/sync-B4eLo2H6.js +3 -0
- package/dist/vite/index.d.ts +1 -1
- package/dist/vite/index.js +2 -1
- package/package.json +2 -1
- package/skills/apply-comments/SKILL.md +83 -0
- package/skills/create-slide/SKILL.md +81 -0
- package/skills/create-theme/SKILL.md +194 -0
- package/skills/slide-authoring/SKILL.md +288 -0
- package/src/app/{App.tsx → app.tsx} +8 -6
- package/src/app/components/{AssetView.tsx → asset-view.tsx} +41 -33
- package/src/app/components/{ClickNavZones.tsx → click-nav-zones.tsx} +1 -1
- package/src/app/components/history-provider.tsx +120 -0
- package/src/app/components/image-placeholder.tsx +121 -0
- package/src/app/components/inspector/{CommentWidget.tsx → comment-widget.tsx} +1 -1
- package/src/app/components/inspector/{InspectOverlay.tsx → inspect-overlay.tsx} +1 -1
- package/src/app/components/inspector/{InspectorPanel.tsx → inspector-panel.tsx} +164 -212
- package/src/app/components/inspector/{InspectorProvider.tsx → inspector-provider.tsx} +186 -18
- package/src/app/components/inspector/save-bar.tsx +47 -0
- package/src/app/components/panel/panel-fields.tsx +60 -0
- package/src/app/components/panel/panel-shell.tsx +78 -0
- package/src/app/components/panel/save-card.tsx +139 -0
- package/src/app/components/pdf-progress-toast.tsx +25 -0
- package/src/app/components/player.tsx +341 -0
- package/src/app/components/present/blackout-overlay.tsx +18 -0
- package/src/app/components/present/control-bar.tsx +204 -0
- package/src/app/components/present/help-overlay.tsx +56 -0
- package/src/app/components/present/jump-input.tsx +74 -0
- package/src/app/components/present/laser-pointer.tsx +40 -0
- package/src/app/components/present/overview-grid.tsx +184 -0
- package/src/app/components/present/progress-bar.tsx +26 -0
- package/src/app/components/present/use-idle.ts +44 -0
- package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
- package/src/app/components/present/use-presenter-channel.ts +71 -0
- package/src/app/components/present/use-touch-swipe.ts +63 -0
- package/src/app/components/sidebar/{FolderItem.tsx → folder-item.tsx} +62 -27
- package/src/app/components/sidebar/{IconPicker.tsx → icon-picker.tsx} +13 -10
- package/src/app/components/sidebar/{Sidebar.tsx → sidebar.tsx} +40 -34
- package/src/app/components/{SlideCanvas.tsx → slide-canvas.tsx} +35 -10
- package/src/app/components/style-panel/design-provider.tsx +139 -0
- package/src/app/components/style-panel/style-panel.tsx +326 -0
- package/src/app/components/style-panel/use-design.ts +112 -0
- package/src/app/components/theme-toggle.tsx +57 -0
- package/src/app/components/thumbnail-rail.tsx +151 -0
- package/src/app/components/ui/button.tsx +51 -19
- package/src/app/components/ui/card.tsx +1 -1
- package/src/app/components/ui/dialog.tsx +25 -9
- package/src/app/components/ui/dropdown-menu.tsx +29 -12
- package/src/app/components/ui/input.tsx +13 -9
- package/src/app/components/ui/popover.tsx +5 -2
- package/src/app/components/ui/progress.tsx +2 -2
- package/src/app/components/ui/select.tsx +11 -5
- package/src/app/components/ui/separator.tsx +1 -1
- package/src/app/components/ui/slider.tsx +4 -4
- package/src/app/components/ui/sonner.tsx +11 -1
- package/src/app/components/ui/tabs.tsx +6 -6
- package/src/app/components/ui/textarea.tsx +11 -7
- package/src/app/components/ui/toggle-group.tsx +2 -2
- package/src/app/components/ui/toggle.tsx +6 -6
- package/src/app/components/ui/tooltip.tsx +5 -2
- package/src/app/lib/export-html.ts +10 -1
- package/src/app/lib/export-pdf.ts +7 -0
- package/src/app/lib/folders.ts +1 -1
- package/src/app/lib/inspector/{useEditor.ts → use-editor.ts} +2 -1
- package/src/app/lib/sdk.ts +5 -0
- package/src/app/lib/slides.ts +1 -1
- package/src/app/lib/utils.ts +1 -1
- package/src/app/main.tsx +5 -2
- package/src/app/routes/{Home.tsx → home.tsx} +266 -97
- package/src/app/routes/presenter.tsx +400 -0
- package/src/app/routes/slide.tsx +519 -0
- package/src/app/styles.css +338 -67
- package/src/app/components/PdfProgressToast.tsx +0 -23
- package/src/app/components/Player.tsx +0 -100
- package/src/app/components/ThumbnailRail.tsx +0 -68
- package/src/app/components/inspector/SaveBar.tsx +0 -77
- package/src/app/routes/Slide.tsx +0 -478
- /package/dist/{config-SXL5qIl6.d.ts → config-DweCbRkQ.d.ts} +0 -0
- /package/src/app/lib/inspector/{useComments.ts → use-comments.ts} +0 -0
- /package/src/app/lib/{useWheelPageNavigation.ts → use-wheel-page-navigation.ts} +0 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
type Pos = { x: number; y: number } | null;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Soft red dot that follows the cursor when the laser tool is active.
|
|
7
|
+
* Hides the system cursor on the player root via a `cursor-none` class
|
|
8
|
+
* applied by the parent.
|
|
9
|
+
*/
|
|
10
|
+
export function PresentLaserPointer({ enabled }: { enabled: boolean }) {
|
|
11
|
+
const [pos, setPos] = useState<Pos>(null);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!enabled) {
|
|
15
|
+
setPos(null);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const onMove = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
|
|
19
|
+
window.addEventListener('mousemove', onMove, { passive: true });
|
|
20
|
+
return () => window.removeEventListener('mousemove', onMove);
|
|
21
|
+
}, [enabled]);
|
|
22
|
+
|
|
23
|
+
if (!enabled || !pos) return null;
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
aria-hidden
|
|
27
|
+
className="pointer-events-none fixed z-[60]"
|
|
28
|
+
style={{
|
|
29
|
+
left: pos.x,
|
|
30
|
+
top: pos.y,
|
|
31
|
+
width: 18,
|
|
32
|
+
height: 18,
|
|
33
|
+
transform: 'translate(-50%, -50%)',
|
|
34
|
+
borderRadius: '50%',
|
|
35
|
+
background: 'radial-gradient(circle, oklch(0.66 0.24 28 / 0.95) 30%, transparent 70%)',
|
|
36
|
+
boxShadow: '0 0 18px 4px oklch(0.66 0.24 28 / 0.55)',
|
|
37
|
+
}}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { cn } from '@/lib/utils';
|
|
3
|
+
import type { DesignSystem } from '../../../design';
|
|
4
|
+
import type { Page } from '../../lib/sdk';
|
|
5
|
+
import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../../lib/sdk';
|
|
6
|
+
import { SlideCanvas } from '../slide-canvas';
|
|
7
|
+
|
|
8
|
+
const THUMB_W = 320;
|
|
9
|
+
const THUMB_H = (THUMB_W * CANVAS_HEIGHT) / CANVAS_WIDTH;
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
pages: Page[];
|
|
13
|
+
design?: DesignSystem;
|
|
14
|
+
open: boolean;
|
|
15
|
+
current: number;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
onSelect: (index: number) => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
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
|
+
export function PresentOverviewGrid({
|
|
26
|
+
pages,
|
|
27
|
+
design,
|
|
28
|
+
open,
|
|
29
|
+
current,
|
|
30
|
+
onClose,
|
|
31
|
+
onSelect,
|
|
32
|
+
}: Props) {
|
|
33
|
+
const [focused, setFocused] = useState(current);
|
|
34
|
+
const gridRef = useRef<HTMLDivElement>(null);
|
|
35
|
+
const focusedRef = useRef<HTMLButtonElement | null>(null);
|
|
36
|
+
|
|
37
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: only re-sync on open transition
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (open) setFocused(current);
|
|
40
|
+
}, [open]);
|
|
41
|
+
|
|
42
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `focused` swaps which button holds the ref; we must re-run to focus the new node
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!open) return;
|
|
45
|
+
focusedRef.current?.focus();
|
|
46
|
+
focusedRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
47
|
+
}, [focused, open]);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!open) return;
|
|
51
|
+
const onKey = (e: KeyboardEvent) => {
|
|
52
|
+
if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
|
|
53
|
+
const cols = computeCols(gridRef.current);
|
|
54
|
+
if (e.key === 'ArrowRight') {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
e.stopPropagation();
|
|
57
|
+
setFocused((i) => Math.min(pages.length - 1, i + 1));
|
|
58
|
+
} else if (e.key === 'ArrowLeft') {
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
e.stopPropagation();
|
|
61
|
+
setFocused((i) => Math.max(0, i - 1));
|
|
62
|
+
} else if (e.key === 'ArrowDown') {
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
e.stopPropagation();
|
|
65
|
+
setFocused((i) => Math.min(pages.length - 1, i + cols));
|
|
66
|
+
} else if (e.key === 'ArrowUp') {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
e.stopPropagation();
|
|
69
|
+
setFocused((i) => Math.max(0, i - cols));
|
|
70
|
+
} else if (e.key === 'Home') {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
e.stopPropagation();
|
|
73
|
+
setFocused(0);
|
|
74
|
+
} else if (e.key === 'End') {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
e.stopPropagation();
|
|
77
|
+
setFocused(pages.length - 1);
|
|
78
|
+
} else if (e.key === 'Enter') {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
e.stopPropagation();
|
|
81
|
+
onSelect(focused);
|
|
82
|
+
onClose();
|
|
83
|
+
} else if (e.key === 'Escape') {
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
e.stopPropagation();
|
|
86
|
+
onClose();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
window.addEventListener('keydown', onKey, true);
|
|
90
|
+
return () => window.removeEventListener('keydown', onKey, true);
|
|
91
|
+
}, [open, pages.length, focused, onClose, onSelect]);
|
|
92
|
+
|
|
93
|
+
if (!open) return null;
|
|
94
|
+
return (
|
|
95
|
+
<div
|
|
96
|
+
role="dialog"
|
|
97
|
+
aria-modal="true"
|
|
98
|
+
aria-label="Slide overview"
|
|
99
|
+
className="absolute inset-0 z-50 flex flex-col bg-black/95 backdrop-blur-sm"
|
|
100
|
+
>
|
|
101
|
+
<div className="flex shrink-0 items-baseline justify-between px-8 pt-6 pb-3">
|
|
102
|
+
<span className="eyebrow text-white/55">Overview</span>
|
|
103
|
+
<span className="font-mono text-[11px] text-white/55 tabular-nums">
|
|
104
|
+
{(focused + 1).toString().padStart(2, '0')} · {pages.length.toString().padStart(2, '0')}
|
|
105
|
+
</span>
|
|
106
|
+
</div>
|
|
107
|
+
<div ref={gridRef} className="min-h-0 flex-1 overflow-auto px-8 pb-8">
|
|
108
|
+
<div
|
|
109
|
+
className="grid gap-5"
|
|
110
|
+
style={{
|
|
111
|
+
gridTemplateColumns: `repeat(auto-fill, minmax(${THUMB_W}px, 1fr))`,
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
{pages.map((PageComp, i) => {
|
|
115
|
+
const isFocused = i === focused;
|
|
116
|
+
const isCurrent = i === current;
|
|
117
|
+
return (
|
|
118
|
+
<button
|
|
119
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: pages list is render-stable
|
|
120
|
+
key={i}
|
|
121
|
+
ref={isFocused ? focusedRef : undefined}
|
|
122
|
+
type="button"
|
|
123
|
+
onClick={() => {
|
|
124
|
+
onSelect(i);
|
|
125
|
+
onClose();
|
|
126
|
+
}}
|
|
127
|
+
onMouseEnter={() => setFocused(i)}
|
|
128
|
+
aria-label={`Go to slide ${i + 1}`}
|
|
129
|
+
aria-current={isCurrent ? 'true' : undefined}
|
|
130
|
+
className={cn(
|
|
131
|
+
'group/thumb flex flex-col items-start gap-2 rounded-[6px] p-1.5 outline-none transition-colors',
|
|
132
|
+
isFocused ? 'bg-white/10' : 'hover:bg-white/5',
|
|
133
|
+
)}
|
|
134
|
+
>
|
|
135
|
+
<div
|
|
136
|
+
className={cn(
|
|
137
|
+
'relative w-full overflow-hidden rounded-[4px] bg-black ring-1 ring-white/10 transition-shadow',
|
|
138
|
+
isFocused && 'ring-2 ring-[var(--brand,#ef4444)]',
|
|
139
|
+
)}
|
|
140
|
+
style={{ height: THUMB_H }}
|
|
141
|
+
>
|
|
142
|
+
<SlideCanvas
|
|
143
|
+
scale={THUMB_W / CANVAS_WIDTH}
|
|
144
|
+
center={false}
|
|
145
|
+
flat
|
|
146
|
+
freezeMotion
|
|
147
|
+
design={design}
|
|
148
|
+
>
|
|
149
|
+
<PageComp />
|
|
150
|
+
</SlideCanvas>
|
|
151
|
+
{isCurrent && (
|
|
152
|
+
<span
|
|
153
|
+
aria-hidden
|
|
154
|
+
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"
|
|
155
|
+
>
|
|
156
|
+
Now
|
|
157
|
+
</span>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
<span
|
|
161
|
+
className={cn(
|
|
162
|
+
'font-mono text-[10.5px] tracking-[0.08em] tabular-nums uppercase',
|
|
163
|
+
isFocused || isCurrent ? 'text-white/85' : 'text-white/45',
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
{(i + 1).toString().padStart(2, '0')}
|
|
167
|
+
</span>
|
|
168
|
+
</button>
|
|
169
|
+
);
|
|
170
|
+
})}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function computeCols(grid: HTMLDivElement | null) {
|
|
178
|
+
if (!grid) return 4;
|
|
179
|
+
const inner = grid.firstElementChild as HTMLElement | null;
|
|
180
|
+
if (!inner) return 4;
|
|
181
|
+
const cs = getComputedStyle(inner);
|
|
182
|
+
const cols = cs.gridTemplateColumns.split(' ').filter(Boolean).length;
|
|
183
|
+
return Math.max(1, cols);
|
|
184
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { cn } from '@/lib/utils';
|
|
2
|
+
|
|
3
|
+
type Props = {
|
|
4
|
+
index: number;
|
|
5
|
+
total: number;
|
|
6
|
+
visible: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function PresentProgressBar({ index, total, visible }: Props) {
|
|
10
|
+
const pct = total > 0 ? ((index + 1) / total) * 100 : 0;
|
|
11
|
+
return (
|
|
12
|
+
<div
|
|
13
|
+
aria-hidden
|
|
14
|
+
className={cn(
|
|
15
|
+
'pointer-events-none absolute inset-x-0 top-0 z-30 h-[2px] bg-white/8',
|
|
16
|
+
'motion-safe:transition-opacity motion-safe:duration-200',
|
|
17
|
+
visible ? 'opacity-100' : 'opacity-0',
|
|
18
|
+
)}
|
|
19
|
+
>
|
|
20
|
+
<div
|
|
21
|
+
className="h-full bg-[var(--brand,#ef4444)] transition-[width] duration-200 ease-out"
|
|
22
|
+
style={{ width: `${pct}%` }}
|
|
23
|
+
/>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reports whether the user has been idle (no pointer / key / touch input)
|
|
5
|
+
* for at least `delayMs`. Resets on any input event. The hook starts in
|
|
6
|
+
* the non-idle state so freshly-mounted UI is visible while the user
|
|
7
|
+
* orients themselves.
|
|
8
|
+
*
|
|
9
|
+
* Pass `enabled = false` to short-circuit (useful when the player is
|
|
10
|
+
* paused on an overlay and we don't want to hide chrome behind it).
|
|
11
|
+
*/
|
|
12
|
+
export function useIdle(delayMs: number, enabled = true) {
|
|
13
|
+
const [idle, setIdle] = useState(false);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!enabled) {
|
|
17
|
+
setIdle(false);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
21
|
+
const reset = () => {
|
|
22
|
+
setIdle(false);
|
|
23
|
+
if (timer) clearTimeout(timer);
|
|
24
|
+
timer = setTimeout(() => setIdle(true), delayMs);
|
|
25
|
+
};
|
|
26
|
+
reset();
|
|
27
|
+
const opts = { passive: true } as const;
|
|
28
|
+
window.addEventListener('mousemove', reset, opts);
|
|
29
|
+
window.addEventListener('mousedown', reset, opts);
|
|
30
|
+
window.addEventListener('keydown', reset);
|
|
31
|
+
window.addEventListener('touchstart', reset, opts);
|
|
32
|
+
window.addEventListener('wheel', reset, opts);
|
|
33
|
+
return () => {
|
|
34
|
+
if (timer) clearTimeout(timer);
|
|
35
|
+
window.removeEventListener('mousemove', reset);
|
|
36
|
+
window.removeEventListener('mousedown', reset);
|
|
37
|
+
window.removeEventListener('keydown', reset);
|
|
38
|
+
window.removeEventListener('touchstart', reset);
|
|
39
|
+
window.removeEventListener('wheel', reset);
|
|
40
|
+
};
|
|
41
|
+
}, [delayMs, enabled]);
|
|
42
|
+
|
|
43
|
+
return idle;
|
|
44
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns true while the mouse pointer sits within `thresholdPx` of the
|
|
5
|
+
* viewport's bottom edge. Pure pointer-position tracking — keyboard input
|
|
6
|
+
* does not affect the result, so arrow-key navigation won't reveal the
|
|
7
|
+
* control chrome.
|
|
8
|
+
*
|
|
9
|
+
* Pass `enabled = false` to short-circuit (e.g. when an overlay owns
|
|
10
|
+
* visibility) and reset to false.
|
|
11
|
+
*/
|
|
12
|
+
export function usePointerNearBottom(thresholdPx: number, enabled = true) {
|
|
13
|
+
const [near, setNear] = useState(false);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!enabled) {
|
|
17
|
+
setNear(false);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const update = (clientY: number) => {
|
|
21
|
+
setNear(clientY >= window.innerHeight - thresholdPx);
|
|
22
|
+
};
|
|
23
|
+
const onMove = (e: MouseEvent) => update(e.clientY);
|
|
24
|
+
const onLeave = () => setNear(false);
|
|
25
|
+
window.addEventListener('mousemove', onMove, { passive: true });
|
|
26
|
+
document.addEventListener('mouseleave', onLeave);
|
|
27
|
+
return () => {
|
|
28
|
+
window.removeEventListener('mousemove', onMove);
|
|
29
|
+
document.removeEventListener('mouseleave', onLeave);
|
|
30
|
+
};
|
|
31
|
+
}, [thresholdPx, enabled]);
|
|
32
|
+
|
|
33
|
+
return near;
|
|
34
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export type PresenterState = {
|
|
4
|
+
index: number;
|
|
5
|
+
pageCount: number;
|
|
6
|
+
blackout: 'black' | 'white' | null;
|
|
7
|
+
startedAt: number; // epoch ms when present mode began
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type PresenterCommand =
|
|
11
|
+
| { type: 'state'; state: PresenterState }
|
|
12
|
+
| { type: 'goto'; index: number }
|
|
13
|
+
| { type: 'next' }
|
|
14
|
+
| { type: 'prev' }
|
|
15
|
+
| { type: 'request-state' }
|
|
16
|
+
| { type: 'restart-timer' }
|
|
17
|
+
| { type: 'toggle-blackout'; mode: 'black' | 'white' };
|
|
18
|
+
|
|
19
|
+
type Handler = (msg: PresenterCommand) => void;
|
|
20
|
+
|
|
21
|
+
const SUPPORTED = typeof window !== 'undefined' && typeof BroadcastChannel !== 'undefined';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* BroadcastChannel wrapper used by both the projection window (Player) and
|
|
25
|
+
* the Presenter View. The channel is keyed by slideId so multiple decks
|
|
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
|
+
*/
|
|
33
|
+
export function usePresenterChannel(slideId: string, onMessage?: Handler) {
|
|
34
|
+
const onMessageRef = useRef(onMessage);
|
|
35
|
+
onMessageRef.current = onMessage;
|
|
36
|
+
|
|
37
|
+
const channelRef = useRef<BroadcastChannel | null>(null);
|
|
38
|
+
const [available, setAvailable] = useState(false);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!SUPPORTED) return;
|
|
42
|
+
const channel = new BroadcastChannel(`open-slide:presenter:${slideId}`);
|
|
43
|
+
channelRef.current = channel;
|
|
44
|
+
setAvailable(true);
|
|
45
|
+
const handler = (e: MessageEvent<PresenterCommand>) => {
|
|
46
|
+
onMessageRef.current?.(e.data);
|
|
47
|
+
};
|
|
48
|
+
channel.addEventListener('message', handler);
|
|
49
|
+
return () => {
|
|
50
|
+
channel.removeEventListener('message', handler);
|
|
51
|
+
channel.close();
|
|
52
|
+
if (channelRef.current === channel) channelRef.current = null;
|
|
53
|
+
setAvailable(false);
|
|
54
|
+
};
|
|
55
|
+
}, [slideId]);
|
|
56
|
+
|
|
57
|
+
return useMemo(
|
|
58
|
+
() => ({
|
|
59
|
+
send(msg: PresenterCommand) {
|
|
60
|
+
try {
|
|
61
|
+
channelRef.current?.postMessage(msg);
|
|
62
|
+
} catch {
|
|
63
|
+
// Channel may have been closed between the availability check
|
|
64
|
+
// and the send (e.g. StrictMode unmount mid-flush). Treat as no-op.
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
available,
|
|
68
|
+
}),
|
|
69
|
+
[available],
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type RefObject, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
const MIN_SWIPE_PX = 50;
|
|
4
|
+
const MAX_SWIPE_MS = 600;
|
|
5
|
+
|
|
6
|
+
type Options<T extends HTMLElement> = {
|
|
7
|
+
ref: RefObject<T | null>;
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
onPrev: () => void;
|
|
10
|
+
onNext: () => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Single-finger horizontal swipe → prev/next. Vertical-dominant gestures
|
|
15
|
+
* are left alone so scroll-y on tablets keeps working. The handler only
|
|
16
|
+
* binds when `enabled`, so overlay layers can suppress it.
|
|
17
|
+
*/
|
|
18
|
+
export function useTouchSwipe<T extends HTMLElement>({
|
|
19
|
+
ref,
|
|
20
|
+
enabled = true,
|
|
21
|
+
onPrev,
|
|
22
|
+
onNext,
|
|
23
|
+
}: Options<T>) {
|
|
24
|
+
const start = useRef<{ x: number; y: number; t: number } | null>(null);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const el = ref.current;
|
|
28
|
+
if (!el || !enabled) return;
|
|
29
|
+
|
|
30
|
+
const onStart = (e: TouchEvent) => {
|
|
31
|
+
if (e.touches.length !== 1) {
|
|
32
|
+
start.current = null;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const t = e.touches[0];
|
|
36
|
+
start.current = { x: t.clientX, y: t.clientY, t: performance.now() };
|
|
37
|
+
};
|
|
38
|
+
const onEnd = (e: TouchEvent) => {
|
|
39
|
+
const s = start.current;
|
|
40
|
+
start.current = null;
|
|
41
|
+
if (!s) return;
|
|
42
|
+
const t = e.changedTouches[0];
|
|
43
|
+
if (!t) return;
|
|
44
|
+
const dx = t.clientX - s.x;
|
|
45
|
+
const dy = t.clientY - s.y;
|
|
46
|
+
if (performance.now() - s.t > MAX_SWIPE_MS) return;
|
|
47
|
+
if (Math.abs(dx) < MIN_SWIPE_PX) return;
|
|
48
|
+
if (Math.abs(dx) <= Math.abs(dy)) return;
|
|
49
|
+
if (dx < 0) onNext();
|
|
50
|
+
else onPrev();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
el.addEventListener('touchstart', onStart, { passive: true });
|
|
54
|
+
el.addEventListener('touchend', onEnd);
|
|
55
|
+
el.addEventListener('touchcancel', () => {
|
|
56
|
+
start.current = null;
|
|
57
|
+
});
|
|
58
|
+
return () => {
|
|
59
|
+
el.removeEventListener('touchstart', onStart);
|
|
60
|
+
el.removeEventListener('touchend', onEnd);
|
|
61
|
+
};
|
|
62
|
+
}, [ref, enabled, onPrev, onNext]);
|
|
63
|
+
}
|
|
@@ -1,24 +1,43 @@
|
|
|
1
1
|
import { MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
|
|
2
|
-
import { useState } from 'react';
|
|
3
|
-
import type { Folder, FolderIcon } from '@/lib/sdk';
|
|
4
|
-
import { cn } from '@/lib/utils';
|
|
5
|
-
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
6
3
|
import {
|
|
7
4
|
DropdownMenu,
|
|
8
5
|
DropdownMenuContent,
|
|
9
6
|
DropdownMenuItem,
|
|
10
7
|
DropdownMenuTrigger,
|
|
11
8
|
} from '@/components/ui/dropdown-menu';
|
|
12
|
-
import {
|
|
9
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
10
|
+
import type { Folder, FolderIcon } from '@/lib/sdk';
|
|
11
|
+
import { cn } from '@/lib/utils';
|
|
12
|
+
import { IconPicker } from './icon-picker';
|
|
13
13
|
|
|
14
14
|
export const SLIDE_DND_MIME = 'application/x-slide-id';
|
|
15
15
|
|
|
16
|
+
function useSlideDragActive() {
|
|
17
|
+
const [active, setActive] = useState(false);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const onStart = (e: DragEvent) => {
|
|
20
|
+
if (e.dataTransfer?.types?.includes(SLIDE_DND_MIME)) setActive(true);
|
|
21
|
+
};
|
|
22
|
+
const onEnd = () => setActive(false);
|
|
23
|
+
document.addEventListener('dragstart', onStart);
|
|
24
|
+
document.addEventListener('dragend', onEnd);
|
|
25
|
+
document.addEventListener('drop', onEnd);
|
|
26
|
+
return () => {
|
|
27
|
+
document.removeEventListener('dragstart', onStart);
|
|
28
|
+
document.removeEventListener('dragend', onEnd);
|
|
29
|
+
document.removeEventListener('drop', onEnd);
|
|
30
|
+
};
|
|
31
|
+
}, []);
|
|
32
|
+
return active;
|
|
33
|
+
}
|
|
34
|
+
|
|
16
35
|
export function FolderIconChip({ icon, className }: { icon: FolderIcon; className?: string }) {
|
|
17
36
|
if (icon.type === 'emoji') {
|
|
18
37
|
return (
|
|
19
38
|
<span
|
|
20
39
|
className={cn(
|
|
21
|
-
'inline-flex size-5 items-center justify-center text-
|
|
40
|
+
'inline-flex size-5 items-center justify-center text-[15px] leading-none',
|
|
22
41
|
className,
|
|
23
42
|
)}
|
|
24
43
|
>
|
|
@@ -28,7 +47,10 @@ export function FolderIconChip({ icon, className }: { icon: FolderIcon; classNam
|
|
|
28
47
|
}
|
|
29
48
|
return (
|
|
30
49
|
<span
|
|
31
|
-
className={cn(
|
|
50
|
+
className={cn(
|
|
51
|
+
'inline-block size-3 rounded-[3px] ring-1 ring-foreground/15 shadow-[inset_0_1px_0_oklch(1_0_0/0.18)]',
|
|
52
|
+
className,
|
|
53
|
+
)}
|
|
32
54
|
style={{ background: icon.value }}
|
|
33
55
|
/>
|
|
34
56
|
);
|
|
@@ -61,18 +83,29 @@ export function FolderItem({
|
|
|
61
83
|
}) {
|
|
62
84
|
const [renaming, setRenaming] = useState(false);
|
|
63
85
|
const [dragOver, setDragOver] = useState(false);
|
|
86
|
+
const dragDepth = useRef(0);
|
|
64
87
|
const [draftName, setDraftName] = useState(row.kind === 'folder' ? row.folder.name : '');
|
|
88
|
+
const slideDragActive = useSlideDragActive();
|
|
65
89
|
|
|
90
|
+
const isSlideDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(SLIDE_DND_MIME);
|
|
91
|
+
const handleDragEnter = (e: React.DragEvent) => {
|
|
92
|
+
if (!isSlideDrag(e)) return;
|
|
93
|
+
dragDepth.current += 1;
|
|
94
|
+
if (dragDepth.current === 1) setDragOver(true);
|
|
95
|
+
};
|
|
66
96
|
const handleDragOver = (e: React.DragEvent) => {
|
|
67
|
-
if (e
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
97
|
+
if (!isSlideDrag(e)) return;
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
e.dataTransfer.dropEffect = 'move';
|
|
100
|
+
};
|
|
101
|
+
const handleDragLeave = (e: React.DragEvent) => {
|
|
102
|
+
if (!isSlideDrag(e)) return;
|
|
103
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
104
|
+
if (dragDepth.current === 0) setDragOver(false);
|
|
72
105
|
};
|
|
73
|
-
const handleDragLeave = () => setDragOver(false);
|
|
74
106
|
const handleDrop = (e: React.DragEvent) => {
|
|
75
107
|
const slideId = e.dataTransfer.getData(SLIDE_DND_MIME);
|
|
108
|
+
dragDepth.current = 0;
|
|
76
109
|
setDragOver(false);
|
|
77
110
|
if (!slideId) return;
|
|
78
111
|
e.preventDefault();
|
|
@@ -94,15 +127,22 @@ export function FolderItem({
|
|
|
94
127
|
// biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target wraps interactive children
|
|
95
128
|
<div
|
|
96
129
|
className={cn(
|
|
97
|
-
'group relative flex items-center gap-2 rounded-
|
|
98
|
-
selected
|
|
99
|
-
|
|
130
|
+
'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
|
+
selected
|
|
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
|
+
: 'text-foreground/70 hover:bg-muted/60 hover:text-foreground',
|
|
136
|
+
slideDragActive && !dragOver && 'ring-1 ring-foreground/10',
|
|
137
|
+
dragOver &&
|
|
138
|
+
'bg-brand/10 text-foreground ring-1 ring-brand ring-offset-1 ring-offset-sidebar motion-safe:scale-[1.01] motion-safe:transition-transform',
|
|
100
139
|
)}
|
|
140
|
+
onDragEnter={handleDragEnter}
|
|
101
141
|
onDragOver={handleDragOver}
|
|
102
142
|
onDragLeave={handleDragLeave}
|
|
103
143
|
onDrop={handleDrop}
|
|
104
144
|
>
|
|
105
|
-
{row.kind === 'folder' ? (
|
|
145
|
+
{row.kind === 'folder' && import.meta.env.DEV ? (
|
|
106
146
|
<Popover>
|
|
107
147
|
<PopoverTrigger asChild>
|
|
108
148
|
<button
|
|
@@ -137,7 +177,7 @@ export function FolderItem({
|
|
|
137
177
|
}
|
|
138
178
|
}}
|
|
139
179
|
maxLength={40}
|
|
140
|
-
className="min-w-0 flex-1 rounded-
|
|
180
|
+
className="min-w-0 flex-1 rounded-[3px] bg-card px-1 text-[12.5px] outline-none ring-1 ring-foreground/20"
|
|
141
181
|
/>
|
|
142
182
|
) : (
|
|
143
183
|
<button type="button" onClick={onSelect} className="min-w-0 flex-1 truncate text-left">
|
|
@@ -145,22 +185,17 @@ export function FolderItem({
|
|
|
145
185
|
</button>
|
|
146
186
|
)}
|
|
147
187
|
|
|
148
|
-
<span
|
|
149
|
-
|
|
150
|
-
'shrink-0 text-xs tabular-nums text-muted-foreground',
|
|
151
|
-
count === 0 && 'opacity-0 group-hover:opacity-100',
|
|
152
|
-
)}
|
|
153
|
-
>
|
|
154
|
-
{count}
|
|
188
|
+
<span className={cn('folio shrink-0', count === 0 && 'opacity-0 group-hover:opacity-100')}>
|
|
189
|
+
{count.toString().padStart(2, '0')}
|
|
155
190
|
</span>
|
|
156
191
|
|
|
157
|
-
{row.kind === 'folder' && (
|
|
192
|
+
{row.kind === 'folder' && import.meta.env.DEV && (
|
|
158
193
|
<DropdownMenu>
|
|
159
194
|
<DropdownMenuTrigger asChild>
|
|
160
195
|
<button
|
|
161
196
|
type="button"
|
|
162
197
|
onClick={(e) => e.stopPropagation()}
|
|
163
|
-
className="size-5 shrink-0 rounded opacity-0 transition-opacity hover:bg-
|
|
198
|
+
className="size-5 shrink-0 rounded opacity-0 transition-opacity hover:bg-foreground/10 group-hover:opacity-100 aria-expanded:opacity-100"
|
|
164
199
|
aria-label="Folder actions"
|
|
165
200
|
>
|
|
166
201
|
<MoreHorizontal className="mx-auto size-3.5" />
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import EmojiPicker, { EmojiStyle, Theme } from 'emoji-picker-react';
|
|
2
|
-
import type { FolderIcon } from '@/lib/sdk';
|
|
3
2
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
3
|
+
import type { FolderIcon } from '@/lib/sdk';
|
|
4
4
|
|
|
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.
|
|
5
8
|
export const PRESET_COLORS = [
|
|
6
|
-
'#
|
|
7
|
-
'#
|
|
8
|
-
'#
|
|
9
|
-
'#
|
|
10
|
-
'#
|
|
11
|
-
'#
|
|
12
|
-
'#
|
|
13
|
-
'#
|
|
9
|
+
'#c0392b', // vermillion
|
|
10
|
+
'#b8743e', // ochre
|
|
11
|
+
'#6f7a3a', // olive
|
|
12
|
+
'#2f6a4f', // forest
|
|
13
|
+
'#3a5a7c', // ink blue
|
|
14
|
+
'#6b4675', // plum
|
|
15
|
+
'#a3543b', // terracotta
|
|
16
|
+
'#3a3a3a', // graphite
|
|
14
17
|
];
|
|
15
18
|
|
|
16
19
|
export function IconPicker({
|
|
@@ -47,7 +50,7 @@ export function IconPicker({
|
|
|
47
50
|
key={c}
|
|
48
51
|
type="button"
|
|
49
52
|
onClick={() => onChange({ type: 'color', value: c })}
|
|
50
|
-
className="size-6 rounded-
|
|
53
|
+
className="size-6 rounded-[4px] ring-1 ring-foreground/10 shadow-[inset_0_1px_0_oklch(1_0_0/0.18)] transition-transform hover:scale-110"
|
|
51
54
|
style={{ background: c }}
|
|
52
55
|
aria-label={c}
|
|
53
56
|
/>
|