Files
Linus Rath ad36f8d382 fix: show the side panel only where the plugin does, and keep it steady
- The button and the panel of Calendar, Contacts, Files and Settings
  show only when the plugin's shouldShow says yes, so a plugin switched
  off for these views (admin, user or no service) leaves no button that
  opens an empty panel, and no extra rows in Contacts and Settings.
- Files: while the details are open the panel steps aside there only;
  the other views keep it as the user left it. With both stored open the
  details fold when the page loads.
- Resizing stops where the view keeps its 480 px, so a drag or the arrow
  keys no longer fold the panel away (and the handle keeps the focus).
- Settings and Contacts render the same element with or without the
  panel, so a form keeps what was typed when the panel starts docking.
- Contacts on a phone: the panel button sits over an open contact too.
- The panel buttons of the reader and the views have one name, "Plugin
  panel", with aria-pressed, and name the plugin's panel shortcut in
  their title; the open panels are named landmarks.

The reader tests look the button up by its new name and pressed state
(viewer-toolbar-over-side-panel, reader-plugin-surfaces,
viewer-plugin-panel-width), since "Show/Hide plugin panel" is gone.
2026-11-02 23:54:53 +01:00

170 lines
6.1 KiB
TypeScript

// Plugin shortcut dispatcher.
//
// Each enabled plugin declares zero-or-more keyboard shortcuts via its
// `shortcuts` export. On init the host registers each binding here. A single
// window keydown listener matches keys against the active bindings and
// dispatches via `instance.invokeHook('shortcut:<id>', [])`.
//
// The listener ignores events when an editable element has focus, matching
// the convention in `use-keyboard-shortcuts.ts`.
import { isEditableEventTarget } from '@/lib/keyboard';
import type { SandboxInstance } from './host-bridge';
interface Binding {
pluginId: string;
shortcutId: string;
keys: string; // "Ctrl+Shift+L"
label: string;
category?: string;
invoke: () => Promise<void>;
}
interface NormalisedCombo {
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
key: string;
}
const bindings = new Map<string, Binding>(); // key: `${pluginId}:${shortcutId}`
let listenerInstalled = false;
/** The keys a plugin's shortcut is bound to ("Alt+A"), or null when it has none by that id. */
export function shortcutKeysFor(pluginId: string, shortcutId: string): string | null {
return bindings.get(`${pluginId}:${shortcutId}`)?.keys ?? null;
}
function normaliseCombo(combo: string): NormalisedCombo | null {
if (typeof combo !== 'string') return null;
const parts = combo.split('+').map(p => p.trim()).filter(Boolean);
if (parts.length === 0) return null;
let ctrl = false, shift = false, alt = false, meta = false;
let key = '';
for (const p of parts) {
const lower = p.toLowerCase();
if (lower === 'ctrl' || lower === 'control') ctrl = true;
else if (lower === 'shift') shift = true;
else if (lower === 'alt' || lower === 'option') alt = true;
else if (lower === 'meta' || lower === 'cmd' || lower === 'command') meta = true;
else key = lower;
}
if (!key) return null;
return { ctrl, shift, alt, meta, key };
}
// Keys a plugin may never bind: the app and the browser need them.
const RESERVED_KEYS = new Set(['escape', 'tab', 'enter', 'f1', 'f5', 'f11', 'f12', 'delete', 'backspace']);
// Ctrl/Cmd+Shift chords browsers use (reopen tab, new private window,
// devtools, …).
const BROWSER_SHIFT_CHORDS = new Set(['t', 'n', 'w', 'q', 'i', 'j', 'c', 'r', 'p', 'b', 'o', 'delete', 'tab', 'enter', 'k']);
/**
* Whether a plugin may bind this combo. The app uses bare keys (j, k, c, /,
* Shift+G…) and Escape, and browsers own plain Ctrl/Cmd chords, so a plugin
* needs Alt, Ctrl/Cmd+Shift or Ctrl/Cmd+Alt.
*/
export function isAllowedPluginCombo(keys: string): boolean {
const combo = normaliseCombo(keys);
if (!combo) return false;
if (RESERVED_KEYS.has(combo.key)) return false;
const primary = combo.ctrl || combo.meta;
if (combo.alt) return true;
if (primary && combo.shift) return !BROWSER_SHIFT_CHORDS.has(combo.key);
return false;
}
function comboId(combo: NormalisedCombo): string {
return `${combo.ctrl || combo.meta ? 'C' : ''}${combo.shift ? 'S' : ''}${combo.alt ? 'A' : ''}+${combo.key}`;
}
function eventMatches(ev: KeyboardEvent, combo: NormalisedCombo): boolean {
if (combo.ctrl !== (ev.ctrlKey || ev.metaKey ? ev.ctrlKey : false)) {
// Treat Ctrl and Cmd as equivalent: a binding declaring Ctrl matches a
// Cmd press on macOS.
if (combo.ctrl) {
if (!(ev.ctrlKey || ev.metaKey)) return false;
} else if (ev.ctrlKey) return false;
}
if (combo.shift !== ev.shiftKey) return false;
if (combo.alt !== ev.altKey) return false;
if (!combo.ctrl && combo.meta !== ev.metaKey) return false;
if (ev.key.toLowerCase() === combo.key) return true;
// With Alt held, macOS types another character (Alt+A is "å"): match the
// physical key for letters and digits.
if (/^[a-z]$/.test(combo.key)) return ev.code === `Key${combo.key.toUpperCase()}`;
if (/^[0-9]$/.test(combo.key)) return ev.code === `Digit${combo.key}`;
return false;
}
/** Whether a key event is the combo ("Ctrl+Shift+A"); Ctrl and Cmd count the same. */
export function matchesCombo(keys: string, ev: Pick<KeyboardEvent, 'key' | 'code' | 'ctrlKey' | 'metaKey' | 'shiftKey' | 'altKey'>): boolean {
const combo = normaliseCombo(keys);
return !!combo && eventMatches(ev as KeyboardEvent, combo);
}
function onKeyDown(ev: KeyboardEvent): void {
if (isEditableEventTarget(ev)) return;
if (bindings.size === 0) return;
for (const binding of bindings.values()) {
const combo = normaliseCombo(binding.keys);
if (!combo) continue;
if (eventMatches(ev, combo)) {
// Not stopPropagation: the app's own listeners still see the key.
ev.preventDefault();
void binding.invoke();
return;
}
}
}
function ensureListener(): void {
if (listenerInstalled || typeof window === 'undefined') return;
listenerInstalled = true;
window.addEventListener('keydown', onKeyDown, true);
}
export function registerShortcuts(
instance: SandboxInstance,
shortcuts: Array<{ id: string; keys: string; label: string; category?: string }>,
): () => void {
ensureListener();
const keys: string[] = [];
const taken = new Set([...bindings.values()].map((b) => {
const c = normaliseCombo(b.keys);
return c ? comboId(c) : '';
}));
for (const sc of shortcuts) {
const key = `${instance.pluginId}:${sc.id}`;
const combo = normaliseCombo(sc.keys);
if (!combo || !isAllowedPluginCombo(sc.keys)) {
console.warn(`[plugin-shortcuts] "${instance.pluginId}" may not bind ${sc.keys}; use Alt, Ctrl+Shift or Ctrl+Alt`);
continue;
}
if (taken.has(comboId(combo))) {
console.warn(`[plugin-shortcuts] ${sc.keys} is already used by another plugin; "${instance.pluginId}" does not get it`);
continue;
}
taken.add(comboId(combo));
bindings.set(key, {
pluginId: instance.pluginId,
shortcutId: sc.id,
keys: sc.keys,
label: sc.label,
category: sc.category,
invoke: async () => {
try {
await instance.invokeHook(`shortcut:${sc.id}`, []);
} catch {
/* hook tracker already logs */
}
},
});
keys.push(key);
}
return () => {
for (const k of keys) bindings.delete(k);
};
}