waybetter/crates/waybetter-host/tab-bridge.js
CRT 29782dcddd host: the tab bridge ships from waybetter-host; create-waybetter uses it
tab-bridge.js (moved from wayBetter for X) is served by bridge::SOURCE,
or by bridge::script(BridgeOptions) with shortcut and link-drag
forwarding switched off for a shell that does not handle them (a
forwarded shortcut is taken from the page). Tests: one options line,
only tab_report/tab_shortcut/tab_link_drag, every forwarded shortcut is
one tabs::valid_shortcut accepts. Generated apps now inject
bridge::script(BridgeOptions::REPORT_ONLY) instead of writing their own
duplicated src-tauri/runtime/tab-bridge.js.
2026-09-29 11:22:54 +07:00

135 lines
6.3 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';
}
if (key === 'f5') return 'reload';
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);
})();