From 674962b76db8410dc06d8a35829ef3d03be3faa7 Mon Sep 17 00:00:00 2001 From: Gjermund Garaba Date: Mon, 23 Mar 2026 20:37:59 +0100 Subject: [PATCH] feat: shortcut-registry --- .../src/components/ShortcutReference.astro | 37 ++ .../docs/reference/keyboard-shortcuts.md | 20 +- apps/marketing/src/lib/shortcutReference.ts | 21 + apps/marketing/src/pages/docs/[...slug].astro | 11 +- bun.lock | 1 + packages/editor/App.tsx | 227 +++++----- packages/editor/package.json | 3 +- packages/editor/shortcuts.ts | 98 +++++ packages/review-editor/App.tsx | 245 ++++++----- .../annotationToolbar.shortcuts.ts | 29 ++ packages/review-editor/components/AITab.tsx | 18 +- .../components/AnnotationToolbar.tsx | 34 +- .../review-editor/components/AskAIInput.tsx | 21 +- .../review-editor/components/FileTree.tsx | 110 +++-- .../components/SuggestionModal.tsx | 15 +- packages/review-editor/fileTree.shortcuts.ts | 34 ++ packages/review-editor/package.json | 3 +- packages/review-editor/shortcuts.ts | 70 ++++ packages/ui/components/AnnotationPanel.tsx | 21 +- packages/ui/components/AnnotationToolbar.tsx | 76 ++-- packages/ui/components/CommentPopover.tsx | 40 +- .../components/FloatingQuickLabelPicker.tsx | 41 +- .../ui/components/ImageAnnotator/Toolbar.tsx | 21 +- .../ui/components/ImageAnnotator/index.tsx | 239 +++++------ packages/ui/components/KeyboardShortcuts.tsx | 146 ++----- packages/ui/components/Settings.tsx | 23 +- packages/ui/components/Viewer.tsx | 37 +- packages/ui/package.json | 1 + packages/ui/shortcuts.test.ts | 264 ++++++++++++ .../shortcuts/annotationToolbar.shortcuts.ts | 39 ++ .../ui/shortcuts/commentPopover.shortcuts.ts | 21 + packages/ui/shortcuts/core.ts | 386 ++++++++++++++++++ .../ui/shortcuts/imageAnnotator.shortcuts.ts | 42 ++ packages/ui/shortcuts/index.ts | 7 + .../ui/shortcuts/inputMethod.shortcuts.ts | 22 + packages/ui/shortcuts/runtime.ts | 193 +++++++++ packages/ui/shortcuts/viewer.shortcuts.ts | 17 + 37 files changed, 1971 insertions(+), 662 deletions(-) create mode 100644 apps/marketing/src/components/ShortcutReference.astro create mode 100644 apps/marketing/src/lib/shortcutReference.ts create mode 100644 packages/editor/shortcuts.ts create mode 100644 packages/review-editor/annotationToolbar.shortcuts.ts create mode 100644 packages/review-editor/fileTree.shortcuts.ts create mode 100644 packages/review-editor/shortcuts.ts create mode 100644 packages/ui/shortcuts.test.ts create mode 100644 packages/ui/shortcuts/annotationToolbar.shortcuts.ts create mode 100644 packages/ui/shortcuts/commentPopover.shortcuts.ts create mode 100644 packages/ui/shortcuts/core.ts create mode 100644 packages/ui/shortcuts/imageAnnotator.shortcuts.ts create mode 100644 packages/ui/shortcuts/index.ts create mode 100644 packages/ui/shortcuts/inputMethod.shortcuts.ts create mode 100644 packages/ui/shortcuts/runtime.ts create mode 100644 packages/ui/shortcuts/viewer.shortcuts.ts diff --git a/apps/marketing/src/components/ShortcutReference.astro b/apps/marketing/src/components/ShortcutReference.astro new file mode 100644 index 00000000..83663b3d --- /dev/null +++ b/apps/marketing/src/components/ShortcutReference.astro @@ -0,0 +1,37 @@ +--- +import { shortcutReferenceSurfaces } from '../lib/shortcutReference'; +import { formatShortcutBindingsText } from '../../../../packages/ui/shortcuts'; +--- + +

Keyboard shortcuts available across the Plannotator review and annotation UIs.

+ +{shortcutReferenceSurfaces.map((surface) => ( + +

{surface.title}

+

{surface.description}

+ + {surface.sections.map((section) => ( + +

{section.title}

+ + + + + + + + + + {section.shortcuts.map((shortcut) => ( + + + + + + ))} + +
ShortcutActionNotes
{formatShortcutBindingsText(shortcut.bindings)}{shortcut.description}{shortcut.hint ?? '—'}
+
+ ))} +
+))} diff --git a/apps/marketing/src/content/docs/reference/keyboard-shortcuts.md b/apps/marketing/src/content/docs/reference/keyboard-shortcuts.md index 91d2ac93..51da9c5b 100644 --- a/apps/marketing/src/content/docs/reference/keyboard-shortcuts.md +++ b/apps/marketing/src/content/docs/reference/keyboard-shortcuts.md @@ -6,22 +6,4 @@ sidebar: section: "Reference" --- -Keyboard shortcuts available in the Plannotator plan review, code review, and annotation UIs. - -## Global shortcuts - -| 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. diff --git a/apps/marketing/src/lib/shortcutReference.ts b/apps/marketing/src/lib/shortcutReference.ts new file mode 100644 index 00000000..80663451 --- /dev/null +++ b/apps/marketing/src/lib/shortcutReference.ts @@ -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 })), +]); diff --git a/apps/marketing/src/pages/docs/[...slug].astro b/apps/marketing/src/pages/docs/[...slug].astro index bc80668e..ac9e9034 100644 --- a/apps/marketing/src/pages/docs/[...slug].astro +++ b/apps/marketing/src/pages/docs/[...slug].astro @@ -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 ?? []; --- - + {isShortcutReference ? : Content && } diff --git a/bun.lock b/bun.lock index 5c79397d..9f4b6a2f 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "plannotator", diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index b95bee00..6ed787b9 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -55,6 +55,7 @@ import type { ArchivedPlan } from '@plannotator/ui/components/sidebar/ArchiveBro import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffViewer'; import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher'; import { DEMO_PLAN_CONTENT } from './demoPlan'; +import { annotateSettingsShortcutRegistry, planReviewSettingsShortcutRegistry, usePlanEditorShortcuts } from './shortcuts'; type NoteAutoSaveResults = { obsidian?: boolean; @@ -146,17 +147,17 @@ const App: React.FC = () => { } }, [sidebar.activeTab]); - // Clear diff view on Escape key - useEffect(() => { - if (!isPlanDiffActive) return; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - setIsPlanDiffActive(false); - } - }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [isPlanDiffActive]); + usePlanEditorShortcuts({ + target: 'document', + handlers: { + exitPlanDiff: { + when: () => isPlanDiffActive, + handle: () => { + setIsPlanDiffActive(false); + }, + }, + }, + }); // Plan diff computation const planDiff = usePlanDiff(markdown, previousPlan, versionInfo); @@ -714,66 +715,127 @@ const App: React.FC = () => { } }; - // Global keyboard shortcuts (Cmd/Ctrl+Enter to submit) - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Only handle Cmd/Ctrl+Enter - if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey)) return; + const isPlanShortcutTarget = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement)?.tagName; + return tag !== 'INPUT' && tag !== 'TEXTAREA'; + }; - // Don't intercept if typing in an input/textarea - const tag = (e.target as HTMLElement)?.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA') return; + // Global keyboard shortcuts (Cmd/Ctrl+Enter to submit) + usePlanEditorShortcuts({ + handlers: { + submitPlan: { + when: (e) => { + // Don't intercept if typing in an input/textarea + if (!isPlanShortcutTarget(e)) return false; - // Don't intercept if any modal is open - if (showExport || showImport || showFeedbackPrompt || showClaudeCodeWarning || - showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; + // Don't intercept if any modal is open + if (showExport || showImport || showFeedbackPrompt || showClaudeCodeWarning || + showAgentWarning || showPermissionModeSetup || pendingPasteImage) return false; - // Don't intercept if already submitted or submitting - if (submitted || isSubmitting) return; + // Don't intercept if already submitted or submitting + if (submitted || isSubmitting) return false; - // Don't intercept in demo/share mode (no API) - if (!isApiMode) return; + // Don't intercept in demo/share mode (no API) + if (!isApiMode) return false; - // Don't submit while viewing a linked doc - if (linkedDocHook.isActive) return; + // Don't submit while viewing a linked doc + if (linkedDocHook.isActive) return false; - e.preventDefault(); + // Don't use the plan submit action in annotate mode + if (annotateMode) return false; - // Annotate mode: always send feedback (empty = "no feedback" message) - if (annotateMode) { - handleAnnotateFeedback(); - return; - } + return true; + }, + handle: (e) => { + e.preventDefault(); - // No annotations → Approve, otherwise → Send Feedback - const docAnnotations = linkedDocHook.getDocAnnotations(); - const hasDocAnnotations = Array.from(docAnnotations.values()).some( - (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 - ); - if (annotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) { - // Check if agent exists for OpenCode users - if (origin === 'opencode') { - const warning = getAgentWarning(); - if (warning) { - setAgentWarningMessage(warning); - setShowAgentWarning(true); - return; + const docAnnotations = linkedDocHook.getDocAnnotations(); + const hasDocAnnotations = Array.from(docAnnotations.values()).some( + (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 + ); + if (annotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) { + if (origin === 'opencode') { + const warning = getAgentWarning(); + if (warning) { + setAgentWarningMessage(warning); + setShowAgentWarning(true); + return; + } + } + handleApprove(); + } else { + handleDeny(); } - } - handleApprove(); - } else { - handleDeny(); - } - }; + }, + }, + submitAnnotations: { + when: (e) => { + // Don't intercept if typing in an input/textarea + if (!isPlanShortcutTarget(e)) return false; + + // Don't intercept if any modal is open + if (showExport || showImport || showFeedbackPrompt || showClaudeCodeWarning || + showAgentWarning || showPermissionModeSetup || pendingPasteImage) return false; + + // Don't intercept if already submitted or submitting + if (submitted || isSubmitting) return false; + + // Don't intercept in demo/share mode (no API) + if (!isApiMode) return false; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [ - showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showAgentWarning, - showPermissionModeSetup, pendingPasteImage, - submitted, isSubmitting, isApiMode, linkedDocHook.isActive, annotations.length, annotateMode, - origin, getAgentWarning, - ]); + // Don't submit while viewing a linked doc + if (linkedDocHook.isActive) return false; + + // Only use the annotation submit action in annotate mode + if (!annotateMode) return false; + + return true; + }, + handle: (e) => { + e.preventDefault(); + + handleAnnotateFeedback(); + }, + }, + // Cmd/Ctrl+S keyboard shortcut — save to default notes app + quickSave: { + when: (e) => { + // Don't intercept if typing in an input/textarea + if (!isPlanShortcutTarget(e)) return false; + + // Don't intercept if any modal is open + if (showExport || showFeedbackPrompt || showClaudeCodeWarning || + showAgentWarning || showPermissionModeSetup || pendingPasteImage) return false; + + // Don't intercept after submission or in demo/share mode (no API) + if (submitted || !isApiMode) return false; + + return true; + }, + handle: (e) => { + e.preventDefault(); + + const defaultApp = getDefaultNotesApp(); + const obsOk = isObsidianConfigured(); + const bearOk = getBearSettings().enabled; + const octOk = isOctarineConfigured(); + + if (defaultApp === 'download') { + handleDownloadAnnotations(); + } else if (defaultApp === 'obsidian' && obsOk) { + handleQuickSaveToNotes('obsidian'); + } else if (defaultApp === 'bear' && bearOk) { + handleQuickSaveToNotes('bear'); + } else if (defaultApp === 'octarine' && octOk) { + handleQuickSaveToNotes('octarine'); + } else { + setInitialExportTab('notes'); + setShowExport(true); + } + }, + }, + }, + }); const handleAddAnnotation = (ann: Annotation) => { setAnnotations(prev => [...prev, ann]); @@ -914,47 +976,6 @@ const App: React.FC = () => { setTimeout(() => setNoteSaveToast(null), 3000); }; - // Cmd/Ctrl+S keyboard shortcut — save to default notes app - useEffect(() => { - const handleSaveShortcut = (e: KeyboardEvent) => { - if (e.key !== 's' || !(e.metaKey || e.ctrlKey)) return; - - const tag = (e.target as HTMLElement)?.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA') return; - - if (showExport || showFeedbackPrompt || showClaudeCodeWarning || - showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; - - if (submitted || !isApiMode) return; - - e.preventDefault(); - - const defaultApp = getDefaultNotesApp(); - const obsOk = isObsidianConfigured(); - const bearOk = getBearSettings().enabled; - const octOk = isOctarineConfigured(); - - if (defaultApp === 'download') { - handleDownloadAnnotations(); - } else if (defaultApp === 'obsidian' && obsOk) { - handleQuickSaveToNotes('obsidian'); - } else if (defaultApp === 'bear' && bearOk) { - handleQuickSaveToNotes('bear'); - } else if (defaultApp === 'octarine' && octOk) { - handleQuickSaveToNotes('octarine'); - } else { - setInitialExportTab('notes'); - setShowExport(true); - } - }; - - window.addEventListener('keydown', handleSaveShortcut); - return () => window.removeEventListener('keydown', handleSaveShortcut); - }, [ - showExport, showFeedbackPrompt, showClaudeCodeWarning, showAgentWarning, - showPermissionModeSetup, pendingPasteImage, - submitted, isApiMode, markdown, annotationsOutput, - ]); // Close export dropdown on click outside useEffect(() => { @@ -1122,7 +1143,7 @@ const App: React.FC = () => { {/* Desktop buttons — hidden on mobile */}
- {!linkedDocHook.isActive && setMobileSettingsOpen(false)} />} + {!linkedDocHook.isActive && setMobileSettingsOpen(false)} />}
- {altKey} - {altKey} + {formatShortcutBindingTokens('Alt', getShortcutPlatform())[0]} + {formatShortcutBindingTokens('Alt', getShortcutPlatform())[0]} to toggle
@@ -1251,6 +1237,7 @@ const ReviewApp: React.FC = () => { onTaterModeChange={() => {}} onIdentityChange={handleIdentityChange} origin={origin} + shortcutRegistry={reviewSettingsShortcutRegistry} mode="review" aiProviders={aiProviders} /> diff --git a/packages/review-editor/annotationToolbar.shortcuts.ts b/packages/review-editor/annotationToolbar.shortcuts.ts new file mode 100644 index 00000000..f204e5c5 --- /dev/null +++ b/packages/review-editor/annotationToolbar.shortcuts.ts @@ -0,0 +1,29 @@ +import { createShortcutScopeHook, defineShortcutScope } from '@plannotator/ui/shortcuts'; + +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); diff --git a/packages/review-editor/components/AITab.tsx b/packages/review-editor/components/AITab.tsx index 4b733703..35510ef8 100644 --- a/packages/review-editor/components/AITab.tsx +++ b/packages/review-editor/components/AITab.tsx @@ -8,7 +8,8 @@ import { CountBadge } from './CountBadge'; import { CopyButton } from './CopyButton'; import { PermissionCard } from './PermissionCard'; import { AIConfigBar } from './AIConfigBar'; -import { submitHint } from '@plannotator/ui/utils/platform'; +import { dispatchShortcutEvent, formatShortcutBindingText, getShortcutPlatform } from '@plannotator/ui/shortcuts'; +import { reviewAnnotationToolbarShortcuts } from '../annotationToolbar.shortcuts'; interface AIProviderInfo { id: string; @@ -312,17 +313,22 @@ const GeneralInput: React.FC<{ style={{ maxHeight: 120 }} disabled={disabled} onKeyDown={(e) => { - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing && !disabled) { - e.preventDefault(); - onSubmit(); - } + dispatchShortcutEvent(reviewAnnotationToolbarShortcuts, { + submitComment: { + when: (event) => !event.isComposing && !disabled, + handle: (event) => { + event.preventDefault(); + onSubmit(); + }, + }, + }, e.nativeEvent); }} />
diff --git a/packages/review-editor/components/AskAIInput.tsx b/packages/review-editor/components/AskAIInput.tsx index d78f2b10..28ff7e9d 100644 --- a/packages/review-editor/components/AskAIInput.tsx +++ b/packages/review-editor/components/AskAIInput.tsx @@ -2,7 +2,8 @@ import React, { useState } from 'react'; import { formatLineRange } from '../utils/formatLineRange'; import { SparklesIcon } from './SparklesIcon'; import type { AIChatEntry } from '../hooks/useAIChat'; -import { submitHint } from '@plannotator/ui/utils/platform'; +import { dispatchShortcutEvent, formatShortcutBindingText, getShortcutPlatform } from '@plannotator/ui/shortcuts'; +import { reviewAnnotationToolbarShortcuts } from '../annotationToolbar.shortcuts'; interface AskAIInputProps { lineStart: number; @@ -65,11 +66,17 @@ export const AskAIInput: React.FC = ({ autoFocus disabled={isLoading} onKeyDown={(e) => { - if (e.key === 'Escape') { - onCancel(); - } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) { - handleSubmit(); - } + dispatchShortcutEvent(reviewAnnotationToolbarShortcuts, { + cancel: () => { + onCancel(); + }, + submitComment: { + when: (event) => !event.isComposing, + handle: () => { + handleSubmit(); + }, + }, + }, e.nativeEvent); }} /> @@ -92,7 +99,7 @@ export const AskAIInput: React.FC = ({ onClick={handleSubmit} disabled={!question.trim() || isLoading} className="review-toolbar-btn primary disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1 ml-auto" - title={`Ask (${submitHint})`} + title={`Ask (${formatShortcutBindingText(reviewAnnotationToolbarShortcuts.shortcuts.submitComment.bindings[0], getShortcutPlatform())})`} > {isLoading ? ( <> diff --git a/packages/review-editor/components/FileTree.tsx b/packages/review-editor/components/FileTree.tsx index c02425e3..0347c673 100644 --- a/packages/review-editor/components/FileTree.tsx +++ b/packages/review-editor/components/FileTree.tsx @@ -5,6 +5,9 @@ import { buildFileTree, getAncestorPaths, getAllFolderPaths } from '../utils/bui import { FileTreeNodeItem } from './FileTreeNode'; import { getReviewSearchSideLabel, type ReviewSearchFileGroup, type ReviewSearchMatch } from '../utils/reviewSearch'; import type { DiffFile } from '../types'; +import { dispatchShortcutEvent } from '@plannotator/ui/shortcuts'; +import { useReviewFileTreeShortcuts } from '../fileTree.shortcuts'; +import { reviewEditorShortcuts } from '../shortcuts'; interface FileTreeProps { files: DiffFile[]; @@ -67,36 +70,44 @@ export const FileTree: React.FC = ({ onSelectSearchMatch, onStepSearchMatch, }) => { - // Keyboard navigation: j/k or arrow keys - const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (!enableKeyboardNav) return; + const isFileTreeShortcutTarget = (target: EventTarget | null) => !(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement); - // Don't interfere with input fields - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { - return; - } - - if (e.key === 'j' || e.key === 'ArrowDown') { - e.preventDefault(); - const nextIndex = Math.min(activeFileIndex + 1, files.length - 1); - onSelectFile(nextIndex); - } else if (e.key === 'k' || e.key === 'ArrowUp') { - e.preventDefault(); - const prevIndex = Math.max(activeFileIndex - 1, 0); - onSelectFile(prevIndex); - } else if (e.key === 'Home') { - e.preventDefault(); - onSelectFile(0); - } else if (e.key === 'End') { - e.preventDefault(); - onSelectFile(files.length - 1); - } - }, [enableKeyboardNav, activeFileIndex, files.length, onSelectFile]); + const canHandleFileTreeShortcut = (e: KeyboardEvent) => enableKeyboardNav && isFileTreeShortcutTarget(e.target); - useEffect(() => { - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleKeyDown]); + useReviewFileTreeShortcuts({ + handlers: { + nextFile: { + when: canHandleFileTreeShortcut, + handle: (e) => { + e.preventDefault(); + const nextIndex = Math.min(activeFileIndex + 1, files.length - 1); + onSelectFile(nextIndex); + }, + }, + prevFile: { + when: canHandleFileTreeShortcut, + handle: (e) => { + e.preventDefault(); + const prevIndex = Math.max(activeFileIndex - 1, 0); + onSelectFile(prevIndex); + }, + }, + firstFile: { + when: canHandleFileTreeShortcut, + handle: (e) => { + e.preventDefault(); + onSelectFile(0); + }, + }, + lastFile: { + when: canHandleFileTreeShortcut, + handle: (e) => { + e.preventDefault(); + onSelectFile(files.length - 1); + }, + }, + }, + }); const annotationCountMap = useMemo(() => { const map = new Map(); @@ -162,22 +173,33 @@ export const FileTree: React.FC = ({ value={searchQuery} onChange={(e) => onSearchChange(e.target.value)} onKeyDown={(e) => { - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'f') { - e.preventDefault(); - return; - } - if (e.key === 'Enter' && searchMatches.length > 0) { - e.preventDefault(); - onStepSearchMatch?.(e.shiftKey ? -1 : 1); - } - if (e.key === 'Escape') { - e.preventDefault(); - if (searchQuery) { - onSearchClear?.(); - } else { - (e.target as HTMLInputElement).blur(); - } - } + dispatchShortcutEvent(reviewEditorShortcuts, { + focusSearch: () => { + e.preventDefault(); + }, + nextSearchMatch: { + when: () => searchMatches.length > 0, + handle: () => { + e.preventDefault(); + onStepSearchMatch?.(1); + }, + }, + prevSearchMatch: { + when: () => searchMatches.length > 0, + handle: () => { + e.preventDefault(); + onStepSearchMatch?.(-1); + }, + }, + clearSearch: () => { + e.preventDefault(); + if (searchQuery) { + onSearchClear?.(); + } else { + (e.target as HTMLInputElement).blur(); + } + }, + }, e.nativeEvent); }} placeholder="Search diff..." className={`w-full pl-7 py-1.5 bg-muted rounded-md text-xs text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50 ${searchQuery ? 'pr-14' : 'pr-7'}`} diff --git a/packages/review-editor/components/SuggestionModal.tsx b/packages/review-editor/components/SuggestionModal.tsx index a2aa86c7..6590fff3 100644 --- a/packages/review-editor/components/SuggestionModal.tsx +++ b/packages/review-editor/components/SuggestionModal.tsx @@ -3,6 +3,8 @@ import { HighlightedCode } from './HighlightedCode'; import { ToolbarState } from '../hooks/useAnnotationToolbar'; import { useTabIndent } from '../hooks/useTabIndent'; import { detectLanguage } from '../utils/detectLanguage'; +import { dispatchShortcutEvent } from '@plannotator/ui/shortcuts'; +import { reviewAnnotationToolbarShortcuts } from '../annotationToolbar.shortcuts'; interface SuggestionModalProps { filePath: string; @@ -103,11 +105,14 @@ export const SuggestionModal: React.FC = ({ autoFocus spellCheck={false} onKeyDown={(e) => { - if (e.key === 'Escape') { - onClose(); - } else if (e.key === 'Tab') { - handleTabIndent(e); - } + dispatchShortcutEvent(reviewAnnotationToolbarShortcuts, { + cancel: () => { + onClose(); + }, + indentSuggestedCode: () => { + handleTabIndent(e); + }, + }, e.nativeEvent); }} /> diff --git a/packages/review-editor/fileTree.shortcuts.ts b/packages/review-editor/fileTree.shortcuts.ts new file mode 100644 index 00000000..aa7d8ac9 --- /dev/null +++ b/packages/review-editor/fileTree.shortcuts.ts @@ -0,0 +1,34 @@ +import { createShortcutScopeHook, defineShortcutScope } from '@plannotator/ui/shortcuts'; + +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); diff --git a/packages/review-editor/package.json b/packages/review-editor/package.json index a41165cd..5ba6d0ee 100644 --- a/packages/review-editor/package.json +++ b/packages/review-editor/package.json @@ -4,7 +4,8 @@ "type": "module", "exports": { ".": "./App.tsx", - "./styles": "./index.css" + "./styles": "./index.css", + "./shortcuts": "./shortcuts.ts" }, "dependencies": { "@plannotator/shared": "workspace:*", diff --git a/packages/review-editor/shortcuts.ts b/packages/review-editor/shortcuts.ts new file mode 100644 index 00000000..ac92eb60 --- /dev/null +++ b/packages/review-editor/shortcuts.ts @@ -0,0 +1,70 @@ +import { createDoubleTapShortcutsHook, createShortcutRegistry, createShortcutScopeHook, defineShortcutScope, type ShortcutSurface } from '@plannotator/ui/shortcuts'; +import { reviewAnnotationToolbarShortcuts } from './annotationToolbar.shortcuts'; +import { reviewFileTreeShortcuts } from './fileTree.shortcuts'; + +export const reviewEditorShortcuts = defineShortcutScope({ + id: 'review-editor', + title: 'Review Editor', + shortcuts: { + submit: { + description: 'Approve / Send feedback', + bindings: ['Mod+Enter'], + section: 'Actions', + displayOrder: 10, + }, + copyDiff: { + description: 'Copy raw diff', + bindings: ['Mod+Shift+C'], + section: 'Actions', + displayOrder: 20, + }, + 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, + }, + }, +}); + +export const useReviewEditorShortcuts = createShortcutScopeHook(reviewEditorShortcuts); +export const useReviewEditorDoubleTap = createDoubleTapShortcutsHook(reviewEditorShortcuts); + +export const reviewSettingsShortcutRegistry = createShortcutRegistry([ + reviewEditorShortcuts, + reviewFileTreeShortcuts, + reviewAnnotationToolbarShortcuts, +] as const); + +export const codeReviewSurface: ShortcutSurface = { + slug: 'code-review', + title: 'Code review', + description: 'Shortcuts surfaced by the code review UI.', + registry: reviewSettingsShortcutRegistry, +}; diff --git a/packages/ui/components/AnnotationPanel.tsx b/packages/ui/components/AnnotationPanel.tsx index 4b0b5766..66d1a6f0 100644 --- a/packages/ui/components/AnnotationPanel.tsx +++ b/packages/ui/components/AnnotationPanel.tsx @@ -4,6 +4,7 @@ import { isCurrentUser } from '../utils/identity'; import { ImageThumbnail } from './ImageThumbnail'; import { EditorAnnotationCard } from './EditorAnnotationCard'; import { useIsMobile } from '../hooks/useIsMobile'; +import { commentPopoverShortcuts, dispatchShortcutEvent } from '../shortcuts'; interface PanelProps { isOpen: boolean; @@ -277,13 +278,19 @@ const AnnotationCard: React.FC<{ }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) { - e.preventDefault(); - handleSaveEdit(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleCancelEdit(); - } + dispatchShortcutEvent(commentPopoverShortcuts, { + submit: { + when: (event) => !event.isComposing, + handle: (event) => { + event.preventDefault(); + handleSaveEdit(); + }, + }, + cancel: (event) => { + event.preventDefault(); + handleCancelEdit(); + }, + }, e.nativeEvent); }; const typeConfig = { diff --git a/packages/ui/components/AnnotationToolbar.tsx b/packages/ui/components/AnnotationToolbar.tsx index f6d10616..f26befa1 100644 --- a/packages/ui/components/AnnotationToolbar.tsx +++ b/packages/ui/components/AnnotationToolbar.tsx @@ -4,6 +4,7 @@ import { createPortal } from "react-dom"; import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape"; import { type QuickLabel, getQuickLabels } from "../utils/quickLabels"; import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker"; +import { getShortcutDigit, useAnnotationToolbarShortcuts } from "../shortcuts"; type PositionMode = 'center-above' | 'top-right'; @@ -103,42 +104,51 @@ export const AnnotationToolbar: React.FC = ({ }; }, [element, positionMode, closeOnScrollOut, onClose]); - // Type-to-comment + Alt+N / bare digit quick label shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.isComposing) return; - if (isEditableElement(e.target) || isEditableElement(document.activeElement)) return; - - // When picker is open, let FloatingQuickLabelPicker own all keyboard input - if (showQuickLabels) return; - - if (e.key === "Escape") { - onClose(); - return; - } - - // Alt+N applies quick label (picker closed) - const isDigit = (e.code >= 'Digit1' && e.code <= 'Digit9') || e.code === 'Digit0'; - if (isDigit && !e.ctrlKey && !e.metaKey && e.altKey) { - e.preventDefault(); - const digit = parseInt(e.code.slice(5), 10); - const index = digit === 0 ? 9 : digit - 1; - if (index < quickLabels.length) { - onQuickLabel?.(quickLabels[index]); - } - return; - } + const canHandleToolbarShortcut = (e: KeyboardEvent) => { + if (showQuickLabels) return false; + if (e.isComposing) return false; + if (isEditableElement(e.target) || isEditableElement(document.activeElement)) return false; + return true; + }; - if (e.ctrlKey || e.metaKey || e.altKey) return; - if (e.key === "Tab" || e.key === "Enter") return; - if (e.key.length !== 1) return; + const canApplyQuickLabel = (e: KeyboardEvent) => { + if (!canHandleToolbarShortcut(e)) return false; + const digit = getShortcutDigit(e); + const index = digit === null ? Number.NaN : digit === 0 ? 9 : digit - 1; + return !!onQuickLabel && index >= 0 && index < quickLabels.length; + }; - onRequestComment?.(e.key); - }; + const canTypeToComment = (e: KeyboardEvent) => { + if (!canHandleToolbarShortcut(e)) return false; + if (e.key === 'Tab' || e.key === 'Enter') return false; + return e.key.length === 1; + }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [onClose, onRequestComment, onQuickLabel, quickLabels, showQuickLabels]); + useAnnotationToolbarShortcuts({ + handlers: { + close: { + when: canHandleToolbarShortcut, + handle: () => { + onClose(); + }, + }, + applyQuickLabel: { + when: canApplyQuickLabel, + handle: (e) => { + const digit = getShortcutDigit(e); + if (digit === null) return; + e.preventDefault(); + onQuickLabel?.(quickLabels[digit === 0 ? 9 : digit - 1]); + }, + }, + typeToComment: { + when: canTypeToComment, + handle: (e) => { + onRequestComment?.(e.key); + }, + }, + }, + }); useDismissOnOutsideAndEscape({ enabled: !showQuickLabels, diff --git a/packages/ui/components/CommentPopover.tsx b/packages/ui/components/CommentPopover.tsx index f9cd9615..96934e25 100644 --- a/packages/ui/components/CommentPopover.tsx +++ b/packages/ui/components/CommentPopover.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import type { ImageAttachment } from '../types'; import { AttachmentsButton } from './AttachmentsButton'; -import { submitHint } from '../utils/platform'; +import { commentPopoverShortcuts, dispatchShortcutEvent, formatShortcutBindingText, getShortcutPlatform } from '../shortcuts'; interface CommentPopoverProps { /** Element to anchor the popover near (re-reads position on scroll) */ @@ -106,19 +106,23 @@ export const CommentPopover: React.FC = ({ }, [text, images, onSubmit]); const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - e.stopPropagation(); - if (mode === 'dialog') { - setMode('popover'); - } else { - onClose(); - } - return; - } - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) { - e.preventDefault(); - handleSubmit(); - } + dispatchShortcutEvent(commentPopoverShortcuts, { + cancel: () => { + e.stopPropagation(); + if (mode === 'dialog') { + setMode('popover'); + } else { + onClose(); + } + }, + submit: { + when: (event) => !event.isComposing, + handle: () => { + e.preventDefault(); + handleSubmit(); + }, + }, + }, e.nativeEvent); }; const headerLabel = isGlobal @@ -128,6 +132,10 @@ export const CommentPopover: React.FC = ({ : 'Comment'; const canSubmit = text.trim().length > 0 || images.length > 0; + const shortcutHint = formatShortcutBindingText( + commentPopoverShortcuts.shortcuts.submit.bindings[0], + getShortcutPlatform(), + ); if (mode === 'dialog') { return createPortal( @@ -198,7 +206,7 @@ export const CommentPopover: React.FC = ({ />
- {submitHint} + {shortcutHint}
- {submitHint} + {shortcutHint}