@open-slide/core 1.0.5 → 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-CoON6kTb.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-Bxtztw-H.js → config-KdiYeWtK.js} +114 -1
- package/dist/{dev-IezNC17X.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-BwYjtENY.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 +96 -19
- package/src/app/components/inspector/inspector-panel.tsx +46 -13
- package/src/app/components/inspector/inspector-provider.tsx +83 -1
- package/src/app/components/inspector/save-bar.tsx +0 -3
- package/src/app/components/player.tsx +22 -26
- package/src/app/components/present/overview-grid.tsx +0 -5
- package/src/app/components/present/use-idle.ts +6 -4
- package/src/app/components/present/use-presenter-channel.ts +3 -10
- package/src/app/components/sidebar/folder-item.tsx +0 -2
- package/src/app/components/sidebar/icon-picker.tsx +0 -3
- package/src/app/components/slide-canvas.tsx +1 -10
- package/src/app/components/style-panel/design-provider.tsx +15 -6
- 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/lib/export-html.ts +1 -9
- package/src/app/lib/export-pdf.ts +0 -5
- package/src/app/lib/print-ready.ts +0 -4
- package/src/app/lib/sdk.ts +1 -2
- 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
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { PANEL_TRANSITION_MS } from '@/components/panel/panel-shell';
|
|
2
3
|
import { findSlideSource, type SlideSourceHit } from '@/lib/inspector/fiber';
|
|
3
4
|
import { useInspector } from './inspector-provider';
|
|
4
5
|
|
|
5
|
-
type Highlight = {
|
|
6
|
+
type Highlight = { hit: SlideSourceHit };
|
|
6
7
|
|
|
7
8
|
type RelRect = { left: number; top: number; width: number; height: number };
|
|
8
9
|
|
|
9
10
|
const FRAME_FADE_MS = 150;
|
|
10
11
|
const FRAME_MORPH_MS = 180;
|
|
12
|
+
const LAYOUT_TRACK_MS = PANEL_TRANSITION_MS + FRAME_MORPH_MS;
|
|
11
13
|
|
|
12
14
|
export function InspectOverlay() {
|
|
13
|
-
const { active, slideId, selected, setSelected, cancel } = useInspector();
|
|
15
|
+
const { active, slideId, selected, setSelected, cancel, openCrop } = useInspector();
|
|
14
16
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
15
17
|
const [hover, setHover] = useState<Highlight | null>(null);
|
|
16
18
|
|
|
@@ -33,7 +35,7 @@ export function InspectOverlay() {
|
|
|
33
35
|
if (!el) return setHover(null);
|
|
34
36
|
const hit = findSlideSource(el, slideId, { hostOnly: true });
|
|
35
37
|
if (!hit) return setHover(null);
|
|
36
|
-
setHover({
|
|
38
|
+
setHover({ hit });
|
|
37
39
|
};
|
|
38
40
|
|
|
39
41
|
const onClick = (e: MouseEvent) => {
|
|
@@ -45,18 +47,33 @@ export function InspectOverlay() {
|
|
|
45
47
|
e.preventDefault();
|
|
46
48
|
e.stopPropagation();
|
|
47
49
|
setSelected({ line: hit.line, column: hit.column, anchor: hit.anchor });
|
|
48
|
-
setHover({
|
|
50
|
+
setHover({ hit });
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const onDblClick = (e: MouseEvent) => {
|
|
54
|
+
if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) return;
|
|
55
|
+
const el = pickElement(e.clientX, e.clientY);
|
|
56
|
+
if (!el) return;
|
|
57
|
+
const hit = findSlideSource(el, slideId, { hostOnly: true });
|
|
58
|
+
if (!hit) return;
|
|
59
|
+
if (!(hit.anchor instanceof HTMLImageElement)) return;
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
e.stopPropagation();
|
|
62
|
+
setSelected({ line: hit.line, column: hit.column, anchor: hit.anchor });
|
|
63
|
+
openCrop(hit.anchor);
|
|
49
64
|
};
|
|
50
65
|
|
|
51
66
|
window.addEventListener('pointermove', onMove, true);
|
|
52
67
|
window.addEventListener('click', onClick, true);
|
|
68
|
+
window.addEventListener('dblclick', onDblClick, true);
|
|
53
69
|
window.addEventListener('keydown', onKey, true);
|
|
54
70
|
return () => {
|
|
55
71
|
window.removeEventListener('pointermove', onMove, true);
|
|
56
72
|
window.removeEventListener('click', onClick, true);
|
|
73
|
+
window.removeEventListener('dblclick', onDblClick, true);
|
|
57
74
|
window.removeEventListener('keydown', onKey, true);
|
|
58
75
|
};
|
|
59
|
-
}, [active, slideId, setSelected, cancel]);
|
|
76
|
+
}, [active, slideId, setSelected, cancel, openCrop]);
|
|
60
77
|
|
|
61
78
|
return (
|
|
62
79
|
<FrameOverlay
|
|
@@ -64,7 +81,7 @@ export function InspectOverlay() {
|
|
|
64
81
|
overlayRef={overlayRef}
|
|
65
82
|
// Pin to the selection so the highlight tracks what the panel
|
|
66
83
|
// is editing even after the cursor moves away.
|
|
67
|
-
|
|
84
|
+
targetAnchor={selected?.anchor ?? hover?.hit.anchor ?? null}
|
|
68
85
|
/>
|
|
69
86
|
);
|
|
70
87
|
}
|
|
@@ -72,26 +89,77 @@ export function InspectOverlay() {
|
|
|
72
89
|
function FrameOverlay({
|
|
73
90
|
active,
|
|
74
91
|
overlayRef,
|
|
75
|
-
|
|
92
|
+
targetAnchor,
|
|
76
93
|
}: {
|
|
77
94
|
active: boolean;
|
|
78
95
|
overlayRef: React.RefObject<HTMLDivElement>;
|
|
79
|
-
|
|
96
|
+
targetAnchor: HTMLElement | null;
|
|
80
97
|
}) {
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
const [rect, setRect] = useState<RelRect | null>(null);
|
|
99
|
+
const [hasTarget, setHasTarget] = useState(false);
|
|
100
|
+
|
|
101
|
+
const measure = useCallback(() => {
|
|
102
|
+
const overlay = overlayRef.current;
|
|
103
|
+
if (!active || !targetAnchor?.isConnected || !overlay) {
|
|
104
|
+
setHasTarget(false);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const targetRect = targetAnchor.getBoundingClientRect();
|
|
109
|
+
const overlayRect = overlay.getBoundingClientRect();
|
|
110
|
+
const next = {
|
|
89
111
|
left: targetRect.left - overlayRect.left,
|
|
90
112
|
top: targetRect.top - overlayRect.top,
|
|
91
113
|
width: targetRect.width,
|
|
92
114
|
height: targetRect.height,
|
|
93
115
|
};
|
|
94
|
-
|
|
116
|
+
|
|
117
|
+
setHasTarget(true);
|
|
118
|
+
setRect((prev) => (sameRect(prev, next) ? prev : next));
|
|
119
|
+
}, [active, overlayRef, targetAnchor]);
|
|
120
|
+
|
|
121
|
+
useLayoutEffect(() => {
|
|
122
|
+
measure();
|
|
123
|
+
}, [measure]);
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!active) {
|
|
127
|
+
setHasTarget(false);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let scheduled = 0;
|
|
132
|
+
let tracking = 0;
|
|
133
|
+
const scheduleMeasure = () => {
|
|
134
|
+
cancelAnimationFrame(scheduled);
|
|
135
|
+
scheduled = requestAnimationFrame(measure);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const resizeObserver = new ResizeObserver(scheduleMeasure);
|
|
139
|
+
const root = document.querySelector<HTMLElement>('[data-inspector-root]');
|
|
140
|
+
if (root) resizeObserver.observe(root);
|
|
141
|
+
if (overlayRef.current) resizeObserver.observe(overlayRef.current);
|
|
142
|
+
if (targetAnchor) resizeObserver.observe(targetAnchor);
|
|
143
|
+
|
|
144
|
+
const stopAt = performance.now() + LAYOUT_TRACK_MS;
|
|
145
|
+
const trackPanelTransition = () => {
|
|
146
|
+
measure();
|
|
147
|
+
if (performance.now() < stopAt) tracking = requestAnimationFrame(trackPanelTransition);
|
|
148
|
+
};
|
|
149
|
+
tracking = requestAnimationFrame(trackPanelTransition);
|
|
150
|
+
|
|
151
|
+
window.addEventListener('resize', scheduleMeasure, true);
|
|
152
|
+
window.addEventListener('scroll', scheduleMeasure, true);
|
|
153
|
+
return () => {
|
|
154
|
+
resizeObserver.disconnect();
|
|
155
|
+
cancelAnimationFrame(scheduled);
|
|
156
|
+
cancelAnimationFrame(tracking);
|
|
157
|
+
window.removeEventListener('resize', scheduleMeasure, true);
|
|
158
|
+
window.removeEventListener('scroll', scheduleMeasure, true);
|
|
159
|
+
};
|
|
160
|
+
}, [active, measure, overlayRef, targetAnchor]);
|
|
161
|
+
|
|
162
|
+
const visible = !!(active && hasTarget && rect);
|
|
95
163
|
|
|
96
164
|
// First render after appearing: snap to the new rect (no transition).
|
|
97
165
|
// Subsequent rect changes in the same visible session: animate.
|
|
@@ -106,7 +174,6 @@ function FrameOverlay({
|
|
|
106
174
|
}, [visible]);
|
|
107
175
|
|
|
108
176
|
if (!active) return null;
|
|
109
|
-
const rect = lastRectRef.current;
|
|
110
177
|
const transition = morph
|
|
111
178
|
? `left ${FRAME_MORPH_MS}ms ease-out, top ${FRAME_MORPH_MS}ms ease-out, ` +
|
|
112
179
|
`width ${FRAME_MORPH_MS}ms ease-out, height ${FRAME_MORPH_MS}ms ease-out, ` +
|
|
@@ -134,6 +201,16 @@ function FrameOverlay({
|
|
|
134
201
|
);
|
|
135
202
|
}
|
|
136
203
|
|
|
204
|
+
function sameRect(a: RelRect | null, b: RelRect): boolean {
|
|
205
|
+
return (
|
|
206
|
+
!!a &&
|
|
207
|
+
Math.abs(a.left - b.left) < 0.5 &&
|
|
208
|
+
Math.abs(a.top - b.top) < 0.5 &&
|
|
209
|
+
Math.abs(a.width - b.width) < 0.5 &&
|
|
210
|
+
Math.abs(a.height - b.height) < 0.5
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
137
214
|
function pickElement(x: number, y: number): HTMLElement | null {
|
|
138
215
|
const stack = document.elementsFromPoint(x, y);
|
|
139
216
|
for (const el of stack) {
|
|
@@ -202,7 +202,12 @@ export function InspectorPanel() {
|
|
|
202
202
|
<>
|
|
203
203
|
<Separator />
|
|
204
204
|
<Section title={t.inspector.imageSection}>
|
|
205
|
-
<ImageField
|
|
205
|
+
<ImageField
|
|
206
|
+
slideId={slideId}
|
|
207
|
+
src={pinSnapshot.imageSrc}
|
|
208
|
+
anchor={pinSelected.anchor}
|
|
209
|
+
apply={apply}
|
|
210
|
+
/>
|
|
206
211
|
</Section>
|
|
207
212
|
</>
|
|
208
213
|
)}
|
|
@@ -587,14 +592,18 @@ function ColorField({
|
|
|
587
592
|
function ImageField({
|
|
588
593
|
slideId,
|
|
589
594
|
src,
|
|
595
|
+
anchor,
|
|
590
596
|
apply,
|
|
591
597
|
}: {
|
|
592
598
|
slideId: string;
|
|
593
599
|
src: string;
|
|
600
|
+
anchor: HTMLElement;
|
|
594
601
|
apply: (ops: EditOp[]) => void;
|
|
595
602
|
}) {
|
|
596
603
|
const [open, setOpen] = useState(false);
|
|
597
604
|
const t = useLocale();
|
|
605
|
+
const { openCrop } = useInspector();
|
|
606
|
+
const isImage = anchor.tagName === 'IMG';
|
|
598
607
|
return (
|
|
599
608
|
<div className="space-y-2">
|
|
600
609
|
<div className="flex items-center gap-3">
|
|
@@ -609,16 +618,29 @@ function ImageField({
|
|
|
609
618
|
}}
|
|
610
619
|
/>
|
|
611
620
|
</div>
|
|
612
|
-
<
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
621
|
+
<div className="flex flex-1 gap-2">
|
|
622
|
+
<Button
|
|
623
|
+
type="button"
|
|
624
|
+
variant="outline"
|
|
625
|
+
size="sm"
|
|
626
|
+
className="flex-1"
|
|
627
|
+
onClick={() => setOpen(true)}
|
|
628
|
+
>
|
|
629
|
+
<ImageIcon className="size-3.5" />
|
|
630
|
+
{t.inspector.replace}
|
|
631
|
+
</Button>
|
|
632
|
+
{isImage && (
|
|
633
|
+
<Button
|
|
634
|
+
type="button"
|
|
635
|
+
variant="outline"
|
|
636
|
+
size="sm"
|
|
637
|
+
className="flex-1"
|
|
638
|
+
onClick={() => openCrop(anchor as HTMLImageElement)}
|
|
639
|
+
>
|
|
640
|
+
{t.inspector.crop}
|
|
641
|
+
</Button>
|
|
642
|
+
)}
|
|
643
|
+
</div>
|
|
622
644
|
</div>
|
|
623
645
|
{open && (
|
|
624
646
|
<AssetPickerDialog
|
|
@@ -626,14 +648,25 @@ function ImageField({
|
|
|
626
648
|
onClose={() => setOpen(false)}
|
|
627
649
|
onPick={(asset) => {
|
|
628
650
|
setOpen(false);
|
|
629
|
-
|
|
651
|
+
const ops: EditOp[] = [
|
|
630
652
|
{
|
|
631
653
|
kind: 'set-attr-asset',
|
|
632
654
|
attr: 'src',
|
|
633
655
|
assetPath: `./assets/${asset.name}`,
|
|
634
656
|
previewUrl: asset.url,
|
|
635
657
|
},
|
|
636
|
-
]
|
|
658
|
+
];
|
|
659
|
+
if (isImage) {
|
|
660
|
+
const cs = window.getComputedStyle(anchor);
|
|
661
|
+
if (cs.objectFit !== 'cover' && cs.objectFit !== 'contain') {
|
|
662
|
+
ops.push({ kind: 'set-style', key: 'objectFit', value: 'cover' });
|
|
663
|
+
}
|
|
664
|
+
const op = cs.objectPosition.trim();
|
|
665
|
+
if (!op || op === '0% 0%' || op === 'auto') {
|
|
666
|
+
ops.push({ kind: 'set-style', key: 'objectPosition', value: '50% 50%' });
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
apply(ops);
|
|
637
670
|
}}
|
|
638
671
|
/>
|
|
639
672
|
)}
|
|
@@ -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() {
|
|
@@ -4,9 +4,6 @@ import { useDesignPanelState } from '@/components/style-panel/design-provider';
|
|
|
4
4
|
import { format, plural, useLocale } from '@/lib/use-locale';
|
|
5
5
|
import { useInspector } from './inspector-provider';
|
|
6
6
|
|
|
7
|
-
// Single save card for both inspector edits and design-token edits.
|
|
8
|
-
// Counts the design draft as one unit; the user sees one combined
|
|
9
|
-
// "N unsaved changes" pill. Save/Discard fan out to both providers.
|
|
10
7
|
export function SaveBar() {
|
|
11
8
|
const insp = useInspector();
|
|
12
9
|
const design = useDesignPanelState();
|
|
@@ -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,20 +45,20 @@ 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);
|
|
72
60
|
const [laser, setLaser] = useState(false);
|
|
61
|
+
const [keyboardDriven, setKeyboardDriven] = useState(false);
|
|
73
62
|
const [startedAt] = useState(() => Date.now());
|
|
74
63
|
|
|
75
64
|
const goPrev = useCallback(() => {
|
|
@@ -97,7 +86,6 @@ export function Player({
|
|
|
97
86
|
onNext: goNext,
|
|
98
87
|
});
|
|
99
88
|
|
|
100
|
-
// ── Fullscreen lifecycle ───────────────────────────────────────────────
|
|
101
89
|
useEffect(() => {
|
|
102
90
|
const el = rootRef.current;
|
|
103
91
|
if (!el) return;
|
|
@@ -118,11 +106,9 @@ export function Player({
|
|
|
118
106
|
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
|
119
107
|
}, [onExit, allowExit]);
|
|
120
108
|
|
|
121
|
-
//
|
|
122
|
-
// Player is the source of truth. It re-publishes state on every change
|
|
109
|
+
// Player is the source of truth: it re-publishes state on every change
|
|
123
110
|
// 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.
|
|
111
|
+
// hydrate immediately.
|
|
126
112
|
const presenterState = useMemo<PresenterState>(
|
|
127
113
|
() => ({ index, pageCount: pages.length, blackout, startedAt }),
|
|
128
114
|
[index, pages.length, blackout, startedAt],
|
|
@@ -155,7 +141,6 @@ export function Player({
|
|
|
155
141
|
channel.send({ type: 'state', state: presenterState });
|
|
156
142
|
}, [controls, channel, presenterState]);
|
|
157
143
|
|
|
158
|
-
// ── Keyboard ───────────────────────────────────────────────────────────
|
|
159
144
|
useEffect(() => {
|
|
160
145
|
const onKey = (e: KeyboardEvent) => {
|
|
161
146
|
const tgt = e.target;
|
|
@@ -197,19 +182,23 @@ export function Player({
|
|
|
197
182
|
|
|
198
183
|
if (isNext) {
|
|
199
184
|
e.preventDefault();
|
|
185
|
+
setKeyboardDriven(true);
|
|
200
186
|
goNext();
|
|
201
187
|
return;
|
|
202
188
|
}
|
|
203
189
|
if (isPrev) {
|
|
204
190
|
e.preventDefault();
|
|
191
|
+
setKeyboardDriven(true);
|
|
205
192
|
goPrev();
|
|
206
193
|
return;
|
|
207
194
|
}
|
|
208
195
|
if (e.key === 'Home') {
|
|
196
|
+
setKeyboardDriven(true);
|
|
209
197
|
onIndexChange(0);
|
|
210
198
|
return;
|
|
211
199
|
}
|
|
212
200
|
if (e.key === 'End') {
|
|
201
|
+
setKeyboardDriven(true);
|
|
213
202
|
onIndexChange(pages.length - 1);
|
|
214
203
|
return;
|
|
215
204
|
}
|
|
@@ -256,14 +245,22 @@ export function Player({
|
|
|
256
245
|
slideId,
|
|
257
246
|
]);
|
|
258
247
|
|
|
259
|
-
// ── Chrome visibility / cursor ─────────────────────────────────────────
|
|
260
248
|
// The control bar + progress strip only surface when the pointer is in
|
|
261
|
-
// the bottom hot zone. Keyboard nav (arrows / space / PgDn) never
|
|
262
|
-
//
|
|
249
|
+
// the bottom hot zone. Keyboard nav (arrows / space / PgDn) never reveals
|
|
250
|
+
// them — intentional so the deck stays clean during a talk.
|
|
263
251
|
const pointerNearBottom = usePointerNearBottom(BAR_HOTZONE_PX, controls && !overlayActive);
|
|
264
252
|
const chromeVisible = pointerNearBottom || overlayActive;
|
|
265
253
|
const idle = useIdle(IDLE_HIDE_MS, controls && !overlayActive);
|
|
266
|
-
|
|
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));
|
|
267
264
|
|
|
268
265
|
const PageComp = pages[index];
|
|
269
266
|
|
|
@@ -279,7 +276,6 @@ export function Player({
|
|
|
279
276
|
{PageComp ? <PageComp /> : null}
|
|
280
277
|
</SlideCanvas>
|
|
281
278
|
|
|
282
|
-
{/* Invisible side click zones — the original mobile-friendly nav. */}
|
|
283
279
|
<button
|
|
284
280
|
type="button"
|
|
285
281
|
aria-label="Previous page"
|
|
@@ -18,11 +18,6 @@ type Props = {
|
|
|
18
18
|
onSelect: (index: number) => void;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
* Full-screen grid of slide thumbnails. Reuses SlideCanvas at fixed scale
|
|
23
|
-
* so each preview is rendered with the slide's design tokens but with
|
|
24
|
-
* motion frozen. Arrow keys move focus; Enter/click jumps and closes.
|
|
25
|
-
*/
|
|
26
21
|
export function PresentOverviewGrid({ pages, design, open, current, onClose, onSelect }: Props) {
|
|
27
22
|
const [focused, setFocused] = useState(current);
|
|
28
23
|
const gridRef = useRef<HTMLDivElement>(null);
|
|
@@ -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
|
};
|
|
@@ -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;
|
|
@@ -130,8 +130,6 @@ export function FolderItem({
|
|
|
130
130
|
<div
|
|
131
131
|
className={cn(
|
|
132
132
|
'group relative flex items-center gap-2.5 rounded-[5px] px-2 py-[5px] text-[12.5px] transition-colors',
|
|
133
|
-
// Editorial selected state: subtle warm tint + a thin vermillion
|
|
134
|
-
// ink-mark on the leading edge. Avoids the heavy "filled pill" look.
|
|
135
133
|
selected
|
|
136
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'
|
|
137
135
|
: 'text-foreground/70 hover:bg-muted/60 hover:text-foreground',
|
|
@@ -3,9 +3,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
|
3
3
|
import type { FolderIcon } from '@/lib/sdk';
|
|
4
4
|
import { useLocale } from '@/lib/use-locale';
|
|
5
5
|
|
|
6
|
-
// Editorial palette — restrained warm/earth tones, no shadcn defaults
|
|
7
|
-
// (no #8b5cf6 violet, no #3b82f6 blue, etc.). Picked to coexist with the
|
|
8
|
-
// vermillion brand accent without shouting over it.
|
|
9
6
|
export const PRESET_COLORS = [
|
|
10
7
|
'#c0392b', // vermillion
|
|
11
8
|
'#b8743e', // ochre
|
|
@@ -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
|
|