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.
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();
},
};
}
