Browse Source

feat: add keyboard shortcuts help dialog

- New KeyboardShortcutsDialog component showing all available shortcuts
- Accessible via ? button in header and ? key on canvas
- Groups shortcuts by category: General, Add Nodes, Layout, Canvas
- Auto-detects Mac vs Windows/Linux for modifier key display
- Follows existing modal patterns (Escape to close, backdrop click)
handoff-20260429-1057
charlieshoelace 5 months ago
parent
commit
3e2c92c871
  1. 17
      src/components/Header.tsx
  2. 149
      src/components/KeyboardShortcutsDialog.tsx
  3. 11
      src/components/WorkflowCanvas.tsx
  4. 12
      src/store/workflowStore.ts

17
src/components/Header.tsx

@ -4,6 +4,7 @@ import { useState, useRef, useMemo, useCallback } from "react";
import { useWorkflowStore, WorkflowFile } from "@/store/workflowStore";
import { ProjectSetupModal } from "./ProjectSetupModal";
import { CostIndicator } from "./CostIndicator";
import { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog";
function CommentsNavigationIcon() {
// Subscribe to nodes so we re-render when comments change
@ -69,6 +70,8 @@ export function Header() {
loadWorkflow,
previousWorkflowSnapshot,
revertToSnapshot,
shortcutsDialogOpen,
setShortcutsDialogOpen,
} = useWorkflowStore();
const [showProjectModal, setShowProjectModal] = useState(false);
@ -401,6 +404,16 @@ export function Header() {
Made by Willie
</a>
<span className="text-neutral-500">·</span>
<button
onClick={() => setShortcutsDialogOpen(true)}
className="text-neutral-400 hover:text-neutral-200 transition-colors"
title="Keyboard shortcuts (?)"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
</button>
<span className="text-neutral-500">·</span>
<a
href="https://discord.com/invite/89Nr6EKkTf"
target="_blank"
@ -419,6 +432,10 @@ export function Header() {
</a>
</div>
</header>
<KeyboardShortcutsDialog
isOpen={shortcutsDialogOpen}
onClose={() => setShortcutsDialogOpen(false)}
/>
</>
);
}

149
src/components/KeyboardShortcutsDialog.tsx

@ -0,0 +1,149 @@
"use client";
import { useEffect } from "react";
interface ShortcutItem {
keys: string[];
description: string;
}
interface ShortcutGroup {
title: string;
shortcuts: ShortcutItem[];
}
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
const modKey = isMac ? "⌘" : "Ctrl";
const shortcutGroups: ShortcutGroup[] = [
{
title: "General",
shortcuts: [
{ keys: [`${modKey}`, "Enter"], description: "Run workflow" },
{ keys: [`${modKey}`, "C"], description: "Copy selected nodes" },
{ keys: [`${modKey}`, "V"], description: "Paste nodes / image / text" },
{ keys: ["?"], description: "Show keyboard shortcuts" },
],
},
{
title: "Add Nodes",
shortcuts: [
{ keys: ["Shift", "P"], description: "Add Prompt node" },
{ keys: ["Shift", "I"], description: "Add Image Input node" },
{ keys: ["Shift", "G"], description: "Add Generate Image node" },
{ keys: ["Shift", "V"], description: "Add Generate Video node" },
{ keys: ["Shift", "L"], description: "Add LLM Text node" },
{ keys: ["Shift", "A"], description: "Add Annotation node" },
],
},
{
title: "Layout (select 2+ nodes first)",
shortcuts: [
{ keys: ["V"], description: "Stack selected vertically" },
{ keys: ["H"], description: "Stack selected horizontally" },
{ keys: ["G"], description: "Arrange selected as grid" },
],
},
{
title: "Canvas",
shortcuts: [
{ keys: ["Scroll"], description: "Zoom in / out" },
{ keys: ["Trackpad"], description: "Pan (macOS)" },
{ keys: ["Delete"], description: "Delete selected nodes" },
],
},
];
function Kbd({ children }: { children: string }) {
return (
<kbd className="inline-flex items-center justify-center min-w-[24px] h-6 px-1.5 text-[11px] font-medium text-neutral-200 bg-neutral-700 border border-neutral-600 rounded shadow-sm">
{children}
</kbd>
);
}
interface KeyboardShortcutsDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function KeyboardShortcutsDialog({ isOpen, onClose }: KeyboardShortcutsDialogProps) {
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50">
<div className="bg-neutral-800 rounded-lg w-[520px] max-h-[80vh] border border-neutral-700 shadow-xl flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-700">
<h2 className="text-base font-semibold text-neutral-100">
Keyboard Shortcuts
</h2>
<button
onClick={onClose}
className="p-1 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-700 rounded transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="overflow-y-auto px-5 py-4 space-y-5">
{shortcutGroups.map((group) => (
<div key={group.title}>
<h3 className="text-[11px] font-semibold text-neutral-400 uppercase tracking-wider mb-2">
{group.title}
</h3>
<div className="space-y-1.5">
{group.shortcuts.map((shortcut, idx) => (
<div
key={idx}
className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-neutral-700/40 transition-colors"
>
<span className="text-sm text-neutral-300">
{shortcut.description}
</span>
<div className="flex items-center gap-1 ml-4 shrink-0">
{shortcut.keys.map((key, keyIdx) => (
<span key={keyIdx} className="flex items-center gap-1">
{keyIdx > 0 && (
<span className="text-[10px] text-neutral-500">+</span>
)}
<Kbd>{key}</Kbd>
</span>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-neutral-700 flex justify-end">
<button
onClick={onClose}
className="px-3 py-1.5 text-xs font-medium text-neutral-300 hover:text-neutral-100 bg-neutral-700 hover:bg-neutral-600 rounded transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}

11
src/components/WorkflowCanvas.tsx

@ -210,7 +210,7 @@ const findScrollableAncestor = (target: HTMLElement, deltaX: number, deltaY: num
};
export function WorkflowCanvas() {
const { nodes, edges, groups, onNodesChange, onEdgesChange, onConnect, addNode, updateNodeData, loadWorkflow, getNodeById, addToGlobalHistory, setNodeGroupId, executeWorkflow, isModalOpen, showQuickstart, setShowQuickstart, navigationTarget, setNavigationTarget, captureSnapshot, applyEditOperations, setWorkflowMetadata } =
const { nodes, edges, groups, onNodesChange, onEdgesChange, onConnect, addNode, updateNodeData, loadWorkflow, getNodeById, addToGlobalHistory, setNodeGroupId, executeWorkflow, isModalOpen, showQuickstart, setShowQuickstart, navigationTarget, setNavigationTarget, captureSnapshot, applyEditOperations, setWorkflowMetadata, setShortcutsDialogOpen } =
useWorkflowStore();
const { screenToFlowPosition, getViewport, zoomIn, zoomOut, setViewport, setCenter } = useReactFlow();
const { show: showToast } = useToast();
@ -982,6 +982,13 @@ export function WorkflowCanvas() {
return;
}
// Handle keyboard shortcuts dialog (? key)
if (event.key === "?" && !event.ctrlKey && !event.metaKey) {
event.preventDefault();
setShortcutsDialogOpen(true);
return;
}
// Handle workflow execution (Ctrl/Cmd + Enter)
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
@ -1225,7 +1232,7 @@ export function WorkflowCanvas() {
]);
});
}
}, [nodes, onNodesChange, copySelectedNodes, pasteNodes, clearClipboard, clipboard, getViewport, addNode, updateNodeData, executeWorkflow]);
}, [nodes, onNodesChange, copySelectedNodes, pasteNodes, clearClipboard, clipboard, getViewport, addNode, updateNodeData, executeWorkflow, setShortcutsDialogOpen]);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);

12
src/store/workflowStore.ts

@ -209,6 +209,10 @@ interface WorkflowStore {
modelSearchOpen: boolean;
modelSearchProvider: ProviderType | null;
// Keyboard shortcuts dialog state
shortcutsDialogOpen: boolean;
setShortcutsDialogOpen: (open: boolean) => void;
// Model search dialog actions
setModelSearchOpen: (open: boolean, provider?: ProviderType | null) => void;
@ -320,6 +324,9 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
modelSearchOpen: false,
modelSearchProvider: null,
// Keyboard shortcuts dialog initial state
shortcutsDialogOpen: false,
// Recent models initial state
recentModels: getRecentModels(),
@ -1544,6 +1551,11 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
saveProviderSettings(updated);
},
// Keyboard shortcuts dialog actions
setShortcutsDialogOpen: (open: boolean) => {
set({ shortcutsDialogOpen: open });
},
// Model search dialog actions
setModelSearchOpen: (open: boolean, provider?: ProviderType | null) => {
set({

Loading…
Cancel
Save