diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index a6e2026..372f127 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -8,6 +8,7 @@ import { NewDocumentDialog } from "../components/NewDocumentDialog"; import { OptionsBar } from "../components/OptionsBar"; import { SplashScreen } from "../components/SplashScreen"; import { StatusBar } from "../components/StatusBar"; +import { ShortcutsDialog } from "../components/ShortcutsDialog"; import { Toolbar, type ToolId } from "../components/Toolbar"; import { FilterDialog, type FilterSpec } from "../components/FilterDialog"; import type { Filter } from "../engine/filters/adjustments"; @@ -113,6 +114,7 @@ export function AppShell() { const [showNew, setShowNew] = useState(false); const [showImageSize, setShowImageSize] = useState(false); const [showAbout, setShowAbout] = useState(false); + const [showShortcuts, setShowShortcuts] = useState(false); const [activeFilter, setActiveFilter] = useState(null); const fittedDocRef = useRef(null); @@ -391,7 +393,7 @@ export function AppShell() { { label: "Help", items: [ - { label: "Keyboard Shortcuts", disabled: true }, + { label: "Keyboard Shortcuts", shortcut: "?", action: () => setShowShortcuts(true) }, { label: "About Photovibe", action: () => setShowAbout(true) }, ], }, @@ -510,7 +512,9 @@ export function AppShell() { return; } if (key === "escape") { - setSelection(null); + // A dialog takes Escape first; clearing the selection underneath it + // would be a surprise. + if (!window.document.querySelector('[role="dialog"]')) setSelection(null); } if (e.ctrlKey && (key === "=" || key === "+")) { e.preventDefault(); @@ -536,6 +540,11 @@ export function AppShell() { e.preventDefault(); selectAll(); } + if (e.key === "?" && !e.ctrlKey && !e.altKey) { + e.preventDefault(); + setShowShortcuts(true); + return; + } if (key === "[" && !e.ctrlKey) { setBrushSize(brushSize - 4); } @@ -633,6 +642,7 @@ export function AppShell() { /> )} {showAbout && setShowAbout(false)} />} + {showShortcuts && setShowShortcuts(false)} />} {activeFilter && activeLayer && ( void; +} + +interface Binding { + keys: string; + label: string; +} + +/** + * Bindings the menus do not show, either because they have no menu entry or + * because the menu names only one of two accepted chords. + */ +const EXTRA_KEYS: Binding[] = [ + { keys: "Ctrl+Y", label: "Redo (alternative to Ctrl+Shift+Z)" }, + { keys: "Esc", label: "Deselect, or cancel a crop or text edit" }, + { keys: "[", label: "Decrease brush size" }, + { keys: "]", label: "Increase brush size" }, +]; + +/** Pointer gestures on the canvas, which have no keyboard equivalent. */ +const GESTURES: Binding[] = [ + { keys: "Space+drag", label: "Pan the canvas" }, + { keys: "Scroll", label: "Zoom in and out" }, + { keys: "Alt+right-drag", label: "Resize the brush without leaving the canvas" }, + { keys: "Drag in selection", label: "Move the selected pixels (Cursor tool)" }, + { keys: "Alt+drag in selection", label: "Move the selection outline only (Cursor tool)" }, + { keys: "Double-click text", label: "Reopen a text layer for editing" }, +]; + +/** + * A reference for every shortcut the app actually handles. + * + * The tool and menu sections are derived rather than retyped: tools come from + * TOOLS, and menu entries from the same definitions the menu bar renders. A + * menu entry is only listed when it has an action, which is what separates a + * command that is merely unavailable right now (Cut, with nothing selected) + * from one that is not implemented at all (Inverse) - the latter must not + * appear here. + */ +export function ShortcutsDialog({ menus, onClose }: ShortcutsDialogProps) { + const menuGroups = menus + .map((menu) => ({ + title: menu.label, + bindings: menu.items.flatMap((item) => + !item.separator && item.shortcut && item.action + ? [{ keys: item.shortcut, label: item.label }] + : [], + ), + })) + .filter((group) => group.bindings.length > 0); + + const groups = [ + { + title: "Tools", + bindings: TOOLS.map((tool) => ({ keys: tool.shortcut, label: tool.label })), + }, + ...menuGroups, + { title: "Other keys", bindings: EXTRA_KEYS }, + { title: "On the canvas", bindings: GESTURES }, + ]; + + return ( + +
+ {groups.map((group) => ( +
+

+ {group.title} +

+
+ {group.bindings.map((binding) => ( +
+
{binding.label}
+
+ + {binding.keys} + +
+
+ ))} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 476e5d2..c203556 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -15,7 +15,8 @@ import { import type { LucideIcon } from "lucide-react"; import { cn } from "../lib/utils"; -const TOOLS = [ +/** Also drives the shortcuts reference, so it stays the single source. */ +export const TOOLS = [ { id: "cursor", label: "Cursor", shortcut: "A", icon: MousePointer2 }, { id: "move", label: "Move", shortcut: "V", icon: Move }, { id: "marquee", label: "Marquee", shortcut: "M", icon: SquareDashed }, diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 3b3bc84..d9fdf40 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { useEffect, type ReactNode } from "react"; export function Modal({ title, @@ -11,6 +11,14 @@ export function Modal({ onClose: () => void; width?: number; }) { + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + return (