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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions packages/editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ import type { AgentTerminalCapability } from '@plannotator/shared/agent-terminal
import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan';
import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo';
import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode';
import {
annotateSidebarShortcuts,
useAnnotateSidebarShortcuts,
useDoubleTapShortcuts,
} from '@plannotator/ui/shortcuts';
const USE_DIFF_DEMO =
import.meta.env.VITE_DIFF_DEMO === '1' ||
import.meta.env.VITE_DIFF_DEMO === 'true';
Expand Down Expand Up @@ -816,6 +821,64 @@ const App: React.FC = () => {
() => !!projectRoot || isFileBrowserEnabled() || isVaultBrowserEnabled(),
[projectRoot, uiPrefs]
);

const canHandleAnnotateSidebarShortcut = useCallback((event: KeyboardEvent) => {
if (!annotateMode || archive.archiveMode || goalSetupMode) return false;
if (event.defaultPrevented) return false;
if (document.querySelector('[data-plannotator-confirm-dialog="true"]')) return false;
if (showExport || showImport || showFeedbackPrompt || showClaudeCodeWarning ||
showSourceFileEditWarning ||
showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return false;
if (submitted || isSubmitting || isExiting || isEditingMarkdown) return false;

const target = event.target as HTMLElement | null;
const tag = target?.tagName;
return tag !== 'INPUT' && tag !== 'TEXTAREA' && !target?.isContentEditable;
}, [
annotateMode,
archive.archiveMode,
goalSetupMode,
showExport,
showImport,
showFeedbackPrompt,
showClaudeCodeWarning,
showSourceFileEditWarning,
showExitWarning,
showAgentWarning,
showPermissionModeSetup,
pendingPasteImage,
submitted,
isSubmitting,
isExiting,
isEditingMarkdown,
]);

useAnnotateSidebarShortcuts({
handlers: {
toggleContents: {
when: canHandleAnnotateSidebarShortcut,
handle: () => toggleSidebarTab('toc'),
},
toggleFiles: {
when: (event) => canHandleAnnotateSidebarShortcut(event) && showFilesTab && !archive.archiveMode,
handle: () => toggleSidebarTab('files'),
},
},
});

useDoubleTapShortcuts({
scope: annotateSidebarShortcuts,
handlers: {
toggleAgentTui: {
when: (event) =>
canHandleAnnotateSidebarShortcut(event) &&
annotateSource !== 'message' &&
agentTerminalCapability !== null,
handle: () => toggleAgentTerminal(),
},
},
});

const fileBrowserDirs = useMemo(() => {
const projectDirs = projectRoot ? [projectRoot] : [];
const userDirs = isFileBrowserEnabled()
Expand Down
1 change: 1 addition & 0 deletions packages/editor/components/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ export const AppHeader = React.memo<AppHeaderProps>(({
onTaterModeChange={onTaterModeChange}
onIdentityChange={onIdentityChange}
origin={origin}
mode={annotateMode ? 'annotate' : 'plan'}
onUIPreferencesChange={onUIPreferencesChange}
externalOpen={mobileSettingsOpen}
onExternalClose={onCloseSettings}
Expand Down
2 changes: 2 additions & 0 deletions packages/editor/shortcuts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
annotationPanelShortcuts,
annotationToolbarShortcuts,
annotateSidebarShortcuts,
commentPopoverShortcuts,
createShortcutRegistry,
createShortcutScopeHook,
Expand Down Expand Up @@ -92,6 +93,7 @@ export const planReviewSettingsShortcutRegistry = createShortcutRegistry([

export const annotateSettingsShortcutRegistry = createShortcutRegistry([
annotateEditorSettingsShortcuts,
annotateSidebarShortcuts,
...sharedPlanSurfaceShortcuts,
] as const);

Expand Down
115 changes: 76 additions & 39 deletions packages/ui/components/KeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const Section: React.FC<{ title: string; children: React.ReactNode }> = ({ title
/* ─── Platform-aware key names ─── */

const enter = isMac ? '⏎' : '↵';
const shiftKey = isMac ? '⇧' : 'Shift';

/* ─── Shortcut data ─── */

Expand All @@ -70,44 +71,80 @@ interface ShortcutSection {
shortcuts: Shortcut[];
}

const inputMethodShortcuts: ShortcutSection = {
title: 'Input Method',
shortcuts: [
{ keys: [altKey, 'hold'], desc: 'Temporarily switch mode', hint: 'Hold to switch between Select and Pinpoint, release to revert' },
{ keys: [altKey, altKey], desc: 'Toggle mode', hint: 'Double-tap to permanently switch between Select and Pinpoint' },
],
};

const annotationShortcuts: ShortcutSection = {
title: 'Annotations',
shortcuts: [
{ keys: ['a-z'], desc: 'Start typing comment', hint: 'When the annotation toolbar is open, any letter key opens the comment editor with that character' },
{ keys: [altKey, '1-0'], desc: 'Apply quick label', hint: 'Instantly applies the Nth preset label (0 = 10th). When the label picker is open, bare digits also work.' },
{ keys: [modKey, enter], desc: 'Submit comment' },
{ keys: [modKey, 'C'], desc: 'Copy selected text' },
{ keys: ['Esc'], desc: 'Close toolbar / Cancel' },
],
};

const imageAnnotatorShortcuts: ShortcutSection = {
title: 'Image Annotator',
shortcuts: [
{ keys: ['1'], desc: 'Pen tool' },
{ keys: ['2'], desc: 'Arrow tool' },
{ keys: ['3'], desc: 'Circle tool' },
{ keys: [modKey, 'Z'], desc: 'Undo' },
{ keys: [enter], desc: 'Finish' },
{ keys: ['Esc'], desc: 'Cancel' },
],
};

const sharedPlanEditorShortcuts: ShortcutSection[] = [
inputMethodShortcuts,
annotationShortcuts,
imageAnnotatorShortcuts,
];

const planActionShortcuts: ShortcutSection = {
title: 'Actions',
shortcuts: [
{ keys: [modKey, enter], desc: 'Submit / Approve' },
{ keys: [modKey, 'S'], desc: 'Save to notes app' },
{ keys: [modKey, 'P'], desc: 'Print plan' },
{ keys: ['Esc'], desc: 'Close dialog' },
],
};

const annotateActionShortcuts: ShortcutSection = {
title: 'Actions',
shortcuts: [
{ keys: [modKey, enter], desc: 'Send annotations' },
{ keys: [modKey, 'S'], desc: 'Save to notes app' },
{ keys: [modKey, 'P'], desc: 'Print document' },
],
};

const annotateSidebarShortcuts: ShortcutSection = {
title: 'Sidebar',
shortcuts: [
{ keys: [modKey, 'B'], desc: 'Toggle Contents sidebar' },
{ keys: [modKey, shiftKey, 'B'], desc: 'Toggle Files sidebar', hint: 'Available when the Files tab is shown.' },
{ keys: [shiftKey, shiftKey], desc: 'Toggle Agent TUI sidebar', hint: 'Available when the Agent control is shown.' },
],
};

const planShortcuts: ShortcutSection[] = [
{
title: 'Actions',
shortcuts: [
{ keys: [modKey, enter], desc: 'Submit / Approve' },
{ keys: [modKey, 'S'], desc: 'Save to notes app' },
{ keys: [modKey, 'P'], desc: 'Print plan' },
{ keys: ['Esc'], desc: 'Close dialog' },
],
},
{
title: 'Input Method',
shortcuts: [
{ keys: [altKey, 'hold'], desc: 'Temporarily switch mode', hint: 'Hold to switch between Select and Pinpoint, release to revert' },
{ keys: [altKey, altKey], desc: 'Toggle mode', hint: 'Double-tap to permanently switch between Select and Pinpoint' },
],
},
{
title: 'Annotations',
shortcuts: [
{ keys: ['a-z'], desc: 'Start typing comment', hint: 'When the annotation toolbar is open, any letter key opens the comment editor with that character' },
{ keys: [altKey, '1-0'], desc: 'Apply quick label', hint: 'Instantly applies the Nth preset label (0 = 10th). When the label picker is open, bare digits also work.' },
{ keys: [modKey, enter], desc: 'Submit comment' },
{ keys: [modKey, 'C'], desc: 'Copy selected text' },
{ keys: ['Esc'], desc: 'Close toolbar / Cancel' },
],
},
{
title: 'Image Annotator',
shortcuts: [
{ keys: ['1'], desc: 'Pen tool' },
{ keys: ['2'], desc: 'Arrow tool' },
{ keys: ['3'], desc: 'Circle tool' },
{ keys: [modKey, 'Z'], desc: 'Undo' },
{ keys: [enter], desc: 'Finish' },
{ keys: ['Esc'], desc: 'Cancel' },
],
},
planActionShortcuts,
...sharedPlanEditorShortcuts,
];

const annotateShortcuts: ShortcutSection[] = [
annotateActionShortcuts,
annotateSidebarShortcuts,
...sharedPlanEditorShortcuts,
];

const reviewShortcuts: ShortcutSection[] = [
Expand Down Expand Up @@ -154,8 +191,8 @@ const reviewShortcuts: ShortcutSection[] = [

/* ─── Exported panel ─── */

export const KeyboardShortcuts: React.FC<{ mode: 'plan' | 'review' }> = ({ mode }) => {
const sections = mode === 'review' ? reviewShortcuts : planShortcuts;
export const KeyboardShortcuts: React.FC<{ mode: 'plan' | 'annotate' | 'review' }> = ({ mode }) => {
const sections = mode === 'review' ? reviewShortcuts : mode === 'annotate' ? annotateShortcuts : planShortcuts;

return (
<div className="space-y-4">
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ interface SettingsProps {
onTaterModeChange: (enabled: boolean) => void;
onIdentityChange?: (oldIdentity: string, newIdentity: string) => void;
origin?: Origin | null;
mode?: 'plan' | 'review';
mode?: 'plan' | 'annotate' | 'review';
onUIPreferencesChange?: (prefs: UIPreferences) => void;
/** Externally controlled open state (for mobile menu integration) */
externalOpen?: boolean;
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/shortcuts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ describe('shortcuts', () => {

expect(annotateSections.map(section => section.title)).toEqual([
'Actions',
'Sidebar',
'Input Method',
'Annotations',
'Image Annotator',
Expand All @@ -89,6 +90,7 @@ describe('shortcuts', () => {
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(getShortcut(annotateSettingsShortcutRegistry, 'annotate-sidebar', 'toggleContents')?.description).toBe('Toggle Contents sidebar');

expect(reviewSections.map(section => section.title)).toEqual([
'Actions',
Expand Down
1 change: 1 addition & 0 deletions packages/ui/shortcuts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export { imageAnnotatorShortcuts, useImageAnnotatorShortcuts } from './plan-revi
export { inputMethodShortcuts } from './plan-review/inputMethod.shortcuts';
export { viewerShortcuts, useViewerShortcuts } from './plan-review/viewer.shortcuts';
export { goalSetupShortcuts, useGoalSetupShortcuts } from './plan-review/goalSetup.shortcuts';
export { annotateSidebarShortcuts, useAnnotateSidebarShortcuts } from './plan-review/sidebar.shortcuts';

// code-review scopes
export { reviewAnnotationToolbarShortcuts, useReviewAnnotationToolbarShortcuts } from './code-review/annotationToolbar.shortcuts';
Expand Down
34 changes: 34 additions & 0 deletions packages/ui/shortcuts/plan-review/sidebar.shortcuts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { defineShortcutScope } from '../core';
import { createShortcutScopeHook } from '../runtime';

export const annotateSidebarShortcuts = defineShortcutScope({
id: 'annotate-sidebar',
title: 'Annotate Sidebar',
shortcuts: {
toggleContents: {
description: 'Toggle Contents sidebar',
bindings: ['Mod+B'],
section: 'Sidebar',
displayOrder: 10,
preventDefault: true,
},
toggleFiles: {
description: 'Toggle Files sidebar',
bindings: ['Mod+Shift+B'],
section: 'Sidebar',
hint: 'Available when the Files tab is shown.',
displayOrder: 20,
preventDefault: true,
},
toggleAgentTui: {
description: 'Toggle Agent TUI sidebar',
bindings: ['Shift Shift'],
section: 'Sidebar',
hint: 'Available when the Agent control is shown.',
displayOrder: 30,
preventDefault: true,
},
},
});

export const useAnnotateSidebarShortcuts = createShortcutScopeHook(annotateSidebarShortcuts);
52 changes: 48 additions & 4 deletions packages/ui/shortcuts/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,15 +164,15 @@ export function useDoubleTapShortcuts<TScope extends ShortcutScopeDefinition<any

useEffect(() => {
// Pre-parse which actions have double-tap bindings
const doubleTapActions: Array<{ actionId: ShortcutActionId<TScope>; keyName: string }> = [];
const doubleTapActions: Array<{ actionId: ShortcutActionId<TScope>; keyName: string; preventDefault: boolean }> = [];
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 });
doubleTapActions.push({ actionId, keyName, preventDefault: shortcut.preventDefault === true });
}
}
}
Expand All @@ -181,11 +181,47 @@ export function useDoubleTapShortcuts<TScope extends ShortcutScopeDefinition<any

// Track last keyup timestamp per key
const lastKeyUp = new Map<string, number>();
// A tap only counts when the key went down and came up ALONE. Without this,
// any two releases of (say) Shift within the window fire the action: typing
// two capitalized words, extending a selection with two Shift+Clicks, or
// pressing a Mod+Shift+<key> chord twice. cleanPress marks a press as solo
// until any other key or pointer interaction intervenes.
const cleanPress = new Map<string, boolean>();
const MODIFIER_KEYS = ['Meta', 'Control', 'Alt', 'Shift'];

const invalidateSequence = () => {
cleanPress.clear();
lastKeyUp.clear();
};

const handleKeyDown = (event: KeyboardEvent) => {
let tracked = false;
for (const { keyName } of doubleTapActions) {
if (matchesKeyName(event, keyName)) {
tracked = true;
if (!event.repeat) cleanPress.set(keyName, true);
}
}
// Any non-tracked keydown breaks both the current press and the sequence.
if (!tracked) invalidateSequence();
};

const handleKeyUp = (event: KeyboardEvent) => {
for (const { actionId, keyName } of doubleTapActions) {
for (const { actionId, keyName, preventDefault } of doubleTapActions) {
if (!matchesKeyName(event, keyName)) continue;

// Release with another modifier still held (Mod+Shift+B) or after a
// non-solo press: not a tap, and it resets the sequence.
const otherModifierHeld = MODIFIER_KEYS.some(
(m) => m !== keyName && event.getModifierState(m),
);
if (otherModifierHeld || cleanPress.get(keyName) !== true) {
lastKeyUp.delete(keyName);
cleanPress.delete(keyName);
continue;
}
cleanPress.delete(keyName); // release consumes the press

const handler = handlersRef.current[actionId];
if (!handler) continue;

Expand All @@ -195,6 +231,7 @@ export function useDoubleTapShortcuts<TScope extends ShortcutScopeDefinition<any
const now = Date.now();
const prev = lastKeyUp.get(keyName) ?? 0;
if (now - prev < tapWindow) {
if (preventDefault) event.preventDefault();
handle(event);
lastKeyUp.set(keyName, 0); // reset so triple-tap doesn't re-fire
} else {
Expand All @@ -203,8 +240,15 @@ export function useDoubleTapShortcuts<TScope extends ShortcutScopeDefinition<any
}
};

window.addEventListener('keydown', handleKeyDown, true);
window.addEventListener('keyup', handleKeyUp);
return () => window.removeEventListener('keyup', handleKeyUp);
// Pointer interaction mid-press (Shift+Click selection) breaks the tap.
window.addEventListener('mousedown', invalidateSequence, true);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('mousedown', invalidateSequence, true);
};
}, [scope, tapWindow]);
}

Expand Down