refactor(navigation): suppress the Space hotkey by prop

Replaces the view hotkey store with a suppressSpaceHotkey prop on
ViewNavigationMenu, alongside the suppressSettings prop which already exists
for exactly this purpose: a view opting out of a navigation chrome behaviour.

The store had one advantage, that it covered the preset route without the
router naming the view. That is a single expression in PresetView, and paying
for it with a global claim was the wrong trade: the claim was lifecycle state
that had to be released on unmount to avoid stranding Space for the whole
client, where a prop simply describes the route.

Verified both routes: /teleprompter and a locked preset targeting the
teleprompter leave Space to the view, while the timer and a preset targeting
the timer still open the navigation menu with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
This commit is contained in:
Claude
2026-08-13 19:13:02 +00:00
parent 323b30ea68
commit e9289685fd
4 changed files with 14 additions and 42 deletions
+6 -2
View File
@@ -100,7 +100,7 @@ export default function AppRouter() {
path='teleprompter'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSpaceHotkey />
<Teleprompter />
</ViewLoader>
}
@@ -225,7 +225,11 @@ function PresetView() {
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
return (
<PresetContext value={preset}>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
<ViewNavigationMenu
isNavigationLocked={getIsNavigationLocked()}
suppressSettings
suppressSpaceHotkey={preset.target === OntimeView.Teleprompter}
/>
{Component ? <Component /> : <NotFound />}
</PresetContext>
);
@@ -3,7 +3,6 @@ import { memo } from 'react';
import { useSearchParams } from 'react-router';
import { hasCustomParams } from '../../stores/savedViewParams';
import { useViewHotkeysStore } from '../../stores/viewHotkeys';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation';
import NavigationMenu from './NavigationMenu';
@@ -14,10 +13,12 @@ interface ViewNavigationMenuProps {
isNavigationLocked?: boolean;
/** prevent showing settings */
suppressSettings?: boolean;
/** leave Space to the view, for views which need the key themselves */
suppressSpaceHotkey?: boolean;
}
export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
function ViewNavigationMenu({ isNavigationLocked, suppressSettings, suppressSpaceHotkey }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams();
@@ -27,9 +28,7 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
[
'Space',
() => {
if (isNavigationLocked) return;
// a view can take over Space for itself, see viewHotkeys store
if (useViewHotkeysStore.getState().spaceClaimed) return;
if (isNavigationLocked || suppressSpaceHotkey) return;
menuHandler.toggle();
},
{ preventDefault: true },
@@ -1,23 +0,0 @@
import { create } from 'zustand';
interface ViewHotkeysStore {
/** a view has taken over the Space key and the navigation menu must not react to it */
spaceClaimed: boolean;
claimSpace: () => void;
releaseSpace: () => void;
}
/**
* Lets a view take ownership of a hotkey which the view chrome also binds.
*
* The navigation menu binds Space at document level, but the teleprompter needs
* it for playback, which is the convention every prompter and every foot pedal
* follows. Prop threading does not work here because AppRouter renders
* ViewNavigationMenu generically for presets, so the claim lives in a store
* which the view sets on mount and clears on unmount.
*/
export const useViewHotkeysStore = create<ViewHotkeysStore>((set) => ({
spaceClaimed: false,
claimSpace: () => set({ spaceClaimed: true }),
releaseSpace: () => set({ spaceClaimed: false }),
}));
@@ -1,7 +1,6 @@
import { useEffect, useRef } from 'react';
import { useViewParamsEditorStore } from '../../common/components/view-params-editor/viewParamsEditor.store';
import { useViewHotkeysStore } from '../../common/stores/viewHotkeys';
import { resolveTeleprompterAction } from './teleprompter.keymap';
import type { TeleprompterAction, TeleprompterController } from './teleprompter.types';
@@ -18,22 +17,15 @@ const ignoredTags = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
/**
* Binds the prompter keymap.
*
* Space is claimed from the navigation menu for the lifetime of the view: it is
* the universal run/stop key on prompter software and on the centre pedal of
* every three pedal controller, so the view cannot leave it to the menu.
* Space is the universal run/stop key on prompter software and the centre pedal
* of every three pedal controller, so this view cannot leave it to the
* navigation menu. The routes which render the teleprompter pass
* suppressSpaceHotkey to ViewNavigationMenu so the menu stands down.
*/
export function useTeleprompterControls(args: UseTeleprompterControlsArgs) {
const argsRef = useRef(args);
argsRef.current = args;
const claimSpace = useViewHotkeysStore((state) => state.claimSpace);
const releaseSpace = useViewHotkeysStore((state) => state.releaseSpace);
useEffect(() => {
claimSpace();
return releaseSpace;
}, [claimSpace, releaseSpace]);
useEffect(() => {
function applyAction(action: TeleprompterAction) {
const { controller, onFlip, onFontSize, onResetFontSize, onToggleHelp } = argsRef.current;