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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
6.3 KiB
JavaScript
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);
|
|
})();
|