Frame: AppMenu.tsx is a menu at the end of the titlebar cluster (before the caption buttons; macOS has none there). Its rows are `ShellSite.menu`: ordered groups of built-in rows by name and the app's own ({id, label, icon, shortcut, hint, when, checked, run | submenu}); `DEFAULT_MENU`, `menuWith` and `menuWithout` order, add and remove. A built-in row shows only when the app serves it (decks, `menu.languages`, themes, `menu.modes`, and `features`: zoom, store, workspaces, developer, updates), so no app gets a dead row. Language, Theme, Mode, Workspaces and Developer are submenus; Language and Theme apply live and keep the menu open, Mode asks the same keep-my-changes question as Settings (panel `menuModes()`). Keyboard: arrows, Home/End, type-ahead, the arrow along the reading direction opens a submenu, Esc closes one level; ARIA menu roles; submenus stay inside the window; one highlight for pointer and keys. It is an overlay like the other shell menus (the shell is raised, panes stay visible, nothing is dimmed).
Words: menuMessages.en.json (`wb.menu.*`), read through the framework i18n with that English as the fallback until the catalogs have them; words the panel already has are reused.
Host: `tab_zoom {id, factor}` (shell-only) sets a tab's page zoom with the engine's own zoom, clamped to 25%..500% and remembered per tab id in app data `zoom.json` (a number per tab id, nothing else; a closed tab's entry goes); without a factor it reads. A tab may forward four more shortcut names: `app-menu`, `zoom-in`, `zoom-out`, `zoom-reset`. The titlebar shows a zoom chip only while the pane in front is not at 100%.
create-waybetter: `tab_zoom` in the shell capability list; the template says which panel sections it lacks (store, updates) and how to add menu rows.
142 lines
6.9 KiB
JavaScript
142 lines
6.9 KiB
JavaScript
// wayBetter tab bridge (waybetter-host `bridge.rs` serves this file). Injected into every browser
|
|
// tab (top frame only). Tells the shell about SPA URL changes, the favicon and focus and, when the
|
|
// app enables them, forwards browser shortcuts pressed while the page has focus and link drags.
|
|
// It can only call `tab_report` / `tab_shortcut` / `tab_link_drag`, which affect this tab's own
|
|
// address bar, forward a shortcut, or tell the shell a link drag started/entered/ended here.
|
|
(() => {
|
|
if (window.top !== window || window.__wbTabBridge) return;
|
|
window.__wbTabBridge = true;
|
|
const options = { shortcuts: true, linkDrags: true }; // waybetter-host: bridge::script() options
|
|
const invoke = (cmd, args) => {
|
|
const ipc = window.__TAURI_INTERNALS__;
|
|
if (ipc && typeof ipc.invoke === 'function') ipc.invoke(cmd, args).catch(() => {});
|
|
};
|
|
|
|
let lastUrl = '';
|
|
let lastTitle = '';
|
|
let lastIcon = '';
|
|
const favicon = () => {
|
|
const link = document.querySelector('link[rel~="icon"][href], link[rel="shortcut icon"][href]');
|
|
try { return link ? new URL(link.getAttribute('href'), location.href).href : `${location.origin}/favicon.ico`; } catch { return ''; }
|
|
};
|
|
const report = (extra) => {
|
|
const url = location.href;
|
|
const title = document.title || '';
|
|
const icon = favicon();
|
|
if (!extra && url === lastUrl && title === lastTitle && icon === lastIcon) return;
|
|
lastUrl = url; lastTitle = title; lastIcon = icon;
|
|
invoke('tab_report', { report: { url, title, favicon: icon, ...extra } });
|
|
};
|
|
let queued = false;
|
|
const schedule = () => { if (!queued) { queued = true; setTimeout(() => { queued = false; report(); }, 50); } };
|
|
|
|
for (const method of ['pushState', 'replaceState']) {
|
|
const original = history[method];
|
|
history[method] = function (...args) {
|
|
const result = original.apply(this, args);
|
|
schedule();
|
|
return result;
|
|
};
|
|
}
|
|
addEventListener('popstate', schedule);
|
|
addEventListener('hashchange', schedule);
|
|
addEventListener('focus', () => report({ focused: true }));
|
|
addEventListener('pointerdown', () => report({ focused: true }), true);
|
|
const watchHead = () => {
|
|
if (!document.head) return;
|
|
new MutationObserver(schedule).observe(document.head, { childList: true, subtree: true, attributes: true, attributeFilter: ['href'] });
|
|
schedule();
|
|
};
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', watchHead, { once: true });
|
|
else watchHead();
|
|
|
|
// Power User Mode: a deck column (the X runtime marks <html data-wb-column>) forwards the deck
|
|
// keys; bare keys only while nothing editable has focus.
|
|
const typing = () => {
|
|
const el = document.activeElement;
|
|
return !!el && (el.isContentEditable || /^(input|textarea|select)$/i.test(el.tagName));
|
|
};
|
|
const deckKey = (event) => {
|
|
const mod = event.ctrlKey || event.metaKey;
|
|
if (mod && event.shiftKey && !event.altKey && event.code === 'KeyD') return 'deck-toggle';
|
|
if (!document.documentElement || !document.documentElement.hasAttribute('data-wb-column')) return null;
|
|
if (event.ctrlKey && event.altKey && !event.metaKey && !event.shiftKey && event.code === 'KeyN' && !typing()) return 'deck-compose';
|
|
if (event.altKey && !mod && !event.shiftKey) {
|
|
const digit = /^Digit([1-9])$/.exec(event.code);
|
|
if (digit) return `column-${digit[1]}`;
|
|
if (event.key === 'PageUp') return 'column-page-prev';
|
|
if (event.key === 'PageDown') return 'column-page-next';
|
|
}
|
|
if (!mod && !event.altKey && !typing()) {
|
|
if (event.key === '[') return 'column-prev';
|
|
if (event.key === ']') return 'column-next';
|
|
}
|
|
return null;
|
|
};
|
|
const shortcut = (event) => {
|
|
const deck = deckKey(event);
|
|
if (deck) return deck;
|
|
const mod = event.ctrlKey || event.metaKey;
|
|
const key = event.key.toLowerCase();
|
|
if (mod && !event.altKey) {
|
|
if (key === 't' && event.shiftKey) return 'reopen-tab';
|
|
if (key === 't') return 'new-tab';
|
|
if (key === 'w' && event.shiftKey) return 'open-panel';
|
|
if (key === 'w') return 'close-tab';
|
|
if (key === 'l') return 'focus-address';
|
|
if (key === 'k' && !event.shiftKey) return 'command-palette';
|
|
if (key === 'tab') return event.shiftKey ? 'prev-tab' : 'next-tab';
|
|
if (key === 'r') return event.shiftKey ? 'hard-reload' : 'reload';
|
|
// Physical key: layouts like Turkish-Q produce other characters on this key.
|
|
if (event.code === 'Backslash' || event.code === 'IntlBackslash') return event.shiftKey ? 'split-down' : 'split-right';
|
|
// Page zoom is the shell's (`tab_zoom`): this tab only says which key was pressed.
|
|
if (key === '=' || key === '+' || event.code === 'NumpadAdd') return 'zoom-in';
|
|
if (key === '-' || event.code === 'NumpadSubtract') return 'zoom-out';
|
|
if (key === '0' || event.code === 'Numpad0') return 'zoom-reset';
|
|
}
|
|
if (key === 'f5') return 'reload';
|
|
// The app menu, like a browser's: F10, and Alt+F where Alt is not a typing key (not on macOS).
|
|
if (key === 'f10' && !mod && !event.altKey && !event.shiftKey) return 'app-menu';
|
|
if (event.altKey && !mod && !event.shiftKey && event.code === 'KeyF' && !/Mac/i.test(navigator.platform)) return 'app-menu';
|
|
if (event.altKey && !mod && key === 'arrowleft') return 'back';
|
|
if (event.altKey && !mod && key === 'arrowright') return 'forward';
|
|
return null;
|
|
};
|
|
// Link drags: the shell hides the tab webviews and shows its split/tab drop zones, so a link
|
|
// dragged out of this page (or into it from another app) can be dropped as a new pane.
|
|
if (options.linkDrags) {
|
|
let localDrag = false;
|
|
let lastEnter = 0;
|
|
const linkTypes = (types) => {
|
|
const list = Array.from(types || []);
|
|
return !list.includes('Files') && (list.includes('text/uri-list') || list.includes('text/x-moz-url'));
|
|
};
|
|
addEventListener('dragstart', (event) => {
|
|
localDrag = true;
|
|
// Links, and images inside links (sites like X wrap photos in `/status/.../photo/N` anchors).
|
|
const target = event.target;
|
|
const anchor = target && target.closest ? target.closest('a[href]') : null;
|
|
const url = anchor ? String(anchor.href) : '';
|
|
if (/^https?:\/\//i.test(url) && url.length <= 4096) invoke('tab_link_drag', { phase: 'start', url });
|
|
}, true);
|
|
addEventListener('dragend', () => {
|
|
localDrag = false;
|
|
invoke('tab_link_drag', { phase: 'end' });
|
|
}, true);
|
|
addEventListener('dragenter', (event) => {
|
|
if (localDrag || !event.dataTransfer || !linkTypes(event.dataTransfer.types)) return;
|
|
const now = Date.now();
|
|
if (now - lastEnter < 500) return;
|
|
lastEnter = now;
|
|
invoke('tab_link_drag', { phase: 'enter' });
|
|
}, true);
|
|
}
|
|
|
|
if (options.shortcuts) addEventListener('keydown', (event) => {
|
|
const action = shortcut(event);
|
|
if (!action) return;
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
invoke('tab_shortcut', { shortcut: action });
|
|
}, true);
|
|
})();
|