diff --git a/frontend/src/Chessrepeat.tsx b/frontend/src/Chessrepeat.tsx index f8ba4b1..27a3ee4 100644 --- a/frontend/src/Chessrepeat.tsx +++ b/frontend/src/Chessrepeat.tsx @@ -30,11 +30,13 @@ import { FolderCog2Icon, GraduationCap, History, + Keyboard, MessageSquareOff, MessageSquareText, NetworkIcon, } from 'lucide-react'; import SettingsModal from './components/modals/SettingsModal'; +import ShortcutsModal from './components/modals/ShortcutsModal'; import { Header } from './components/Header'; import { CollaboratorsPanel } from './components/collaborators/CollaboratorsPanel'; import { @@ -64,6 +66,9 @@ import './css/chessrepeat.css'; import { Debug } from './components/Debug'; import { useWebsocket } from './hooks/useWebsocket'; import { useStartup } from './hooks/useStartup'; +import { useHotkeys } from './hooks/useHotkeys'; +import { useHotkeyStore, comboParts } from './store/hotkeys'; +import { firstMove, lastMove, nextMove, prevMove } from './util/navigation'; //TODO we should use chessops library to get promotion role instead of regex.. // unclear if trainingContext stores enough state to get promotion role dynamically @@ -128,6 +133,7 @@ export const Chessrepeat = () => { guess, makeMove, + setTrainingMethod, } = useTrainerStore(); const connectedUsers = useTrainerStore((s) => s.connectedUsers); @@ -186,6 +192,7 @@ export const Chessrepeat = () => { const [sounds, setSounds] = useState(SOUNDS); const [activeMoveId, setActiveMoveId] = useState(); const [settingsOpen, setSettingsOpen] = useState(false); + const [shortcutsOpen, setShortcutsOpen] = useState(false); const [fenCopied, setFenCopied] = useState(false); const [showComments, setShowComments] = useState(true); const movesContainerRef = useRef(null); @@ -364,8 +371,8 @@ export const Chessrepeat = () => { return selectedPath == trainingPath; }; - const prevMove = prevMoveIfExists(); - const lastMove = selectedNode ? prevMove : undefined; + const prevMoveUci = prevMoveIfExists(); + const lastMoveUci = selectedNode ? prevMoveUci : undefined; const finishMove = (san: string, meta: MoveMetadata, to: Key) => { if (!isEditing) { @@ -427,6 +434,39 @@ export const Chessrepeat = () => { updateDueCounts(); }; + const copyFen = async () => { + const fen = selectedNode?.data.fen || INITIAL_BOARD_FEN; + if (!fen) return; + await navigator.clipboard.writeText(fen); + setFenCopied(true); + setTimeout(() => setFenCopied(false), 1200); + }; + + // Same sequence as the Learn / Recall buttons in TrainingControls. + const startTraining = (method: 'learn' | 'recall') => { + setTrainingMethod(method); + setNextTrainablePosition(); + updateDueCounts(); + }; + + useHotkeys( + { + firstMove, + prevMove, + nextMove, + lastMove, + modeEdit: () => setTrainingMethod('edit'), + modeLearn: () => startTraining('learn'), + modeRecall: () => startTraining('recall'), + toggleComments: () => setShowComments((v) => !v), + copyFen: () => void copyFen(), + openSettings: () => setSettingsOpen(true), + openShortcuts: () => setShortcutsOpen(true), + }, + !pendingPromo && !showingAddToRepertoireMenu, + ); + const shortcutsKey = useHotkeyStore((s) => s.bindings.openShortcuts); + //TODO dont try to calculate properties when we haven't initialized the repertoire yet return ( @@ -511,7 +551,7 @@ export const Chessrepeat = () => { orientation={chapter?.trainAs || 'white'} fen={selectedNode?.data.fen || initial} turnColor={turn} - lastMove={lastMove} + lastMove={lastMoveUci} movable={{ free: false, color: turn, @@ -550,6 +590,19 @@ export const Chessrepeat = () => {
+ +
+ +
+ {HOTKEY_GROUPS.map((group) => ( +
+

{group.label}

+
    + {group.actions.map(({ id, label }) => ( +
  • + {label} +
  • + ))} +
+
+ ))} +
+ + ); +}; + +export default ShortcutsModal; diff --git a/frontend/src/components/pgn/TreeControls.tsx b/frontend/src/components/pgn/TreeControls.tsx index 3c36858..9c10130 100644 --- a/frontend/src/components/pgn/TreeControls.tsx +++ b/frontend/src/components/pgn/TreeControls.tsx @@ -6,52 +6,16 @@ import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from 'lucide-react'; import { useTrainerStore } from '../../store/state'; -import { fromNodeList, init } from '../../util/path'; +import { firstMove as first, lastMove as last, nextMove as next, prevMove as prev } from '../../util/navigation'; import './TreeControls.css'; const PgnControls = () => { - const setSelectedPath = useTrainerStore((state) => state.setSelectedPath); - const setSelectedNode = useTrainerStore((state) => state.setSelectedNode); - const selectedPath = useTrainerStore().selectedPath || ''; const selectedNode = useTrainerStore().selectedNode; const trainingPath = useTrainerStore().trainableContext?.startingPath || ''; - const chapter = useTrainerStore.getState().activeChapter; - const trainingMethod = useTrainerStore().trainingMethod; - const jump = useTrainerStore((s) => s.jump); - - const next = (): void => { - if (trainingMethod == 'edit') { - const child = selectedNode.children[0]; - if (child) jump(selectedPath + child.data.id); - } - // learn or recall - else { - } - const pathToTrain = useTrainerStore.getState().trainableContext?.startingPath || ''; - // dynamically generate training path from string path - const currentPath = selectedPath; - - const nextId = pathToTrain.slice(currentPath.length, currentPath.length + 2); - - if (currentPath.length < pathToTrain.length) jump(selectedPath + nextId); - }; - - //TODO we can also generate the mainline as part of initialization - const last = (): void => { - if (trainingMethod != 'edit') jump(trainingPath); - else { - const mainline = mainlineNodeList(chapter.root); - jump(fromNodeList(mainline)); - } - }; - - const first = (): void => jump(''); - const prev = (): void => jump(init(selectedPath)); - const atStart = selectedPath === ''; const atEnd = trainingMethod === 'edit' ? !selectedNode?.children?.[0] : selectedPath.length >= trainingPath.length; diff --git a/frontend/src/hooks/useHotkeys.ts b/frontend/src/hooks/useHotkeys.ts new file mode 100644 index 0000000..c2187c8 --- /dev/null +++ b/frontend/src/hooks/useHotkeys.ts @@ -0,0 +1,63 @@ +import { useEffect, useRef } from 'react'; +import { comboFromEvent, HotkeyAction, useHotkeyStore } from '../store/hotkeys'; + +export type HotkeyHandlers = Partial void>>; + +// Only move navigation makes sense held down; everything else fires once. +const REPEATABLE = new Set(['prevMove', 'nextMove']); + +// Anything that takes typed input keeps its keys to itself. +function isTypingTarget(el: EventTarget | null): boolean { + if (!(el instanceof HTMLElement)) return false; + if (el.isContentEditable) return true; + // includes range sliders and the like — they don't take text, but they do take arrows + return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT'; +} + +// Every dialog in the app is either an open or sits in a scrim. +// While one is up, the page behind it shouldn't react to the keyboard. +const OPEN_MODAL = 'dialog[open], .modal-scrim, .simple-modal-scrim'; + +/** + * Listens for the user's configured shortcuts on the document and runs the + * matching handler. `enabled` lets the caller pause it for overlays that + * aren't dialogs (e.g. the promotion picker). + */ +export function useHotkeys(handlers: HotkeyHandlers, enabled = true) { + const bindings = useHotkeyStore((s) => s.bindings); + + // Handlers close over render-time state; keep the latest without + // re-subscribing the listener on every render. + const handlersRef = useRef(handlers); + handlersRef.current = handlers; + + useEffect(() => { + if (!enabled) return; + + const byCombo = new Map(); + for (const [action, combo] of Object.entries(bindings)) { + if (combo) byCombo.set(combo, action as HotkeyAction); + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.defaultPrevented || e.isComposing) return; + if (isTypingTarget(e.target)) return; + if (document.querySelector(OPEN_MODAL)) return; + + const combo = comboFromEvent(e); + if (!combo) return; + const action = byCombo.get(combo); + if (!action) return; + if (e.repeat && !REPEATABLE.has(action)) return; + + const handler = handlersRef.current[action]; + if (!handler) return; + // stops arrows scrolling the page and letters landing anywhere else + e.preventDefault(); + handler(); + }; + + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [bindings, enabled]); +} diff --git a/frontend/src/store/hotkeys.ts b/frontend/src/store/hotkeys.ts new file mode 100644 index 0000000..2311f07 --- /dev/null +++ b/frontend/src/store/hotkeys.ts @@ -0,0 +1,180 @@ +import { create } from 'zustand'; + +export type HotkeyAction = + | 'firstMove' + | 'prevMove' + | 'nextMove' + | 'lastMove' + | 'modeEdit' + | 'modeLearn' + | 'modeRecall' + | 'toggleComments' + | 'copyFen' + | 'openSettings' + | 'openShortcuts'; + +// A binding is a normalized combo string (see comboFromEvent), or null when +// the action is deliberately left unbound. +export type Bindings = Record; + +// Listed in the order the shortcuts modal shows them. +export const HOTKEY_GROUPS: { label: string; actions: { id: HotkeyAction; label: string }[] }[] = [ + { + label: 'Moves', + actions: [ + { id: 'prevMove', label: 'Previous move' }, + { id: 'nextMove', label: 'Next move' }, + { id: 'firstMove', label: 'First move' }, + { id: 'lastMove', label: 'Last move' }, + ], + }, + { + label: 'Mode', + actions: [ + { id: 'modeEdit', label: 'Edit' }, + { id: 'modeLearn', label: 'Learn' }, + { id: 'modeRecall', label: 'Recall' }, + ], + }, + { + label: 'Chapter', + actions: [ + { id: 'toggleComments', label: 'Show / hide comments' }, + { id: 'copyFen', label: 'Copy FEN' }, + ], + }, + { + label: 'App', + actions: [ + { id: 'openSettings', label: 'Training settings' }, + { id: 'openShortcuts', label: 'Keyboard shortcuts' }, + ], + }, +]; + +export const DEFAULT_BINDINGS: Bindings = { + firstMove: 'ArrowUp', + prevMove: 'ArrowLeft', + nextMove: 'ArrowRight', + lastMove: 'ArrowDown', + modeEdit: 'e', + modeLearn: 'l', + modeRecall: 'r', + toggleComments: 'c', + copyFen: 'f', + openSettings: 's', + openShortcuts: '?', +}; + +export const HOTKEYS_KEY = 'chessrepeat:hotkeys'; + +const MODIFIER_KEYS = new Set(['Control', 'Alt', 'Shift', 'Meta', 'AltGraph', 'CapsLock', 'OS']); + +// Turns a keydown into the string bindings are stored as, e.g. "ctrl+k", +// "shift+ArrowLeft", "?". Returns null for a bare modifier press. +// +// Shift is only spelled out where e.key doesn't already carry it: "?" is +// shift+/ on most layouts, but binding it as "shift+?" would stop matching on +// layouts where it isn't. Letters are the exception — e.key gives "E" for +// shift+e, which is lowercased and marked explicitly so the two read apart. +export function comboFromEvent(e: KeyboardEvent): string | null { + if (MODIFIER_KEYS.has(e.key)) return null; + let key = e.key === ' ' ? 'Space' : e.key; + const isChar = key.length === 1; + const isLetter = isChar && key.toLowerCase() !== key.toUpperCase(); + if (isLetter) key = key.toLowerCase(); + + const mods: string[] = []; + if (e.ctrlKey) mods.push('ctrl'); + if (e.altKey) mods.push('alt'); + if (e.metaKey) mods.push('meta'); + if (e.shiftKey && (!isChar || isLetter)) mods.push('shift'); + return [...mods, key].join('+'); +} + +const KEY_LABELS: Record = { + ArrowLeft: '←', + ArrowRight: '→', + ArrowUp: '↑', + ArrowDown: '↓', + ctrl: 'Ctrl', + alt: 'Alt', + meta: 'Meta', + shift: 'Shift', + Escape: 'Esc', +}; + +// The combo split into the parts a row shows, e.g. ["Ctrl", "K"]. +export function comboParts(combo: string): string[] { + // "+" can itself be the key ("ctrl++"), so split off modifiers from the left + const parts: string[] = []; + let rest = combo; + for (const mod of ['ctrl', 'alt', 'meta', 'shift']) { + if (rest.startsWith(mod + '+') && rest.length > mod.length + 1) { + parts.push(mod); + rest = rest.slice(mod.length + 1); + } + } + parts.push(rest); + return parts.map((p) => KEY_LABELS[p] ?? (p.length === 1 ? p.toUpperCase() : p)); +} + +function loadBindings(): Bindings { + try { + const raw = localStorage.getItem(HOTKEYS_KEY); + if (!raw) return { ...DEFAULT_BINDINGS }; + const stored = JSON.parse(raw) as Partial; + // Merge over the defaults so an action added after the user last saved + // still gets its key, and one removed since is dropped. + const merged = { ...DEFAULT_BINDINGS }; + for (const id of Object.keys(DEFAULT_BINDINGS) as HotkeyAction[]) { + if (id in stored) merged[id] = stored[id] ?? null; + } + return merged; + } catch { + return { ...DEFAULT_BINDINGS }; + } +} + +function saveBindings(bindings: Bindings) { + try { + localStorage.setItem(HOTKEYS_KEY, JSON.stringify(bindings)); + } catch { + // storage blocked — the bindings still apply for this tab + } +} + +type HotkeyState = { + bindings: Bindings; + // Binds combo to action. Any other action holding the same combo is + // unbound and returned, so the caller can say what was displaced. + setBinding: (action: HotkeyAction, combo: string | null) => HotkeyAction | null; + resetBindings: () => void; +}; + +export const useHotkeyStore = create((set, get) => ({ + bindings: loadBindings(), + + setBinding: (action, combo) => { + const bindings = { ...get().bindings }; + let displaced: HotkeyAction | null = null; + if (combo) { + for (const id of Object.keys(bindings) as HotkeyAction[]) { + if (id !== action && bindings[id] === combo) { + bindings[id] = null; + displaced = id; + } + } + } + bindings[action] = combo; + saveBindings(bindings); + set({ bindings }); + return displaced; + }, + + resetBindings: () => { + const bindings = { ...DEFAULT_BINDINGS }; + saveBindings(bindings); + set({ bindings }); + }, +})); diff --git a/frontend/src/util/navigation.ts b/frontend/src/util/navigation.ts new file mode 100644 index 0000000..cee0929 --- /dev/null +++ b/frontend/src/util/navigation.ts @@ -0,0 +1,46 @@ +// Stepping through the selected chapter's moves. Shared by the buttons under +// the PGN tree and the keyboard shortcuts, so both read the store at call time. + +import { useTrainerStore } from '../store/state'; +import { fromNodeList, init } from './path'; +import { collect } from './tree'; + +export const firstMove = (): void => useTrainerStore.getState().jump(''); + +export const prevMove = (): void => { + const { selectedPath, jump } = useTrainerStore.getState(); + jump(init(selectedPath || '')); +}; + +export const nextMove = (): void => { + const { trainingMethod, selectedNode, trainableContext, jump } = useTrainerStore.getState(); + const selectedPath = useTrainerStore.getState().selectedPath || ''; + + if (trainingMethod == 'edit') { + const child = selectedNode?.children?.[0]; + if (child) jump(selectedPath + child.data.id); + return; + } + + // learn or recall: step along the line leading to the position being trained + const pathToTrain = trainableContext?.startingPath || ''; + if (selectedPath.length < pathToTrain.length) { + jump(selectedPath + pathToTrain.slice(selectedPath.length, selectedPath.length + 2)); + } +}; + +export const lastMove = (): void => { + const { trainingMethod, trainableContext, repertoire, selectedChapterId, jump } = + useTrainerStore.getState(); + + if (trainingMethod != 'edit') { + jump(trainableContext?.startingPath || ''); + return; + } + + const chapter = repertoire.find((c) => c.uuid === selectedChapterId); + if (!chapter) return; + // mainline: always the first child. The root itself carries no id. + const mainline = collect(chapter.root, (n) => n.children[0]); + jump(fromNodeList(mainline.slice(1))); +}; diff --git a/frontend/test-results/.last-run.json b/frontend/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/frontend/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/frontend/test-results/playground-first-run--Last-990ff-f-the-mainline-in-edit-mode-playground/error-context.md b/frontend/test-results/playground-first-run--Last-990ff-f-the-mainline-in-edit-mode-playground/error-context.md new file mode 100644 index 0000000..cb161b5 --- /dev/null +++ b/frontend/test-results/playground-first-run--Last-990ff-f-the-mainline-in-edit-mode-playground/error-context.md @@ -0,0 +1,99 @@ +# Instructions + +- Following Playwright test failed. +- Explain why, be concise, respect Playwright best practices. +- Provide a snippet of code with the fix, if possible. + +# Test info + +- Name: playground/first-run.spec.ts >> "Last move" jumps to the end of the mainline in edit mode +- Location: e2e/playground/first-run.spec.ts:53:1 + +# Error details + +``` +Error: expect(locator).toHaveText(expected) failed + +Locator: locator('.tree-body .move.active') +Expected: "Rd8#" +Received: "e5" +Timeout: 5000ms + +Call log: + - Expect "toHaveText" locator('.tree-body .move.active') with timeout 5000ms + - waiting for locator('.tree-body .move.active') + 14 × locator resolved to
e5
+ - unexpected value "e5" + +``` + +```yaml +- text: e5 +``` + +# Test source + +```ts + 1 | import { expect, test } from '@playwright/test'; + 2 | import { activeMove, chapterRow, move, setMode, tipTitle } from '../support/app'; + 3 | + 4 | test('a first visit is seeded with the example chapter', async ({ page }) => { + 5 | await page.goto('/'); + 6 | + 7 | const row = chapterRow(page, 'Example Chapter'); + 8 | await expect(row).toBeVisible(); + 9 | await expect(row).toHaveClass(/is-selected/); + 10 | await expect(row.locator('.chapter-side')).toHaveAttribute('title', 'Trained as white'); + 11 | + 12 | // Nothing is being trained yet, and the seed chapter nudges the user + 13 | // toward Learn. Outside edit mode the tree only draws the line being + 14 | // trained, so it is empty until a mode is picked. + 15 | await expect(tipTitle(page)).toHaveText('No training mode selected'); + 16 | await expect(page.locator('.training-btn-learn')).toHaveClass(/is-prompted/); + 17 | await expect(page.locator('.tree-body .move')).toHaveCount(0); + 18 | + 19 | await setMode(page, 'edit'); + 20 | await expect(move(page, 'e4')).toBeVisible(); + 21 | await expect(move(page, 'Rd8#')).toBeVisible(); + 22 | await expect(page.locator('.tree-body .move')).toHaveCount(33); + 23 | }); + 24 | + 25 | test('the seed is written once — a reload does not add a second copy', async ({ page }) => { + 26 | await page.goto('/'); + 27 | await expect(chapterRow(page, 'Example Chapter')).toBeVisible(); + 28 | + 29 | await page.reload(); + 30 | await expect(chapterRow(page, 'Example Chapter')).toBeVisible(); + 31 | await expect(page.locator('.chapter-row')).toHaveCount(1); + 32 | }); + 33 | + 34 | test('clicking a move in the tree selects it', async ({ page }) => { + 35 | await page.goto('/'); + 36 | await setMode(page, 'edit'); + 37 | await expect(move(page, 'e4')).toBeVisible(); + 38 | + 39 | await move(page, 'Nf3').click(); + 40 | await expect(activeMove(page)).toHaveText('Nf3'); + 41 | + 42 | await page.getByRole('button', { name: 'Previous move' }).click(); + 43 | await expect(activeMove(page)).toHaveText('e5'); + 44 | + 45 | await page.getByRole('button', { name: 'First move' }).click(); + 46 | await expect(activeMove(page)).toHaveCount(0); + 47 | }); + 48 | + 49 | // Known bug: in edit mode "Last move" reads `activeChapter` off the store + 50 | // (src/components/pgn/TreeControls.tsx), which no longer exists, so the + 51 | // click throws and the selection stays put. Flip this to a plain test once + 52 | // it's fixed — Playwright fails the run when a test.fail() test passes. + 53 | test('"Last move" jumps to the end of the mainline in edit mode', async ({ page }) => { + 54 | test.fail(); + 55 | await page.goto('/'); + 56 | await setMode(page, 'edit'); + 57 | await move(page, 'e5').click(); + 58 | await page.getByRole('button', { name: 'Last move' }).click(); +> 59 | await expect(activeMove(page)).toHaveText('Rd8#'); + | ^ Error: expect(locator).toHaveText(expected) failed + 60 | }); + 61 | +``` \ No newline at end of file