Menu
Public repository / ecdd82c68dc11022c41dbf1ff22cac68e002a3e1
AkurAI-Mail / src/framework/shortcuts.js
Latest change c3ef99b092caa55fb6a3eed9d3e776734cc99e97 - OLI-241: framework migration plan; shortcuts slice onto AkurAI-Framework signals runtime by Ólafur Búi Ólafsson
// Webmail keyboard shortcuts on the AkurAI-Framework client runtime (native ESM, signals.js, no build step).
import { signal } from "./vendor/signals.js";
/** @typedef {"help"|"close"|"closeTyping"|"compose"|"search"|"next"|"prev"|"delete"|"reply"|"replyAll"|"forward"|"archive"|"star"|"unread"|"spam"} ShortcutAction */
/** @type {Record<string, ShortcutAction>} */
const GLOBAL_KEYS = { "?": "help", Escape: "close", c: "compose", "/": "search", j: "next", k: "prev" };
/** @type {Record<string, ShortcutAction>} */
const SELECTION_KEYS = { "#": "delete", Delete: "delete", r: "reply", R: "replyAll", f: "forward", e: "archive", s: "star", u: "unread", "!": "spam" };
export const SHORTCUT_HELP = [
{ key: "j / k", label: "Next / previous message" },
{ key: "Enter", label: "Open message" },
{ key: "r / R / f", label: "Reply / reply all / forward" },
{ key: "c", label: "Compose" },
{ key: "e", label: "Archive" },
{ key: "Delete / #", label: "Delete" },
{ key: "s / u / !", label: "Flag / unread / spam" },
{ key: "/", label: "Search" },
{ key: "?", label: "Show this help" },
{ key: "Esc", label: "Close compose / overlay" },
];
/** @param {any} target */
function isTypingTarget(target) {
return !!target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable === true);
}
/**
* @param {KeyboardEvent} e
* @param {{enabled: boolean, hasSelection: boolean, inList?: boolean}} ctx
* @returns {ShortcutAction|null}
*/
export function resolveKey(e, { enabled, hasSelection, inList = false }) {
if (e.metaKey || e.ctrlKey || e.altKey) return null;
if (!enabled && e.key !== "Escape") return null;
if (isTypingTarget(e.target)) return e.key === "Escape" ? "closeTyping" : null;
if (e.key === "ArrowDown") return inList ? "next" : null;
if (e.key === "ArrowUp") return inList ? "prev" : null;
if (e.key === "Enter") return !inList && !hasSelection ? "next" : null;
if (e.key in GLOBAL_KEYS) return GLOBAL_KEYS[e.key];
return hasSelection ? (SELECTION_KEYS[e.key] ?? null) : null;
}
/** @param {number} index @param {number} length @param {"next"|"prev"} dir */
export function moveSelection(index, length, dir) {
if (length === 0) return -1;
const target = dir === "next" ? index + 1 : index - 1;
return Math.min(length - 1, Math.max(0, target));
}
/** @param {{querySelector: (selector: string) => any}} root */
export function scrollSelectedIntoView(root) {
root.querySelector("[data-message-list] button.selected")?.scrollIntoView({ block: "nearest" });
}
const PREVENT_DEFAULT = new Set(["help", "search", "next", "prev"]);
/**
* Binds one keydown listener whose presence follows the `enabled` signal.
* @param {{window: Window, enabled: boolean, hasSelection: () => boolean, onAction: (a: ShortcutAction, e: KeyboardEvent) => void, persist: (enabled: boolean) => void}} opts
*/
export function createShortcuts({ window: win, enabled, hasSelection, onAction, persist }) {
const enabledSignal = signal(enabled);
/** @param {any} e */
const onKey = (e) => {
const inList = !!e.target?.closest?.("[data-message-list]");
const action = resolveKey(e, { enabled: enabledSignal(), hasSelection: hasSelection(), inList });
if (!action) return;
if (PREVENT_DEFAULT.has(action)) e.preventDefault();
onAction(action, e);
};
win.addEventListener("keydown", onKey);
return {
enabled: enabledSignal,
/** @param {boolean} value */
setEnabled(value) {
enabledSignal(value);
persist(value);
},
destroy: () => win.removeEventListener("keydown", onKey),
};
}