160 lines
5.3 KiB
TypeScript
160 lines
5.3 KiB
TypeScript
/*
|
|||
|
|
Copyright 2026 Lotus Guild
|
||
|
|
|
||
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||
|
|
Please see LICENSE in the repository root for full details.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { type IWidgetApiRequest } from "matrix-widget-api";
|
||
|
|
|
||
|
|
import { widget } from "../widget";
|
||
|
|
import { LotusWidgetActions, lotusSendToHost } from "./lotusWidget";
|
||
|
|
|
||
|
|
/** fromWidget `io.lotus.hotkey` payload. */
|
||
|
|
export type LotusHotkeyReport =
|
||
|
|
| {
|
||
|
|
type: "keydown" | "keyup";
|
||
|
|
code: string;
|
||
|
|
repeat: boolean;
|
||
|
|
ctrlKey: boolean;
|
||
|
|
altKey: boolean;
|
||
|
|
metaKey: boolean;
|
||
|
|
shiftKey: boolean;
|
||
|
|
/** The key went to a text field (typing, not a hotkey). */
|
||
|
|
editable: boolean;
|
||
|
|
/** The key went to a button/link/etc. (Space activates it). */
|
||
|
|
interactive: boolean;
|
||
|
|
}
|
||
|
|
/** This window gained or lost focus: the host releases a held PTT. */
|
||
|
|
| { type: "focus" };
|
||
|
|
|
||
|
|
const MAX_CODES = 8;
|
||
|
|
|
||
|
|
// Codes the host currently owns. Module-level so EC's own shortcuts
|
||
|
|
// (useCallViewKeyboardShortcuts) can stand down for them.
|
||
|
|
let hostCodes = new Set<string>();
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Whether `code` is one of the host's call hotkeys (its PTT or deafen key).
|
||
|
|
* EC's built-in shortcuts ignore such keys: with the defaults, M would
|
||
|
|
* otherwise both toggle EC's mic and the host's deafen, and Space would open
|
||
|
|
* EC's own push-to-talk alongside the host's.
|
||
|
|
*/
|
||
|
|
export function isLotusHostHotkey(code: string): boolean {
|
||
|
|
return hostCodes.has(code);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** `{ codes }` payload → the KeyboardEvent.code values to watch. Exported for tests. */
|
||
|
|
export function parseHotkeyCodes(data: unknown): string[] {
|
||
|
|
if (typeof data !== "object" || data === null) return [];
|
||
|
|
const { codes } = data as { codes?: unknown };
|
||
|
|
if (!Array.isArray(codes)) return [];
|
||
|
|
return codes
|
||
|
|
.filter(
|
||
|
|
(c): c is string =>
|
||
|
|
typeof c === "string" && /^[A-Za-z0-9]{1,32}$/.test(c),
|
||
|
|
)
|
||
|
|
.slice(0, MAX_CODES);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Same rules as the host's useCallHotkeys. Exported for tests. */
|
||
|
|
export function isEditableTarget(el: Element | null): boolean {
|
||
|
|
if (!(el instanceof HTMLElement)) return false;
|
||
|
|
const tag = el.tagName;
|
||
|
|
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||
|
|
let node: HTMLElement | null = el;
|
||
|
|
while (node && node !== el.ownerDocument.body) {
|
||
|
|
const ce = node.getAttribute("contenteditable");
|
||
|
|
if (ce === "" || ce === "true" || ce === "plaintext-only") return true;
|
||
|
|
if (ce === "false") return false;
|
||
|
|
node = node.parentElement;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Exported for tests. */
|
||
|
|
export function isInteractiveTarget(el: Element | null): boolean {
|
||
|
|
if (!(el instanceof HTMLElement)) return false;
|
||
|
|
const tag = el.tagName;
|
||
|
|
if (tag === "BUTTON" || tag === "A" || tag === "SELECT") return true;
|
||
|
|
let node: HTMLElement | null = el;
|
||
|
|
while (node && node !== el.ownerDocument.body) {
|
||
|
|
const role = node.getAttribute("role");
|
||
|
|
if (
|
||
|
|
role === "button" ||
|
||
|
|
role === "link" ||
|
||
|
|
role === "menuitem" ||
|
||
|
|
role === "tab"
|
||
|
|
)
|
||
|
|
return true;
|
||
|
|
node = node.parentElement;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* [cinny #43] Call hotkeys (push-to-talk, deafen) while focus is inside this
|
||
|
|
* frame. The host used to add key listeners to this window directly, which
|
||
|
|
* needs same-origin access. Now it sends `io.lotus.set_hotkeys { codes }` and
|
||
|
|
* we report those keys back as `io.lotus.hotkey`. The default action is
|
||
|
|
* cancelled here, synchronously (it can't be across frames), with the host's
|
||
|
|
* rules: never while typing in a field, and not on a focused button/link so
|
||
|
|
* keyboard users can still activate it. Returns a teardown function.
|
||
|
|
*/
|
||
|
|
export function startLotusHotkeys(): () => void {
|
||
|
|
const w = widget;
|
||
|
|
if (!w) return (): void => undefined;
|
||
|
|
hostCodes = new Set<string>();
|
||
|
|
|
||
|
|
const onSetHotkeys = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||
|
|
w.api.transport.reply(ev.detail, {});
|
||
|
|
hostCodes = new Set(parseHotkeyCodes(ev.detail.data));
|
||
|
|
};
|
||
|
|
const onKey = (e: KeyboardEvent): void => {
|
||
|
|
if (!hostCodes.has(e.code)) return;
|
||
|
|
const target = e.target instanceof Element ? e.target : null;
|
||
|
|
const editable = isEditableTarget(target);
|
||
|
|
const interactive = isInteractiveTarget(target);
|
||
|
|
if (
|
||
|
|
e.type === "keydown" &&
|
||
|
|
!editable &&
|
||
|
|
!interactive &&
|
||
|
|
!e.ctrlKey &&
|
||
|
|
!e.altKey &&
|
||
|
|
!e.metaKey
|
||
|
|
) {
|
||
|
|
e.preventDefault();
|
||
|
|
}
|
||
|
|
const report: LotusHotkeyReport = {
|
||
|
|
type: e.type === "keyup" ? "keyup" : "keydown",
|
||
|
|
code: e.code,
|
||
|
|
repeat: e.repeat,
|
||
|
|
ctrlKey: e.ctrlKey,
|
||
|
|
altKey: e.altKey,
|
||
|
|
metaKey: e.metaKey,
|
||
|
|
shiftKey: e.shiftKey,
|
||
|
|
editable,
|
||
|
|
interactive,
|
||
|
|
};
|
||
|
|
lotusSendToHost(LotusWidgetActions.Hotkey, report);
|
||
|
|
};
|
||
|
|
const onFocusChange = (): void => {
|
||
|
|
if (hostCodes.size > 0)
|
||
|
|
lotusSendToHost(LotusWidgetActions.Hotkey, { type: "focus" });
|
||
|
|
};
|
||
|
|
|
||
|
|
w.lazyActions.on(LotusWidgetActions.SetHotkeys, onSetHotkeys);
|
||
|
|
window.addEventListener("keydown", onKey, true);
|
||
|
|
window.addEventListener("keyup", onKey, true);
|
||
|
|
window.addEventListener("blur", onFocusChange);
|
||
|
|
window.addEventListener("focus", onFocusChange);
|
||
|
|
return (): void => {
|
||
|
|
w.lazyActions.off(LotusWidgetActions.SetHotkeys, onSetHotkeys);
|
||
|
|
window.removeEventListener("keydown", onKey, true);
|
||
|
|
window.removeEventListener("keyup", onKey, true);
|
||
|
|
window.removeEventListener("blur", onFocusChange);
|
||
|
|
window.removeEventListener("focus", onFocusChange);
|
||
|
|
hostCodes = new Set<string>();
|
||
|
|
};
|
||
|
|
}
|