wrolpi/app/src/components/KeyboardShortcutsProvider.js
Roland e00f0439da
ui: the last of Semantic UI, in name and in dead CSS
The library has been uninstalled for some time, but the codebase still read as
though it were there: identifiers named after it, rules written against its
class names, and about 180 comments explaining a rule in terms of a thing that
is no longer installed.

Renamed, keeping the vocabulary the app actually uses: semanticUIColorMap ->
navColorHexMap, semanticColorNames -> paletteColorNames, semanticNames ->
iconNameAliases, semanticSizes -> sizeAliases, and SemanticUIColors ->
NavColors in wrolpi/schema.py, which is what validates nav_color on the way in.
ColorToSemanticHexColor had no callers at all and is gone.

The baseline ratchet has done its job -- the list reached zero, so the JSON is
deleted and the guard keeps only the checks with teeth: no package, no import,
no `className="ui "`, no toast library.  Its Theme.tsx check went with the
wrappers it was watching for.  It also asserts its own walk found files, so a
clean result cannot mean it scanned nothing.

Deleted from App.css: every rule requiring `.ui`, `.inverted`, `.segment` or
`.modal`, none of which any component has set since the stylesheet stopped
loading, plus the form skeletons, ui-alerts and progress-text rules that
nothing renders.  Two of them were doing harm rather than nothing: the drag
tints had a dark-theme variant under `.inverted` that stopped applying, leaving
15% of a fixed blue over a dark row, and now mix a token instead.

Two selectors naming markup that no longer exists were live bugs.  The file
browser's filter had `.ui.input.file-browser-filter`, so it rendered at the
browser default width on a bar laid out to give it 34em -- now qualified by its
container, because `.wrolpi-action-input` sets `width: 100%` at the same
specificity and is loaded later.  And SearchBox takes named props rather than a
rest spread, so the `id` DestinationForm hands it was dropped in silence and
the label it renders pointed at nothing; the download forms' Destination field
has had no associated label since the migration.

"Semantic roles" survives in four places, where it means what it says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:54 -06:00

248 lines
8.7 KiB
JavaScript

import React, {useCallback, useEffect, useRef, useState} from "react";
import {useNavigate} from "react-router";
import {useHotkeys} from "react-hotkeys-hook";
import {KeyboardShortcutsContext} from "../contexts/KeyboardShortcutsContext";
import {Modal} from "./ui";
import {SearchResultsInput} from "./Common";
import {useSearchSuggestions} from "./Search";
import HelpModal from "./HelpModal";
// Define all keyboard shortcuts with their metadata
export const SHORTCUTS = [
// Search & Help
{keys: 'meta+k, ctrl+k', description: 'Open search', category: 'Search'},
{keys: '/', description: 'Open search', category: 'Search', enableOnFormTags: false},
{keys: 'escape', description: 'Close modal', category: 'General'},
{keys: 'shift+/', description: 'Show keyboard shortcuts', category: 'Help'},
// Navigation (g+ sequences)
{keys: 'g h', description: 'Go to Home', category: 'Navigation', path: '/'},
{keys: 'g v', description: 'Go to Videos', category: 'Navigation', path: '/videos'},
{keys: 'g a', description: 'Go to Archives', category: 'Navigation', path: '/archives'},
{keys: 'g m', description: 'Go to Map', category: 'Navigation', path: '/map'},
{keys: 'g z', description: 'Go to Zim', category: 'Navigation', path: '/zim'},
{keys: 'g f', description: 'Go to Files', category: 'Navigation', path: '/files'},
{keys: 'g s', description: 'Go to Settings', category: 'Navigation', path: '/admin'},
{keys: 'g i', description: 'Go to Inventory', category: 'Navigation', path: '/inventory'},
// Page Search
{keys: 'f', description: 'Focus search input', category: 'Page Search'},
// Video Player
{keys: 'k', description: 'Play/pause video', category: 'Video Player'},
{keys: 'j', description: 'Jump back 15 seconds', category: 'Video Player'},
{keys: 'l', description: 'Jump forward 30 seconds', category: 'Video Player'},
{keys: 'n', description: 'Navigate to newer video', category: 'Video Player'},
{keys: 'p', description: 'Navigate to older video', category: 'Video Player'},
{keys: 'f', description: 'Toggle fullscreen', category: 'Video Player'},
];
function SearchModal({open, onClose}) {
const {
suggestionsResults,
handleResultSelect,
resultRenderer,
loading,
searchStr,
setSearchStr,
} = useSearchSuggestions();
const inputRef = useRef();
const prevOpen = useRef(open);
const localHandleResultSelect = (i) => {
onClose();
handleResultSelect(i);
};
useEffect(() => {
if (prevOpen.current === true && !open) {
// User has closed the modal.
setSearchStr('');
}
prevOpen.current = open;
}, [open, setSearchStr]);
useEffect(() => {
/*
* Focus the search input when the modal opens.
*
* `autoFocus` on the input below is what actually does it: the modal mounts its
* content on a later tick, so this effect runs while `inputRef.current` is still
* null. The modal this replaced mounted synchronously, which is why focusing here used
* to be enough. The ref call is kept for the case where the modal is already
* mounted and merely re-opened.
*/
if (open && inputRef.current) {
inputRef.current.focus();
}
}, [open]);
if (!open) return null;
return (
<Modal size='small' open={open} onClose={onClose} centered={false} title='Search'>
<Modal.Content>
{/*
* `wrolpi-search-modal` makes the suggestion list part of the panel rather
* than an overlay on the page; see ui.css. Everywhere else the list floats
* over the page, which is right -- results that reflow the page while you
* type are unusable. Inside a modal it is wrong twice over: an absolutely
* positioned list adds no height, so the panel stayed 114px tall, and the
* panel's own `overflow: auto` then clipped the list at the panel's edge.
* The modal showed a search box and the first word of the first result.
*/}
<div className='wrolpi-search-modal'>
<SearchResultsInput
clearable
searchStr={searchStr}
onChange={setSearchStr}
onSubmit={setSearchStr}
size='large'
placeholder='Search everywhere...'
results={suggestionsResults}
handleResultSelect={localHandleResultSelect}
resultRenderer={resultRenderer}
loading={loading}
inputRef={inputRef}
autoFocus
/>
</div>
</Modal.Content>
</Modal>
);
}
export function KeyboardShortcutsProvider({children}) {
const navigate = useNavigate();
const [searchModalOpen, setSearchModalOpen] = useState(false);
const [helpModalOpen, setHelpModalOpen] = useState(false);
const openSearchModal = useCallback(() => {
setHelpModalOpen(false);
setSearchModalOpen(true);
}, []);
const closeSearchModal = useCallback(() => {
setSearchModalOpen(false);
}, []);
const openHelpModal = useCallback(() => {
setSearchModalOpen(false);
setHelpModalOpen(true);
}, []);
const closeHelpModal = useCallback(() => {
setHelpModalOpen(false);
}, []);
const closeAllModals = useCallback(() => {
setSearchModalOpen(false);
setHelpModalOpen(false);
}, []);
// Search shortcuts - Cmd/Ctrl+K works everywhere
useHotkeys('meta+k, ctrl+k', (e) => {
e.preventDefault();
openSearchModal();
}, {
enableOnFormTags: true,
preventDefault: true,
});
// / for search - only when not in an input field
// Use 'Slash' (the keyboard code) for cross-browser compatibility
useHotkeys('Slash', (e) => {
// Only trigger for / not ? (shift+/)
if (e.shiftKey) return;
e.preventDefault();
openSearchModal();
}, {
enableOnFormTags: false,
});
// Escape closes any modal - works in inputs too
useHotkeys('escape', () => {
closeAllModals();
}, {
enableOnFormTags: true,
});
// ? for help modal - use shift+Slash
useHotkeys('shift+Slash', (e) => {
e.preventDefault();
openHelpModal();
}, {
enableOnFormTags: false,
preventDefault: true,
});
// Navigation shortcuts (g+ sequences) - custom implementation
// react-hotkeys-hook doesn't handle sequences well, so we implement our own
const gPressedRef = useRef(false);
const gTimeoutRef = useRef(null);
const NAV_KEYS = {
'h': '/',
'v': '/videos',
'a': '/archives',
'm': '/map',
'z': '/zim',
'f': '/files',
's': '/admin',
'i': '/inventory',
};
useEffect(() => {
const handleKeyDown = (e) => {
// Don't trigger in input fields
const tagName = e.target.tagName.toLowerCase();
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select' || e.target.isContentEditable) {
return;
}
if (e.key === 'g' && !e.metaKey && !e.ctrlKey && !e.altKey) {
gPressedRef.current = true;
// Clear after 1 second
if (gTimeoutRef.current) clearTimeout(gTimeoutRef.current);
gTimeoutRef.current = setTimeout(() => {
gPressedRef.current = false;
}, 1000);
return;
}
if (gPressedRef.current && NAV_KEYS[e.key]) {
e.preventDefault();
gPressedRef.current = false;
if (gTimeoutRef.current) clearTimeout(gTimeoutRef.current);
navigate(NAV_KEYS[e.key]);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (gTimeoutRef.current) clearTimeout(gTimeoutRef.current);
};
}, [navigate]);
const value = {
searchModalOpen,
setSearchModalOpen,
openSearchModal,
closeSearchModal,
helpModalOpen,
setHelpModalOpen,
openHelpModal,
closeHelpModal,
closeAllModals,
};
return (
<KeyboardShortcutsContext.Provider value={value}>
{children}
<SearchModal open={searchModalOpen} onClose={closeSearchModal}/>
<HelpModal open={helpModalOpen} onClose={closeHelpModal}/>
</KeyboardShortcutsContext.Provider>
);
}