mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
feat(ui): shortcut registry foundation (#652)
* feat(ui): shortcut registry foundation Salvages the foundation of #380 by @gjermundgaraba — the original PR sat 6 weeks waiting for review and was closed by the author. This lands the engine, scope data, and auto-generated marketing docs page, deferring per-component handler migration to follow-up PRs. Layout: - packages/ui/shortcuts/{core,runtime,index}.ts — engine + barrel - packages/ui/shortcuts/plan-review/* — scopes for plan editor surfaces - packages/ui/shortcuts/code-review/* — scopes for review editor surfaces - packages/{editor,review-editor}/shortcuts.ts — per-app surface composition - apps/marketing/src/{components,lib} — auto-generated docs page App behavior is unchanged: existing ad-hoc keydown handlers keep running. The registry is plumbing waiting for migration PRs to wire to it. Co-authored-by: Gjermund Garaba <gjermund@garaba.net> * feat(ui): add missing shortcuts; warn on hold-binding gap Addresses code review on #652. Adds five live shortcuts that were missing from the registry, so the auto-generated /docs/reference/keyboard-shortcuts page is no longer incomplete: - Mod+P (print plan) — plan review + annotate - Mod+B (toggle file tree) — code review (new "Layout" section) - Mod+. (toggle review sidebar) — code review - Mod+Shift+T (toggle demo tour, dev-only) — code review - Mod+Shift+F (focus PR comments search) — new code-review/prComments scope Also whitelists `.` as a valid normalized binding token in the registry validator (keeps catching typos like Cmd/Ctrl while allowing legitimate punctuation keys). Hold-binding gap: the registry's `Alt hold` advertises a shortcut that `useShortcutScope` cannot dispatch. Today this is harmless because the existing `useInputMethodSwitch` hook handles it bespoke. Added warning comments at the binding declaration and next to `useDoubleTapShortcuts` so future migrators don't fall in. A `useHoldShortcuts` hook is the proper fix and will land with the App.tsx migration that needs it. * feat(ui): comprehensive shortcut registry coverage Two-Sonnet inventory audit found 19 more user-facing shortcuts not in the registry, on top of the 5 added in the previous commit. Adding all of them so the auto-generated /docs/reference/keyboard-shortcuts page ships honest and the in-app help modal (once migrated to read from the registry) is comprehensive. Plan / annotate surface (4 added): - Escape closes image lightbox (Viewer) - Mod+Enter saves annotation edit (AnnotationPanel) - Escape cancels annotation edit (AnnotationPanel) - Enter confirms image name (ImageAnnotator name field) Code review surface (15 added): - v / a in single-file diff mode (toggle viewed / stage) - v, a, x, z, c, [, ] in all-files (new AllFilesDiff scope) - Mod+Enter / Escape / Tab (new SuggestionModal scope) - Mod+Enter / Escape (new AI Assistant scope: AITab + AskAIInput) - Escape closes tour dialog (new TourDialog scope) Engine: extended NAMED_TOKENS with `[` and `]` for the all-files navigation bindings. Tests updated for the new section ordering. App behavior unchanged — this is pure declarative metadata. The existing ad-hoc keydown handlers keep running; the registry is plumbing for the migration follow-up PRs. * fix(ui): suggestion modal scope advertised non-existent submit shortcut Self-review caught: SuggestionModal.tsx only has Escape (close) and Tab (indent) handlers — no Mod+Enter. The Mod+Enter the inventory agent referenced lives in AnnotationToolbar's suggested-code textarea, which is already covered by reviewAnnotationToolbarShortcuts.submitComment. Removed the bogus submit action from the scope. Renamed cancel→close since the modal preserves suggestion state on Escape (it's not a discard). * fix(review): remove broken Cmd+Shift+C copy-diff shortcut The handler at App.tsx:695 checked `e.key === 'c'` but Shift forces `e.key` to uppercase `'C'`, so the shortcut never fired. The in-app help modal also advertised it incorrectly as "Toggle comment mode". Removing the broken shortcut entirely rather than fixing the case mismatch — the toolbar's Copy button already does the same thing, and there's no evidence anyone was relying on the keyboard shortcut. Three deletions: - packages/review-editor/App.tsx — handler block - packages/review-editor/shortcuts.ts — copyDiff registry action - packages/ui/components/KeyboardShortcuts.tsx — help modal entry * chore(ui): address review nits - F3: refresh AGENTS.md scope inventory (added scopes were missing) - F5: getShortcutPlatform() now delegates to packages/ui/utils/platform isMac instead of duplicating the regex+SSR guard - F7: drop the stand-alone imageAnnotator.confirmName entry; the behaviour is folded into the existing save action's hint so the marketing docs page no longer renders two Enter rows with different descriptions - F8: remove the unused listenerOptions parameter from useShortcutScope (no callers; inline objects in the dep array would have caused re-render loops if anyone tried to use it) TODOs left for the migration follow-up: - F2: cross-scope arbitration — dispatchShortcutEvent should bail on event.defaultPrevented; needs preventDefault to be claimed consistently across scopes first - F6: matchesKeyToken doesn't actually match the Space token; needs a special case before any scope binds Space App behaviour unchanged. --------- Co-authored-by: Gjermund Garaba <gjermund@garaba.net>
This commit is contained in:
@@ -52,6 +52,13 @@ plannotator/
|
||||
│ │ │ ├── icons/ # Shared SVG icon components (themeIcons, etc.)
|
||||
│ │ │ ├── plan-diff/ # PlanDiffBadge, PlanDiffViewer, clean/raw diff views
|
||||
│ │ │ └── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser, ArchiveBrowser
|
||||
│ │ ├── shortcuts/ # Keyboard shortcut registry (see Keyboard Shortcuts section below)
|
||||
│ │ │ ├── core.ts # Engine: parser, formatter, dispatcher, validator
|
||||
│ │ │ ├── runtime.ts # Engine: useShortcutScope, useDoubleTapShortcuts hooks
|
||||
│ │ │ ├── index.ts # Barrel — re-exports engine + scopes from both subfolders
|
||||
│ │ │ ├── plan-review/ # Scopes for plan-editor surfaces (annotationToolbar, annotationPanel, commentPopover, imageAnnotator, inputMethod, viewer)
|
||||
│ │ │ └── code-review/ # Scopes for review-editor surfaces (ai, allFilesDiff, annotationToolbar, fileTree, prComments, suggestionModal, tourDialog)
|
||||
│ │ ├── shortcuts.test.ts # Registry unit tests (parser, dispatcher, validator)
|
||||
│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts, planAgentInstructions.ts
|
||||
│ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts, useArchive.ts
|
||||
│ │ └── types.ts
|
||||
@@ -60,9 +67,12 @@ plannotator/
|
||||
│ │ ├── storage.ts # Plan saving, version history, archive listing (node:fs only)
|
||||
│ │ ├── draft.ts # Annotation draft persistence (node:fs only)
|
||||
│ │ └── project.ts # Pure string helpers (sanitizeTag, extractRepoName, extractDirName)
|
||||
│ ├── editor/ # Plan review App.tsx
|
||||
│ ├── editor/ # Plan review app
|
||||
│ │ ├── App.tsx # Main plan review app
|
||||
│ │ └── shortcuts.ts # planReviewSurface + annotateSurface — composes plan-review scopes into per-surface registries
|
||||
│ └── review-editor/ # Code review UI
|
||||
│ ├── App.tsx # Main review app
|
||||
│ ├── shortcuts.ts # codeReviewSurface — composes code-review scopes into the review registry
|
||||
│ ├── components/ # DiffViewer, FileTree, ReviewSidebar
|
||||
│ ├── dock/ # Dockview center panel infrastructure
|
||||
│ ├── demoData.ts # Demo diff for standalone mode
|
||||
@@ -380,6 +390,20 @@ interface Block {
|
||||
|
||||
Text highlighting uses `web-highlighter` library. Code blocks use manual `<mark>` wrapping (web-highlighter can't select inside `<pre>`).
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
**Location:** `packages/ui/shortcuts/` (engine + scope data), `packages/editor/shortcuts.ts` and `packages/review-editor/shortcuts.ts` (per-app surfaces).
|
||||
|
||||
The shortcut system has three layers:
|
||||
|
||||
1. **Engine** (`packages/ui/shortcuts/{core,runtime}.ts`) — parser for declarative bindings (`Mod+Enter`, `Alt Alt` double-tap, `Alt hold`), dispatcher, platform-aware formatter (mac glyphs vs. `Ctrl`), validator, and the `useShortcutScope` / `useDoubleTapShortcuts` React hooks. Truly shared — both apps use it as-is.
|
||||
2. **Scopes** — `defineShortcutScope({ id, title, shortcuts: { actionId: { bindings, description, section, ... } } })`. One scope per UI surface (annotation toolbar, comment popover, file tree, etc.). Lives in `packages/ui/shortcuts/{plan-review,code-review}/` — **the subfolder names which app's UI the scope serves**. Components/Apps wire handlers to a scope via `useShortcutScope({ scope, handlers: { actionId: () => ... } })`.
|
||||
3. **Surfaces** (`packages/editor/shortcuts.ts`, `packages/review-editor/shortcuts.ts`) — each app composes its scopes into a `ShortcutSurface` (`planReviewSurface`, `annotateSurface`, `codeReviewSurface`). Surfaces feed both the in-app help modal and the marketing site's auto-generated docs page.
|
||||
|
||||
**Convention for adding new shortcuts:** define the action in the relevant scope file under the right subfolder (`plan-review/` or `code-review/`), declare the binding(s) and description, then wire a handler at the call site with `useShortcutScope`. The marketing docs page picks it up automatically at next build. Unit tests in `packages/ui/shortcuts.test.ts` enforce normalized binding tokens (`Mod`, `Shift`, `Alt`, `A-Z`, `1-0`, named keys, `F1`–`F12`) and unique scope ids.
|
||||
|
||||
**Marketing docs auto-generation:** `apps/marketing/src/lib/shortcutReference.ts` reads the three surfaces and `apps/marketing/src/components/ShortcutReference.astro` renders them as tables. The `/docs/reference/keyboard-shortcuts` page is special-cased in `apps/marketing/src/pages/docs/[...slug].astro` to render the component instead of the markdown body.
|
||||
|
||||
## URL Sharing
|
||||
|
||||
**Location:** `packages/ui/utils/sharing.ts`, `packages/ui/hooks/useSharing.ts`
|
||||
@@ -482,6 +506,8 @@ Running only `build:opencode` will copy stale HTML files.
|
||||
|
||||
`apps/marketing/` is the plannotator.ai website — landing page, documentation, and blog. Built with Astro 5 (static output, zero client JS except a theme toggle island). Docs are markdown files in `src/content/docs/`, blog posts in `src/content/blog/`, both using Astro content collections. Tailwind CSS v4 via `@tailwindcss/vite`. Deploys to S3/CloudFront via GitHub Actions on push to main.
|
||||
|
||||
The `/docs/reference/keyboard-shortcuts` page is auto-generated from the shortcut registry at build time — see the Keyboard Shortcuts section above. Editing the markdown body has no effect; update the scope files instead.
|
||||
|
||||
## Test plugin locally
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
import { shortcutReferenceSurfaces } from '../lib/shortcutReference';
|
||||
import { formatShortcutBindingsText } from '../../../../packages/ui/shortcuts';
|
||||
---
|
||||
|
||||
<p>Keyboard shortcuts available across the Plannotator review and annotation UIs.</p>
|
||||
|
||||
{shortcutReferenceSurfaces.map((surface) => (
|
||||
<Fragment>
|
||||
<h2 id={surface.slug}>{surface.title}</h2>
|
||||
<p>{surface.description}</p>
|
||||
|
||||
{surface.sections.map((section) => (
|
||||
<Fragment>
|
||||
<h3 id={section.slug}>{section.title}</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Shortcut</th>
|
||||
<th>Action</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{section.shortcuts.map((shortcut) => (
|
||||
<tr>
|
||||
<td><code>{formatShortcutBindingsText(shortcut.bindings)}</code></td>
|
||||
<td>{shortcut.description}</td>
|
||||
<td>{shortcut.hint ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Fragment>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
@@ -6,22 +6,14 @@ sidebar:
|
||||
section: "Reference"
|
||||
---
|
||||
|
||||
Keyboard shortcuts available in the Plannotator plan review, code review, and annotation UIs.
|
||||
<!--
|
||||
This page is auto-generated from the shortcut registry at build time.
|
||||
Editing the body below has no effect — the slug page renders
|
||||
`apps/marketing/src/components/ShortcutReference.astro` instead.
|
||||
|
||||
## Global shortcuts
|
||||
To change a shortcut, edit the relevant scope file under
|
||||
`packages/ui/shortcuts/plan-review/` or `packages/ui/shortcuts/code-review/`,
|
||||
or a per-app surface in `packages/{editor,review-editor}/shortcuts.ts`.
|
||||
-->
|
||||
|
||||
| Shortcut | Context | Action |
|
||||
|----------|---------|--------|
|
||||
| `Cmd/Ctrl+Enter` | Plan review (no annotations) | Approve plan |
|
||||
| `Cmd/Ctrl+Enter` | Plan review (with annotations) | Send feedback |
|
||||
| `Cmd/Ctrl+Enter` | Code review | Send feedback / Approve |
|
||||
| `Cmd/Ctrl+Enter` | Annotate mode | Send annotations |
|
||||
| `Cmd/Ctrl+S` | Any mode (with API) | Quick save to default notes app |
|
||||
| `Escape` | Annotation toolbar | Close toolbar |
|
||||
|
||||
## Notes
|
||||
|
||||
- `Cmd/Ctrl+Enter` is blocked when a modal or dialog is open (export, import, confirm dialogs, image annotator)
|
||||
- `Cmd/Ctrl+Enter` is blocked when typing in an input or textarea
|
||||
- `Cmd/Ctrl+S` opens the Export modal if no default notes app is configured
|
||||
- `Escape` in the annotation toolbar closes it without creating an annotation
|
||||
This page is generated from the shared shortcut registry at build time.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { planReviewSurface, annotateSurface } from '../../../../packages/editor/shortcuts';
|
||||
import { codeReviewSurface } from '../../../../packages/review-editor/shortcuts';
|
||||
import { listRegistryShortcutSections } from '../../../../packages/ui/shortcuts';
|
||||
import type { ShortcutSurface } from '../../../../packages/ui/shortcuts';
|
||||
|
||||
const slugify = (value: string) => value.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
const allSurfaces: ShortcutSurface[] = [planReviewSurface, annotateSurface, codeReviewSurface];
|
||||
|
||||
export const shortcutReferenceSurfaces = allSurfaces.map((surface) => ({
|
||||
...surface,
|
||||
sections: listRegistryShortcutSections(surface.registry).map((section) => ({
|
||||
...section,
|
||||
slug: `${surface.slug}-${slugify(section.title)}`,
|
||||
})),
|
||||
}));
|
||||
|
||||
export const shortcutReferenceHeadings = shortcutReferenceSurfaces.flatMap((surface) => [
|
||||
{ depth: 2 as const, slug: surface.slug, text: surface.title },
|
||||
...surface.sections.map((section) => ({ depth: 3 as const, slug: section.slug, text: section.title })),
|
||||
]);
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import Docs from '../../layouts/Docs.astro';
|
||||
import ShortcutReference from '../../components/ShortcutReference.astro';
|
||||
import { shortcutReferenceHeadings } from '../../lib/shortcutReference';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const docs = await getCollection('docs');
|
||||
@@ -11,7 +13,12 @@ export async function getStaticPaths() {
|
||||
}
|
||||
|
||||
const { doc } = Astro.props;
|
||||
const { Content, headings } = await render(doc);
|
||||
const isShortcutReference = doc.id === 'reference/keyboard-shortcuts';
|
||||
const rendered = isShortcutReference ? null : await render(doc);
|
||||
const Content = rendered?.Content;
|
||||
const headings = isShortcutReference
|
||||
? shortcutReferenceHeadings
|
||||
: rendered?.headings ?? [];
|
||||
---
|
||||
<Docs
|
||||
title={doc.data.title}
|
||||
@@ -19,5 +26,5 @@ const { Content, headings } = await render(doc);
|
||||
headings={headings}
|
||||
currentId={doc.id}
|
||||
>
|
||||
<Content />
|
||||
{isShortcutReference ? <ShortcutReference /> : Content && <Content />}
|
||||
</Docs>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./App.tsx",
|
||||
"./styles": "./index.css"
|
||||
"./styles": "./index.css",
|
||||
"./shortcuts": "./shortcuts.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plannotator/shared": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
annotationPanelShortcuts,
|
||||
annotationToolbarShortcuts,
|
||||
commentPopoverShortcuts,
|
||||
createShortcutRegistry,
|
||||
createShortcutScopeHook,
|
||||
defineShortcutScope,
|
||||
imageAnnotatorShortcuts,
|
||||
inputMethodShortcuts,
|
||||
viewerShortcuts,
|
||||
type ShortcutSurface,
|
||||
} from '@plannotator/ui/shortcuts';
|
||||
|
||||
export const planEditorShortcuts = defineShortcutScope({
|
||||
id: 'plan-editor',
|
||||
title: 'Plan Editor',
|
||||
shortcuts: {
|
||||
submitPlan: {
|
||||
description: 'Approve / Send feedback',
|
||||
bindings: ['Mod+Enter'],
|
||||
section: 'Actions',
|
||||
hint: 'Approves when there are no annotations and sends feedback when there are.',
|
||||
displayOrder: 10,
|
||||
},
|
||||
submitAnnotations: {
|
||||
description: 'Send annotations',
|
||||
bindings: ['Mod+Enter'],
|
||||
section: 'Actions',
|
||||
displayOrder: 10,
|
||||
},
|
||||
quickSave: {
|
||||
description: 'Save to notes app',
|
||||
bindings: ['Mod+S'],
|
||||
section: 'Actions',
|
||||
hint: 'Opens Export if no default notes app is configured.',
|
||||
displayOrder: 20,
|
||||
},
|
||||
exitPlanDiff: {
|
||||
description: 'Close diff view',
|
||||
bindings: ['Escape'],
|
||||
section: 'Actions',
|
||||
hint: 'Available while plan diff is open.',
|
||||
displayOrder: 30,
|
||||
},
|
||||
printPlan: {
|
||||
description: 'Print',
|
||||
bindings: ['Mod+P'],
|
||||
section: 'Actions',
|
||||
hint: 'Opens the browser print dialog for the current document.',
|
||||
displayOrder: 40,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const usePlanEditorShortcuts = createShortcutScopeHook(planEditorShortcuts);
|
||||
|
||||
const planReviewEditorSettingsShortcuts = defineShortcutScope({
|
||||
id: 'plan-review-editor-settings',
|
||||
title: 'Plan Editor',
|
||||
shortcuts: {
|
||||
submitPlan: planEditorShortcuts.shortcuts.submitPlan,
|
||||
quickSave: planEditorShortcuts.shortcuts.quickSave,
|
||||
exitPlanDiff: planEditorShortcuts.shortcuts.exitPlanDiff,
|
||||
printPlan: planEditorShortcuts.shortcuts.printPlan,
|
||||
},
|
||||
});
|
||||
|
||||
const annotateEditorSettingsShortcuts = defineShortcutScope({
|
||||
id: 'annotate-editor-settings',
|
||||
title: 'Annotate Editor',
|
||||
shortcuts: {
|
||||
submitAnnotations: planEditorShortcuts.shortcuts.submitAnnotations,
|
||||
quickSave: planEditorShortcuts.shortcuts.quickSave,
|
||||
printPlan: planEditorShortcuts.shortcuts.printPlan,
|
||||
},
|
||||
});
|
||||
|
||||
const sharedPlanSurfaceShortcuts = [
|
||||
inputMethodShortcuts,
|
||||
annotationToolbarShortcuts,
|
||||
viewerShortcuts,
|
||||
commentPopoverShortcuts,
|
||||
annotationPanelShortcuts,
|
||||
imageAnnotatorShortcuts,
|
||||
] as const;
|
||||
|
||||
export const planReviewSettingsShortcutRegistry = createShortcutRegistry([
|
||||
planReviewEditorSettingsShortcuts,
|
||||
...sharedPlanSurfaceShortcuts,
|
||||
] as const);
|
||||
|
||||
export const annotateSettingsShortcutRegistry = createShortcutRegistry([
|
||||
annotateEditorSettingsShortcuts,
|
||||
...sharedPlanSurfaceShortcuts,
|
||||
] as const);
|
||||
|
||||
export const planReviewSurface: ShortcutSurface = {
|
||||
slug: 'plan-review',
|
||||
title: 'Plan review',
|
||||
description: 'Shortcuts surfaced by the plan review UI.',
|
||||
registry: planReviewSettingsShortcutRegistry,
|
||||
};
|
||||
|
||||
export const annotateSurface: ShortcutSurface = {
|
||||
slug: 'annotate-mode',
|
||||
title: 'Annotate mode',
|
||||
description: 'Shortcuts surfaced by the standalone annotation UI.',
|
||||
registry: annotateSettingsShortcutRegistry,
|
||||
};
|
||||
@@ -691,11 +691,6 @@ const ReviewApp: React.FC = () => {
|
||||
clearSearch();
|
||||
}
|
||||
}
|
||||
// Cmd/Ctrl+Shift+C to copy diff
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') {
|
||||
e.preventDefault();
|
||||
handleCopyDiff();
|
||||
}
|
||||
// Cmd/Ctrl+B to toggle file tree
|
||||
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'b' && !isTypingTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./App.tsx",
|
||||
"./styles": "./index.css"
|
||||
"./styles": "./index.css",
|
||||
"./shortcuts": "./shortcuts.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pierre/diffs": "^1.1.12",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
createDoubleTapShortcutsHook,
|
||||
createShortcutRegistry,
|
||||
createShortcutScopeHook,
|
||||
defineShortcutScope,
|
||||
reviewAiShortcuts,
|
||||
reviewAllFilesDiffShortcuts,
|
||||
reviewAnnotationToolbarShortcuts,
|
||||
reviewFileTreeShortcuts,
|
||||
reviewPrCommentsShortcuts,
|
||||
reviewSuggestionModalShortcuts,
|
||||
reviewTourDialogShortcuts,
|
||||
type ShortcutSurface,
|
||||
} from '@plannotator/ui/shortcuts';
|
||||
|
||||
export const reviewEditorShortcuts = defineShortcutScope({
|
||||
id: 'review-editor',
|
||||
title: 'Review Editor',
|
||||
shortcuts: {
|
||||
submit: {
|
||||
description: 'Approve / Send feedback',
|
||||
bindings: ['Mod+Enter'],
|
||||
section: 'Actions',
|
||||
displayOrder: 10,
|
||||
},
|
||||
focusSearch: {
|
||||
description: 'Focus search',
|
||||
bindings: ['Mod+F'],
|
||||
section: 'Search',
|
||||
hint: 'Available when the file tree search bar is shown.',
|
||||
displayOrder: 10,
|
||||
},
|
||||
nextSearchMatch: {
|
||||
description: 'Next search result',
|
||||
bindings: ['Enter', 'F3'],
|
||||
section: 'Search',
|
||||
displayOrder: 20,
|
||||
},
|
||||
prevSearchMatch: {
|
||||
description: 'Previous search result',
|
||||
bindings: ['Shift+Enter', 'Shift+F3'],
|
||||
section: 'Search',
|
||||
displayOrder: 30,
|
||||
},
|
||||
clearSearch: {
|
||||
description: 'Clear search / close panel',
|
||||
bindings: ['Escape'],
|
||||
section: 'Search',
|
||||
displayOrder: 40,
|
||||
},
|
||||
toggleDestination: {
|
||||
description: 'Toggle review destination',
|
||||
bindings: ['Alt Alt'],
|
||||
section: 'Actions',
|
||||
hint: 'Double-tap to switch between platform and agent in PR review mode.',
|
||||
displayOrder: 30,
|
||||
},
|
||||
toggleFileTree: {
|
||||
description: 'Toggle file tree',
|
||||
bindings: ['Mod+B'],
|
||||
section: 'Layout',
|
||||
displayOrder: 10,
|
||||
},
|
||||
toggleSidebar: {
|
||||
description: 'Toggle review sidebar',
|
||||
bindings: ['Mod+.'],
|
||||
section: 'Layout',
|
||||
displayOrder: 20,
|
||||
},
|
||||
toggleTour: {
|
||||
description: 'Toggle demo tour dialog',
|
||||
bindings: ['Mod+Shift+T'],
|
||||
section: 'Layout',
|
||||
hint: 'Available in dev builds only.',
|
||||
displayOrder: 30,
|
||||
},
|
||||
toggleViewed: {
|
||||
description: 'Toggle file viewed',
|
||||
bindings: ['V'],
|
||||
section: 'File Actions',
|
||||
hint: 'Marks the active diff file as viewed (and auto-collapses in all-files view).',
|
||||
displayOrder: 10,
|
||||
},
|
||||
stageFile: {
|
||||
description: 'Stage current file',
|
||||
bindings: ['A'],
|
||||
section: 'File Actions',
|
||||
hint: 'Available when staging is supported (not in PR review mode).',
|
||||
displayOrder: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const useReviewEditorShortcuts = createShortcutScopeHook(reviewEditorShortcuts);
|
||||
export const useReviewEditorDoubleTap = createDoubleTapShortcutsHook(reviewEditorShortcuts);
|
||||
|
||||
export const reviewSettingsShortcutRegistry = createShortcutRegistry([
|
||||
reviewEditorShortcuts,
|
||||
reviewFileTreeShortcuts,
|
||||
reviewAllFilesDiffShortcuts,
|
||||
reviewAnnotationToolbarShortcuts,
|
||||
reviewSuggestionModalShortcuts,
|
||||
reviewAiShortcuts,
|
||||
reviewPrCommentsShortcuts,
|
||||
reviewTourDialogShortcuts,
|
||||
] as const);
|
||||
|
||||
export const codeReviewSurface: ShortcutSurface = {
|
||||
slug: 'code-review',
|
||||
title: 'Code review',
|
||||
description: 'Shortcuts surfaced by the code review UI.',
|
||||
registry: reviewSettingsShortcutRegistry,
|
||||
};
|
||||
@@ -116,7 +116,6 @@ const reviewShortcuts: ShortcutSection[] = [
|
||||
shortcuts: [
|
||||
{ keys: [modKey, enter], desc: 'Approve / Send feedback' },
|
||||
{ keys: [altKey, altKey], desc: 'Toggle destination', hint: 'Double-tap to switch between GitHub and Agent in PR review mode' },
|
||||
{ keys: [modKey, '⇧', 'C'], desc: 'Toggle comment mode' },
|
||||
{ keys: [modKey, 'B'], desc: 'Toggle file tree' },
|
||||
{ keys: [modKey, '.'], desc: 'Toggle sidebar' },
|
||||
{ keys: ['Esc'], desc: 'Collapse sidebar' },
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"./components/plan-diff/*": "./components/plan-diff/*.tsx",
|
||||
"./utils/*": "./utils/*.ts",
|
||||
"./hooks/*": "./hooks/*.ts",
|
||||
"./shortcuts": "./shortcuts/index.ts",
|
||||
"./config": "./config/index.ts",
|
||||
"./types": "./types.ts",
|
||||
"./theme": "./theme.css"
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { annotateSettingsShortcutRegistry, planEditorShortcuts, planReviewSettingsShortcutRegistry } from '../editor/shortcuts';
|
||||
import { reviewSettingsShortcutRegistry } from '../review-editor/shortcuts';
|
||||
import {
|
||||
createShortcutRegistry,
|
||||
defineShortcutScope,
|
||||
dispatchShortcutEvent,
|
||||
formatShortcutBindingText,
|
||||
formatShortcutBindingTokens,
|
||||
getShortcut,
|
||||
listRegistryShortcutSections,
|
||||
matchesKeyName,
|
||||
matchesShortcutBinding,
|
||||
parseDoubleTapBinding,
|
||||
validateShortcutRegistry,
|
||||
} from './shortcuts';
|
||||
|
||||
describe('shortcuts', () => {
|
||||
it('formats bindings for docs and keycaps', () => {
|
||||
expect(formatShortcutBindingText('Mod+Enter')).toBe('Cmd/Ctrl+Enter');
|
||||
expect(formatShortcutBindingText('Alt hold')).toBe('Hold Alt');
|
||||
expect(formatShortcutBindingText('Alt Alt')).toBe('Double-tap Alt');
|
||||
expect(formatShortcutBindingText('Alt Alt', 'mac')).toBe('Double-tap Option');
|
||||
expect(formatShortcutBindingTokens('Mod+Enter', 'mac')).toEqual(['⌘', '⏎']);
|
||||
expect(formatShortcutBindingTokens('Mod+Enter', 'non-mac')).toEqual(['Ctrl', '↵']);
|
||||
expect(formatShortcutBindingTokens('Alt Alt', 'mac')).toEqual(['⌥', '×2']);
|
||||
expect(formatShortcutBindingTokens('Alt Alt', 'non-mac')).toEqual(['Alt', '×2']);
|
||||
});
|
||||
|
||||
it('validates duplicate scope ids and non-normalized tokens', () => {
|
||||
const duplicateScope = defineShortcutScope({
|
||||
id: 'dup',
|
||||
title: 'Duplicate',
|
||||
shortcuts: {
|
||||
submit: {
|
||||
description: 'Submit',
|
||||
bindings: ['Mod+Enter'],
|
||||
section: 'Actions',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const badScope = defineShortcutScope({
|
||||
id: 'bad',
|
||||
title: 'Bad',
|
||||
shortcuts: {
|
||||
broken: {
|
||||
description: 'Broken',
|
||||
bindings: ['Cmd+Enter'],
|
||||
section: 'Actions',
|
||||
},
|
||||
missingCopy: {
|
||||
description: '',
|
||||
bindings: ['Mod+C'],
|
||||
section: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const errors = validateShortcutRegistry([duplicateScope, duplicateScope, badScope]);
|
||||
|
||||
expect(errors).toContain('Duplicate shortcut scope id: dup');
|
||||
expect(errors.some(error => error.includes('Cmd'))).toBe(true);
|
||||
expect(errors).toContain('Shortcut bad.missingCopy is missing a section.');
|
||||
expect(errors).toContain('Shortcut bad.missingCopy is missing a description.');
|
||||
expect(() => createShortcutRegistry([duplicateScope, duplicateScope])).toThrow();
|
||||
});
|
||||
|
||||
it('lists plan review, annotate, and review sections from assembled registries', () => {
|
||||
const planReviewSections = listRegistryShortcutSections(planReviewSettingsShortcutRegistry);
|
||||
const annotateSections = listRegistryShortcutSections(annotateSettingsShortcutRegistry);
|
||||
const reviewSections = listRegistryShortcutSections(reviewSettingsShortcutRegistry);
|
||||
|
||||
expect(planReviewSections.map(section => section.title)).toEqual([
|
||||
'Actions',
|
||||
'Input Method',
|
||||
'Annotations',
|
||||
'Image Annotator',
|
||||
]);
|
||||
|
||||
expect(annotateSections.map(section => section.title)).toEqual([
|
||||
'Actions',
|
||||
'Input Method',
|
||||
'Annotations',
|
||||
'Image Annotator',
|
||||
]);
|
||||
|
||||
expect(getShortcut(planReviewSettingsShortcutRegistry, 'plan-review-editor-settings', 'submitPlan')?.description).toBe('Approve / Send feedback');
|
||||
expect(getShortcut(planReviewSettingsShortcutRegistry, 'plan-review-editor-settings', 'submitAnnotations')).toBeUndefined();
|
||||
expect(getShortcut(annotateSettingsShortcutRegistry, 'annotate-editor-settings', 'submitAnnotations')?.description).toBe('Send annotations');
|
||||
expect(getShortcut(annotateSettingsShortcutRegistry, 'annotate-editor-settings', 'submitPlan')).toBeUndefined();
|
||||
|
||||
expect(reviewSections.map(section => section.title)).toEqual([
|
||||
'Actions',
|
||||
'Search',
|
||||
'Layout',
|
||||
'File Actions',
|
||||
'File Navigation',
|
||||
'All-Files View',
|
||||
'Annotations',
|
||||
'Suggestion Editor',
|
||||
'AI Assistant',
|
||||
'PR Comments',
|
||||
'Tour',
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches normalized runtime bindings', () => {
|
||||
const submitEvent = { key: 'Enter', ctrlKey: true, metaKey: false, shiftKey: false, altKey: false, code: 'Enter' } as KeyboardEvent;
|
||||
const reverseSearchEvent = { key: 'F3', ctrlKey: false, metaKey: false, shiftKey: true, altKey: false, code: 'F3' } as KeyboardEvent;
|
||||
const typeEvent = { key: 'A', ctrlKey: false, metaKey: false, shiftKey: true, altKey: false, code: 'KeyA' } as KeyboardEvent;
|
||||
const quickLabelEvent = { key: '3', ctrlKey: false, metaKey: false, shiftKey: false, altKey: true, code: 'Digit3' } as KeyboardEvent;
|
||||
const macOptionQuickLabelEvent = { key: '£', ctrlKey: false, metaKey: false, shiftKey: false, altKey: true, code: 'Digit3' } as KeyboardEvent;
|
||||
const wrongEvent = { key: 'Enter', ctrlKey: false, metaKey: false, shiftKey: false, altKey: true, code: 'Enter' } as KeyboardEvent;
|
||||
|
||||
expect(matchesShortcutBinding(submitEvent, 'Mod+Enter')).toBe(true);
|
||||
expect(matchesShortcutBinding(reverseSearchEvent, 'Shift+F3')).toBe(true);
|
||||
expect(matchesShortcutBinding(typeEvent, 'A-Z')).toBe(true);
|
||||
expect(matchesShortcutBinding(quickLabelEvent, 'Alt+1-0')).toBe(true);
|
||||
expect(matchesShortcutBinding(macOptionQuickLabelEvent, 'Alt+1-0')).toBe(true);
|
||||
expect(matchesShortcutBinding(wrongEvent, 'Mod+Enter')).toBe(false);
|
||||
});
|
||||
|
||||
it('dispatches matching registry actions', () => {
|
||||
const calls: string[] = [];
|
||||
const event = { key: 'Enter', ctrlKey: true, metaKey: false, shiftKey: false, altKey: false } as KeyboardEvent;
|
||||
|
||||
const handled = dispatchShortcutEvent(planReviewSettingsShortcutRegistry[0], {
|
||||
submitPlan: () => calls.push('submitPlan'),
|
||||
quickSave: () => calls.push('quickSave'),
|
||||
}, event);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(calls).toEqual(['submitPlan']);
|
||||
});
|
||||
|
||||
it('can dispatch annotate submit after plan submit declines the same binding', () => {
|
||||
const calls: string[] = [];
|
||||
const event = {
|
||||
key: 'Enter',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
preventDefault: () => calls.push('preventDefault'),
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
const handled = dispatchShortcutEvent(planEditorShortcuts, {
|
||||
submitPlan: {
|
||||
when: () => false,
|
||||
handle: () => calls.push('submitPlan'),
|
||||
},
|
||||
submitAnnotations: {
|
||||
when: () => true,
|
||||
handle: () => {
|
||||
event.preventDefault();
|
||||
calls.push('submitAnnotations');
|
||||
},
|
||||
},
|
||||
}, event);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(calls).toEqual(['preventDefault', 'submitAnnotations']);
|
||||
});
|
||||
|
||||
it('supports guarded handlers and continues after a failed guard', () => {
|
||||
const guardedScope = defineShortcutScope({
|
||||
id: 'guarded',
|
||||
title: 'Guarded',
|
||||
shortcuts: {
|
||||
primary: {
|
||||
description: 'Primary',
|
||||
bindings: ['Enter'],
|
||||
section: 'Actions',
|
||||
preventDefault: true,
|
||||
},
|
||||
fallback: {
|
||||
description: 'Fallback',
|
||||
bindings: ['Enter'],
|
||||
section: 'Actions',
|
||||
preventDefault: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const calls: string[] = [];
|
||||
const event = {
|
||||
key: 'Enter',
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
preventDefault: () => calls.push('preventDefault'),
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
const handled = dispatchShortcutEvent(guardedScope, {
|
||||
primary: {
|
||||
when: () => false,
|
||||
handle: () => calls.push('primary'),
|
||||
},
|
||||
fallback: {
|
||||
when: () => true,
|
||||
handle: () => calls.push('fallback'),
|
||||
},
|
||||
}, event);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(calls).toEqual(['preventDefault', 'fallback']);
|
||||
});
|
||||
|
||||
it('parses double-tap bindings', () => {
|
||||
expect(parseDoubleTapBinding('Alt Alt')).toBe('Alt');
|
||||
expect(parseDoubleTapBinding('Shift Shift')).toBe('Shift');
|
||||
expect(parseDoubleTapBinding('Alt hold')).toBeNull();
|
||||
expect(parseDoubleTapBinding('Mod+Enter')).toBeNull();
|
||||
expect(parseDoubleTapBinding('Alt Shift')).toBeNull(); // different keys
|
||||
expect(parseDoubleTapBinding('Alt+Shift Alt+Shift')).toBeNull(); // multi-key groups
|
||||
});
|
||||
|
||||
it('matches key names for sequential binding support', () => {
|
||||
const altEvent = { key: 'Alt' } as KeyboardEvent;
|
||||
const shiftEvent = { key: 'Shift' } as KeyboardEvent;
|
||||
const metaEvent = { key: 'Meta' } as KeyboardEvent;
|
||||
const ctrlEvent = { key: 'Control' } as KeyboardEvent;
|
||||
|
||||
expect(matchesKeyName(altEvent, 'Alt')).toBe(true);
|
||||
expect(matchesKeyName(altEvent, 'Shift')).toBe(false);
|
||||
expect(matchesKeyName(shiftEvent, 'Shift')).toBe(true);
|
||||
expect(matchesKeyName(metaEvent, 'Mod')).toBe(true);
|
||||
expect(matchesKeyName(ctrlEvent, 'Mod')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not handle or prevent default when a guard fails', () => {
|
||||
const guardedScope = defineShortcutScope({
|
||||
id: 'guarded-skip',
|
||||
title: 'Guarded Skip',
|
||||
shortcuts: {
|
||||
save: {
|
||||
description: 'Save',
|
||||
bindings: ['Mod+S'],
|
||||
section: 'Actions',
|
||||
preventDefault: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let preventDefaultCalls = 0;
|
||||
const event = {
|
||||
key: 's',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
preventDefault: () => {
|
||||
preventDefaultCalls += 1;
|
||||
},
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
const handled = dispatchShortcutEvent(guardedScope, {
|
||||
save: {
|
||||
when: () => false,
|
||||
handle: () => {
|
||||
throw new Error('should not run');
|
||||
},
|
||||
},
|
||||
}, event);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(preventDefaultCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineShortcutScope } from '../core';
|
||||
import { createShortcutScopeHook } from '../runtime';
|
||||
|
||||
export const reviewAiShortcuts = defineShortcutScope({
|
||||
id: 'review-ai',
|
||||
title: 'AI Assistant',
|
||||
shortcuts: {
|
||||
submit: {
|
||||
description: 'Send message',
|
||||
bindings: ['Mod+Enter'],
|
||||
section: 'AI Assistant',
|
||||
hint: 'Available in the AI tab and the Ask AI inline input.',
|
||||
displayOrder: 10,
|
||||
},
|
||||
cancel: {
|
||||
description: 'Cancel inline AI input',
|
||||
bindings: ['Escape'],
|
||||
section: 'AI Assistant',
|
||||
hint: 'Available in the Ask AI inline input.',
|
||||
displayOrder: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const useReviewAiShortcuts = createShortcutScopeHook(reviewAiShortcuts);
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineShortcutScope } from '../core';
|
||||
import { createShortcutScopeHook } from '../runtime';
|
||||
|
||||
export const reviewAllFilesDiffShortcuts = defineShortcutScope({
|
||||
id: 'review-all-files-diff',
|
||||
title: 'All-Files Diff View',
|
||||
shortcuts: {
|
||||
toggleCollapse: {
|
||||
description: 'Collapse / expand current file',
|
||||
bindings: ['X'],
|
||||
section: 'All-Files View',
|
||||
hint: 'Available when the all-files diff panel is active.',
|
||||
displayOrder: 10,
|
||||
},
|
||||
undoCollapse: {
|
||||
description: 'Undo last collapse',
|
||||
bindings: ['Z'],
|
||||
section: 'All-Files View',
|
||||
hint: 'Reopens the most recently auto-collapsed file (auto-collapse fires when toggling viewed).',
|
||||
displayOrder: 20,
|
||||
},
|
||||
addFileComment: {
|
||||
description: 'Add file-scoped comment',
|
||||
bindings: ['C'],
|
||||
section: 'All-Files View',
|
||||
hint: 'Opens the file-scoped comment popover for the active file.',
|
||||
displayOrder: 30,
|
||||
},
|
||||
nextFile: {
|
||||
description: 'Scroll to next expanded file',
|
||||
bindings: [']'],
|
||||
section: 'All-Files View',
|
||||
hint: 'All-files panel only.',
|
||||
displayOrder: 40,
|
||||
},
|
||||
prevFile: {
|
||||
description: 'Scroll to previous expanded file',
|
||||
bindings: ['['],
|
||||
section: 'All-Files View',
|
||||
hint: 'All-files panel only.',
|
||||
displayOrder: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const useReviewAllFilesDiffShortcuts = createShortcutScopeHook(reviewAllFilesDiffShortcuts);
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineShortcutScope } from '../core';
|
||||
import { createShortcutScopeHook } from '../runtime';
|
||||
|
||||
export const reviewAnnotationToolbarShortcuts = defineShortcutScope({
|
||||
id: 'review-annotation-toolbar',
|
||||
title: 'Review Annotation Toolbar',
|
||||
shortcuts: {
|
||||
submitComment: {
|
||||
description: 'Submit comment',
|
||||
bindings: ['Mod+Enter'],
|
||||
section: 'Annotations',
|
||||
displayOrder: 10,
|
||||
},
|
||||
indentSuggestedCode: {
|
||||
description: 'Indent suggested code',
|
||||
bindings: ['Tab'],
|
||||
section: 'Annotations',
|
||||
displayOrder: 20,
|
||||
},
|
||||
cancel: {
|
||||
description: 'Close comment editor',
|
||||
bindings: ['Escape'],
|
||||
section: 'Annotations',
|
||||
hint: 'Available while the review comment editor is open.',
|
||||
displayOrder: 30,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const useReviewAnnotationToolbarShortcuts = createShortcutScopeHook(reviewAnnotationToolbarShortcuts);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineShortcutScope } from '../core';
|
||||
import { createShortcutScopeHook } from '../runtime';
|
||||
|
||||
export const reviewFileTreeShortcuts = defineShortcutScope({
|
||||
id: 'review-file-tree',
|
||||
title: 'File Navigation',
|
||||
shortcuts: {
|
||||
nextFile: {
|
||||
description: 'Next file',
|
||||
bindings: ['J', 'ArrowDown'],
|
||||
section: 'File Navigation',
|
||||
displayOrder: 10,
|
||||
},
|
||||
prevFile: {
|
||||
description: 'Previous file',
|
||||
bindings: ['K', 'ArrowUp'],
|
||||
section: 'File Navigation',
|
||||
displayOrder: 20,
|
||||
},
|
||||
firstFile: {
|
||||
description: 'First file',
|
||||
bindings: ['Home'],
|
||||
section: 'File Navigation',
|
||||
displayOrder: 30,
|
||||
},
|
||||
lastFile: {
|
||||
description: 'Last file',
|
||||
bindings: ['End'],
|
||||
section: 'File Navigation',
|
||||
displayOrder: 40,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const useReviewFileTreeShortcuts = createShortcutScopeHook(reviewFileTreeShortcuts);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineShortcutScope } from '../core';
|
||||
import { createShortcutScopeHook } from '../runtime';
|
||||
|
||||
export const reviewPrCommentsShortcuts = defineShortcutScope({
|
||||
id: 'review-pr-comments',
|
||||
title: 'PR Comments',
|
||||
shortcuts: {
|
||||
focusSearch: {
|
||||
description: 'Focus PR comments search',
|
||||
bindings: ['Mod+Shift+F'],
|
||||
section: 'PR Comments',
|
||||
hint: 'Available while the PR Comments tab is open in the review sidebar.',
|
||||
displayOrder: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const useReviewPrCommentsShortcuts = createShortcutScopeHook(reviewPrCommentsShortcuts);
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineShortcutScope } from '../core';
|
||||
import { createShortcutScopeHook } from '../runtime';
|
||||
|
||||
export const reviewSuggestionModalShortcuts = defineShortcutScope({
|
||||
id: 'review-suggestion-modal',
|
||||
title: 'Suggestion Editor',
|
||||
shortcuts: {
|
||||
indent: {
|
||||
description: 'Indent (insert spaces)',
|
||||
bindings: ['Tab'],
|
||||
section: 'Suggestion Editor',
|
||||
hint: 'Available in the expanded suggestion editor.',
|
||||
displayOrder: 10,
|
||||
},
|
||||
close: {
|
||||
description: 'Close suggestion editor',
|
||||
bindings: ['Escape'],
|
||||
section: 'Suggestion Editor',
|
||||
hint: 'Closes the modal; suggestion text is preserved on the underlying comment.',
|
||||
displayOrder: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const useReviewSuggestionModalShortcuts = createShortcutScopeHook(reviewSuggestionModalShortcuts);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineShortcutScope } from '../core';
|
||||
import { createShortcutScopeHook } from '../runtime';
|
||||
|
||||
export const reviewTourDialogShortcuts = defineShortcutScope({
|
||||
id: 'review-tour-dialog',
|
||||
title: 'Tour Dialog',
|
||||
shortcuts: {
|
||||
close: {
|
||||
description: 'Close tour',
|
||||
bindings: ['Escape'],
|
||||
section: 'Tour',
|
||||
hint: 'Available while the code tour dialog is open.',
|
||||
displayOrder: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const useReviewTourDialogShortcuts = createShortcutScopeHook(reviewTourDialogShortcuts);
|
||||
@@ -0,0 +1,399 @@
|
||||
import { isMac } from '../utils/platform';
|
||||
|
||||
export type ShortcutPlatform = 'mac' | 'non-mac' | 'cross-platform';
|
||||
|
||||
export interface ShortcutDefinition {
|
||||
description: string;
|
||||
/** Alternative bindings for the same action, shown as “or” in the shortcuts UI. */
|
||||
bindings: string[];
|
||||
section: string;
|
||||
hint?: string;
|
||||
preventDefault?: boolean;
|
||||
/** Stable help-menu ordering within a section, without relying on object key order. */
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
export interface ShortcutScopeDefinition<TAction extends string = string> {
|
||||
id: string;
|
||||
title: string;
|
||||
shortcuts: Record<TAction, ShortcutDefinition>;
|
||||
}
|
||||
|
||||
export interface ShortcutEntry extends ShortcutDefinition {
|
||||
scopeId: string;
|
||||
scopeTitle: string;
|
||||
actionId: string;
|
||||
}
|
||||
|
||||
export interface ShortcutSection {
|
||||
title: string;
|
||||
shortcuts: ShortcutEntry[];
|
||||
}
|
||||
|
||||
export type ShortcutRegistry = readonly ShortcutScopeDefinition[];
|
||||
|
||||
export interface ShortcutSurface {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
registry: ShortcutRegistry;
|
||||
}
|
||||
|
||||
const NAMED_TOKENS = new Set([
|
||||
'Mod',
|
||||
'Shift',
|
||||
'Alt',
|
||||
'Enter',
|
||||
'Escape',
|
||||
'Tab',
|
||||
// TODO(migration): `matchesKeyToken` does not currently match `Space` —
|
||||
// pressing Spacebar produces `event.key === ' '` (length 1), which the
|
||||
// matcher uppercases to `' '` and then compares to the literal `'Space'`,
|
||||
// always failing. Add a special case in `matchesKeyToken` (e.g.
|
||||
// `if (token === 'Space') return event.key === ' ' || event.code === 'Space'`)
|
||||
// before any scope binds Space.
|
||||
'Space',
|
||||
'Backspace',
|
||||
'Delete',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'Home',
|
||||
'End',
|
||||
'A-Z',
|
||||
'1-0',
|
||||
'hold',
|
||||
// Punctuation keys used as shortcut targets. Add new ones as needed; we
|
||||
// whitelist explicitly so typos like `Cmd` instead of `Mod` keep failing
|
||||
// validation.
|
||||
'.',
|
||||
'[',
|
||||
']',
|
||||
]);
|
||||
|
||||
for (let n = 1; n <= 12; n += 1) {
|
||||
NAMED_TOKENS.add(`F${n}`);
|
||||
}
|
||||
|
||||
const MODIFIER_TOKENS = new Set(['Mod', 'Shift', 'Alt']);
|
||||
|
||||
export function defineShortcutScope<TAction extends string>(scope: ShortcutScopeDefinition<TAction>): ShortcutScopeDefinition<TAction> {
|
||||
return scope;
|
||||
}
|
||||
|
||||
function isSingleLetter(token: string): boolean {
|
||||
return /^[A-Z]$/.test(token);
|
||||
}
|
||||
|
||||
function isSingleDigit(token: string): boolean {
|
||||
return /^[0-9]$/.test(token);
|
||||
}
|
||||
|
||||
function getBindingTokens(binding: string): string[] {
|
||||
return binding.trim().split(/[+\s]+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function getBindingGroups(binding: string): string[][] {
|
||||
return binding.trim().split(/\s+/).filter(Boolean).map(group => group.split('+').filter(Boolean));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a double-tap binding like `"Alt Alt"` and return the key name.
|
||||
* Returns null for non-double-tap bindings.
|
||||
*/
|
||||
export function parseDoubleTapBinding(binding: string): string | null {
|
||||
const groups = getBindingGroups(binding);
|
||||
if (groups.length !== 2) return null;
|
||||
if (groups[0].length !== 1 || groups[1].length !== 1) return null;
|
||||
if (groups[0][0] !== groups[1][0]) return null;
|
||||
if (groups[1][0] === 'hold') return null;
|
||||
return groups[0][0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a KeyboardEvent matches a named key token (for sequential/stateful matching).
|
||||
* Unlike `matchesShortcutBinding`, this matches a single key identity without modifier checks.
|
||||
*/
|
||||
export function matchesKeyName(event: KeyboardEvent, keyName: string): boolean {
|
||||
if (keyName === 'Alt') return event.key === 'Alt';
|
||||
if (keyName === 'Shift') return event.key === 'Shift';
|
||||
if (keyName === 'Mod') return event.key === 'Meta' || event.key === 'Control';
|
||||
return matchesKeyToken(event, keyName);
|
||||
}
|
||||
|
||||
function isNormalizedToken(token: string): boolean {
|
||||
return NAMED_TOKENS.has(token) || isSingleLetter(token) || isSingleDigit(token);
|
||||
}
|
||||
|
||||
function normalizeShortcutEntry(
|
||||
scope: ShortcutScopeDefinition,
|
||||
actionId: string,
|
||||
shortcut: ShortcutDefinition,
|
||||
): ShortcutEntry {
|
||||
return {
|
||||
...shortcut,
|
||||
scopeId: scope.id,
|
||||
scopeTitle: scope.title,
|
||||
actionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function listScopeShortcuts(
|
||||
scope: ShortcutScopeDefinition,
|
||||
options?: { actionIds?: readonly string[] },
|
||||
): ShortcutEntry[] {
|
||||
const allowedActionIds = options?.actionIds ? new Set(options.actionIds) : null;
|
||||
const shortcuts: ShortcutEntry[] = [];
|
||||
|
||||
for (const [actionId, shortcut] of Object.entries(scope.shortcuts)) {
|
||||
if (allowedActionIds && !allowedActionIds.has(actionId)) continue;
|
||||
|
||||
shortcuts.push(normalizeShortcutEntry(scope, actionId, shortcut));
|
||||
}
|
||||
|
||||
return shortcuts;
|
||||
}
|
||||
|
||||
export function validateShortcutRegistry(registry: ShortcutRegistry): string[] {
|
||||
const errors: string[] = [];
|
||||
const scopeIds = new Set<string>();
|
||||
|
||||
for (const scope of registry) {
|
||||
if (scopeIds.has(scope.id)) {
|
||||
errors.push(`Duplicate shortcut scope id: ${scope.id}`);
|
||||
}
|
||||
scopeIds.add(scope.id);
|
||||
|
||||
for (const [actionId, shortcut] of Object.entries(scope.shortcuts)) {
|
||||
const id = `${scope.id}.${actionId}`;
|
||||
|
||||
if (!shortcut.section.trim()) {
|
||||
errors.push(`Shortcut ${id} is missing a section.`);
|
||||
}
|
||||
|
||||
if (!shortcut.description.trim()) {
|
||||
errors.push(`Shortcut ${id} is missing a description.`);
|
||||
}
|
||||
|
||||
if (shortcut.bindings.length === 0) {
|
||||
errors.push(`Shortcut ${id} must define at least one binding.`);
|
||||
}
|
||||
|
||||
for (const binding of shortcut.bindings) {
|
||||
if (!binding.trim()) {
|
||||
errors.push(`Shortcut ${id} contains an empty binding.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const token of getBindingTokens(binding)) {
|
||||
if (!isNormalizedToken(token)) {
|
||||
errors.push(`Shortcut ${id} uses non-normalized token \`${token}\` in binding \`${binding}\`.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function createShortcutRegistry<TRegistry extends ShortcutRegistry>(registry: TRegistry): TRegistry {
|
||||
const errors = validateShortcutRegistry(registry);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Invalid shortcut registry:\n- ${errors.join('\n- ')}`);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function mergeShortcutRegistries(...registries: ShortcutRegistry[]): ShortcutRegistry {
|
||||
return createShortcutRegistry(registries.flat());
|
||||
}
|
||||
|
||||
export function getShortcutScope(registry: ShortcutRegistry, scopeId: string): ShortcutScopeDefinition | undefined {
|
||||
return registry.find(scope => scope.id === scopeId);
|
||||
}
|
||||
|
||||
export function getShortcut(
|
||||
registry: ShortcutRegistry,
|
||||
scopeId: string,
|
||||
actionId: string,
|
||||
): ShortcutEntry | undefined {
|
||||
const scope = getShortcutScope(registry, scopeId);
|
||||
const shortcut = scope?.shortcuts[actionId];
|
||||
return scope && shortcut ? normalizeShortcutEntry(scope, actionId, shortcut) : undefined;
|
||||
}
|
||||
|
||||
export function listRegistryShortcuts(registry: ShortcutRegistry): ShortcutEntry[] {
|
||||
const shortcuts: ShortcutEntry[] = [];
|
||||
|
||||
for (const scope of registry) {
|
||||
shortcuts.push(...listScopeShortcuts(scope));
|
||||
}
|
||||
|
||||
return shortcuts;
|
||||
}
|
||||
|
||||
export function listShortcutSections(shortcuts: readonly ShortcutEntry[]): ShortcutSection[] {
|
||||
const sections = new Map<string, ShortcutEntry[]>();
|
||||
|
||||
for (const shortcut of shortcuts) {
|
||||
const existing = sections.get(shortcut.section);
|
||||
if (existing) {
|
||||
existing.push(shortcut);
|
||||
} else {
|
||||
sections.set(shortcut.section, [shortcut]);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(sections.entries()).map(([title, sectionShortcuts]) => ({
|
||||
title,
|
||||
shortcuts: [...sectionShortcuts].sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0) || a.description.localeCompare(b.description)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function listRegistryShortcutSections(registry: ShortcutRegistry): ShortcutSection[] {
|
||||
return listShortcutSections(listRegistryShortcuts(registry));
|
||||
}
|
||||
|
||||
export function getShortcutPlatform(): Exclude<ShortcutPlatform, 'cross-platform'> {
|
||||
return isMac ? 'mac' : 'non-mac';
|
||||
}
|
||||
|
||||
function formatKeycapToken(token: string, platform: Exclude<ShortcutPlatform, 'cross-platform'>): string {
|
||||
if (platform === 'mac') {
|
||||
if (token === 'Mod') return '⌘';
|
||||
if (token === 'Alt') return '⌥';
|
||||
if (token === 'Shift') return '⇧';
|
||||
if (token === 'Enter') return '⏎';
|
||||
if (token === 'Escape') return 'Esc';
|
||||
}
|
||||
|
||||
if (platform === 'non-mac') {
|
||||
if (token === 'Mod') return 'Ctrl';
|
||||
if (token === 'Enter') return '↵';
|
||||
if (token === 'Escape') return 'Esc';
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
function formatTextToken(token: string, platform: ShortcutPlatform): string {
|
||||
if (token === 'Mod') {
|
||||
if (platform === 'mac') return 'Cmd';
|
||||
if (platform === 'non-mac') return 'Ctrl';
|
||||
return 'Cmd/Ctrl';
|
||||
}
|
||||
|
||||
if (token === 'Alt') {
|
||||
return platform === 'mac' ? 'Option' : 'Alt';
|
||||
}
|
||||
|
||||
if (token === 'Escape') return 'Escape';
|
||||
if (token === 'hold') return 'hold';
|
||||
return token;
|
||||
}
|
||||
|
||||
export function formatShortcutBindingTokens(
|
||||
binding: string,
|
||||
platform: Exclude<ShortcutPlatform, 'cross-platform'> = getShortcutPlatform(),
|
||||
): string[] {
|
||||
const doubleTapKey = parseDoubleTapBinding(binding);
|
||||
if (doubleTapKey) {
|
||||
return [formatKeycapToken(doubleTapKey, platform), '×2'];
|
||||
}
|
||||
|
||||
return getBindingTokens(binding).map(token => formatKeycapToken(token, platform));
|
||||
}
|
||||
|
||||
export function formatShortcutBindingText(
|
||||
binding: string,
|
||||
platform: ShortcutPlatform = 'cross-platform',
|
||||
): string {
|
||||
const groups = getBindingGroups(binding);
|
||||
|
||||
if (groups.length === 2 && groups[1].length === 1 && groups[1][0] === 'hold' && groups[0].length === 1) {
|
||||
return `Hold ${formatTextToken(groups[0][0], platform)}`;
|
||||
}
|
||||
|
||||
const doubleTapKey = parseDoubleTapBinding(binding);
|
||||
if (doubleTapKey) {
|
||||
return `Double-tap ${formatTextToken(doubleTapKey, platform)}`;
|
||||
}
|
||||
|
||||
return groups
|
||||
.map(group => group.map(token => formatTextToken(token, platform)).join('+'))
|
||||
.join(' then ');
|
||||
}
|
||||
|
||||
export function formatShortcutBindingsText(
|
||||
bindings: string[],
|
||||
platform: ShortcutPlatform = 'cross-platform',
|
||||
): string {
|
||||
return bindings.map(binding => formatShortcutBindingText(binding, platform)).join(' or ');
|
||||
}
|
||||
|
||||
function getDigitCode(event: KeyboardEvent): string | null {
|
||||
const code = typeof event.code === 'string' ? event.code : '';
|
||||
const match = code.match(/^Digit([0-9])$/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export function getShortcutDigit(event: KeyboardEvent): number | null {
|
||||
const parsed = Number.parseInt(event.key, 10);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
|
||||
const digitCode = getDigitCode(event);
|
||||
return digitCode === null ? null : Number.parseInt(digitCode, 10);
|
||||
}
|
||||
|
||||
function matchesKeyToken(event: KeyboardEvent, token: string): boolean {
|
||||
const key = event.key.length === 1 ? event.key.toUpperCase() : event.key;
|
||||
const shortcutDigit = getShortcutDigit(event);
|
||||
|
||||
if (token === 'A-Z') {
|
||||
return /^[A-Z]$/.test(key);
|
||||
}
|
||||
|
||||
if (token === '1-0') {
|
||||
return /^[0-9]$/.test(key) || shortcutDigit !== null;
|
||||
}
|
||||
|
||||
if (isSingleLetter(token)) {
|
||||
return key === token;
|
||||
}
|
||||
|
||||
if (isSingleDigit(token)) {
|
||||
return key === token || String(shortcutDigit ?? '') === token;
|
||||
}
|
||||
|
||||
return key === token;
|
||||
}
|
||||
|
||||
export function matchesShortcutBinding(event: KeyboardEvent, binding: string): boolean {
|
||||
if (binding.includes(' ') || binding.includes('hold')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tokens = binding.split('+').filter(Boolean);
|
||||
if (tokens.length === 0) return false;
|
||||
|
||||
const requiresMod = tokens.includes('Mod');
|
||||
const requiresShift = tokens.includes('Shift');
|
||||
const requiresAlt = tokens.includes('Alt');
|
||||
const keyTokens = tokens.filter(token => !MODIFIER_TOKENS.has(token));
|
||||
if (keyTokens.length !== 1) return false;
|
||||
|
||||
const keyToken = keyTokens[0];
|
||||
const shiftMatches = requiresShift === event.shiftKey || (!requiresShift && keyToken === 'A-Z' && event.shiftKey);
|
||||
|
||||
if (requiresMod !== (event.metaKey || event.ctrlKey)) return false;
|
||||
if (!shiftMatches) return false;
|
||||
if (requiresAlt !== event.altKey) return false;
|
||||
|
||||
return matchesKeyToken(event, keyToken);
|
||||
}
|
||||
|
||||
export function getMatchingShortcutBindingIndex(event: KeyboardEvent, bindings: string[]): number {
|
||||
return bindings.findIndex(binding => matchesShortcutBinding(event, binding));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export * from './core';
|
||||
export * from './runtime';
|
||||
|
||||
// plan-review scopes
|
||||
export { annotationToolbarShortcuts, useAnnotationToolbarShortcuts } from './plan-review/annotationToolbar.shortcuts';
|
||||
export { annotationPanelShortcuts, useAnnotationPanelShortcuts } from './plan-review/annotationPanel.shortcuts';
|
||||
export { commentPopoverShortcuts } from './plan-review/commentPopover.shortcuts';
|
||||
export { imageAnnotatorShortcuts, useImageAnnotatorShortcuts } from './plan-review/imageAnnotator.shortcuts';
|
||||
export { inputMethodShortcuts } from './plan-review/inputMethod.shortcuts';
|
||||
export { viewerShortcuts, useViewerShortcuts } from './plan-review/viewer.shortcuts';
|
||||
|
||||
// code-review scopes
|
||||
export { reviewAnnotationToolbarShortcuts, useReviewAnnotationToolbarShortcuts } from './code-review/annotationToolbar.shortcuts';
|
||||
export { reviewFileTreeShortcuts, useReviewFileTreeShortcuts } from './code-review/fileTree.shortcuts';
|
||||
export { reviewPrCommentsShortcuts, useReviewPrCommentsShortcuts } from './code-review/prComments.shortcuts';
|
||||
export { reviewAllFilesDiffShortcuts, useReviewAllFilesDiffShortcuts } from './code-review/allFilesDiff.shortcuts';
|
||||
export { reviewAiShortcuts, useReviewAiShortcuts } from './code-review/ai.shortcuts';
|
||||
export { reviewSuggestionModalShortcuts, useReviewSuggestionModalShortcuts } from './code-review/suggestionModal.shortcuts';
|
||||
export { reviewTourDialogShortcuts, useReviewTourDialogShortcuts } from './code-review/tourDialog.shortcuts';
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineShortcutScope } from '../core';
|
||||
import { createShortcutScopeHook } from '../runtime';
|
||||
|
||||
export const annotationPanelShortcuts = defineShortcutScope({
|
||||
id: 'annotation-panel',
|
||||
title: 'Annotation Panel',
|
||||
shortcuts: {
|
||||
saveEdit: {
|
||||
description: 'Save annotation edit',
|
||||
bindings: ['Mod+Enter'],
|
||||
section: 'Annotations',
|
||||
hint: 'Available while editing an annotation in the side panel.',
|
||||
displayOrder: 50,
|
||||
},
|
||||
cancelEdit: {
|
||||
description: 'Cancel annotation edit',
|
||||
bindings: ['Escape'],
|
||||
section: 'Annotations',
|
||||
hint: 'Available while editing an annotation in the side panel.',
|
||||
displayOrder: 60,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const useAnnotationPanelShortcuts = createShortcutScopeHook(annotationPanelShortcuts);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineShortcutScope } from '../core';
|
||||
import { createShortcutScopeHook } from '../runtime';
|
||||
|
||||
export const annotationToolbarShortcuts = defineShortcutScope({
|
||||
id: 'annotation-toolbar',
|
||||
title: 'Annotation Toolbar',
|
||||
shortcuts: {
|
||||
typeToComment: {
|
||||
description: 'Start comment',
|
||||
bindings: ['A-Z'],
|
||||
section: 'Annotations',
|
||||
hint: 'Typing a letter opens the comment editor with that character.',
|
||||
displayOrder: 10,
|
||||
},
|
||||
applyQuickLabel: {
|
||||
description: 'Apply toolbar label',
|
||||
bindings: ['Alt+1-0'],
|
||||
section: 'Annotations',
|
||||
hint: 'Applies the matching preset label while the annotation toolbar is open.',
|
||||
displayOrder: 20,
|
||||
},
|
||||
applyQuickLabelFromPicker: {
|
||||
description: 'Apply picker label',
|
||||
bindings: ['1-0'],
|
||||
section: 'Annotations',
|
||||
hint: 'Available while the quick label picker is open.',
|
||||
displayOrder: 30,
|
||||
},
|
||||
close: {
|
||||
description: 'Close toolbar',
|
||||
bindings: ['Escape'],
|
||||
section: 'Annotations',
|
||||
hint: 'Available while the annotation toolbar is open.',
|
||||
displayOrder: 40,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const useAnnotationToolbarShortcuts = createShortcutScopeHook(annotationToolbarShortcuts);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineShortcutScope } from '../core';
|
||||
|
||||
export const commentPopoverShortcuts = defineShortcutScope({
|
||||
id: 'comment-popover',
|
||||
title: 'Comment Editor',
|
||||
shortcuts: {
|
||||
submit: {
|
||||
description: 'Submit comment',
|
||||
bindings: ['Mod+Enter'],
|
||||
section: 'Annotations',
|
||||
displayOrder: 30,
|
||||
},
|
||||
cancel: {
|
||||
description: 'Close comment',
|
||||
bindings: ['Escape'],
|
||||
section: 'Annotations',
|
||||
hint: 'Available while the comment editor is open.',
|
||||
displayOrder: 40,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { defineShortcutScope } from '../core';
|
||||
import { createShortcutScopeHook } from '../runtime';
|
||||
|
||||
export const imageAnnotatorShortcuts = defineShortcutScope({
|
||||
id: 'image-annotator',
|
||||
title: 'Image Annotator',
|
||||
shortcuts: {
|
||||
penTool: {
|
||||
description: 'Pen tool',
|
||||
bindings: ['1'],
|
||||
section: 'Image Annotator',
|
||||
displayOrder: 10,
|
||||
},
|
||||
arrowTool: {
|
||||
description: 'Arrow tool',
|
||||
bindings: ['2'],
|
||||
section: 'Image Annotator',
|
||||
displayOrder: 20,
|
||||
},
|
||||
circleTool: {
|
||||
description: 'Circle tool',
|
||||
bindings: ['3'],
|
||||
section: 'Image Annotator',
|
||||
displayOrder: 30,
|
||||
},
|
||||
undo: {
|
||||
description: 'Undo',
|
||||
bindings: ['Mod+Z'],
|
||||
section: 'Image Annotator',
|
||||
displayOrder: 40,
|
||||
},
|
||||
save: {
|
||||
description: 'Save and close annotator',
|
||||
bindings: ['Enter', 'Escape'],
|
||||
section: 'Image Annotator',
|
||||
hint: 'When the image name field is focused, Escape blurs it first and Enter confirms the name; both close the annotator otherwise.',
|
||||
displayOrder: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const useImageAnnotatorShortcuts = createShortcutScopeHook(imageAnnotatorShortcuts);
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineShortcutScope } from '../core';
|
||||
|
||||
// Heads-up for future migrators: these bindings (`Alt hold`, `Alt Alt`) are
|
||||
// declarative metadata for the help modal and marketing docs. They do NOT
|
||||
// dispatch through `useShortcutScope`. `Alt Alt` works via
|
||||
// `useDoubleTapShortcuts`; `Alt hold` has no shared hook yet — it's wired
|
||||
// today by the bespoke `useInputMethodSwitch` hook in
|
||||
// `packages/editor/hooks/`. See the comment block above
|
||||
// `useDoubleTapShortcuts` in `packages/ui/shortcuts/runtime.ts`.
|
||||
|
||||
export const inputMethodShortcuts = defineShortcutScope({
|
||||
id: 'input-method',
|
||||
title: 'Input Method',
|
||||
shortcuts: {
|
||||
temporarySwitch: {
|
||||
description: 'Temporarily switch input method',
|
||||
bindings: ['Alt hold'],
|
||||
section: 'Input Method',
|
||||
hint: 'Hold Alt to switch between Select and Pinpoint, then release to revert.',
|
||||
displayOrder: 10,
|
||||
},
|
||||
toggleSwitch: {
|
||||
description: 'Toggle input method',
|
||||
bindings: ['Alt Alt'],
|
||||
section: 'Input Method',
|
||||
hint: 'Double-tap Alt to switch between Select and Pinpoint until you toggle again.',
|
||||
displayOrder: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineShortcutScope } from '../core';
|
||||
import { createShortcutScopeHook } from '../runtime';
|
||||
|
||||
export const viewerShortcuts = defineShortcutScope({
|
||||
id: 'viewer',
|
||||
title: 'Viewer',
|
||||
shortcuts: {
|
||||
copySelection: {
|
||||
description: 'Copy selected text',
|
||||
bindings: ['Mod+C'],
|
||||
section: 'Annotations',
|
||||
displayOrder: 25,
|
||||
},
|
||||
closeLightbox: {
|
||||
description: 'Close image lightbox',
|
||||
bindings: ['Escape'],
|
||||
section: 'Annotations',
|
||||
hint: 'Available while an image is open in the lightbox.',
|
||||
displayOrder: 70,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const useViewerShortcuts = createShortcutScopeHook(viewerShortcuts);
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { ShortcutDefinition, ShortcutScopeDefinition } from './core';
|
||||
import { matchesKeyName, matchesShortcutBinding, parseDoubleTapBinding } from './core';
|
||||
|
||||
type ShortcutActionId<TScope extends ShortcutScopeDefinition<any>> = keyof TScope['shortcuts'] & string;
|
||||
|
||||
export interface ShortcutHandlerConfig {
|
||||
when?: (event: KeyboardEvent) => boolean;
|
||||
handle: (event: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export type ShortcutHandler = ((event: KeyboardEvent) => void) | ShortcutHandlerConfig;
|
||||
|
||||
export type ShortcutHandlers<TScope extends ShortcutScopeDefinition<any>> = Partial<
|
||||
Record<ShortcutActionId<TScope>, ShortcutHandler>
|
||||
>;
|
||||
|
||||
type ShortcutEventTarget = 'window' | 'document' | EventTarget | null;
|
||||
|
||||
export interface UseShortcutScopeOptions<TScope extends ShortcutScopeDefinition<any>> {
|
||||
scope: TScope;
|
||||
handlers: ShortcutHandlers<TScope>;
|
||||
target?: ShortcutEventTarget;
|
||||
stopOnMatch?: boolean;
|
||||
}
|
||||
|
||||
function normalizeShortcutHandler(handler: ShortcutHandler): ShortcutHandlerConfig {
|
||||
if (typeof handler === 'function') {
|
||||
return { handle: handler };
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
||||
// TODO(migration): no cross-scope arbitration. When two scopes bind the
|
||||
// same key (e.g. `Escape` in both an outer editor scope and an inner
|
||||
// dialog scope), both `useShortcutScope` listeners fire on a single
|
||||
// keypress. Add `if (event.defaultPrevented) return false;` at the top
|
||||
// of this function once shortcut definitions consistently set
|
||||
// `preventDefault: true` (or once we flip the default). Until then,
|
||||
// callers must guard with `when` to prevent double-handling.
|
||||
export function dispatchShortcutEvent<TScope extends ShortcutScopeDefinition<any>>(
|
||||
scope: TScope,
|
||||
handlers: ShortcutHandlers<TScope>,
|
||||
event: KeyboardEvent,
|
||||
options?: { stopOnMatch?: boolean },
|
||||
): boolean {
|
||||
const stopOnMatch = options?.stopOnMatch ?? true;
|
||||
let handled = false;
|
||||
|
||||
for (const [actionId, shortcut] of Object.entries(scope.shortcuts) as Array<[
|
||||
ShortcutActionId<TScope>,
|
||||
ShortcutDefinition,
|
||||
]>) {
|
||||
const handler = handlers[actionId];
|
||||
if (!handler) continue;
|
||||
if (!shortcut.bindings.some(binding => matchesShortcutBinding(event, binding))) continue;
|
||||
|
||||
const { when, handle } = normalizeShortcutHandler(handler);
|
||||
if (when && !when(event)) continue;
|
||||
|
||||
if (shortcut.preventDefault) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
handle(event);
|
||||
handled = true;
|
||||
|
||||
if (stopOnMatch) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return handled;
|
||||
}
|
||||
|
||||
function getEventTarget(target: ShortcutEventTarget): EventTarget | null {
|
||||
if (target === 'window') {
|
||||
return typeof window === 'undefined' ? null : window;
|
||||
}
|
||||
if (target === 'document') {
|
||||
return typeof document === 'undefined' ? null : document;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export function useShortcutScope<TScope extends ShortcutScopeDefinition<any>>({
|
||||
scope,
|
||||
handlers,
|
||||
target = 'window',
|
||||
stopOnMatch = true,
|
||||
}: UseShortcutScopeOptions<TScope>) {
|
||||
const handlersRef = useRef(handlers);
|
||||
|
||||
useEffect(() => {
|
||||
handlersRef.current = handlers;
|
||||
}, [handlers]);
|
||||
|
||||
useEffect(() => {
|
||||
const eventTarget = getEventTarget(target);
|
||||
if (!eventTarget || !('addEventListener' in eventTarget)) return;
|
||||
|
||||
const handleKeyDown = (event: Event) => {
|
||||
dispatchShortcutEvent(scope, handlersRef.current, event as KeyboardEvent, { stopOnMatch });
|
||||
};
|
||||
|
||||
eventTarget.addEventListener('keydown', handleKeyDown as EventListener);
|
||||
return () => {
|
||||
eventTarget.removeEventListener('keydown', handleKeyDown as EventListener);
|
||||
};
|
||||
}, [scope, stopOnMatch, target]);
|
||||
}
|
||||
|
||||
export function createShortcutScopeHook<TScope extends ShortcutScopeDefinition<any>>(scope: TScope) {
|
||||
return function useScopedShortcutScope(options: Omit<UseShortcutScopeOptions<TScope>, 'scope'>) {
|
||||
useShortcutScope({ scope, ...options });
|
||||
};
|
||||
}
|
||||
|
||||
// --- Multi-press shortcut support ---
|
||||
//
|
||||
// `useShortcutScope` only dispatches single-press bindings — anything with
|
||||
// whitespace (e.g. `"Alt Alt"`) or the `hold` token (e.g. `"Alt hold"`)
|
||||
// short-circuits in `matchesShortcutBinding`. Multi-press bindings need a
|
||||
// dedicated hook that knows their semantics:
|
||||
//
|
||||
// - Double-tap → `useDoubleTapShortcuts` below.
|
||||
// - Hold → no shared hook yet; wire by hand in the consuming
|
||||
// component until one is built. The `Alt hold` binding in
|
||||
// `inputMethodShortcuts` is currently driven by the
|
||||
// bespoke `useInputMethodSwitch` hook in the plan editor.
|
||||
//
|
||||
// TODO: when the App.tsx migration starts touching hold semantics, add a
|
||||
// `useHoldShortcuts` here paired with a `parseHoldBinding` in core.ts so the
|
||||
// registry-driven path actually fires.
|
||||
|
||||
// --- Double-tap shortcut support ---
|
||||
|
||||
export type DoubleTapHandlers<TScope extends ShortcutScopeDefinition<any>> = Partial<
|
||||
Record<ShortcutActionId<TScope>, ShortcutHandler>
|
||||
>;
|
||||
|
||||
export interface UseDoubleTapShortcutsOptions<TScope extends ShortcutScopeDefinition<any>> {
|
||||
scope: TScope;
|
||||
handlers: DoubleTapHandlers<TScope>;
|
||||
/** Max gap between two key releases to count as a double-tap (default: 300ms). */
|
||||
window?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for handling double-tap shortcuts (bindings like `"Alt Alt"`).
|
||||
*
|
||||
* Double-tap is detected on keyup: if the same key is released twice
|
||||
* within `window` ms, the handler fires. Regular (keydown) bindings
|
||||
* in the same scope are ignored — use `useShortcutScope` for those.
|
||||
*/
|
||||
export function useDoubleTapShortcuts<TScope extends ShortcutScopeDefinition<any>>({
|
||||
scope,
|
||||
handlers,
|
||||
window: tapWindow = 300,
|
||||
}: UseDoubleTapShortcutsOptions<TScope>) {
|
||||
const handlersRef = useRef(handlers);
|
||||
useEffect(() => { handlersRef.current = handlers; }, [handlers]);
|
||||
|
||||
useEffect(() => {
|
||||
// Pre-parse which actions have double-tap bindings
|
||||
const doubleTapActions: Array<{ actionId: ShortcutActionId<TScope>; keyName: string }> = [];
|
||||
for (const [actionId, shortcut] of Object.entries(scope.shortcuts) as Array<[
|
||||
ShortcutActionId<TScope>,
|
||||
ShortcutDefinition,
|
||||
]>) {
|
||||
for (const binding of shortcut.bindings) {
|
||||
const keyName = parseDoubleTapBinding(binding);
|
||||
if (keyName) {
|
||||
doubleTapActions.push({ actionId, keyName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (doubleTapActions.length === 0) return;
|
||||
|
||||
// Track last keyup timestamp per key
|
||||
const lastKeyUp = new Map<string, number>();
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
for (const { actionId, keyName } of doubleTapActions) {
|
||||
if (!matchesKeyName(event, keyName)) continue;
|
||||
|
||||
const handler = handlersRef.current[actionId];
|
||||
if (!handler) continue;
|
||||
|
||||
const { when, handle } = normalizeShortcutHandler(handler);
|
||||
if (when && !when(event)) continue;
|
||||
|
||||
const now = Date.now();
|
||||
const prev = lastKeyUp.get(keyName) ?? 0;
|
||||
if (now - prev < tapWindow) {
|
||||
handle(event);
|
||||
lastKeyUp.set(keyName, 0); // reset so triple-tap doesn't re-fire
|
||||
} else {
|
||||
lastKeyUp.set(keyName, now);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
return () => window.removeEventListener('keyup', handleKeyUp);
|
||||
}, [scope, tapWindow]);
|
||||
}
|
||||
|
||||
export function createDoubleTapShortcutsHook<TScope extends ShortcutScopeDefinition<any>>(scope: TScope) {
|
||||
return function useScopedDoubleTap(options: Omit<UseDoubleTapShortcutsOptions<TScope>, 'scope'>) {
|
||||
useDoubleTapShortcuts({ scope, ...options });
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user