@slidev-react/client 0.2.5
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/CHANGELOG.md +42 -0
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/package.json +44 -0
- package/src/addons/AddonProvider.tsx +25 -0
- package/src/addons/g2/G2Chart.tsx +370 -0
- package/src/addons/g2/chartPresets.ts +43 -0
- package/src/addons/g2/chartThemeTokens.ts +124 -0
- package/src/addons/g2/index.ts +36 -0
- package/src/addons/g2/style.css +31 -0
- package/src/addons/insight/Insight.tsx +10 -0
- package/src/addons/insight/InsightAddonProvider.tsx +20 -0
- package/src/addons/insight/SpotlightLayout.tsx +11 -0
- package/src/addons/insight/index.ts +17 -0
- package/src/addons/insight/style.css +34 -0
- package/src/addons/mermaid/MermaidDiagram.tsx +379 -0
- package/src/addons/mermaid/index.ts +10 -0
- package/src/addons/registry.test.ts +28 -0
- package/src/addons/registry.ts +61 -0
- package/src/addons/types.ts +6 -0
- package/src/app/App.tsx +125 -0
- package/src/app/README.md +18 -0
- package/src/app/providers/SlidesNavigationProvider.tsx +82 -0
- package/src/app/usePresentationBootstrap.ts +85 -0
- package/src/features/presentation/PresentationStatus.tsx +514 -0
- package/src/features/presentation/PrintSlidesView.tsx +350 -0
- package/src/features/presentation/browser.ts +5 -0
- package/src/features/presentation/draw/DrawOverlay.tsx +170 -0
- package/src/features/presentation/draw/DrawProvider.tsx +394 -0
- package/src/features/presentation/draw/persistence.test.ts +80 -0
- package/src/features/presentation/draw/persistence.ts +54 -0
- package/src/features/presentation/exportArtifacts.test.ts +48 -0
- package/src/features/presentation/exportArtifacts.ts +6 -0
- package/src/features/presentation/location.test.ts +73 -0
- package/src/features/presentation/location.ts +113 -0
- package/src/features/presentation/navigation/KeyboardController.tsx +73 -0
- package/src/features/presentation/navigation/PresentationNavbar.tsx +162 -0
- package/src/features/presentation/navigation/ShortcutsHelpOverlay.test.tsx +24 -0
- package/src/features/presentation/navigation/ShortcutsHelpOverlay.tsx +111 -0
- package/src/features/presentation/navigation/keyboardShortcuts.test.ts +74 -0
- package/src/features/presentation/navigation/keyboardShortcuts.ts +221 -0
- package/src/features/presentation/navigation/useSlidesNavigation.ts +15 -0
- package/src/features/presentation/overview/NotesOverview.tsx +200 -0
- package/src/features/presentation/overview/QuickOverview.tsx +126 -0
- package/src/features/presentation/path.ts +137 -0
- package/src/features/presentation/presenter/FlowTimelinePreview.test.tsx +54 -0
- package/src/features/presentation/presenter/FlowTimelinePreview.tsx +274 -0
- package/src/features/presentation/presenter/PresenterModeView.tsx +93 -0
- package/src/features/presentation/presenter/PresenterShell.tsx +286 -0
- package/src/features/presentation/presenter/PresenterSidePreview.tsx +68 -0
- package/src/features/presentation/presenter/PresenterTopProgress.tsx +28 -0
- package/src/features/presentation/presenter/SpeakerNotesPanel.tsx +51 -0
- package/src/features/presentation/presenter/StandaloneModeView.tsx +36 -0
- package/src/features/presentation/presenter/persistence.test.ts +26 -0
- package/src/features/presentation/presenter/persistence.ts +31 -0
- package/src/features/presentation/presenter/presentationSyncBridge.test.ts +87 -0
- package/src/features/presentation/presenter/presentationSyncBridge.ts +82 -0
- package/src/features/presentation/presenter/stage.ts +15 -0
- package/src/features/presentation/presenter/types.ts +30 -0
- package/src/features/presentation/presenter/useFullscreen.ts +58 -0
- package/src/features/presentation/presenter/useIdleCursor.ts +37 -0
- package/src/features/presentation/presenter/usePresentationFlowRuntime.ts +238 -0
- package/src/features/presentation/presenter/usePresenterChromeRuntime.ts +358 -0
- package/src/features/presentation/presenter/usePresenterSessionState.ts +226 -0
- package/src/features/presentation/presenter/useWakeLock.ts +110 -0
- package/src/features/presentation/recordingFilename.test.ts +46 -0
- package/src/features/presentation/recordingFilename.ts +56 -0
- package/src/features/presentation/reveal/Reveal.tsx +119 -0
- package/src/features/presentation/reveal/RevealContext.tsx +29 -0
- package/src/features/presentation/reveal/useRevealStep.ts +35 -0
- package/src/features/presentation/session.test.ts +122 -0
- package/src/features/presentation/session.ts +124 -0
- package/src/features/presentation/stage/SlidePreviewSurface.tsx +92 -0
- package/src/features/presentation/stage/SlideStage.tsx +159 -0
- package/src/features/presentation/stage/slideSurface.ts +71 -0
- package/src/features/presentation/stage/slideViewport.tsx +47 -0
- package/src/features/presentation/sync/adapters/broadcastChannelTransport.ts +40 -0
- package/src/features/presentation/sync/adapters/websocketTransport.ts +128 -0
- package/src/features/presentation/sync/model/presence.test.ts +42 -0
- package/src/features/presentation/sync/model/presence.ts +33 -0
- package/src/features/presentation/sync/model/replication.test.ts +72 -0
- package/src/features/presentation/sync/model/replication.ts +113 -0
- package/src/features/presentation/sync/model/status.test.ts +52 -0
- package/src/features/presentation/sync/model/status.ts +33 -0
- package/src/features/presentation/types.ts +1 -0
- package/src/features/presentation/usePresentationRecorder.ts +194 -0
- package/src/features/presentation/usePresentationSync.ts +423 -0
- package/src/index.ts +7 -0
- package/src/main.tsx +12 -0
- package/src/theme/ThemeProvider.test.ts +36 -0
- package/src/theme/ThemeProvider.tsx +79 -0
- package/src/theme/__mocks__/active-theme.ts +3 -0
- package/src/theme/base.css +14 -0
- package/src/theme/components.css +231 -0
- package/src/theme/index.css +11 -0
- package/src/theme/layouts/center.tsx +9 -0
- package/src/theme/layouts/cover.tsx +9 -0
- package/src/theme/layouts/default.tsx +5 -0
- package/src/theme/layouts/defaultLayouts.ts +20 -0
- package/src/theme/layouts/helpers.tsx +12 -0
- package/src/theme/layouts/image-right.tsx +21 -0
- package/src/theme/layouts/immersive.tsx +9 -0
- package/src/theme/layouts/resolveLayout.ts +9 -0
- package/src/theme/layouts/section.tsx +9 -0
- package/src/theme/layouts/statement.tsx +9 -0
- package/src/theme/layouts/two-cols.tsx +21 -0
- package/src/theme/layouts/types.ts +1 -0
- package/src/theme/layouts.css +133 -0
- package/src/theme/mark.css +379 -0
- package/src/theme/print.css +106 -0
- package/src/theme/prose.css +263 -0
- package/src/theme/registry.test.ts +21 -0
- package/src/theme/registry.ts +40 -0
- package/src/theme/tokens.css +148 -0
- package/src/theme/transitions.css +141 -0
- package/src/theme/types.ts +9 -0
- package/src/theme/useResolvedLayout.ts +24 -0
- package/src/types/generated-slides.d.ts +7 -0
- package/src/types/mdx-components.ts +7 -0
- package/src/types/plantuml-encoder.d.ts +7 -0
- package/src/ui/diagrams/PlantUmlDiagram.tsx +33 -0
- package/src/ui/mdx/MagicMoveDemo.tsx +114 -0
- package/src/ui/mdx/index.ts +21 -0
- package/src/ui/primitives/Annotate.test.tsx +64 -0
- package/src/ui/primitives/Annotate.tsx +82 -0
- package/src/ui/primitives/Badge.tsx +5 -0
- package/src/ui/primitives/Callout.tsx +24 -0
- package/src/ui/primitives/ChromeIconButton.tsx +58 -0
- package/src/ui/primitives/ChromePanel.tsx +79 -0
- package/src/ui/primitives/ChromeTag.tsx +70 -0
- package/src/ui/primitives/FormSelect.tsx +51 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildRolePathFromBase,
|
|
3
|
+
buildStandalonePathFromBase,
|
|
4
|
+
parsePresentationPath,
|
|
5
|
+
parseStandalonePath,
|
|
6
|
+
resolvePresentationBasePath,
|
|
7
|
+
type PresentationPathRole,
|
|
8
|
+
} from "./path";
|
|
9
|
+
|
|
10
|
+
export interface PresentationRouteMode {
|
|
11
|
+
kind: "role" | "standalone";
|
|
12
|
+
basePath: string;
|
|
13
|
+
role?: PresentationPathRole;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SlidesLocationState {
|
|
17
|
+
index: number;
|
|
18
|
+
mode: PresentationRouteMode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SessionLocationState {
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
role: "presenter" | "viewer";
|
|
24
|
+
currentSlideNumber: number;
|
|
25
|
+
normalizedPath: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function clampSlideIndex(index: number, total: number): number {
|
|
29
|
+
return Math.min(Math.max(index, 0), Math.max(total - 1, 0));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveSlidesLocationState(pathname: string, total: number): SlidesLocationState {
|
|
33
|
+
const parsedPresentation = parsePresentationPath(pathname);
|
|
34
|
+
if (parsedPresentation) {
|
|
35
|
+
return {
|
|
36
|
+
index: clampSlideIndex((parsedPresentation.slideNumber ?? 1) - 1, total),
|
|
37
|
+
mode: {
|
|
38
|
+
kind: "role",
|
|
39
|
+
role: parsedPresentation.role,
|
|
40
|
+
basePath: parsedPresentation.basePath,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const parsedStandalone = parseStandalonePath(pathname);
|
|
46
|
+
if (parsedStandalone) {
|
|
47
|
+
return {
|
|
48
|
+
index: clampSlideIndex(parsedStandalone.slideNumber - 1, total),
|
|
49
|
+
mode: {
|
|
50
|
+
kind: "standalone",
|
|
51
|
+
basePath: parsedStandalone.basePath,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
index: clampSlideIndex(0, total),
|
|
58
|
+
mode: {
|
|
59
|
+
kind: "standalone",
|
|
60
|
+
basePath: resolvePresentationBasePath(pathname),
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function resolveSessionLocationState(pathname: string): SessionLocationState {
|
|
66
|
+
const parsedPresentation = parsePresentationPath(pathname);
|
|
67
|
+
if (parsedPresentation) {
|
|
68
|
+
const currentSlideNumber = parsedPresentation.slideNumber ?? 1;
|
|
69
|
+
return {
|
|
70
|
+
enabled: true,
|
|
71
|
+
role: "presenter",
|
|
72
|
+
currentSlideNumber,
|
|
73
|
+
normalizedPath: buildRolePathFromBase(
|
|
74
|
+
parsedPresentation.basePath,
|
|
75
|
+
parsedPresentation.role,
|
|
76
|
+
currentSlideNumber,
|
|
77
|
+
),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const parsedStandalone = parseStandalonePath(pathname);
|
|
82
|
+
if (parsedStandalone) {
|
|
83
|
+
return {
|
|
84
|
+
enabled: true,
|
|
85
|
+
role: "viewer",
|
|
86
|
+
currentSlideNumber: parsedStandalone.slideNumber,
|
|
87
|
+
normalizedPath: buildStandalonePathFromBase(
|
|
88
|
+
parsedStandalone.basePath,
|
|
89
|
+
parsedStandalone.slideNumber,
|
|
90
|
+
),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
enabled: false,
|
|
96
|
+
role: "viewer",
|
|
97
|
+
currentSlideNumber: 1,
|
|
98
|
+
normalizedPath: null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function buildSlidesPath(mode: PresentationRouteMode, index: number) {
|
|
103
|
+
const slideNumber = index + 1;
|
|
104
|
+
return mode.kind === "role"
|
|
105
|
+
? buildRolePathFromBase(mode.basePath, mode.role ?? "presenter", slideNumber)
|
|
106
|
+
: buildStandalonePathFromBase(mode.basePath, slideNumber);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function normalizePathname(pathname: string) {
|
|
110
|
+
if (pathname.length > 1 && pathname.endsWith("/")) return pathname.slice(0, -1);
|
|
111
|
+
|
|
112
|
+
return pathname;
|
|
113
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useReveal } from "../reveal/RevealContext";
|
|
3
|
+
import { resolveNavigationShortcutAction } from "./keyboardShortcuts";
|
|
4
|
+
import { useSlidesNavigation } from "./useSlidesNavigation";
|
|
5
|
+
import { isTypingElement } from "../browser";
|
|
6
|
+
|
|
7
|
+
export function KeyboardController({
|
|
8
|
+
enabled = true,
|
|
9
|
+
overlayOpen = false,
|
|
10
|
+
onAdvance,
|
|
11
|
+
onRetreat,
|
|
12
|
+
onFirst,
|
|
13
|
+
onLast,
|
|
14
|
+
}: {
|
|
15
|
+
enabled?: boolean;
|
|
16
|
+
overlayOpen?: boolean;
|
|
17
|
+
onAdvance?: () => void;
|
|
18
|
+
onRetreat?: () => void;
|
|
19
|
+
onFirst?: () => void;
|
|
20
|
+
onLast?: () => void;
|
|
21
|
+
}) {
|
|
22
|
+
const navigation = useSlidesNavigation();
|
|
23
|
+
const reveal = useReveal();
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!enabled) return;
|
|
27
|
+
|
|
28
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
29
|
+
if (isTypingElement(event.target)) return;
|
|
30
|
+
|
|
31
|
+
if (overlayOpen) return;
|
|
32
|
+
|
|
33
|
+
const action = resolveNavigationShortcutAction({
|
|
34
|
+
key: event.key,
|
|
35
|
+
shiftKey: event.shiftKey,
|
|
36
|
+
});
|
|
37
|
+
if (!action) return;
|
|
38
|
+
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
|
|
41
|
+
if (action === "advance") {
|
|
42
|
+
if (onAdvance) onAdvance();
|
|
43
|
+
else if (reveal) reveal.advance();
|
|
44
|
+
else navigation.next();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (action === "retreat") {
|
|
49
|
+
if (onRetreat) onRetreat();
|
|
50
|
+
else if (reveal) reveal.retreat();
|
|
51
|
+
else navigation.prev();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (action === "first") {
|
|
56
|
+
if (onFirst) onFirst();
|
|
57
|
+
else navigation.first();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (action === "last") {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
if (onLast) onLast();
|
|
64
|
+
else navigation.last();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
window.addEventListener("keydown", onKeyDown);
|
|
69
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
70
|
+
}, [enabled, navigation, onAdvance, onFirst, onLast, onRetreat, overlayOpen, reveal]);
|
|
71
|
+
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BookOpenText,
|
|
3
|
+
ChevronLeft,
|
|
4
|
+
ChevronRight,
|
|
5
|
+
Keyboard,
|
|
6
|
+
LayoutGrid,
|
|
7
|
+
NotebookText,
|
|
8
|
+
PenLine,
|
|
9
|
+
Radio,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import { useState } from "react";
|
|
12
|
+
import { useDraw } from "../draw/DrawProvider";
|
|
13
|
+
import { ChromeIconButton } from "../../../ui/primitives/ChromeIconButton";
|
|
14
|
+
|
|
15
|
+
function DrawControls() {
|
|
16
|
+
const draw = useDraw();
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<ChromeIconButton
|
|
20
|
+
onClick={draw.toggleEnabled}
|
|
21
|
+
title="Toggle draw (D)"
|
|
22
|
+
aria-label="Toggle draw mode"
|
|
23
|
+
tone={draw.enabled ? "active" : "default"}
|
|
24
|
+
size="sm"
|
|
25
|
+
radius="soft"
|
|
26
|
+
>
|
|
27
|
+
<PenLine size={16} />
|
|
28
|
+
</ChromeIconButton>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function PresentationNavbar({
|
|
33
|
+
slideTitle,
|
|
34
|
+
currentIndex,
|
|
35
|
+
total,
|
|
36
|
+
canPrev,
|
|
37
|
+
canNext,
|
|
38
|
+
showPresenterModeButton,
|
|
39
|
+
overviewOpen,
|
|
40
|
+
notesOpen,
|
|
41
|
+
shortcutsOpen,
|
|
42
|
+
canOpenOverview,
|
|
43
|
+
onEnterPresenterMode,
|
|
44
|
+
onToggleOverview,
|
|
45
|
+
onToggleNotes,
|
|
46
|
+
onToggleShortcuts,
|
|
47
|
+
onPrev,
|
|
48
|
+
onNext,
|
|
49
|
+
canControl,
|
|
50
|
+
}: {
|
|
51
|
+
slideTitle?: string;
|
|
52
|
+
currentIndex: number;
|
|
53
|
+
total: number;
|
|
54
|
+
canPrev: boolean;
|
|
55
|
+
canNext: boolean;
|
|
56
|
+
showPresenterModeButton: boolean;
|
|
57
|
+
overviewOpen: boolean;
|
|
58
|
+
notesOpen: boolean;
|
|
59
|
+
shortcutsOpen: boolean;
|
|
60
|
+
canOpenOverview: boolean;
|
|
61
|
+
onEnterPresenterMode?: () => void;
|
|
62
|
+
onToggleOverview: () => void;
|
|
63
|
+
onToggleNotes: () => void;
|
|
64
|
+
onToggleShortcuts: () => void;
|
|
65
|
+
onPrev: () => void;
|
|
66
|
+
onNext: () => void;
|
|
67
|
+
canControl: boolean;
|
|
68
|
+
}) {
|
|
69
|
+
const [open, setOpen] = useState(false);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
className="absolute bottom-0 left-4 z-40"
|
|
74
|
+
onMouseEnter={() => setOpen(true)}
|
|
75
|
+
onMouseLeave={() => setOpen(false)}
|
|
76
|
+
>
|
|
77
|
+
<div aria-hidden className="h-14 w-20 rounded-t-2xl" />
|
|
78
|
+
<nav
|
|
79
|
+
className={`absolute bottom-0 left-0 flex items-center gap-1 rounded-t-xl border border-b-0 border-slate-200 bg-white/95 px-2 py-1.5 text-slate-800 ring-1 ring-black/5 backdrop-blur-md transition-[opacity,transform] ${open ? "pointer-events-auto translate-y-0 opacity-100 duration-0" : "pointer-events-none translate-y-2 opacity-0 duration-180"}`}
|
|
80
|
+
aria-label="Presentation navbar"
|
|
81
|
+
>
|
|
82
|
+
<ChromeIconButton
|
|
83
|
+
title={`${slideTitle ?? "Slide"} (${currentIndex + 1}/${total})`}
|
|
84
|
+
aria-label="Current slide info"
|
|
85
|
+
size="sm"
|
|
86
|
+
radius="soft"
|
|
87
|
+
>
|
|
88
|
+
<BookOpenText size={15} />
|
|
89
|
+
</ChromeIconButton>
|
|
90
|
+
<ChromeIconButton
|
|
91
|
+
onClick={onToggleShortcuts}
|
|
92
|
+
title="Keyboard shortcuts (?)"
|
|
93
|
+
aria-label="Toggle keyboard shortcuts"
|
|
94
|
+
tone={shortcutsOpen ? "active" : "default"}
|
|
95
|
+
size="sm"
|
|
96
|
+
radius="soft"
|
|
97
|
+
>
|
|
98
|
+
<Keyboard size={15} />
|
|
99
|
+
</ChromeIconButton>
|
|
100
|
+
<ChromeIconButton
|
|
101
|
+
onClick={onToggleNotes}
|
|
102
|
+
title="Notes Workspace (N)"
|
|
103
|
+
aria-label="Toggle notes workspace"
|
|
104
|
+
tone={notesOpen ? "active" : "default"}
|
|
105
|
+
size="sm"
|
|
106
|
+
radius="soft"
|
|
107
|
+
disabled={!canControl}
|
|
108
|
+
>
|
|
109
|
+
<NotebookText size={16} />
|
|
110
|
+
</ChromeIconButton>
|
|
111
|
+
<ChromeIconButton
|
|
112
|
+
onClick={onToggleOverview}
|
|
113
|
+
title="Quick Overview (O)"
|
|
114
|
+
aria-label="Toggle quick overview"
|
|
115
|
+
tone={overviewOpen ? "active" : "default"}
|
|
116
|
+
size="sm"
|
|
117
|
+
radius="soft"
|
|
118
|
+
disabled={!canOpenOverview}
|
|
119
|
+
>
|
|
120
|
+
<LayoutGrid size={16} />
|
|
121
|
+
</ChromeIconButton>
|
|
122
|
+
{showPresenterModeButton && (
|
|
123
|
+
<ChromeIconButton
|
|
124
|
+
onClick={onEnterPresenterMode}
|
|
125
|
+
title="Enter presenter mode"
|
|
126
|
+
aria-label="Enter presenter mode"
|
|
127
|
+
size="sm"
|
|
128
|
+
radius="soft"
|
|
129
|
+
>
|
|
130
|
+
<Radio size={15} />
|
|
131
|
+
</ChromeIconButton>
|
|
132
|
+
)}
|
|
133
|
+
{canControl && (
|
|
134
|
+
<>
|
|
135
|
+
<ChromeIconButton
|
|
136
|
+
onClick={onPrev}
|
|
137
|
+
disabled={!canPrev}
|
|
138
|
+
title="Previous slide"
|
|
139
|
+
aria-label="Previous slide"
|
|
140
|
+
size="sm"
|
|
141
|
+
radius="soft"
|
|
142
|
+
>
|
|
143
|
+
<ChevronLeft size={16} />
|
|
144
|
+
</ChromeIconButton>
|
|
145
|
+
<ChromeIconButton
|
|
146
|
+
onClick={onNext}
|
|
147
|
+
disabled={!canNext}
|
|
148
|
+
title="Next slide"
|
|
149
|
+
aria-label="Next slide"
|
|
150
|
+
size="sm"
|
|
151
|
+
radius="soft"
|
|
152
|
+
>
|
|
153
|
+
<ChevronRight size={16} />
|
|
154
|
+
</ChromeIconButton>
|
|
155
|
+
<div className="mx-1 h-5 w-px bg-slate-200" aria-hidden />
|
|
156
|
+
<DrawControls />
|
|
157
|
+
</>
|
|
158
|
+
)}
|
|
159
|
+
</nav>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { ShortcutsHelpOverlay } from "./ShortcutsHelpOverlay";
|
|
4
|
+
import { buildShortcutHelpSections } from "./keyboardShortcuts";
|
|
5
|
+
|
|
6
|
+
describe("ShortcutsHelpOverlay", () => {
|
|
7
|
+
it("renders the supported shortcut groups and help triggers", () => {
|
|
8
|
+
const html = renderToStaticMarkup(
|
|
9
|
+
<ShortcutsHelpOverlay
|
|
10
|
+
open
|
|
11
|
+
sections={buildShortcutHelpSections({
|
|
12
|
+
canControl: true,
|
|
13
|
+
canOpenOverview: true,
|
|
14
|
+
})}
|
|
15
|
+
onClose={() => {}}
|
|
16
|
+
/>,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
expect(html).toContain("Keyboard Shortcuts");
|
|
20
|
+
expect(html).toContain("Shift Shift");
|
|
21
|
+
expect(html).toContain("Toggle quick overview");
|
|
22
|
+
expect(html).toContain("Toggle draw mode");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Keyboard, X } from "lucide-react";
|
|
2
|
+
import { ChromeIconButton } from "../../../ui/primitives/ChromeIconButton";
|
|
3
|
+
import { ChromePanel } from "../../../ui/primitives/ChromePanel";
|
|
4
|
+
import { ChromeTag } from "../../../ui/primitives/ChromeTag";
|
|
5
|
+
import type { ShortcutHelpSection } from "./keyboardShortcuts";
|
|
6
|
+
|
|
7
|
+
function ShortcutKeys({ value }: { value: string }) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
10
|
+
{value.split(" / ").map((part, index, list) => (
|
|
11
|
+
<span key={`${part}-${index}`} className="inline-flex items-center gap-1">
|
|
12
|
+
<kbd className="rounded-md border border-slate-200 bg-white px-1.5 py-0.5 font-mono text-[10px] font-semibold text-slate-700 shadow-[inset_0_1px_0_rgba(255,255,255,0.72)]">
|
|
13
|
+
{part}
|
|
14
|
+
</kbd>
|
|
15
|
+
{index < list.length - 1 ? <span className="text-[10px] text-slate-300">/</span> : null}
|
|
16
|
+
</span>
|
|
17
|
+
))}
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ShortcutsHelpOverlay({
|
|
23
|
+
open,
|
|
24
|
+
sections,
|
|
25
|
+
onClose,
|
|
26
|
+
}: {
|
|
27
|
+
open: boolean;
|
|
28
|
+
sections: ShortcutHelpSection[];
|
|
29
|
+
onClose: () => void;
|
|
30
|
+
}) {
|
|
31
|
+
if (!open) return null;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="absolute inset-0 z-50 bg-black/30 backdrop-blur-sm">
|
|
35
|
+
<div className="mx-auto flex h-full w-full max-w-[1640px] items-center justify-center px-4 py-4 sm:px-6">
|
|
36
|
+
<ChromePanel
|
|
37
|
+
tone="solid"
|
|
38
|
+
radius="section"
|
|
39
|
+
padding="none"
|
|
40
|
+
className="flex max-h-full w-full max-w-[1180px] flex-col overflow-hidden bg-white/90"
|
|
41
|
+
>
|
|
42
|
+
<header className="border-b border-slate-200/80 px-4 py-4 sm:px-5">
|
|
43
|
+
<div className="flex items-start justify-between gap-4">
|
|
44
|
+
<div className="min-w-0">
|
|
45
|
+
<div className="mb-2 flex flex-wrap items-center gap-2">
|
|
46
|
+
<ChromeTag
|
|
47
|
+
tone="active"
|
|
48
|
+
size="sm"
|
|
49
|
+
weight="semibold"
|
|
50
|
+
className="uppercase tracking-[0.18em]"
|
|
51
|
+
>
|
|
52
|
+
<Keyboard size={13} />
|
|
53
|
+
Keyboard Shortcuts
|
|
54
|
+
</ChromeTag>
|
|
55
|
+
<ChromeTag size="sm">Press `?` or double-tap `Shift`</ChromeTag>
|
|
56
|
+
</div>
|
|
57
|
+
<h2 className="text-lg font-semibold text-slate-950 sm:text-xl">
|
|
58
|
+
Everything the runtime can do from the keyboard
|
|
59
|
+
</h2>
|
|
60
|
+
<p className="mt-1.5 max-w-3xl text-sm leading-5 text-slate-600">
|
|
61
|
+
This list only shows shortcuts that are actually implemented right now, so it
|
|
62
|
+
stays trustworthy as the product evolves.
|
|
63
|
+
</p>
|
|
64
|
+
</div>
|
|
65
|
+
<ChromeIconButton
|
|
66
|
+
onClick={onClose}
|
|
67
|
+
aria-label="Close keyboard shortcuts"
|
|
68
|
+
title="Close keyboard shortcuts"
|
|
69
|
+
className="rounded-full"
|
|
70
|
+
>
|
|
71
|
+
<X size={18} />
|
|
72
|
+
</ChromeIconButton>
|
|
73
|
+
</div>
|
|
74
|
+
</header>
|
|
75
|
+
<div className="min-h-0 overflow-auto px-4 py-4 sm:px-5 sm:py-5">
|
|
76
|
+
<div className="grid gap-3 lg:grid-cols-3">
|
|
77
|
+
{sections.map((section) => (
|
|
78
|
+
<ChromePanel
|
|
79
|
+
key={section.title}
|
|
80
|
+
tone="frame"
|
|
81
|
+
radius="section"
|
|
82
|
+
className="overflow-hidden border border-slate-200/80 bg-slate-50/72"
|
|
83
|
+
>
|
|
84
|
+
<div className="border-b border-slate-200/75 px-3.5 py-3">
|
|
85
|
+
<h3 className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-700">
|
|
86
|
+
{section.title}
|
|
87
|
+
</h3>
|
|
88
|
+
<p className="mt-1.5 text-xs leading-5 text-slate-500">{section.description}</p>
|
|
89
|
+
</div>
|
|
90
|
+
<div className="px-3 py-2.5">
|
|
91
|
+
<div className="space-y-2">
|
|
92
|
+
{section.items.map((item) => (
|
|
93
|
+
<div
|
|
94
|
+
key={`${section.title}:${item.keys}:${item.action}`}
|
|
95
|
+
className="flex flex-col gap-1.5 rounded-md border border-white/80 bg-white/82 px-2.5 py-2.5"
|
|
96
|
+
>
|
|
97
|
+
<ShortcutKeys value={item.keys} />
|
|
98
|
+
<div className="text-[13px] leading-5 text-slate-700">{item.action}</div>
|
|
99
|
+
</div>
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</ChromePanel>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</ChromePanel>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildShortcutHelpSections,
|
|
4
|
+
createShortcutHelpTriggerState,
|
|
5
|
+
isShortcutHelpOpenKey,
|
|
6
|
+
registerShortcutHelpKeyDown,
|
|
7
|
+
registerShortcutHelpKeyUp,
|
|
8
|
+
resolveNavigationShortcutAction,
|
|
9
|
+
} from "./keyboardShortcuts";
|
|
10
|
+
|
|
11
|
+
describe("keyboardShortcuts", () => {
|
|
12
|
+
it("treats shift+space as retreat and plain space as advance", () => {
|
|
13
|
+
expect(resolveNavigationShortcutAction({ key: " ", shiftKey: false })).toBe("advance");
|
|
14
|
+
expect(resolveNavigationShortcutAction({ key: " ", shiftKey: true })).toBe("retreat");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("detects question-mark help shortcut without modifiers", () => {
|
|
18
|
+
expect(
|
|
19
|
+
isShortcutHelpOpenKey({
|
|
20
|
+
key: "?",
|
|
21
|
+
shiftKey: true,
|
|
22
|
+
metaKey: false,
|
|
23
|
+
ctrlKey: false,
|
|
24
|
+
altKey: false,
|
|
25
|
+
}),
|
|
26
|
+
).toBe(true);
|
|
27
|
+
|
|
28
|
+
expect(
|
|
29
|
+
isShortcutHelpOpenKey({
|
|
30
|
+
key: "?",
|
|
31
|
+
shiftKey: true,
|
|
32
|
+
metaKey: true,
|
|
33
|
+
ctrlKey: false,
|
|
34
|
+
altKey: false,
|
|
35
|
+
}),
|
|
36
|
+
).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("toggles on an isolated double shift tap", () => {
|
|
40
|
+
let state = createShortcutHelpTriggerState();
|
|
41
|
+
|
|
42
|
+
state = registerShortcutHelpKeyDown(state, "Shift");
|
|
43
|
+
let result = registerShortcutHelpKeyUp(state, "Shift", 100);
|
|
44
|
+
expect(result.shouldToggle).toBe(false);
|
|
45
|
+
|
|
46
|
+
state = registerShortcutHelpKeyDown(result.nextState, "Shift");
|
|
47
|
+
result = registerShortcutHelpKeyUp(state, "Shift", 320);
|
|
48
|
+
expect(result.shouldToggle).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does not toggle when shift was used as part of another shortcut chord", () => {
|
|
52
|
+
let state = createShortcutHelpTriggerState();
|
|
53
|
+
|
|
54
|
+
state = registerShortcutHelpKeyDown(state, "Shift");
|
|
55
|
+
state = registerShortcutHelpKeyDown(state, "/");
|
|
56
|
+
|
|
57
|
+
const result = registerShortcutHelpKeyUp(state, "Shift", 220);
|
|
58
|
+
expect(result.shouldToggle).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("only includes draw shortcuts when local control is available", () => {
|
|
62
|
+
const viewerSections = buildShortcutHelpSections({
|
|
63
|
+
canControl: false,
|
|
64
|
+
canOpenOverview: true,
|
|
65
|
+
});
|
|
66
|
+
const presenterSections = buildShortcutHelpSections({
|
|
67
|
+
canControl: true,
|
|
68
|
+
canOpenOverview: true,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(viewerSections.some((section) => section.title === "Draw")).toBe(false);
|
|
72
|
+
expect(presenterSections.some((section) => section.title === "Draw")).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
});
|