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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/marketing/src/components/ShortcutReference.astro
Original file line number Diff line number Diff line change
@@ -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>
))}
20 changes: 1 addition & 19 deletions apps/marketing/src/content/docs/reference/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
21 changes: 21 additions & 0 deletions apps/marketing/src/lib/shortcutReference.ts
Original file line number Diff line number Diff line change
@@ -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 })),
]);
11 changes: 9 additions & 2 deletions apps/marketing/src/pages/docs/[...slug].astro
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -11,13 +13,18 @@ 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}
description={doc.data.description}
headings={headings}
currentId={doc.id}
>
<Content />
{isShortcutReference ? <ShortcutReference /> : Content && <Content />}
</Docs>
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

227 changes: 124 additions & 103 deletions packages/editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -1122,7 +1143,7 @@ const App: React.FC = () => {
{/* Desktop buttons — hidden on mobile */}
<div className="hidden md:flex items-center gap-2">
<ModeToggle />
{!linkedDocHook.isActive && <Settings taterMode={taterMode} onTaterModeChange={handleTaterModeChange} onIdentityChange={handleIdentityChange} origin={origin} onUIPreferencesChange={setUiPrefs} externalOpen={mobileSettingsOpen} onExternalClose={() => setMobileSettingsOpen(false)} />}
{!linkedDocHook.isActive && <Settings taterMode={taterMode} onTaterModeChange={handleTaterModeChange} onIdentityChange={handleIdentityChange} origin={origin} shortcutRegistry={annotateMode ? annotateSettingsShortcutRegistry : planReviewSettingsShortcutRegistry} onUIPreferencesChange={setUiPrefs} externalOpen={mobileSettingsOpen} onExternalClose={() => setMobileSettingsOpen(false)} />}

<button
onClick={() => setIsPanelOpen(!isPanelOpen)}
Expand Down
Loading