T8/Phase 6: OSS component swap polish
- Calendar: react-big-calendar w/ withDragAndDrop, date-fns localizer, custom event renderer (color tokens), custom toolbar, responsive (agenda on mobile), now indicator - Graph: react-force-graph-2d w/ custom node/link canvas renderers, hover highlighting, search-to-fly, filter panel (entity + relationship types), ResizeObserver, 500-node cap, zoom controls - Command palette: cmdk polish (recent items, @mention filter, settings/logout actions, mobile full-screen) - Shortcuts: @github/hotkey for g+letter nav, n+letter create, ?, /, c; Cmd+K for palette - README.md added with architecture, page list, shortcuts, dev guide - Bundle: 369 KB gzipped (under 1.5 MB budget)
This commit is contained in:
@@ -1,17 +1,69 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useKeyboardShortcutsStore } from "@/lib/stores/use-keyboard-shortcuts-store";
|
||||
import { install, uninstall } from "@github/hotkey";
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
const navigate = useNavigate();
|
||||
const { enabled } = useKeyboardShortcutsStore();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
let pendingKey = "";
|
||||
let pendingTimeout: ReturnType<typeof setTimeout>;
|
||||
// Create a hidden container for @github/hotkey installs
|
||||
const container = document.createElement("div");
|
||||
container.setAttribute("aria-hidden", "true");
|
||||
container.style.display = "none";
|
||||
document.body.appendChild(container);
|
||||
containerRef.current = container;
|
||||
|
||||
// Helper: install a hotkey on a synthetic element
|
||||
const addHotkey = (hotkey: string, handler: () => void) => {
|
||||
const el = document.createElement("span");
|
||||
el.setAttribute("data-hotkey", hotkey);
|
||||
el.addEventListener("hotkey-fire", (e: Event) => {
|
||||
e.preventDefault();
|
||||
handler();
|
||||
});
|
||||
container.appendChild(el);
|
||||
install(el, hotkey);
|
||||
return el;
|
||||
};
|
||||
|
||||
// g+letter navigation sequences
|
||||
const navMap: Record<string, string> = {
|
||||
"g d": "/",
|
||||
"g t": "/tasks",
|
||||
"g h": "/habits",
|
||||
"g p": "/projects",
|
||||
"g n": "/notes",
|
||||
"g c": "/calendar",
|
||||
"g g": "/graph",
|
||||
"g s": "/settings",
|
||||
};
|
||||
|
||||
for (const [seq, path] of Object.entries(navMap)) {
|
||||
addHotkey(seq, () => {
|
||||
navigate({ to: path });
|
||||
});
|
||||
}
|
||||
|
||||
// n+letter new-entity sequences
|
||||
const newMap: Record<string, string> = {
|
||||
"n t": "/tasks",
|
||||
"n h": "/habits",
|
||||
"n p": "/projects",
|
||||
"n n": "/notes",
|
||||
};
|
||||
|
||||
for (const [seq, path] of Object.entries(newMap)) {
|
||||
addHotkey(seq, () => {
|
||||
navigate({ to: path });
|
||||
});
|
||||
}
|
||||
|
||||
// Global keydown handler for single-key shortcuts and Cmd+K
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Never override native behavior inside controls or modal UI
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -19,7 +71,6 @@ export function useKeyboardShortcuts() {
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === "SELECT" ||
|
||||
target.tagName === "BUTTON" ||
|
||||
target.isContentEditable ||
|
||||
target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]')
|
||||
) {
|
||||
@@ -33,61 +84,35 @@ export function useKeyboardShortcuts() {
|
||||
return;
|
||||
}
|
||||
|
||||
// ? — show shortcuts help
|
||||
if (e.key === "?" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
document.dispatchEvent(new CustomEvent("open-shortcuts-help"));
|
||||
return;
|
||||
}
|
||||
|
||||
// / — focus search (when not in an input)
|
||||
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
document.dispatchEvent(new CustomEvent("open-command-palette"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore if modifier keys are pressed
|
||||
// Single-key shortcuts (no modifiers)
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
// Handle two-key combos (G then letter)
|
||||
if (pendingKey) {
|
||||
clearTimeout(pendingTimeout);
|
||||
pendingKey = "";
|
||||
|
||||
const navMap: Record<string, string> = {
|
||||
d: "/",
|
||||
t: "/tasks",
|
||||
h: "/habits",
|
||||
p: "/projects",
|
||||
n: "/notes",
|
||||
c: "/calendar",
|
||||
g: "/graph",
|
||||
s: "/settings",
|
||||
};
|
||||
|
||||
if (navMap[key]) {
|
||||
switch (e.key) {
|
||||
case "?":
|
||||
e.preventDefault();
|
||||
navigate({ to: navMap[key] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Single key shortcuts
|
||||
switch (key) {
|
||||
case "g":
|
||||
pendingKey = "g";
|
||||
pendingTimeout = setTimeout(() => {
|
||||
pendingKey = "";
|
||||
}, 1000);
|
||||
document.dispatchEvent(new CustomEvent("open-shortcuts-help"));
|
||||
break;
|
||||
case "/":
|
||||
e.preventDefault();
|
||||
document.dispatchEvent(new CustomEvent("open-command-palette"));
|
||||
break;
|
||||
case "c":
|
||||
e.preventDefault();
|
||||
document.dispatchEvent(new CustomEvent("open-command-palette"));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
for (const child of container.children) {
|
||||
uninstall(child as HTMLElement);
|
||||
}
|
||||
document.body.removeChild(container);
|
||||
containerRef.current = null;
|
||||
};
|
||||
}, [navigate, enabled]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user