import { getContext, setContext, untrack } from "svelte"; export interface Binding { group?: string; hidden?: boolean; input?: boolean; keys: string; label: string; run: (event: KeyboardEvent) => void; } const SEQUENCE_MS = 1200; const EDITABLE = "input, textarea, select, [contenteditable=''], [contenteditable='true']"; function baseKey(event: KeyboardEvent): string { if (event.altKey && event.code.startsWith("Key")) { return event.code.slice(3).toLowerCase(); } if (event.altKey && event.code.startsWith("Digit")) { return event.code.slice(5); } return event.key === " " ? "Space" : event.key; } function token(event: KeyboardEvent): string { const parts: string[] = []; if (event.ctrlKey || event.metaKey) { parts.push("mod"); } if (event.altKey) { parts.push("alt"); } const key = baseKey(event); if (event.shiftKey && key.length > 1) { parts.push("shift"); } parts.push(key.length === 1 && parts.length > 0 ? key.toLowerCase() : key); return parts.join("+"); } export function isEditable(target: EventTarget | null): boolean { return target instanceof Element && target.closest(EDITABLE) !== null; } class Keys { layers = $state.raw([]); pending = $state(""); sheetOpen = $state(false); private timer: ReturnType | undefined; all = $derived(this.layers.flat()); private readonly depths = new WeakMap(); register(bindings: Binding[], depth = 0): () => void { const layer = bindings; this.depths.set(layer, depth); const above = this.layers.findIndex( (l) => (this.depths.get(l) ?? 0) > depth ); const next = [...this.layers]; next.splice(above < 0 ? next.length : above, 0, layer); this.layers = next; return () => { this.layers = this.layers.filter((l) => l !== layer); }; } private find(combo: string, editable: boolean): Binding | undefined { for (let i = this.layers.length - 1; i >= 0; i -= 1) { const match = this.layers[i].find( (b) => b.keys === combo && (!editable || b.input) ); if (match) { return match; } } } private isPrefix(combo: string): boolean { return this.all.some((b) => b.keys.startsWith(`${combo} `)); } private clear() { this.pending = ""; clearTimeout(this.timer); } handle = (event: KeyboardEvent) => { if ( event.defaultPrevented || event.isComposing || document.querySelector("dialog:modal") ) { return; } if (["Shift", "Control", "Meta", "Alt"].includes(event.key)) { return; } const editable = isEditable(event.target); const current = token(event); const combo = this.pending ? `${this.pending} ${current}` : current; const binding = this.find(combo, editable) ?? (this.pending ? this.find(current, editable) : undefined); if (binding) { event.preventDefault(); this.clear(); binding.run(event); return; } if (!editable && this.isPrefix(current)) { event.preventDefault(); this.clear(); this.pending = current; this.timer = setTimeout(() => this.clear(), SEQUENCE_MS); return; } this.clear(); }; } export const keys = new Keys(); const DEPTH = Symbol("keys-depth"); export function useKeys(bindings: () => Binding[]) { const depth = getContext(DEPTH) ?? 0; setContext(DEPTH, depth + 1); $effect(() => { const list = bindings(); return untrack(() => keys.register(list, depth)); }); } const APPLE = /Mac|iPhone|iPad/; const PC_NAMES: Record = { alt: "Alt", Enter: "↵", Escape: "Esc", mod: "Ctrl", shift: "Shift", }; const MAC_NAMES: Record = { ...PC_NAMES, alt: "⌥", mod: "⌘" }; export function prettyKeys(combo: string): string[] { const mac = typeof navigator !== "undefined" && APPLE.test(navigator.platform); return combo.split(" ").map((part) => part .split("+") .map((piece) => (mac ? MAC_NAMES : PC_NAMES)[piece] ?? piece) .join(mac ? "" : "+") ); }