feat: add advanced context actions and optional translation

This commit is contained in:
golem
2026-10-05 15:38:16 -06:00
parent 33c4dad00f
commit 5308932bf1
34 changed files with 1579 additions and 159 deletions
+42
View File
@@ -0,0 +1,42 @@
import { normalizeLanguage } from '../lib/settings.js';
export const MENU_SETTING_KEYS = ['contextMenuMode', 'showTranslate', 'translationMode', 'translationLanguage'];
export function initializeMenuSettings({ document, onChange }) {
const field = id => document.getElementById(id);
function update() {
const advanced = field('contextMenuMode').value === 'advanced';
const translation = advanced && field('showTranslate').checked;
const preferred = translation && field('translationMode').value === 'preferred';
field('advanced-settings').hidden = !advanced;
field('translation-settings').hidden = !translation;
field('preferred-language-field').hidden = !preferred;
const valid = Boolean(normalizeLanguage(field('translationLanguage').value));
field('language-validation').textContent = preferred && !valid
? 'Enter a language using up to 100 characters. Until then, Translate will ask each time.' : '';
field('translationLanguage').setAttribute('aria-invalid', String(preferred && !valid));
}
for (const id of MENU_SETTING_KEYS) {
const changed = () => { update(); onChange(); };
field(id).addEventListener('input', changed);
field(id).addEventListener('change', changed);
}
return {
render(settings) {
for (const id of MENU_SETTING_KEYS) {
const element = field(id);
if (element === document.activeElement) continue;
if (id === 'showTranslate') element.checked = settings[id];
else element.value = settings[id];
}
update();
},
collect() {
return {
contextMenuMode: field('contextMenuMode').value,
showTranslate: field('showTranslate').checked,
translationMode: field('translationMode').value,
translationLanguage: normalizeLanguage(field('translationLanguage').value),
};
},
};
}
+56 -3
View File
@@ -1,7 +1,7 @@
:root {
--fg: #15141a;
--fg-dim: #5b5b66;
--bg: transparent;
--fg-dim: #38383d;
--bg: #ffffff;
--border: #8f8f9d;
--field-bg: #ffffff;
--accent: #0060df;
@@ -10,7 +10,8 @@
@media (prefers-color-scheme: dark) {
:root {
--fg: #fbfbfe;
--fg-dim: #b1b1bd;
--fg-dim: #e0e0e6;
--bg: #23222b;
--border: #8f8f9d;
--field-bg: #2b2a33;
--accent: #0df;
@@ -28,6 +29,10 @@ body {
max-width: 42rem;
}
.intro {
margin: 0 0 1.5rem;
}
.field {
margin-bottom: 1.15rem;
}
@@ -118,6 +123,47 @@ kbd {
font-size: 0.9em;
}
.note h2 {
margin: 0 0 0.5rem;
color: var(--fg);
font-size: 1rem;
}
.note p {
margin: 0 0 0.75rem;
}
.note code {
overflow-wrap: anywhere;
}
.project-links {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.65rem;
margin-top: 1rem;
}
.project-link {
display: inline-flex;
align-items: baseline;
gap: 0.65rem;
}
.link-separator {
color: var(--fg-dim);
}
a {
color: var(--accent);
text-decoration: underline;
}
a:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* The inline about:addons surface can be very narrow. */
@media (max-width: 26rem) {
.actions {
@@ -125,3 +171,10 @@ kbd {
flex-direction: column;
}
}
[hidden] { display: none !important; }
.dependent-settings {
border-inline-start: 2px solid var(--border);
padding-inline-start: 1rem;
margin-bottom: 1.15rem;
}
+77 -16
View File
@@ -6,16 +6,44 @@
<title>Search with ChatGPT — Options</title>
<link rel="stylesheet" href="options.css" />
<p class="intro">
This extension adds <a href="https://chatgpt.com/" target="_blank" rel="noopener noreferrer">ChatGPT</a> as a Firefox search engine. Type
<code>gpt your question</code> in the address bar, select text on a webpage and
choose <strong>Ask ChatGPT</strong> from the right-click menu.<span id="shortcut-intro"></span> If the prompt isn’t sent automatically, press Enter in
ChatGPT.
</p>
<form id="settings" autocomplete="off">
<div class="field">
<label for="model">Model <span class="tag">experimental</span></label>
<!-- maxlength is set from MAX_MODEL_CHARS in options.js, so the input and
mergeSettings() can never disagree about the bound. -->
<input type="text" id="model" placeholder="auto" spellcheck="false" />
<p class="hint">
ChatGPT may ignore this and use your account default. Leave blank to
always use your account default.
</p>
<label for="contextMenuMode">Context menu</label>
<select id="contextMenuMode">
<option value="off">Off</option>
<option value="simple">Simple</option>
<option value="advanced">Advanced</option>
</select>
<p class="hint">Simple asks about selected text. Advanced adds Explain, Summarize, Debug code, and link/page actions.</p>
<p class="hint" id="shortcut-hint">The keyboard shortcut always runs your default action.</p>
</div>
<div id="advanced-settings" class="dependent-settings" hidden>
<div class="field checkbox">
<label><input type="checkbox" id="showTranslate"> Show Translate in advanced menu</label>
</div>
<div id="translation-settings" hidden>
<div class="field">
<label for="translationMode">Translate into</label>
<select id="translationMode">
<option value="ask">Ask each time</option>
<option value="preferred">Preferred language</option>
</select>
<p class="hint">Ask each time opens a language dialog. A preferred language opens ChatGPT directly.</p>
</div>
<div class="field" id="preferred-language-field" hidden>
<label for="translationLanguage">Preferred language</label>
<input type="text" id="translationLanguage" placeholder="For example, Spanish" aria-describedby="language-validation">
<p class="hint" id="language-validation" aria-live="polite"></p>
</div>
</div>
</div>
<div class="field checkbox">
@@ -43,7 +71,7 @@
<div class="field">
<label for="promptTemplate">Prompt template</label>
<input type="text" id="promptTemplate" spellcheck="false" />
<p class="hint"><code>{query}</code> is replaced with your selected text.</p>
<p class="hint"><code>{query}</code> is replaced with your selected text. Used by Ask normally, Simple mode, and the keyboard shortcut. Other advanced actions use their own prompts.</p>
</div>
<div class="actions">
@@ -52,12 +80,45 @@
</div>
</form>
<p class="note">
Selecting text and choosing <strong>Ask ChatGPT</strong> — or pressing
<kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd> — opens ChatGPT with your text
filled in. ChatGPT no longer submits prompts that arrive from a link, so press
Enter there to send. Change the shortcut under
<em>Manage Extension Shortcuts</em> in Add-ons.
</p>
<section class="note" aria-labelledby="search-settings-title">
<h2 id="search-settings-title">Search keyword and default engine</h2>
<p>
Open Firefox’s <strong>Settings → Search</strong>, or paste
<code>about:preferences#search</code> into the address bar.
Under <strong>Search Shortcuts</strong>, edit ChatGPT’s keyword to change
<code>gpt</code>. Under <strong>Default Search Engine</strong>, choose
ChatGPT to use it for address-bar searches without a prefix. Choose your
previous engine there to switch back.
<a href="https://support.mozilla.org/en-US/kb/change-your-default-search-settings-firefox" target="_blank" rel="noopener noreferrer">Firefox search settings guide</a>.
</p>
<p>
The settings above apply to the right-click menu and keyboard shortcut;
address-bar searches use Firefox’s search engine settings.
Restore defaults only resets extension settings, including the prompt
template; it does not change Firefox’s search engine or keyboard shortcut.
</p>
</section>
<section class="note" aria-labelledby="shortcut-title">
<h2 id="shortcut-title">Keyboard shortcut</h2>
<p id="shortcut-current" role="status" aria-live="polite">Checking keyboard shortcut…</p>
<button type="button" id="change-shortcut" hidden>Change keyboard shortcut</button>
<p class="hint">
You can also open Add-ons, then the gear menu →
<em>Manage Extension Shortcuts</em> to change or remove the shortcut.
</p>
</section>
<nav class="project-links" aria-label="Project links">
<a href="https://zavage-software.com/portfolio/search-with-ChatGPT" target="_blank" rel="noopener noreferrer">Project website</a>
<span class="project-link">
<span class="link-separator" aria-hidden="true">|</span>
<a href="https://git-repos.zavage.net/zavage-software/search-with-chatgpt-powered-by-openai-extension" target="_blank" rel="noopener noreferrer">Source code</a>
</span>
<span class="project-link">
<span class="link-separator" aria-hidden="true">|</span>
<a href="https://zavage-software.com/contact-us" target="_blank" rel="noopener noreferrer">Feedback or report a bug</a>
</span>
</nav>
<script type="module" src="options.js"></script>
+10 -7
View File
@@ -1,5 +1,7 @@
import { DEFAULT_SETTINGS, MAX_MODEL_CHARS } from "../lib/defaults.js";
import { DEFAULT_SETTINGS } from "../lib/defaults.js";
import { load, save } from "../lib/settings.js";
import { initializeMenuSettings, MENU_SETTING_KEYS } from "./menu-settings.js";
import { initializeShortcut } from "./shortcut.js";
const SAVE_DEBOUNCE_MS = 400;
const STATUS_CLEAR_MS = 1500;
@@ -9,8 +11,12 @@ const field = (id) => document.getElementById(id);
let saveTimer = null;
let statusTimer = null;
const menuSettings = initializeMenuSettings({ document, onChange: scheduleSave });
function render(settings) {
menuSettings.render(settings);
for (const [id, value] of Object.entries(settings)) {
if (MENU_SETTING_KEYS.includes(id)) continue;
const element = field(id);
// Never overwrite the control the user is actively editing — this page's
// own saves echo back through storage.onChanged (see below).
@@ -27,7 +33,7 @@ function render(settings) {
function collect() {
return {
model: field("model").value,
...menuSettings.collect(),
temporaryChat: field("temporaryChat").checked,
webSearch: field("webSearch").checked,
openIn: field("openIn").value,
@@ -59,15 +65,12 @@ function scheduleSave() {
saveTimer = setTimeout(() => persist(collect()), SAVE_DEBOUNCE_MS);
}
// Derived rather than hard-coded in the markup: if the two ever disagreed, the
// input would accept a model that mergeSettings() then silently discards,
// blanking the setting with no explanation.
field("model").maxLength = MAX_MODEL_CHARS;
// Module scripts are deferred, so the DOM is already parsed here.
void initializeShortcut({ commands: browser.commands, document, window });
render(await load());
for (const id of Object.keys(DEFAULT_SETTINGS)) {
if (MENU_SETTING_KEYS.includes(id)) continue;
field(id).addEventListener("input", scheduleSave);
field(id).addEventListener("change", scheduleSave);
}
+48
View File
@@ -0,0 +1,48 @@
// Firefox owns shortcut preferences; never copy them into extension storage.
export async function initializeShortcut({ commands, document, window }) {
const field = (id) => document.getElementById(id);
let refreshId = 0;
function render(shortcut) {
field("shortcut-intro").textContent = shortcut
? ` Or press ${shortcut} to use your selection. With nothing selected, the shortcut opens a blank chat.`
: "";
field("shortcut-hint").textContent = shortcut
? `${shortcut} runs your default action even when the context menu is off.`
: "The keyboard shortcut runs your default action independently of the context menu.";
field("shortcut-current").textContent = shortcut === null
? "Could not read the keyboard shortcut. Check Manage Extension Shortcuts in Add-ons."
: shortcut ? `Current shortcut: ${shortcut}` : "No keyboard shortcut assigned.";
}
async function refresh() {
const requestId = ++refreshId;
try {
const all = await commands.getAll();
const command = all.find(({ name }) => name === "ask-chatgpt");
if (requestId === refreshId) render(command?.shortcut ?? "");
} catch {
if (requestId === refreshId) render(null);
}
}
const button = field("change-shortcut");
button.hidden = typeof commands?.openShortcutSettings !== "function";
button.addEventListener("click", async () => {
try {
await commands.openShortcutSettings();
} catch {
field("shortcut-current").textContent =
"Open Add-ons, then the gear menu → Manage Extension Shortcuts to change your shortcut.";
}
});
commands?.onChanged?.addListener(({ name }) => {
if (name === "ask-chatgpt") void refresh();
});
window.addEventListener("focus", refresh);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") void refresh();
});
await refresh();
}