@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.
Files changed (131) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/LICENSE +21 -0
  3. package/README.md +16 -0
  4. package/package.json +44 -0
  5. package/src/addons/AddonProvider.tsx +25 -0
  6. package/src/addons/g2/G2Chart.tsx +370 -0
  7. package/src/addons/g2/chartPresets.ts +43 -0
  8. package/src/addons/g2/chartThemeTokens.ts +124 -0
  9. package/src/addons/g2/index.ts +36 -0
  10. package/src/addons/g2/style.css +31 -0
  11. package/src/addons/insight/Insight.tsx +10 -0
  12. package/src/addons/insight/InsightAddonProvider.tsx +20 -0
  13. package/src/addons/insight/SpotlightLayout.tsx +11 -0
  14. package/src/addons/insight/index.ts +17 -0
  15. package/src/addons/insight/style.css +34 -0
  16. package/src/addons/mermaid/MermaidDiagram.tsx +379 -0
  17. package/src/addons/mermaid/index.ts +10 -0
  18. package/src/addons/registry.test.ts +28 -0
  19. package/src/addons/registry.ts +61 -0
  20. package/src/addons/types.ts +6 -0
  21. package/src/app/App.tsx +125 -0
  22. package/src/app/README.md +18 -0
  23. package/src/app/providers/SlidesNavigationProvider.tsx +82 -0
  24. package/src/app/usePresentationBootstrap.ts +85 -0
  25. package/src/features/presentation/PresentationStatus.tsx +514 -0
  26. package/src/features/presentation/PrintSlidesView.tsx +350 -0
  27. package/src/features/presentation/browser.ts +5 -0
  28. package/src/features/presentation/draw/DrawOverlay.tsx +170 -0
  29. package/src/features/presentation/draw/DrawProvider.tsx +394 -0
  30. package/src/features/presentation/draw/persistence.test.ts +80 -0
  31. package/src/features/presentation/draw/persistence.ts +54 -0
  32. package/src/features/presentation/exportArtifacts.test.ts +48 -0
  33. package/src/features/presentation/exportArtifacts.ts +6 -0
  34. package/src/features/presentation/location.test.ts +73 -0
  35. package/src/features/presentation/location.ts +113 -0
  36. package/src/features/presentation/navigation/KeyboardController.tsx +73 -0
  37. package/src/features/presentation/navigation/PresentationNavbar.tsx +162 -0
  38. package/src/features/presentation/navigation/ShortcutsHelpOverlay.test.tsx +24 -0
  39. package/src/features/presentation/navigation/ShortcutsHelpOverlay.tsx +111 -0
  40. package/src/features/presentation/navigation/keyboardShortcuts.test.ts +74 -0
  41. package/src/features/presentation/navigation/keyboardShortcuts.ts +221 -0
  42. package/src/features/presentation/navigation/useSlidesNavigation.ts +15 -0
  43. package/src/features/presentation/overview/NotesOverview.tsx +200 -0
  44. package/src/features/presentation/overview/QuickOverview.tsx +126 -0
  45. package/src/features/presentation/path.ts +137 -0
  46. package/src/features/presentation/presenter/FlowTimelinePreview.test.tsx +54 -0
  47. package/src/features/presentation/presenter/FlowTimelinePreview.tsx +274 -0
  48. package/src/features/presentation/presenter/PresenterModeView.tsx +93 -0
  49. package/src/features/presentation/presenter/PresenterShell.tsx +286 -0
  50. package/src/features/presentation/presenter/PresenterSidePreview.tsx +68 -0
  51. package/src/features/presentation/presenter/PresenterTopProgress.tsx +28 -0
  52. package/src/features/presentation/presenter/SpeakerNotesPanel.tsx +51 -0
  53. package/src/features/presentation/presenter/StandaloneModeView.tsx +36 -0
  54. package/src/features/presentation/presenter/persistence.test.ts +26 -0
  55. package/src/features/presentation/presenter/persistence.ts +31 -0
  56. package/src/features/presentation/presenter/presentationSyncBridge.test.ts +87 -0
  57. package/src/features/presentation/presenter/presentationSyncBridge.ts +82 -0
  58. package/src/features/presentation/presenter/stage.ts +15 -0
  59. package/src/features/presentation/presenter/types.ts +30 -0
  60. package/src/features/presentation/presenter/useFullscreen.ts +58 -0
  61. package/src/features/presentation/presenter/useIdleCursor.ts +37 -0
  62. package/src/features/presentation/presenter/usePresentationFlowRuntime.ts +238 -0
  63. package/src/features/presentation/presenter/usePresenterChromeRuntime.ts +358 -0
  64. package/src/features/presentation/presenter/usePresenterSessionState.ts +226 -0
  65. package/src/features/presentation/presenter/useWakeLock.ts +110 -0
  66. package/src/features/presentation/recordingFilename.test.ts +46 -0
  67. package/src/features/presentation/recordingFilename.ts +56 -0
  68. package/src/features/presentation/reveal/Reveal.tsx +119 -0
  69. package/src/features/presentation/reveal/RevealContext.tsx +29 -0
  70. package/src/features/presentation/reveal/useRevealStep.ts +35 -0
  71. package/src/features/presentation/session.test.ts +122 -0
  72. package/src/features/presentation/session.ts +124 -0
  73. package/src/features/presentation/stage/SlidePreviewSurface.tsx +92 -0
  74. package/src/features/presentation/stage/SlideStage.tsx +159 -0
  75. package/src/features/presentation/stage/slideSurface.ts +71 -0
  76. package/src/features/presentation/stage/slideViewport.tsx +47 -0
  77. package/src/features/presentation/sync/adapters/broadcastChannelTransport.ts +40 -0
  78. package/src/features/presentation/sync/adapters/websocketTransport.ts +128 -0
  79. package/src/features/presentation/sync/model/presence.test.ts +42 -0
  80. package/src/features/presentation/sync/model/presence.ts +33 -0
  81. package/src/features/presentation/sync/model/replication.test.ts +72 -0
  82. package/src/features/presentation/sync/model/replication.ts +113 -0
  83. package/src/features/presentation/sync/model/status.test.ts +52 -0
  84. package/src/features/presentation/sync/model/status.ts +33 -0
  85. package/src/features/presentation/types.ts +1 -0
  86. package/src/features/presentation/usePresentationRecorder.ts +194 -0
  87. package/src/features/presentation/usePresentationSync.ts +423 -0
  88. package/src/index.ts +7 -0
  89. package/src/main.tsx +12 -0
  90. package/src/theme/ThemeProvider.test.ts +36 -0
  91. package/src/theme/ThemeProvider.tsx +79 -0
  92. package/src/theme/__mocks__/active-theme.ts +3 -0
  93. package/src/theme/base.css +14 -0
  94. package/src/theme/components.css +231 -0
  95. package/src/theme/index.css +11 -0
  96. package/src/theme/layouts/center.tsx +9 -0
  97. package/src/theme/layouts/cover.tsx +9 -0
  98. package/src/theme/layouts/default.tsx +5 -0
  99. package/src/theme/layouts/defaultLayouts.ts +20 -0
  100. package/src/theme/layouts/helpers.tsx +12 -0
  101. package/src/theme/layouts/image-right.tsx +21 -0
  102. package/src/theme/layouts/immersive.tsx +9 -0
  103. package/src/theme/layouts/resolveLayout.ts +9 -0
  104. package/src/theme/layouts/section.tsx +9 -0
  105. package/src/theme/layouts/statement.tsx +9 -0
  106. package/src/theme/layouts/two-cols.tsx +21 -0
  107. package/src/theme/layouts/types.ts +1 -0
  108. package/src/theme/layouts.css +133 -0
  109. package/src/theme/mark.css +379 -0
  110. package/src/theme/print.css +106 -0
  111. package/src/theme/prose.css +263 -0
  112. package/src/theme/registry.test.ts +21 -0
  113. package/src/theme/registry.ts +40 -0
  114. package/src/theme/tokens.css +148 -0
  115. package/src/theme/transitions.css +141 -0
  116. package/src/theme/types.ts +9 -0
  117. package/src/theme/useResolvedLayout.ts +24 -0
  118. package/src/types/generated-slides.d.ts +7 -0
  119. package/src/types/mdx-components.ts +7 -0
  120. package/src/types/plantuml-encoder.d.ts +7 -0
  121. package/src/ui/diagrams/PlantUmlDiagram.tsx +33 -0
  122. package/src/ui/mdx/MagicMoveDemo.tsx +114 -0
  123. package/src/ui/mdx/index.ts +21 -0
  124. package/src/ui/primitives/Annotate.test.tsx +64 -0
  125. package/src/ui/primitives/Annotate.tsx +82 -0
  126. package/src/ui/primitives/Badge.tsx +5 -0
  127. package/src/ui/primitives/Callout.tsx +24 -0
  128. package/src/ui/primitives/ChromeIconButton.tsx +58 -0
  129. package/src/ui/primitives/ChromePanel.tsx +79 -0
  130. package/src/ui/primitives/ChromeTag.tsx +70 -0
  131. 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
+ });