@open-slide/core 1.0.6 → 1.1.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-DSqSio-T.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-D2y1AXaN.d.ts → config-C7vMYzFD.d.ts} +1 -1
- package/dist/{config-evLWCV1-.js → config-KdiYeWtK.js} +109 -0
- package/dist/{dev-BUr0S-Ij.js → dev-B_GVbr11.js} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +40 -4
- package/dist/{preview-DP_gIphz.js → preview-D_mxhj7w.js} +1 -1
- package/dist/{types-BVvl_xup.d.ts → types-DYgVpIGo.d.ts} +9 -0
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +5 -1
- package/src/app/components/inspector/image-crop-dialog.tsx +168 -0
- package/src/app/components/inspector/inspect-overlay.tsx +17 -2
- package/src/app/components/inspector/inspector-panel.tsx +46 -13
- package/src/app/components/inspector/inspector-provider.tsx +83 -1
- package/src/app/components/player.tsx +15 -1
- 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 +220 -53
- package/src/app/lib/design-presets.ts +94 -0
- package/src/app/routes/presenter.tsx +27 -24
- package/src/app/routes/slide.tsx +53 -1
- package/src/locale/en.ts +9 -0
- package/src/locale/ja.ts +9 -0
- package/src/locale/types.ts +9 -0
- package/src/locale/zh-cn.ts +9 -0
- package/src/locale/zh-tw.ts +9 -0
|
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
|
|
|
15
15
|
import { type SlideComment, useComments } from '@/lib/inspector/use-comments';
|
|
16
16
|
import { type Edit, type EditOp, type EditResult, useEditor } from '@/lib/inspector/use-editor';
|
|
17
17
|
import { useLocale } from '@/lib/use-locale';
|
|
18
|
+
import { ImageCropDialog } from './image-crop-dialog';
|
|
18
19
|
|
|
19
20
|
export type SelectedTarget = {
|
|
20
21
|
line: number;
|
|
@@ -69,6 +70,7 @@ type InspectorCtx = {
|
|
|
69
70
|
commitEdits: () => Promise<void>;
|
|
70
71
|
cancelEdits: () => void;
|
|
71
72
|
committing: boolean;
|
|
73
|
+
openCrop: (anchor: HTMLImageElement) => void;
|
|
72
74
|
};
|
|
73
75
|
|
|
74
76
|
const Ctx = createContext<InspectorCtx | null>(null);
|
|
@@ -90,6 +92,16 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
90
92
|
const instanceCounterRef = useRef(0);
|
|
91
93
|
const [pendingCount, setPendingCount] = useState(0);
|
|
92
94
|
const [committing, setCommitting] = useState(false);
|
|
95
|
+
const [cropTarget, setCropTarget] = useState<{
|
|
96
|
+
line: number;
|
|
97
|
+
column: number;
|
|
98
|
+
anchor: HTMLImageElement;
|
|
99
|
+
src: string;
|
|
100
|
+
targetWidth: number;
|
|
101
|
+
targetHeight: number;
|
|
102
|
+
initialFit: 'cover' | 'contain';
|
|
103
|
+
initialPosition: { x: number; y: number };
|
|
104
|
+
} | null>(null);
|
|
93
105
|
const t = useLocale();
|
|
94
106
|
|
|
95
107
|
const ensureInstanceId = useCallback((el: HTMLElement): string => {
|
|
@@ -529,6 +541,26 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
529
541
|
setSelected(null);
|
|
530
542
|
}, []);
|
|
531
543
|
|
|
544
|
+
const openCrop = useCallback((anchor: HTMLImageElement) => {
|
|
545
|
+
const loc = anchor.dataset.slideLoc;
|
|
546
|
+
if (!loc) return;
|
|
547
|
+
const [lineStr, columnStr] = loc.split(':');
|
|
548
|
+
const line = Number(lineStr);
|
|
549
|
+
const column = Number(columnStr);
|
|
550
|
+
if (!Number.isFinite(line) || !Number.isFinite(column)) return;
|
|
551
|
+
const cs = window.getComputedStyle(anchor);
|
|
552
|
+
setCropTarget({
|
|
553
|
+
line,
|
|
554
|
+
column,
|
|
555
|
+
anchor,
|
|
556
|
+
src: anchor.currentSrc || anchor.src,
|
|
557
|
+
targetWidth: anchor.offsetWidth || anchor.getBoundingClientRect().width,
|
|
558
|
+
targetHeight: anchor.offsetHeight || anchor.getBoundingClientRect().height,
|
|
559
|
+
initialFit: cs.objectFit === 'contain' ? 'contain' : 'cover',
|
|
560
|
+
initialPosition: parseObjectPosition(cs.objectPosition),
|
|
561
|
+
});
|
|
562
|
+
}, []);
|
|
563
|
+
|
|
532
564
|
const value = useMemo<InspectorCtx>(
|
|
533
565
|
() => ({
|
|
534
566
|
slideId,
|
|
@@ -549,6 +581,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
549
581
|
commitEdits,
|
|
550
582
|
cancelEdits,
|
|
551
583
|
committing,
|
|
584
|
+
openCrop,
|
|
552
585
|
}),
|
|
553
586
|
[
|
|
554
587
|
slideId,
|
|
@@ -568,10 +601,59 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
568
601
|
commitEdits,
|
|
569
602
|
cancelEdits,
|
|
570
603
|
committing,
|
|
604
|
+
openCrop,
|
|
571
605
|
],
|
|
572
606
|
);
|
|
573
607
|
|
|
574
|
-
return
|
|
608
|
+
return (
|
|
609
|
+
<Ctx.Provider value={value}>
|
|
610
|
+
{children}
|
|
611
|
+
{cropTarget && (
|
|
612
|
+
<ImageCropDialog
|
|
613
|
+
src={cropTarget.src}
|
|
614
|
+
targetWidth={cropTarget.targetWidth}
|
|
615
|
+
targetHeight={cropTarget.targetHeight}
|
|
616
|
+
initialFit={cropTarget.initialFit}
|
|
617
|
+
initialPosition={cropTarget.initialPosition}
|
|
618
|
+
onClose={() => setCropTarget(null)}
|
|
619
|
+
onApply={(result) => {
|
|
620
|
+
const { line, column, anchor } = cropTarget;
|
|
621
|
+
if (anchor.isConnected) {
|
|
622
|
+
bufferOps(line, column, anchor, [
|
|
623
|
+
{ kind: 'set-style', key: 'objectFit', value: result.fit },
|
|
624
|
+
{
|
|
625
|
+
kind: 'set-style',
|
|
626
|
+
key: 'objectPosition',
|
|
627
|
+
value: `${round2(result.x)}% ${round2(result.y)}%`,
|
|
628
|
+
},
|
|
629
|
+
]);
|
|
630
|
+
}
|
|
631
|
+
setCropTarget(null);
|
|
632
|
+
}}
|
|
633
|
+
/>
|
|
634
|
+
)}
|
|
635
|
+
</Ctx.Provider>
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function round2(n: number): number {
|
|
640
|
+
return Math.round(n * 100) / 100;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function parseObjectPosition(value: string): { x: number; y: number } {
|
|
644
|
+
const parts = value.trim().split(/\s+/);
|
|
645
|
+
const xRaw = parts[0] ?? '50%';
|
|
646
|
+
const yRaw = parts[1] ?? xRaw;
|
|
647
|
+
return { x: parsePercent(xRaw, 50), y: parsePercent(yRaw, 50) };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function parsePercent(s: string, fallback: number): number {
|
|
651
|
+
if (s === 'center') return 50;
|
|
652
|
+
if (s === 'left' || s === 'top') return 0;
|
|
653
|
+
if (s === 'right' || s === 'bottom') return 100;
|
|
654
|
+
const m = s.match(/(-?\d+(?:\.\d+)?)%/);
|
|
655
|
+
if (m?.[1]) return Number(m[1]);
|
|
656
|
+
return fallback;
|
|
575
657
|
}
|
|
576
658
|
|
|
577
659
|
export function InspectToggleButton() {
|
|
@@ -58,6 +58,7 @@ 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
|
|
|
63
64
|
const goPrev = useCallback(() => {
|
|
@@ -181,19 +182,23 @@ export function Player({
|
|
|
181
182
|
|
|
182
183
|
if (isNext) {
|
|
183
184
|
e.preventDefault();
|
|
185
|
+
setKeyboardDriven(true);
|
|
184
186
|
goNext();
|
|
185
187
|
return;
|
|
186
188
|
}
|
|
187
189
|
if (isPrev) {
|
|
188
190
|
e.preventDefault();
|
|
191
|
+
setKeyboardDriven(true);
|
|
189
192
|
goPrev();
|
|
190
193
|
return;
|
|
191
194
|
}
|
|
192
195
|
if (e.key === 'Home') {
|
|
196
|
+
setKeyboardDriven(true);
|
|
193
197
|
onIndexChange(0);
|
|
194
198
|
return;
|
|
195
199
|
}
|
|
196
200
|
if (e.key === 'End') {
|
|
201
|
+
setKeyboardDriven(true);
|
|
197
202
|
onIndexChange(pages.length - 1);
|
|
198
203
|
return;
|
|
199
204
|
}
|
|
@@ -246,7 +251,16 @@ export function Player({
|
|
|
246
251
|
const pointerNearBottom = usePointerNearBottom(BAR_HOTZONE_PX, controls && !overlayActive);
|
|
247
252
|
const chromeVisible = pointerNearBottom || overlayActive;
|
|
248
253
|
const idle = useIdle(IDLE_HIDE_MS, controls && !overlayActive);
|
|
249
|
-
|
|
254
|
+
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (!keyboardDriven) return;
|
|
257
|
+
const clear = () => setKeyboardDriven(false);
|
|
258
|
+
window.addEventListener('mousemove', clear, { passive: true });
|
|
259
|
+
return () => window.removeEventListener('mousemove', clear);
|
|
260
|
+
}, [keyboardDriven]);
|
|
261
|
+
|
|
262
|
+
const hideCursor =
|
|
263
|
+
controls && (laser || keyboardDriven || (idle && !overlayActive && !pointerNearBottom));
|
|
250
264
|
|
|
251
265
|
const PageComp = pages[index];
|
|
252
266
|
|
|
@@ -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={
|
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closestCenter,
|
|
3
|
+
DndContext,
|
|
4
|
+
type DragEndEvent,
|
|
5
|
+
KeyboardSensor,
|
|
6
|
+
PointerSensor,
|
|
7
|
+
useSensor,
|
|
8
|
+
useSensors,
|
|
9
|
+
} from '@dnd-kit/core';
|
|
10
|
+
import {
|
|
11
|
+
SortableContext,
|
|
12
|
+
sortableKeyboardCoordinates,
|
|
13
|
+
useSortable,
|
|
14
|
+
verticalListSortingStrategy,
|
|
15
|
+
} from '@dnd-kit/sortable';
|
|
16
|
+
import { CSS } from '@dnd-kit/utilities';
|
|
1
17
|
import { useEffect, useRef } from 'react';
|
|
2
18
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
3
19
|
import { format, useLocale } from '@/lib/use-locale';
|
|
@@ -14,6 +30,7 @@ type Props = {
|
|
|
14
30
|
design?: DesignSystem;
|
|
15
31
|
current: number;
|
|
16
32
|
onSelect: (index: number) => void;
|
|
33
|
+
onReorder?: (from: number, to: number) => void;
|
|
17
34
|
orientation?: Orientation;
|
|
18
35
|
};
|
|
19
36
|
|
|
@@ -25,6 +42,7 @@ export function ThumbnailRail({
|
|
|
25
42
|
design,
|
|
26
43
|
current,
|
|
27
44
|
onSelect,
|
|
45
|
+
onReorder,
|
|
28
46
|
orientation = 'vertical',
|
|
29
47
|
}: Props) {
|
|
30
48
|
const activeRef = useRef<HTMLButtonElement | null>(null);
|
|
@@ -93,61 +111,210 @@ export function ThumbnailRail({
|
|
|
93
111
|
|
|
94
112
|
const scale = VERTICAL_THUMB_WIDTH / CANVAS_WIDTH;
|
|
95
113
|
const height = CANVAS_HEIGHT * scale;
|
|
114
|
+
|
|
115
|
+
const renderThumb = (PageComp: Page, i: number) => {
|
|
116
|
+
const active = i === current;
|
|
117
|
+
const inner = (
|
|
118
|
+
<ThumbContents
|
|
119
|
+
index={i}
|
|
120
|
+
active={active}
|
|
121
|
+
page={PageComp}
|
|
122
|
+
design={design}
|
|
123
|
+
scale={scale}
|
|
124
|
+
height={height}
|
|
125
|
+
/>
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (onReorder) {
|
|
129
|
+
return (
|
|
130
|
+
<SortableThumb
|
|
131
|
+
key={i}
|
|
132
|
+
index={i}
|
|
133
|
+
active={active}
|
|
134
|
+
activeRef={active ? activeRef : undefined}
|
|
135
|
+
onSelect={() => onSelect(i)}
|
|
136
|
+
ariaLabel={format(t.thumbnailRail.goToPageAria, { n: i + 1 })}
|
|
137
|
+
>
|
|
138
|
+
{inner}
|
|
139
|
+
</SortableThumb>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<button
|
|
145
|
+
key={i}
|
|
146
|
+
type="button"
|
|
147
|
+
ref={active ? activeRef : undefined}
|
|
148
|
+
onClick={() => onSelect(i)}
|
|
149
|
+
aria-label={format(t.thumbnailRail.goToPageAria, { n: i + 1 })}
|
|
150
|
+
aria-current={active ? 'true' : undefined}
|
|
151
|
+
className={thumbButtonClass(active)}
|
|
152
|
+
>
|
|
153
|
+
{inner}
|
|
154
|
+
</button>
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const list = (
|
|
159
|
+
<aside className="flex flex-col gap-2 px-3 py-3">
|
|
160
|
+
<div className="flex items-baseline justify-between px-1 pb-1">
|
|
161
|
+
<span className="eyebrow">{t.thumbnailRail.pages}</span>
|
|
162
|
+
<span className="folio">{pages.length.toString().padStart(2, '0')}</span>
|
|
163
|
+
</div>
|
|
164
|
+
{pages.map(renderThumb)}
|
|
165
|
+
</aside>
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (!onReorder) {
|
|
169
|
+
return <ScrollArea className="h-full border-r border-hairline bg-sidebar">{list}</ScrollArea>;
|
|
170
|
+
}
|
|
171
|
+
|
|
96
172
|
return (
|
|
97
173
|
<ScrollArea className="h-full border-r border-hairline bg-sidebar">
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
<span className="folio">{pages.length.toString().padStart(2, '0')}</span>
|
|
102
|
-
</div>
|
|
103
|
-
{pages.map((PageComp, i) => {
|
|
104
|
-
const active = i === current;
|
|
105
|
-
return (
|
|
106
|
-
<button
|
|
107
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: pages list is render-stable
|
|
108
|
-
key={i}
|
|
109
|
-
type="button"
|
|
110
|
-
ref={active ? activeRef : undefined}
|
|
111
|
-
onClick={() => onSelect(i)}
|
|
112
|
-
aria-label={`Go to page ${i + 1}`}
|
|
113
|
-
aria-current={active ? 'true' : undefined}
|
|
114
|
-
className={cn(
|
|
115
|
-
'group/thumb flex items-start gap-2.5 rounded-[6px] p-1.5 text-left motion-safe:transition-colors',
|
|
116
|
-
'hover:bg-muted/60',
|
|
117
|
-
active && 'bg-muted',
|
|
118
|
-
)}
|
|
119
|
-
>
|
|
120
|
-
<span
|
|
121
|
-
className={cn(
|
|
122
|
-
'mt-1.5 w-7 shrink-0 text-right font-mono text-[10px] font-medium tracking-[0.06em] tabular-nums uppercase',
|
|
123
|
-
active ? 'text-brand' : 'text-muted-foreground/70',
|
|
124
|
-
)}
|
|
125
|
-
>
|
|
126
|
-
{(i + 1).toString().padStart(2, '0')}
|
|
127
|
-
</span>
|
|
128
|
-
<div
|
|
129
|
-
className={cn(
|
|
130
|
-
'relative shrink-0 overflow-hidden rounded-[4px] border bg-card motion-safe:transition-all',
|
|
131
|
-
active
|
|
132
|
-
? 'border-brand shadow-[0_0_0_1px_var(--brand)]'
|
|
133
|
-
: 'border-hairline group-hover/thumb:border-foreground/25',
|
|
134
|
-
)}
|
|
135
|
-
style={{ width: VERTICAL_THUMB_WIDTH, height }}
|
|
136
|
-
>
|
|
137
|
-
<SlideCanvas scale={scale} center={false} flat freezeMotion design={design}>
|
|
138
|
-
<PageComp />
|
|
139
|
-
</SlideCanvas>
|
|
140
|
-
{active && (
|
|
141
|
-
<span
|
|
142
|
-
aria-hidden
|
|
143
|
-
className="pointer-events-none absolute inset-y-0 left-0 w-[2px] bg-brand"
|
|
144
|
-
/>
|
|
145
|
-
)}
|
|
146
|
-
</div>
|
|
147
|
-
</button>
|
|
148
|
-
);
|
|
149
|
-
})}
|
|
150
|
-
</aside>
|
|
174
|
+
<SortableRail pages={pages} onReorder={onReorder}>
|
|
175
|
+
{list}
|
|
176
|
+
</SortableRail>
|
|
151
177
|
</ScrollArea>
|
|
152
178
|
);
|
|
153
179
|
}
|
|
180
|
+
|
|
181
|
+
function thumbButtonClass(active: boolean): string {
|
|
182
|
+
return cn(
|
|
183
|
+
'group/thumb flex w-full items-start gap-2.5 rounded-[6px] p-1.5 text-left motion-safe:transition-colors',
|
|
184
|
+
'hover:bg-muted/60',
|
|
185
|
+
active && 'bg-muted',
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function ThumbContents({
|
|
190
|
+
index,
|
|
191
|
+
active,
|
|
192
|
+
page: PageComp,
|
|
193
|
+
design,
|
|
194
|
+
scale,
|
|
195
|
+
height,
|
|
196
|
+
}: {
|
|
197
|
+
index: number;
|
|
198
|
+
active: boolean;
|
|
199
|
+
page: Page;
|
|
200
|
+
design?: DesignSystem;
|
|
201
|
+
scale: number;
|
|
202
|
+
height: number;
|
|
203
|
+
}) {
|
|
204
|
+
return (
|
|
205
|
+
<>
|
|
206
|
+
<span
|
|
207
|
+
className={cn(
|
|
208
|
+
'mt-1.5 w-7 shrink-0 text-right font-mono text-[10px] font-medium tracking-[0.06em] tabular-nums uppercase',
|
|
209
|
+
active ? 'text-brand' : 'text-muted-foreground/70',
|
|
210
|
+
)}
|
|
211
|
+
>
|
|
212
|
+
{(index + 1).toString().padStart(2, '0')}
|
|
213
|
+
</span>
|
|
214
|
+
<div
|
|
215
|
+
className={cn(
|
|
216
|
+
'relative shrink-0 overflow-hidden rounded-[4px] border bg-card motion-safe:transition-all',
|
|
217
|
+
active
|
|
218
|
+
? 'border-brand shadow-[0_0_0_1px_var(--brand)]'
|
|
219
|
+
: 'border-hairline group-hover/thumb:border-foreground/25',
|
|
220
|
+
)}
|
|
221
|
+
style={{ width: VERTICAL_THUMB_WIDTH, height }}
|
|
222
|
+
>
|
|
223
|
+
<SlideCanvas scale={scale} center={false} flat freezeMotion design={design}>
|
|
224
|
+
<PageComp />
|
|
225
|
+
</SlideCanvas>
|
|
226
|
+
{active && (
|
|
227
|
+
<span
|
|
228
|
+
aria-hidden
|
|
229
|
+
className="pointer-events-none absolute inset-y-0 left-0 w-[2px] bg-brand"
|
|
230
|
+
/>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
</>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function SortableRail({
|
|
238
|
+
pages,
|
|
239
|
+
onReorder,
|
|
240
|
+
children,
|
|
241
|
+
}: {
|
|
242
|
+
pages: Page[];
|
|
243
|
+
onReorder: (from: number, to: number) => void;
|
|
244
|
+
children: React.ReactNode;
|
|
245
|
+
}) {
|
|
246
|
+
const sensors = useSensors(
|
|
247
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
248
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const items = pages.map((_, i) => i + 1);
|
|
252
|
+
|
|
253
|
+
const handleDragEnd = (event: DragEndEvent) => {
|
|
254
|
+
const { active, over } = event;
|
|
255
|
+
if (!over || active.id === over.id) return;
|
|
256
|
+
const from = (active.id as number) - 1;
|
|
257
|
+
const to = (over.id as number) - 1;
|
|
258
|
+
if (from < 0 || to < 0 || from === to) return;
|
|
259
|
+
onReorder(from, to);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
264
|
+
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
|
265
|
+
{children}
|
|
266
|
+
</SortableContext>
|
|
267
|
+
</DndContext>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function SortableThumb({
|
|
272
|
+
index,
|
|
273
|
+
active,
|
|
274
|
+
activeRef,
|
|
275
|
+
onSelect,
|
|
276
|
+
ariaLabel,
|
|
277
|
+
children,
|
|
278
|
+
}: {
|
|
279
|
+
index: number;
|
|
280
|
+
active: boolean;
|
|
281
|
+
activeRef: React.MutableRefObject<HTMLButtonElement | null> | undefined;
|
|
282
|
+
onSelect: () => void;
|
|
283
|
+
ariaLabel: string;
|
|
284
|
+
children: React.ReactNode;
|
|
285
|
+
}) {
|
|
286
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
287
|
+
id: index + 1,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const setRef = (node: HTMLButtonElement | null) => {
|
|
291
|
+
setNodeRef(node);
|
|
292
|
+
if (activeRef) activeRef.current = node;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const yOnlyTransform = transform ? { ...transform, x: 0 } : transform;
|
|
296
|
+
|
|
297
|
+
return (
|
|
298
|
+
<button
|
|
299
|
+
ref={setRef}
|
|
300
|
+
type="button"
|
|
301
|
+
onClick={onSelect}
|
|
302
|
+
aria-label={ariaLabel}
|
|
303
|
+
aria-current={active ? 'true' : undefined}
|
|
304
|
+
style={{
|
|
305
|
+
transform: CSS.Transform.toString(yOnlyTransform),
|
|
306
|
+
transition,
|
|
307
|
+
touchAction: 'none',
|
|
308
|
+
}}
|
|
309
|
+
className={cn(
|
|
310
|
+
thumbButtonClass(active),
|
|
311
|
+
'cursor-grab active:cursor-grabbing',
|
|
312
|
+
isDragging && 'z-10 opacity-60 shadow-edge ring-1 ring-brand',
|
|
313
|
+
)}
|
|
314
|
+
{...attributes}
|
|
315
|
+
{...listeners}
|
|
316
|
+
>
|
|
317
|
+
{children}
|
|
318
|
+
</button>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { type DesignSystem, defaultDesign } from './design';
|
|
2
|
+
|
|
3
|
+
const SANS_SYSTEM = '-apple-system, BlinkMacSystemFont, "Inter", system-ui, sans-serif';
|
|
4
|
+
const SANS_INTER = '"Inter", system-ui, sans-serif';
|
|
5
|
+
const SANS_HELV = '"Helvetica Neue", Helvetica, Arial, sans-serif';
|
|
6
|
+
const SERIF_GEORGIA = 'Georgia, "Times New Roman", serif';
|
|
7
|
+
const SERIF_TIMES = '"Times New Roman", Times, serif';
|
|
8
|
+
const MONO_SF = '"SF Mono", "JetBrains Mono", Menlo, monospace';
|
|
9
|
+
|
|
10
|
+
export const designPresets: DesignSystem[] = [
|
|
11
|
+
defaultDesign,
|
|
12
|
+
{
|
|
13
|
+
palette: { bg: '#0f1115', text: '#f5f3ee', accent: '#7cc4ff' },
|
|
14
|
+
fonts: { display: SERIF_GEORGIA, body: SANS_SYSTEM },
|
|
15
|
+
typeScale: { hero: 192, body: 32 },
|
|
16
|
+
radius: 6,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
palette: { bg: '#eef1f4', text: '#1c2733', accent: '#ff6a5b' },
|
|
20
|
+
fonts: { display: SANS_HELV, body: SANS_SYSTEM },
|
|
21
|
+
typeScale: { hero: 156, body: 30 },
|
|
22
|
+
radius: 8,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
palette: { bg: '#fdf6e3', text: '#073642', accent: '#b58900' },
|
|
26
|
+
fonts: { display: SERIF_GEORGIA, body: SANS_INTER },
|
|
27
|
+
typeScale: { hero: 144, body: 28 },
|
|
28
|
+
radius: 14,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
palette: { bg: '#ede2cc', text: '#3a2a1a', accent: '#2f6e3a' },
|
|
32
|
+
fonts: { display: SERIF_TIMES, body: SERIF_GEORGIA },
|
|
33
|
+
typeScale: { hero: 168, body: 32 },
|
|
34
|
+
radius: 4,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
palette: { bg: '#ffffff', text: '#0a0a0a', accent: '#e11d48' },
|
|
38
|
+
fonts: { display: SANS_HELV, body: SANS_HELV },
|
|
39
|
+
typeScale: { hero: 200, body: 28 },
|
|
40
|
+
radius: 0,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
palette: { bg: '#fde9d9', text: '#3a1f3d', accent: '#f97316' },
|
|
44
|
+
fonts: { display: SERIF_GEORGIA, body: SANS_SYSTEM },
|
|
45
|
+
typeScale: { hero: 184, body: 36 },
|
|
46
|
+
radius: 24,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
palette: { bg: '#e9f5ee', text: '#0f3324', accent: '#ec4899' },
|
|
50
|
+
fonts: { display: SANS_INTER, body: SANS_INTER },
|
|
51
|
+
typeScale: { hero: 160, body: 32 },
|
|
52
|
+
radius: 16,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
palette: { bg: '#0a0a0a', text: '#f3edd9', accent: '#eab308' },
|
|
56
|
+
fonts: { display: SERIF_GEORGIA, body: SANS_HELV },
|
|
57
|
+
typeScale: { hero: 200, body: 32 },
|
|
58
|
+
radius: 2,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
palette: { bg: '#ece2f5', text: '#2a1c4a', accent: '#facc15' },
|
|
62
|
+
fonts: { display: SERIF_GEORGIA, body: SANS_SYSTEM },
|
|
63
|
+
typeScale: { hero: 168, body: 34 },
|
|
64
|
+
radius: 20,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
palette: { bg: '#101418', text: '#a7f3d0', accent: '#fbbf24' },
|
|
68
|
+
fonts: { display: MONO_SF, body: MONO_SF },
|
|
69
|
+
typeScale: { hero: 144, body: 24 },
|
|
70
|
+
radius: 4,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
palette: { bg: '#fafafa', text: '#0a0a0a', accent: '#facc15' },
|
|
74
|
+
fonts: { display: SANS_HELV, body: SANS_HELV },
|
|
75
|
+
typeScale: { hero: 220, body: 32 },
|
|
76
|
+
radius: 0,
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
function pickRandom(): DesignSystem {
|
|
81
|
+
const idx = Math.floor(Math.random() * designPresets.length);
|
|
82
|
+
return designPresets[idx] ?? defaultDesign;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function shuffleDesign(current?: DesignSystem | null): DesignSystem {
|
|
86
|
+
if (designPresets.length === 0) return defaultDesign;
|
|
87
|
+
if (designPresets.length === 1) return designPresets[0] ?? defaultDesign;
|
|
88
|
+
const currentJson = current ? JSON.stringify(current) : null;
|
|
89
|
+
for (let i = 0; i < 8; i++) {
|
|
90
|
+
const pick = pickRandom();
|
|
91
|
+
if (JSON.stringify(pick) !== currentJson) return pick;
|
|
92
|
+
}
|
|
93
|
+
return pickRandom();
|
|
94
|
+
}
|