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/shortcuts.ts
import { keys } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { isTypingTarget, matchesCombo } from './key-combo';

export interface ShortcutAction {
    /** Stable NAP-KEYS action id (`feature.action`) so shells can remember user bindings. */
    id: string;
    label: string;
    group: string;
    defaultKey: string;
    run: () => void;
}
/**
 * - `host`: the shell assigned a binding and delivers the action (NAP-KEYS shim or `keys.action`).
 * - `default`: no shell binding; the editor answers the built-in key itself.
 * - `pending`: registration in flight; nothing fires yet, which avoids double delivery.
 */
export type ShortcutSource = 'host' | 'default' | 'pending';
export interface ShortcutState {
    action: ShortcutAction;
    binding?: string;
    source: ShortcutSource;
}

export interface Shortcuts {
    states(): ShortcutState[];
    get(id: string): ShortcutState | undefined;
    close(): void;
}

/**
 * Register editor actions with NAP-KEYS when the host offers it and keep the
 * built-in keys working when it does not, when registration fails, or when the
 * host leaves an action unbound. Host bindings always win over built-in keys.
 */
export function installShortcuts(actions: ShortcutAction[], onChange: () => void): Shortcuts {
    const states = new Map<string, ShortcutState>(
        actions.map((action) => [
            action.id,
            { action, binding: action.defaultKey, source: 'default' },
        ]),
    );
    const handles: { close: () => void }[] = [];
    let closed = false;

    if (runtimeHasDomain('keys')) {
        for (const state of states.values()) {
            state.source = 'pending';
            keys.register(
                {
                    id: state.action.id,
                    label: state.action.label,
                    defaultKey: state.action.defaultKey,
                },
                () => {
                    if (!closed) state.action.run();
                },
            )
                .then((handle) => {
                    if (closed) {
                        handle.close();
                        return;
                    }
                    handles.push(handle);
                    // An unbound registration keeps the editor-owned default so hosts without a binding UI stay usable.
                    if (handle.binding) {
                        state.binding = handle.binding;
                        state.source = 'host';
                    } else {
                        state.binding = state.action.defaultKey;
                        state.source = 'default';
                    }
                })
                .catch(() => {
                    state.binding = state.action.defaultKey;
                    state.source = 'default';
                })
                .finally(() => {
                    if (!closed) onChange();
                });
        }
    }

    const onKeydown = (event: KeyboardEvent) => {
        // The NAP-KEYS shim runs first in the capture phase and prevents default for host-bound keys.
        if (event.defaultPrevented || event.isComposing || isTypingTarget(event.target)) return;
        for (const state of states.values()) {
            if (state.source !== 'default' || !state.binding || !matchesCombo(state.binding, event))
                continue;
            event.preventDefault();
            state.action.run();
            return;
        }
    };
    document.addEventListener('keydown', onKeydown);

    return {
        states: () => [...states.values()],
        get: (id) => states.get(id),
        close() {
            closed = true;
            document.removeEventListener('keydown', onKeydown);
            for (const handle of handles.splice(0)) handle.close();
        },
    };
}