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
14 changes: 12 additions & 2 deletions src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<FilterSpec | null>(null);
const fittedDocRef = useRef<string | null>(null);

Expand Down Expand Up @@ -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) },
],
},
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}
Expand Down Expand Up @@ -633,6 +642,7 @@ export function AppShell() {
/>
)}
{showAbout && <AboutDialog onClose={() => setShowAbout(false)} />}
{showShortcuts && <ShortcutsDialog menus={menus} onClose={() => setShowShortcuts(false)} />}
{activeFilter && activeLayer && (
<FilterDialog
spec={activeFilter}
Expand Down
94 changes: 94 additions & 0 deletions src/components/ShortcutsDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { TOOLS } from "./Toolbar";
import type { MenuDefinition } from "./MenuBar";
import { Modal } from "./ui/Modal";

interface ShortcutsDialogProps {
/** The same definitions the menu bar renders, so the two cannot drift. */
menus: MenuDefinition[];
onClose: () => 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<Binding>((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 (
<Modal title="Keyboard shortcuts" onClose={onClose} width={620}>
<div className="max-h-[70vh] gap-x-8 overflow-y-auto text-[11px] sm:columns-2">
{groups.map((group) => (
<section key={group.title} className="mb-5 break-inside-avoid">
<h3 className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-text-muted">
{group.title}
</h3>
<dl className="space-y-1">
{group.bindings.map((binding) => (
<div key={`${binding.keys}-${binding.label}`} className="flex items-baseline justify-between gap-4">
<dt className="min-w-0 truncate text-text">{binding.label}</dt>
<dd className="shrink-0">
<kbd className="rounded border border-border border-b-2 bg-surface-2 px-1.5 py-0.5 text-[10px] text-text-muted">
{binding.keys}
</kbd>
</dd>
</div>
))}
</dl>
</section>
))}
</div>
</Modal>
);
}
3 changes: 2 additions & 1 deletion src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
10 changes: 9 additions & 1 deletion src/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { useEffect, type ReactNode } from "react";

export function Modal({
title,
Expand All @@ -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 (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/55" onMouseDown={onClose}>
<div
Expand Down
Loading