feat: add advanced context actions and optional translation
This commit is contained in:
+28
-56
@@ -1,7 +1,9 @@
|
||||
import { load } from "../lib/settings.js";
|
||||
import { buildChatGptUrl } from "../lib/build-url.js";
|
||||
import { prepareAction } from "../lib/prepare-action.js";
|
||||
import { openChat } from "./open-chat.js";
|
||||
import { showActionDialog } from "./dialogs.js";
|
||||
import { initializeMenus } from "./menus.js";
|
||||
|
||||
const MENU_ID = "ask-chatgpt-selection";
|
||||
const COMMAND_ID = "ask-chatgpt";
|
||||
|
||||
// Reads the user's selection for the keyboard-shortcut path. The context menu
|
||||
@@ -19,7 +21,7 @@ const COMMAND_ID = "ask-chatgpt";
|
||||
// therefore gated on document.hasFocus(), which is true for the focused
|
||||
// document and all of its ancestors: the top frame wins when the user
|
||||
// selected there, and an iframe's selection is only picked up when it is
|
||||
// itself the focused frame.
|
||||
// itself the focused frame. Exclude ancestors whose active element is a frame.
|
||||
//
|
||||
// Failure has three measured shapes, all handled here: a thrown
|
||||
// "Missing host permission for the tab", a thrown variant naming frames, and a
|
||||
@@ -43,7 +45,7 @@ async function readSelection() {
|
||||
let results;
|
||||
try {
|
||||
results = await browser.tabs.executeScript(tabId, {
|
||||
code: "document.hasFocus() ? window.getSelection().toString() : ''",
|
||||
code: "document.hasFocus() && !['IFRAME', 'FRAME'].includes(document.activeElement?.tagName) ? window.getSelection().toString() : ''",
|
||||
allFrames: true,
|
||||
matchAboutBlank: true,
|
||||
});
|
||||
@@ -62,59 +64,29 @@ async function readSelection() {
|
||||
return "";
|
||||
}
|
||||
|
||||
async function openChatGpt(query) {
|
||||
try {
|
||||
const settings = await load();
|
||||
const url = buildChatGptUrl(settings, query);
|
||||
|
||||
if (settings.openIn === "new-window") {
|
||||
try {
|
||||
await browser.windows.create({ url });
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to a tab rather than doing nothing.
|
||||
}
|
||||
}
|
||||
|
||||
await browser.tabs.create({ url, active: settings.openIn !== "background-tab" });
|
||||
} catch {
|
||||
// Nothing useful to say and nowhere useful to say it: the user expects a
|
||||
// tab or nothing at all. Both call sites invoke this without awaiting, so
|
||||
// without this catch a failure would log an unhandled rejection.
|
||||
}
|
||||
}
|
||||
|
||||
// removeAll() first, so a background event page waking up and re-running this
|
||||
// module cannot throw on a duplicate menu id.
|
||||
async function registerMenu() {
|
||||
await browser.menus.removeAll();
|
||||
browser.menus.create({
|
||||
id: MENU_ID,
|
||||
title: 'Ask ChatGPT about "%s"',
|
||||
contexts: ["selection"],
|
||||
});
|
||||
}
|
||||
|
||||
browser.menus.onClicked.addListener((info) => {
|
||||
if (info.menuItemId !== MENU_ID) {
|
||||
return;
|
||||
}
|
||||
openChatGpt(info.selectionText ?? "");
|
||||
});
|
||||
|
||||
// browser.commands does not exist on Firefox for Android. This extension is
|
||||
// desktop-only, but guard rather than throwing at startup.
|
||||
if (browser.commands) {
|
||||
browser.commands.onCommand.addListener(async (command) => {
|
||||
if (command !== COMMAND_ID) {
|
||||
async function runAction(request) {
|
||||
let preparation = prepareAction(request);
|
||||
if (preparation.kind === "ready") {
|
||||
try {
|
||||
await openChat(browser, preparation.url, request.settings.openIn);
|
||||
return;
|
||||
} catch {
|
||||
// Keep the ready payload so the dialog's Continue button retries it.
|
||||
}
|
||||
openChatGpt(await readSelection());
|
||||
});
|
||||
}
|
||||
try {
|
||||
await showActionDialog(browser, { request, preparation });
|
||||
} catch {
|
||||
console.error("Could not open the action dialog. Please try the action again.");
|
||||
}
|
||||
}
|
||||
|
||||
registerMenu().catch(() => {
|
||||
// menus.removeAll() is promise-based and can reject; a failed re-registration
|
||||
// cannot be retried usefully from here. (menus.create() is callback-based and
|
||||
// cannot reject, so it needs no guard.)
|
||||
});
|
||||
void initializeMenus({ browser, load, onAction: runAction }).catch(console.error);
|
||||
if (browser.commands) {
|
||||
browser.commands.onCommand.addListener(async command => {
|
||||
if (command !== COMMAND_ID) return;
|
||||
// Read the selection before another asynchronous API loses activeTab context.
|
||||
const source = await readSelection();
|
||||
await runAction({ actionId: "ask", source, settings: await load() });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Pending source content exists only during the initial handshake. The dialog
|
||||
// owns a structured clone after receipt, independent of the event page's life.
|
||||
export async function showActionDialog(browser, payload) {
|
||||
const id = crypto.randomUUID();
|
||||
const url = browser.runtime.getURL('dialog/action.html') + '?request=' + id;
|
||||
let tabId;
|
||||
let port;
|
||||
let finished = false;
|
||||
let resolveOpened;
|
||||
const opened = new Promise(resolve => { resolveOpened = resolve; });
|
||||
let timer;
|
||||
let resolveDone, rejectDone;
|
||||
const done = new Promise((resolve, reject) => { resolveDone = resolve; rejectDone = reject; });
|
||||
// Attach rejection handling immediately, including when window creation stalls.
|
||||
void done.catch(() => {});
|
||||
function finish(error) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
payload = null;
|
||||
browser.runtime.onConnect.removeListener(onConnect);
|
||||
browser.tabs.onRemoved.removeListener(onRemoved);
|
||||
if (port) {
|
||||
port.onMessage.removeListener(onMessage);
|
||||
port.onDisconnect.removeListener(onDisconnect);
|
||||
port.disconnect();
|
||||
}
|
||||
if (error) rejectDone(error); else resolveDone();
|
||||
}
|
||||
function onMessage(message) {
|
||||
if (message?.type === 'received') finish();
|
||||
}
|
||||
function onDisconnect() { finish(); }
|
||||
function onRemoved(removedId) { if (removedId === tabId) finish(); }
|
||||
async function onConnect(candidate) {
|
||||
if (candidate.name !== 'action-dialog:' + id) return;
|
||||
await opened;
|
||||
if (finished || port || candidate.sender?.id !== browser.runtime.id ||
|
||||
candidate.sender?.url !== url || candidate.sender?.tab?.id !== tabId) return;
|
||||
port = candidate;
|
||||
port.onMessage.addListener(onMessage);
|
||||
port.onDisconnect.addListener(onDisconnect);
|
||||
try { port.postMessage({ type: 'initialize', payload }); }
|
||||
catch { finish(new Error('Could not initialize action dialog')); }
|
||||
}
|
||||
browser.runtime.onConnect.addListener(onConnect);
|
||||
browser.tabs.onRemoved.addListener(onRemoved);
|
||||
timer = setTimeout(() => finish(new Error('Action dialog initialization timed out')), 15000);
|
||||
try {
|
||||
try {
|
||||
const win = await browser.windows.create({ url, type: 'popup', width: 480, height: 360 });
|
||||
tabId = win.tabs?.[0]?.id;
|
||||
if (typeof tabId !== 'number') throw new Error('Missing dialog tab');
|
||||
} catch {
|
||||
const tab = await browser.tabs.create({ url });
|
||||
tabId = tab.id;
|
||||
}
|
||||
} catch (error) { finish(error); }
|
||||
finally { resolveOpened(); }
|
||||
return done;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { getActions, getContext } from '../lib/actions.js';
|
||||
|
||||
const ROOT = 'ask-chatgpt';
|
||||
const SIMPLE = 'ask-chatgpt-selection';
|
||||
const PREFIX = 'ask-chatgpt:';
|
||||
const CONTEXTS = ['selection', 'link', 'page'];
|
||||
const MENU_KEYS = ['contextMenuMode', 'showContextMenu', 'showTranslate', 'translationMode', 'translationLanguage'];
|
||||
|
||||
export function initializeMenus({ browser, load, onAction }) {
|
||||
let queue = Promise.resolve();
|
||||
let mode = 'off';
|
||||
let children = [];
|
||||
let menuInstance = 0;
|
||||
const enqueue = task => {
|
||||
queue = queue.catch(() => {}).then(task);
|
||||
return queue;
|
||||
};
|
||||
const create = properties => new Promise((resolve, reject) => {
|
||||
browser.menus.create(properties, () => {
|
||||
const error = browser.runtime.lastError;
|
||||
if (error) reject(new Error(error.message)); else resolve();
|
||||
});
|
||||
});
|
||||
function rebuild() {
|
||||
return enqueue(async () => {
|
||||
const settings = await load();
|
||||
await browser.menus.removeAll();
|
||||
children = [];
|
||||
mode = settings.contextMenuMode;
|
||||
if (mode === 'off') return;
|
||||
if (mode === 'simple') {
|
||||
await create({ id: SIMPLE, title: 'Ask ChatGPT', contexts: ['selection'] });
|
||||
return;
|
||||
}
|
||||
await create({ id: ROOT, title: 'Ask ChatGPT', contexts: CONTEXTS });
|
||||
for (const kind of ['selection', 'link', 'page']) {
|
||||
for (const action of getActions({ kind }, settings)) {
|
||||
const id = PREFIX + action.id;
|
||||
await create({ id, parentId: ROOT, title: action.title, contexts: CONTEXTS, visible: false });
|
||||
children.push({ id, kind });
|
||||
if (action.id === 'ask') {
|
||||
const separator = PREFIX + 'separator';
|
||||
await create({ id: separator, parentId: ROOT, type: 'separator', contexts: CONTEXTS, visible: false });
|
||||
children.push({ id: separator, kind });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
browser.storage.onChanged.addListener((changes, area) => {
|
||||
if (area === 'sync' && MENU_KEYS.some(key => Object.hasOwn(changes, key))) return rebuild().catch(console.error);
|
||||
});
|
||||
browser.menus.onHidden.addListener(() => { menuInstance++; });
|
||||
browser.menus.onShown.addListener(info => {
|
||||
const instance = ++menuInstance;
|
||||
// Context labels are available without host access; selectionText/pageUrl
|
||||
// are deliberately NOT required until the user invokes an item.
|
||||
const contexts = info.contexts ?? [];
|
||||
const kind = contexts.includes('selection') ? 'selection'
|
||||
: contexts.includes('link') ? 'link'
|
||||
: contexts.includes('page') && !info.editable && !contexts.some(c => ['image', 'video', 'audio'].includes(c)) ? 'page' : null;
|
||||
return enqueue(async () => {
|
||||
if (instance !== menuInstance || mode !== 'advanced') return;
|
||||
await Promise.all([
|
||||
browser.menus.update(ROOT, { visible: kind !== null }),
|
||||
...children.map(child => browser.menus.update(child.id, { visible: child.kind === kind })),
|
||||
]);
|
||||
if (instance === menuInstance) await browser.menus.refresh();
|
||||
}).catch(console.error);
|
||||
});
|
||||
browser.menus.onClicked.addListener(async info => {
|
||||
try {
|
||||
const settings = await load();
|
||||
const context = getContext(info);
|
||||
if (!context || settings.contextMenuMode === 'off') return;
|
||||
let actionId;
|
||||
if (settings.contextMenuMode === 'simple') {
|
||||
if (info.menuItemId !== SIMPLE || context.kind !== 'selection') return;
|
||||
actionId = 'ask';
|
||||
} else {
|
||||
const allowed = getActions(context, settings);
|
||||
actionId = allowed.find(action => PREFIX + action.id === info.menuItemId)?.id;
|
||||
if (!actionId) return;
|
||||
}
|
||||
await onAction({ actionId, source: context.source, settings });
|
||||
} catch { console.error('Could not run context menu action.'); }
|
||||
});
|
||||
return rebuild();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export async function openChat(browser, url, openIn) {
|
||||
if (openIn === 'new-window') {
|
||||
try {
|
||||
await browser.windows.create({ url });
|
||||
return;
|
||||
} catch { /* Fall back to a tab. */ }
|
||||
}
|
||||
await browser.tabs.create({ url, active: openIn !== 'background-tab' });
|
||||
}
|
||||
Reference in New Issue
Block a user