diff --git a/README.md b/README.md index a3b4032..5316619 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,14 @@ Civil / Chemical engineering.** 5. **Recover.** Closed the tab? Click the toolbar icon → **Load conversation** rebuilds the workspace from Gemini's saved history. No server, no login. +## Keyboard shortcuts + +- `Ctrl+Alt+L` opens or closes the stemLM panel. +- `Ctrl+Alt+J` / `Ctrl+Alt+K` move to the previous / next step. +- `Ctrl+Alt+1` / `Ctrl+Alt+2` switch between Steps and Solution. +- `Ctrl+Alt+M` marks the active step reviewed. +- `Ctrl+Alt+T`, `Ctrl+Alt+S`, and `Ctrl+Alt+P` toggle theme, save, and export PDF. + ## Supported site **[gemini.google.com](https://gemini.google.com)** only. diff --git a/entrypoints/content/App.tsx b/entrypoints/content/App.tsx index 688a9dd..0af0447 100644 --- a/entrypoints/content/App.tsx +++ b/entrypoints/content/App.tsx @@ -5,6 +5,7 @@ import { OverlayButton } from '@/src/components/OverlayButton'; import { Panel } from '@/src/components/Panel'; import { ErrorBoundary } from '@/src/components/ErrorBoundary'; import { applySplit, removeSplit } from '@/src/lib/split-screen'; +import { shortcutActionFromEvent } from '@/src/lib/keyboard-shortcuts'; /** * Root content-script app: the docked overlay button + the split-screen study @@ -23,6 +24,17 @@ export default function App() { useEffect(() => () => removeSplit(), []); + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (shortcutActionFromEvent(e) !== 'toggle-panel') return; + useStore.getState().togglePanel(); + e.preventDefault(); + e.stopPropagation(); + }; + document.addEventListener('keydown', onKeyDown, true); + return () => document.removeEventListener('keydown', onKeyDown, true); + }, []); + return ( diff --git a/src/components/Panel.tsx b/src/components/Panel.tsx index 8bdddb1..defc32e 100644 --- a/src/components/Panel.tsx +++ b/src/components/Panel.tsx @@ -15,6 +15,7 @@ import { StorageQuotaError } from '@/src/lib/storage-errors'; import { setSettings } from '@/src/lib/settings'; import { exportSessionPdf } from '@/src/lib/pdf'; import { trackEvent } from '@/src/lib/analytics'; +import { shortcutActionFromEvent, shortcutLabel } from '@/src/lib/keyboard-shortcuts'; function isArrowKey(key: string) { return key === 'ArrowLeft' || key === 'ArrowRight'; @@ -163,6 +164,61 @@ export function Panel() { } } + function runShortcutAction(action: ReturnType): boolean { + if (!action || action === 'toggle-panel') return false; + if (action === 'toggle-theme') { + void onToggleTheme(); + return true; + } + if (!session) return false; + if (action === 'previous-step') { + setView('steps'); + prevStep(); + return true; + } + if (action === 'next-step') { + setView('steps'); + nextStep(); + return true; + } + if (action === 'steps-view') { + setView('steps'); + return true; + } + if (action === 'solution-view') { + setView('solution'); + return true; + } + if (action === 'toggle-reviewed') { + const activeStep = session.capsule.steps[activeStepIndex]; + if (!activeStep) return false; + toggleReviewed(activeStep.id); + void trackEvent('step_reviewed', { platform: session.platform }); + return true; + } + if (action === 'toggle-save') { + void onToggleSave(); + return true; + } + if (action === 'export-pdf') { + void onExportPdf(); + return true; + } + return false; + } + + useEffect(() => { + if (!panelOpen) return; + const onDocumentKeyDown = (e: KeyboardEvent) => { + const action = shortcutActionFromEvent(e); + if (!runShortcutAction(action)) return; + e.preventDefault(); + e.stopPropagation(); + }; + document.addEventListener('keydown', onDocumentKeyDown, true); + return () => document.removeEventListener('keydown', onDocumentKeyDown, true); + }, [panelOpen, session, activeStepIndex, view, theme, saved]); + function onPanelPointerDown(e: React.PointerEvent) { if (panelRef.current?.contains(e.target as Node)) { panelRef.current.focus({ preventScroll: true }); @@ -260,11 +316,15 @@ export function Panel() { className="slm-btn slm-btn-ghost" onClick={prevStep} disabled={activeStepIndex === 0} - aria-label="Previous step" + aria-label={`Previous step (${shortcutLabel('previous-step')})`} + title={`Previous step (${shortcutLabel('previous-step')})`} > Prev - + {activeStepIndex + 1} / {total} diff --git a/src/components/PanelHeader.tsx b/src/components/PanelHeader.tsx index 3ce8f81..748b851 100644 --- a/src/components/PanelHeader.tsx +++ b/src/components/PanelHeader.tsx @@ -2,6 +2,7 @@ import type { Session } from '@/src/protocol/types'; import type { PanelView } from '@/src/state/store'; import type { ResolvedTheme } from '@/src/lib/theme'; import { sessionQuestionHeading } from '@/src/lib/session-question'; +import { shortcutLabel } from '@/src/lib/keyboard-shortcuts'; import { MathMarkdown } from './MathMarkdown'; import { BrandWordmark } from './BrandWordmark'; import { ExtensionLogo } from './ExtensionLogo'; @@ -53,6 +54,7 @@ export function PanelHeader({ type="button" className="slm-icon-btn" aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'} + title={`${theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'} (${shortcutLabel('toggle-theme')})`} onClick={onToggleTheme} > {theme === 'dark' ? : } @@ -62,7 +64,7 @@ export function PanelHeader({ className="slm-icon-btn" aria-label={saved ? 'Remove from saved sessions' : 'Save session'} aria-pressed={saved} - title={saved ? 'Remove from saved' : 'Save session'} + title={`${saved ? 'Remove from saved' : 'Save session'} (${shortcutLabel('toggle-save')})`} onClick={onToggleSave} disabled={!session} data-active={saved ? 'true' : undefined} @@ -73,12 +75,19 @@ export function PanelHeader({ type="button" className="slm-icon-btn" aria-label="Export PDF" + title={`Export PDF (${shortcutLabel('export-pdf')})`} onClick={onExportPdf} disabled={!session} > - @@ -100,6 +109,7 @@ export function PanelHeader({ aria-controls="slm-panel-steps" aria-selected={view === 'steps'} className={`slm-tab ${view === 'steps' ? 'is-active' : ''}`} + title={`Steps (${shortcutLabel('steps-view')})`} onClick={() => onSetView('steps')} > Steps @@ -111,6 +121,7 @@ export function PanelHeader({ aria-controls="slm-panel-solution" aria-selected={view === 'solution'} className={`slm-tab ${view === 'solution' ? 'is-active' : ''}`} + title={`Solution (${shortcutLabel('solution-view')})`} onClick={() => onSetView('solution')} > Solution diff --git a/src/components/panel.test.tsx b/src/components/panel.test.tsx index 620d3f8..5c6a263 100644 --- a/src/components/panel.test.tsx +++ b/src/components/panel.test.tsx @@ -66,6 +66,18 @@ function buildTwoDiagramSession(): Session { }; } +function dispatchShortcut(target: EventTarget, key: string) { + target.dispatchEvent( + new KeyboardEvent('keydown', { + key, + ctrlKey: true, + altKey: true, + bubbles: true, + cancelable: true, + }), + ); +} + beforeEach(() => { saveSessionMock.mockClear(); deleteSavedSessionMock.mockClear(); @@ -264,6 +276,127 @@ describe('Panel step diagram', () => { }); container.remove(); }); + + it('handles document-level keyboard shortcuts for step navigation and views', async () => { + const session = buildTwoDiagramSession(); + const container = document.createElement('div'); + document.body.appendChild(container); + let root: Root | undefined; + + useStore.getState().resetSessions(); + useStore.getState().addSession(session); + useStore.setState({ + panelOpen: true, + status: 'ready', + view: 'steps', + theme: 'light', + activeStepIndex: 0, + }); + + act(() => { + root = createRoot(container); + root.render(); + }); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + act(() => dispatchShortcut(document, 'k')); + expect(useStore.getState().activeStepIndex).toBe(1); + + act(() => dispatchShortcut(document, '2')); + expect(useStore.getState().view).toBe('solution'); + + act(() => dispatchShortcut(document, 'j')); + expect(useStore.getState().view).toBe('steps'); + expect(useStore.getState().activeStepIndex).toBe(0); + + act(() => dispatchShortcut(document, '2')); + expect(useStore.getState().view).toBe('solution'); + act(() => dispatchShortcut(document, '1')); + expect(useStore.getState().view).toBe('steps'); + + act(() => { + root?.unmount(); + }); + container.remove(); + }); + + it('marks the active step reviewed with the keyboard shortcut', async () => { + const session = buildTwoDiagramSession(); + const container = document.createElement('div'); + document.body.appendChild(container); + let root: Root | undefined; + + useStore.getState().resetSessions(); + useStore.getState().addSession(session); + useStore.setState({ panelOpen: true, status: 'ready', view: 'steps', theme: 'light' }); + + act(() => { + root = createRoot(container); + root.render(); + }); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + act(() => dispatchShortcut(document, 'm')); + expect(useStore.getState().sessions[0]?.reviewedStepIds).toEqual(['s1']); + + act(() => dispatchShortcut(document, 'm')); + expect(useStore.getState().sessions[0]?.reviewedStepIds).toEqual([]); + + act(() => { + root?.unmount(); + }); + container.remove(); + }); + + it('saves with the keyboard shortcut and ignores shortcuts while typing', async () => { + const session = buildTwoDiagramSession(); + const container = document.createElement('div'); + document.body.appendChild(container); + let root: Root | undefined; + + useStore.getState().resetSessions(); + useStore.getState().addSession(session); + useStore.setState({ + panelOpen: true, + status: 'ready', + view: 'steps', + theme: 'light', + activeStepIndex: 0, + }); + + act(() => { + root = createRoot(container); + root.render(); + }); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + const input = document.createElement('input'); + document.body.appendChild(input); + + act(() => dispatchShortcut(input, 'k')); + expect(useStore.getState().activeStepIndex).toBe(0); + + await act(async () => { + dispatchShortcut(document, 's'); + await new Promise((r) => setTimeout(r, 0)); + }); + expect(saveSessionMock).toHaveBeenCalledOnce(); + + input.remove(); + act(() => { + root?.unmount(); + }); + container.remove(); + }); }); describe('Panel save toggle', () => { @@ -315,4 +448,3 @@ describe('Panel save toggle', () => { container.remove(); }); }); - diff --git a/src/lib/keyboard-shortcuts.test.ts b/src/lib/keyboard-shortcuts.test.ts new file mode 100644 index 0000000..29a145f --- /dev/null +++ b/src/lib/keyboard-shortcuts.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { + eventTargetAcceptsText, + shortcutActionFromEvent, + shortcutLabel, +} from './keyboard-shortcuts'; + +function keydown(key: string, opt: Partial = {}) { + return new KeyboardEvent('keydown', { + key, + ctrlKey: true, + altKey: true, + bubbles: true, + cancelable: true, + ...opt, + }); +} + +describe('keyboard shortcuts', () => { + it('maps chorded keys to stemLM actions', () => { + expect(shortcutActionFromEvent(keydown('l'))).toBe('toggle-panel'); + expect(shortcutActionFromEvent(keydown('J'))).toBe('previous-step'); + expect(shortcutActionFromEvent(keydown('k'))).toBe('next-step'); + expect(shortcutActionFromEvent(keydown('1'))).toBe('steps-view'); + expect(shortcutActionFromEvent(keydown('2'))).toBe('solution-view'); + expect(shortcutActionFromEvent(keydown('m'))).toBe('toggle-reviewed'); + expect(shortcutActionFromEvent(keydown('t'))).toBe('toggle-theme'); + expect(shortcutActionFromEvent(keydown('s'))).toBe('toggle-save'); + expect(shortcutActionFromEvent(keydown('p'))).toBe('export-pdf'); + }); + + it('ignores partial chords and repeated keydown events', () => { + expect(shortcutActionFromEvent(keydown('l', { altKey: false }))).toBeNull(); + expect(shortcutActionFromEvent(keydown('l', { ctrlKey: false }))).toBeNull(); + expect(shortcutActionFromEvent(keydown('l', { repeat: true }))).toBeNull(); + expect(shortcutActionFromEvent(keydown('l', { metaKey: true }))).toBeNull(); + }); + + it('detects editable targets so typing is left alone', () => { + const input = document.createElement('input'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + const editor = document.createElement('div'); + editor.contentEditable = 'true'; + + expect(eventTargetAcceptsText(input)).toBe(true); + expect(eventTargetAcceptsText(document.createElement('textarea'))).toBe(true); + expect(eventTargetAcceptsText(editor)).toBe(true); + expect(eventTargetAcceptsText(checkbox)).toBe(false); + expect(eventTargetAcceptsText(document.createElement('button'))).toBe(false); + }); + + it('exposes labels for UI hints', () => { + expect(shortcutLabel('toggle-panel')).toBe('Ctrl+Alt+L'); + expect(shortcutLabel('export-pdf')).toBe('Ctrl+Alt+P'); + }); +}); diff --git a/src/lib/keyboard-shortcuts.ts b/src/lib/keyboard-shortcuts.ts new file mode 100644 index 0000000..ef15ff6 --- /dev/null +++ b/src/lib/keyboard-shortcuts.ts @@ -0,0 +1,125 @@ +export type ShortcutAction = + | 'toggle-panel' + | 'previous-step' + | 'next-step' + | 'steps-view' + | 'solution-view' + | 'toggle-reviewed' + | 'toggle-theme' + | 'toggle-save' + | 'export-pdf'; + +export interface KeyboardShortcut { + action: ShortcutAction; + label: string; + key: string; + ctrl: boolean; + alt: boolean; + shift?: boolean; + panelOnly?: boolean; + requiresSession?: boolean; +} + +export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ + { action: 'toggle-panel', label: 'Ctrl+Alt+L', key: 'l', ctrl: true, alt: true }, + { + action: 'previous-step', + label: 'Ctrl+Alt+J', + key: 'j', + ctrl: true, + alt: true, + panelOnly: true, + requiresSession: true, + }, + { + action: 'next-step', + label: 'Ctrl+Alt+K', + key: 'k', + ctrl: true, + alt: true, + panelOnly: true, + requiresSession: true, + }, + { + action: 'steps-view', + label: 'Ctrl+Alt+1', + key: '1', + ctrl: true, + alt: true, + panelOnly: true, + requiresSession: true, + }, + { + action: 'solution-view', + label: 'Ctrl+Alt+2', + key: '2', + ctrl: true, + alt: true, + panelOnly: true, + requiresSession: true, + }, + { + action: 'toggle-reviewed', + label: 'Ctrl+Alt+M', + key: 'm', + ctrl: true, + alt: true, + panelOnly: true, + requiresSession: true, + }, + { action: 'toggle-theme', label: 'Ctrl+Alt+T', key: 't', ctrl: true, alt: true, panelOnly: true }, + { + action: 'toggle-save', + label: 'Ctrl+Alt+S', + key: 's', + ctrl: true, + alt: true, + panelOnly: true, + requiresSession: true, + }, + { + action: 'export-pdf', + label: 'Ctrl+Alt+P', + key: 'p', + ctrl: true, + alt: true, + panelOnly: true, + requiresSession: true, + }, +]; + +export function shortcutLabel(action: ShortcutAction): string { + return KEYBOARD_SHORTCUTS.find((s) => s.action === action)?.label ?? ''; +} + +export function eventTargetAcceptsText(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable || target.closest('[contenteditable="true"], [contenteditable="plaintext-only"]')) { + return true; + } + const tag = target.tagName; + if (tag === 'TEXTAREA' || tag === 'SELECT') return true; + if (tag !== 'INPUT') return false; + const type = (target as HTMLInputElement).type?.toLowerCase(); + return !['button', 'checkbox', 'color', 'file', 'radio', 'range', 'reset', 'submit'].includes(type); +} + +export function shouldIgnoreShortcutEvent(e: KeyboardEvent): boolean { + if (e.repeat) return true; + if (e.defaultPrevented) return true; + return eventTargetAcceptsText(e.target); +} + +export function shortcutActionFromEvent(e: KeyboardEvent): ShortcutAction | null { + if (shouldIgnoreShortcutEvent(e)) return null; + const key = e.key.toLowerCase(); + const match = KEYBOARD_SHORTCUTS.find( + (s) => + s.key === key && + e.ctrlKey === s.ctrl && + e.altKey === s.alt && + e.shiftKey === Boolean(s.shift) && + !e.metaKey, + ); + return match?.action ?? null; +}