Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions entrypoints/content/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
<ErrorBoundary>
<OverlayButton />
Expand Down
67 changes: 64 additions & 3 deletions src/components/Panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -163,6 +164,61 @@ export function Panel() {
}
}

function runShortcutAction(action: ReturnType<typeof shortcutActionFromEvent>): 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 });
Expand Down Expand Up @@ -260,19 +316,24 @@ 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')})`}
>
<IconChevronLeft /> Prev
</button>
<span className="slm-stepnav-count" title="Use ← → arrow keys">
<span
className="slm-stepnav-count"
title={`Use Left/Right arrows or ${shortcutLabel('previous-step')} / ${shortcutLabel('next-step')}`}
>
{activeStepIndex + 1} / {total}
</span>
<button
type="button"
className="slm-btn slm-btn-soft"
onClick={nextStep}
disabled={activeStepIndex >= total - 1}
aria-label="Next step"
aria-label={`Next step (${shortcutLabel('next-step')})`}
title={`Next step (${shortcutLabel('next-step')})`}
>
Next <IconChevronRight />
</button>
Expand Down
15 changes: 13 additions & 2 deletions src/components/PanelHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' ? <IconSun /> : <IconMoon />}
Expand All @@ -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}
Expand All @@ -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}
>
<IconPdf />
</button>
<button type="button" className="slm-icon-btn" aria-label="Close panel" onClick={onClose}>
<button
type="button"
className="slm-icon-btn"
aria-label="Close panel"
title={`Close panel (Esc, ${shortcutLabel('toggle-panel')})`}
onClick={onClose}
>
<IconClose />
</button>
</div>
Expand All @@ -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')}
>
<IconLayers /> Steps
Expand All @@ -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')}
>
<IconBook /> Solution
Expand Down
134 changes: 133 additions & 1 deletion src/components/panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(<Panel />);
});

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(<Panel />);
});

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(<Panel />);
});

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', () => {
Expand Down Expand Up @@ -315,4 +448,3 @@ describe('Panel save toggle', () => {
container.remove();
});
});

Loading