SOURCE / PINNED RELEASE
Made of little things.
Black Site Map Editor
- Release
- 32a57733b7e9…
- Author-recorded commit
- 3f54890f542f…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/black-site-map-editor
Archive hash verified: 7a4b1abbac7db5b0…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Key combo strings follow the NAP-KEYS shim format: modifiers in Alt, Ctrl,
// Meta, Shift order followed by KeyboardEvent.key, joined with "+".
export type KeyLike = Pick<KeyboardEvent, 'key' | 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>;
export interface Combo {
alt: boolean;
ctrl: boolean;
meta: boolean;
shift: boolean;
key: string;
}
const aliases: Record<string, string> = {
esc: 'escape',
del: 'delete',
space: ' ',
spacebar: ' ',
plus: '+',
up: 'arrowup',
down: 'arrowdown',
left: 'arrowleft',
right: 'arrowright',
return: 'enter',
};
// Shifted symbols ("?", "+") already carry Shift in their key value; keyboard layouts differ on whether Shift is held.
const implicitShift = (key: string) => key.length === 1 && !/[a-z0-9 ]/i.test(key);
export function parseCombo(value: string): Combo | undefined {
const parts = value.trim().split(/\+(?!$)/);
const combo: Combo = { alt: false, ctrl: false, meta: false, shift: false, key: '' };
for (const [index, raw] of parts.entries()) {
const part = raw.trim().toLowerCase();
if (index === parts.length - 1) {
combo.key = aliases[part] ?? part;
break;
}
if (part === 'alt' || part === 'option') combo.alt = true;
else if (part === 'ctrl' || part === 'control') combo.ctrl = true;
else if (part === 'meta' || part === 'cmd' || part === 'command') combo.meta = true;
else if (part === 'shift') combo.shift = true;
else return undefined;
}
if (!combo.key || ['alt', 'control', 'ctrl', 'meta', 'shift'].includes(combo.key))
return undefined;
if (implicitShift(combo.key)) combo.shift = false;
return combo;
}
export function eventCombo(event: KeyLike): Combo {
const key = event.key.toLowerCase();
return {
alt: event.altKey,
ctrl: event.ctrlKey,
meta: event.metaKey,
shift: implicitShift(key) ? false : event.shiftKey,
key,
};
}
/** Match a built-in default combo. Ctrl defaults also accept Cmd so Apple keyboards get the familiar chord. */
export function matchesCombo(binding: string, event: KeyLike): boolean {
const want = parseCombo(binding);
if (!want) return false;
const got = eventCombo(event);
const command =
want.ctrl && !want.meta
? (got.ctrl || got.meta) && !(got.ctrl && got.meta)
: got.ctrl === want.ctrl && got.meta === want.meta;
return command && got.alt === want.alt && got.shift === want.shift && got.key === want.key;
}
const names: Record<string, string> = {
arrowup: '↑',
arrowdown: '↓',
arrowleft: '←',
arrowright: '→',
' ': 'Space',
escape: 'Esc',
delete: 'Del',
backspace: 'Backspace',
enter: 'Enter',
};
/** Human-readable label for a binding, e.g. "Ctrl+Shift+Z" or "Shift+↑". */
export function formatCombo(binding: string): string {
const combo = parseCombo(binding);
if (!combo) return binding;
const parts = [
combo.ctrl && 'Ctrl',
combo.alt && 'Alt',
combo.meta && 'Meta',
combo.shift && 'Shift',
].filter(Boolean) as string[];
parts.push(
names[combo.key] ??
(combo.key.length === 1
? combo.key.toUpperCase()
: combo.key[0].toUpperCase() + combo.key.slice(1)),
);
return parts.join('+');
}
/** Value for aria-keyshortcuts, which uses the KeyboardEvent.key names. */
export function ariaCombo(binding: string): string {
const combo = parseCombo(binding);
if (!combo) return '';
const parts = [
combo.ctrl && 'Control',
combo.alt && 'Alt',
combo.meta && 'Meta',
combo.shift && 'Shift',
].filter(Boolean) as string[];
const key = {
arrowup: 'ArrowUp',
arrowdown: 'ArrowDown',
arrowleft: 'ArrowLeft',
arrowright: 'ArrowRight',
' ': 'Space',
delete: 'Delete',
escape: 'Escape',
enter: 'Enter',
backspace: 'Backspace',
}[combo.key];
parts.push(key ?? (combo.key.length === 1 ? combo.key.toUpperCase() : combo.key));
return parts.join('+');
}
/** True when keystrokes belong to a text field rather than to the editor. */
export function isTypingTarget(target: EventTarget | null): boolean {
if (!target || typeof (target as Element).tagName !== 'string') return false;
const element = target as HTMLElement;
if (element.isContentEditable || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT')
return true;
if (element.tagName !== 'INPUT') return false;
return ![
'button',
'checkbox',
'color',
'file',
'image',
'radio',
'range',
'reset',
'submit',
].includes((element as HTMLInputElement).type);
}
