Back to Black Site Map Editor
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.

src/key-combo.ts
// 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);
}