Compare commits

..
13 changed files with 116 additions and 931 deletions
+4 -17
View File
@@ -35,17 +35,6 @@ Firefox Desktop is the only current implementation and release target. Do not ad
Use two-space indentation in JSON, YAML, and JavaScript configuration. Preserve logical key grouping in `manifest.json`. Use lowercase, hyphenated filenames and include dimensions in icon names. Keep changes focused and avoid adding runtime dependencies without a concrete need. Use two-space indentation in JSON, YAML, and JavaScript configuration. Preserve logical key grouping in `manifest.json`. Use lowercase, hyphenated filenames and include dimensions in icon names. Keep changes focused and avoid adding runtime dependencies without a concrete need.
## Options Page
- Keep every setting. Customization in a lightweight extension is what sets
this one apart, so simplify wording and layout, but never remove a setting
without the maintainer's agreement. Less-used settings can sit under
**More options**.
- Write hotkeys as `<Ctrl>+L`: named keys in angle brackets, single characters
plain. `options/shortcut.js` swaps in Mac key names.
- Say that ChatGPT opens with the text filled in, never that the text is sent,
searched, or asked. The options design spec explains why.
## Testing Guidelines ## Testing Guidelines
Unit tests cover the pure logic in `lib/` via `node:test`; automated checks Unit tests cover the pure logic in `lib/` via `node:test`; automated checks
@@ -57,12 +46,10 @@ After `npm test`, use `npm run dev` and confirm:
- `gpt <query>` opens ChatGPT, the provider stays non-default, and Firefox - `gpt <query>` opens ChatGPT, the provider stays non-default, and Firefox
search settings can configure it. search settings can configure it.
- The Options/Preferences tab renders the drawings, every setting (including - The Options/Preferences tab renders the settings plus Restore defaults,
those under More options), and Restore defaults,
in both light and dark themes and at a narrow window width, and values in both light and dark themes and at a narrow window width, and values
persist across a restart. Expanding and collapsing More options and the persist across a restart.
collapsed sections resizes the inline panel without clipping or scrolling. - **Context menu → Simple** shows the direct **Ask ChatGPT** item only for
- **Right-click menu → Simple** shows the direct **Ask ChatGPT** item only for
selected text. **Advanced** shows the contextual submenu; selection takes selected text. **Advanced** shows the contextual submenu; selection takes
precedence over links. **Off** hides the menu. Switch modes without restarting precedence over links. **Off** hides the menu. Switch modes without restarting
and confirm Alt+Shift+G still works in every mode. Restore defaults returns and confirm Alt+Shift+G still works in every mode. Restore defaults returns
@@ -87,7 +74,7 @@ After `npm test`, use `npm run dev` and confirm:
stale cross-origin frame silently seeding the prompt was the worse failure. stale cross-origin frame silently seeding the prompt was the worse failure.
- Click the **Ask ChatGPT** toolbar button with no selection, a top-frame - Click the **Ask ChatGPT** toolbar button with no selection, a top-frame
selection, and an iframe selection after a stale top-frame selection. selection, and an iframe selection after a stale top-frame selection.
Check restricted-page fallback, right-click menu Off, saved prompt and opening Check restricted-page fallback, context menu Off, saved prompt and opening
preferences, and consent before shortening long selections. Test both the preferences, and consent before shortening long selections. Test both the
pinned toolbar button and its entry in Firefox’s extensions menu. pinned toolbar button and its entry in Firefox’s extensions menu.
-11
View File
@@ -2,17 +2,6 @@
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version). All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version).
## [1.3.0] (2026-10-18)
- Use the extension's bundled icon for the ChatGPT search engine.
- Rewrite the options page for first-time users: usage instructions with a
small drawing for each way to use the extension, a preview of the Advanced
right-click menu, plainer wording, and the prompt template, temporary chat,
and web search settings under More options. Hotkeys are written as
`<Ctrl>+L`, with Mac key names on a Mac.
- Correct the keyword instructions: a custom keyword works alongside `gpt`
rather than replacing it.
## [1.2.0] (2026-10-18) ## [1.2.0] (2026-10-18)
The first update on addons.mozilla.org since 1.0. It also delivers everything The first update on addons.mozilla.org since 1.0. It also delivers everything
+11 -16
View File
@@ -31,24 +31,23 @@ Click the extension’s **Ask ChatGPT** toolbar button to use selected text or,
with nothing selected, open a blank chat. On pages Firefox does not allow with nothing selected, open a blank chat. On pages Firefox does not allow
extensions to read, the button opens a blank chat. You can pin the button to extensions to read, the button opens a blank chat. You can pin the button to
the toolbar from Firefox’s extensions menu. It uses your saved prompt and the toolbar from Firefox’s extensions menu. It uses your saved prompt and
tab/window preferences, and works even when the right-click menu is Off. tab/window preferences, and works even when the context menu is Off.
The options panel's collapsed **Change the keyboard shortcut** section shows The options panel shows your currently configured shortcut and refreshes when
your currently configured shortcut and refreshes when it changes or you return it changes or you return to the panel. If you remove it, the panel shows
to the panel. If you remove it, the panel shows
“No keyboard shortcut assigned”. Its **Change keyboard shortcut** button opens “No keyboard shortcut assigned”. Its **Change keyboard shortcut** button opens
Firefox’s shortcut editor when supported; otherwise follow the menu path above. Firefox’s shortcut editor when supported; otherwise follow the menu path above.
**Restore defaults** does not change your keyboard shortcut. **Restore defaults** does not change your keyboard shortcut.
To customize the prompt, open **More options** and set **Prompt template** to something like To customize the prompt, set **Prompt template** to something like
`Explain simply: {query}` or `Translate into Spanish: {query}`. The Simple menu action, Ask normally, toolbar button, and shortcut use your template. `Explain simply: {query}` or `Translate into Spanish: {query}`. The Simple menu action, Ask normally, toolbar button, and shortcut use your template.
Other advanced actions use their own dedicated prompts. Other advanced actions use their own dedicated prompts.
## Search keyword and default engine ## Search keyword and default engine
Open Firefox’s **Settings → Search**, or paste `about:preferences#search` into Open Firefox’s **Settings → Search**, or paste `about:preferences#search` into
the address bar. Under **Search Shortcuts**, edit ChatGPT’s keyword to add the address bar. Under **Search Shortcuts**, edit ChatGPT’s keyword to replace
your own prefix; `gpt` keeps working alongside it. Under **Default Search Engine**, choose `gpt` with your preferred prefix. Under **Default Search Engine**, choose
ChatGPT to search without a prefix, or choose your previous engine to switch ChatGPT to search without a prefix, or choose your previous engine to switch
back. See [Firefox’s search settings guide](https://support.mozilla.org/en-US/kb/change-your-default-search-settings-firefox) back. See [Firefox’s search settings guide](https://support.mozilla.org/en-US/kb/change-your-default-search-settings-firefox)
for illustrated instructions. for illustrated instructions.
@@ -61,25 +60,21 @@ not change them.
Open `about:addons`, select this extension, and choose the **Options** tab Open `about:addons`, select this extension, and choose the **Options** tab
(**Preferences** on macOS and Linux). Available settings: (**Preferences** on macOS and Linux). Available settings:
- **Right-click menu** — **Simple** (default) keeps the direct selection action; - **Context menu** — **Simple** (default) keeps the direct selection action;
**Advanced** offers contextual presets; **Off** hides the menu. The toolbar **Advanced** offers contextual presets; **Off** hides the menu. The toolbar
button and keyboard shortcut work in every mode. Existing enabled/disabled preferences migrate button and keyboard shortcut work in every mode. Existing enabled/disabled preferences migrate
to Simple/Off. Changes apply without restarting Firefox. to Simple/Off. Changes apply without restarting Firefox.
- **Show Translate in the Advanced menu** — off by default. When enabled, choose - **Show Translate in advanced menu** — off by default. When enabled, choose
**Ask each time** for a language dialog, or enter a **Preferred language** **Ask each time** for a language dialog, or enter a **Preferred language**
to translate directly. An empty or invalid preferred language falls back to to translate directly. An empty or invalid preferred language falls back to
asking. Enter continues; Escape or Cancel opens nothing. asking. Enter continues; Escape or Cancel opens nothing.
- **Open ChatGPT in** — a new tab, a new background tab, or a new window.
Under **More options**, which opens by itself once you have changed one of
them:
- **Prompt template** — wraps your text, for example `Explain simply: {query}`.
Up to 1,000 characters.
- **Request a temporary chat** — asks ChatGPT not to save the conversation. - **Request a temporary chat** — asks ChatGPT not to save the conversation.
ChatGPT may not honour this; check its own indicator before relying on it. ChatGPT may not honour this; check its own indicator before relying on it.
- **Ask ChatGPT to use web search** — a hint to ChatGPT's interface, not a - **Ask ChatGPT to use web search** — a hint to ChatGPT's interface, not a
guarantee. guarantee.
- **Open in** — a new tab, a new background tab, or a new window.
- **Prompt template** — wraps your text, for example `Explain simply: {query}`.
Up to 1,000 characters.
Restore defaults resets extension preferences to Simple mode with translation Restore defaults resets extension preferences to Simple mode with translation
hidden; it does not change Firefox's search engine or shortcut settings. hidden; it does not change Firefox's search engine or shortcut settings.
@@ -1,125 +0,0 @@
# Options page for first-time users
Status: implemented on `feat/options-page-cleanup` (PR #23).
## Goal
Make the options page usable by people who are not comfortable with computers,
without losing any setting. Customization in a lightweight extension is what
sets this one apart, so the page simplifies wording and layout, never features.
## Layout
1. **Description**, no heading: "This extension adds ChatGPT as a Firefox
search engine."
2. **Usage instructions**: one labelled line per way to open ChatGPT, each with
one drawing:
- Address bar
- Right-click
- Keyboard (hidden when no shortcut is assigned)
- Toolbar
A small tip follows: ChatGPT opens with the text filled in, press
`<Enter>` if it isn't sent, and sign-in may be needed.
3. **Settings**:
- Visible: **Right-click menu** (renamed from "Context menu"), its
Translate sub-settings, and **Open ChatGPT in**.
- Choosing **Advanced** reveals a drawing of the Ask ChatGPT submenu. It
sits in the block that only shows for Advanced, so it previews the one
setting newcomers find hardest to picture, at no cost to anyone else.
- Under **More options**: prompt template, temporary chat, and web search.
The summary names all three. The section opens on load, or when a sync
changes one of them, if any differs from its default. That way a
customised setting never looks lost.
- **Restore defaults** sits on the right in red, because it discards every
customised setting at once.
4. Two collapsed sections:
- **Use your own keyword or make ChatGPT your default search engine**, as
numbered steps.
- **Change the keyboard shortcut**.
5. Project links, centred, stacked on narrow panels.
## Drawings, not screenshots
Each drawing is HTML, CSS, and a little inline SVG. The only image is the
bundled extension icon. The drawings are `aria-hidden`; the text next to them
carries the instructions.
Real screenshots were considered and rejected for the page:
- they go stale when Firefox redesigns its address bar or menus;
- the right-click menu looks different on Windows, macOS, and Linux;
- they need separate light and dark versions;
- they must be bundled, since loading them remotely would repeat the problem
fixed in #20.
Size was not the issue. Screenshots would have added roughly 50–150 KB. The
whole rewrite, drawings included, added under 4 KB; the package went from
52.8 KB to 56.5 KB.
Real screenshots belong on the AMO listing instead, which has none.
The address-bar drawing shows a ChatGPT label after `gpt` and a space. The
basis is the code shipped in Firefox 157.0.1, read from its `omni.ja`; it has
not yet been checked on screen:
- `UrlbarProviderAliasEngines` gives keyword results a `keyword` payload;
- `UrlbarInputBase.onFirstResult` enters search mode for such a result.
If Firefox shows no label, the drawing should show plain text instead.
The keyboard drawing draws key caps for the user's actual shortcut, which
`options/shortcut.js` builds from `commands.getAll()`.
## Hotkeys
Hotkeys are written as `<Ctrl>+L`: named keys in angle brackets, single
characters plain. This is the maintainer's own convention from the AMO
listing. It applies to the static text and to the live shortcut.
On macOS the page uses Mac key names, detected with
`runtime.getPlatformInfo()`, which needs no permission:
- `Ctrl` and `Command` become `Cmd`; Firefox reads a manifest's `Ctrl` as
Command there (`ShortcutUtils.chromeModifierKeyMap`).
- `MacCtrl` becomes `Ctrl`.
- `Alt` becomes `Option`.
- `Enter` becomes `Return`.
Shortcuts set in Firefox's shortcut editor on macOS are stored with
`MacCtrl`, `Alt`, `Command`, and `Shift` (`addon-shortcuts.mjs`,
`getShortcutForEvent`).
## Copy rules kept
These come from the
[options design](2026-07-29-extension-options-design.md):
- Copy says ChatGPT opens with the text filled in, never that it is sent,
searched, or asked.
- The temporary-chat label and warning are unchanged, with no "private",
"incognito", or "secure" near them.
- Web search stays labelled as a hint.
## Testing
`npm test` covers:
- the key formatter, including the macOS key names;
- the static hotkeys in `options.html` matching the formatter;
- the key caps drawn for the live shortcut.
Manual checks are listed in `AGENTS.md`.
To preview the page outside the extension:
1. Copy `options/`, `lib/`, and `icons/` to a scratch folder.
2. Add a script before `options.js` that defines a stand-in `browser` with
`runtime.getPlatformInfo`, `commands`, and `storage`.
3. Serve the folder on localhost.
4. Run `firefox --headless -no-remote -profile <tmp> --screenshot out.png
--window-size=680,1600 <url>`.
For dark mode, give the throwaway profile
`layout.css.prefers-color-scheme.content-override` = 0 and
`ui.systemUsesDarkTheme` = 1. This renders with Firefox's own engine, but not
inside `about:addons`.
## Not in scope
- Screenshots and an updated description for the AMO listing.
- Pinning the toolbar button by default (`browser_action.default_area`).
- Rewriting the README's hotkeys in the `<Alt>+<Shift>+G` style.
@@ -1,108 +0,0 @@
# Add a privacy policy
Status: design approved on 2026-10-21. The policy isn't written yet, and it
will be published after the release that fixes #16. Researched, but not legal
advice.
## Problem
- The AMO listing has no privacy policy. On 2026-10-21 the API reported
`has_privacy_policy: false`.
- The README has a short "Privacy Policy" section. The website's page for the
extension says only "Extension collects zero user information."
- The owner wants a policy that says the extension collects nothing and that
data will never be sold: "basically the most privacy respecting as possible".
## Goals
1. Say in plain English what happens to data, with every claim checkable
against the code.
2. Make clear that Zavage Software receives nothing and will never sell data.
3. Make clear that ChatGPT is a separate service under OpenAI's own privacy
policy.
## Decisions
- **Plain English, organised by where data goes.** A one-line "we collect
nothing" would hide the hand-off to ChatGPT. A formal GDPR-style template is
the legalese Mozilla advises against (Extension Workshop, "Create an appealing
listing").
- **The extension only.** One line says the policy doesn't cover the website or
email, although the options page links to the website and its contact form.
The policy ends with mat@zavage.net for questions. The owner chose this over
also covering messages or the whole website.
- **The current version only.** The policy describes the current version and
says so.
- **#16 is fixed first.** Up to 1.3, a chat started in a private window can open
in a normal window. The owner chose to fix that before publishing rather than
disclose it, so the policy doesn't mention it.
- **Promises.** Nothing is ever sold. Any future collection would be opt-in, and
the policy is updated before any version that changes how data is handled.
- **No "can't".** Nothing in the code sends data to Zavage Software, but that is
a fact about the current code. The policy says so and points to the public
source instead of claiming the extension is unable to send data.
- **No claim that Mozilla agreed.** #17 asks whether the manifest's "no data
collection" declaration covers the hand-off to ChatGPT, and it is still open.
The policy describes what happens and doesn't say Mozilla confirmed anything.
- **`PRIVACY.md` is the only copy.** It is pasted into AMO unchanged. The README
section shrinks to a summary and a link. AGENTS.md names `PRIVACY.md` as the
file every data-handling change must update.
- **Not packaged.** `web-ext-config.mjs` ignores `PRIVACY.md`, so AMO and the
repository hold it.
- **Its own pull request,** separate from the license change in #33.
- **Review.** Three readers: one checks each claim against the code, one checks
Mozilla's policies and privacy law, and one reads it cold for plain English.
## What each claim rests on
| Claim | Evidence |
| --- | --- |
| Zavage Software receives nothing | The code has no `fetch`, `XMLHttpRequest`, `sendBeacon`, `WebSocket` or `EventSource`, and the manifest has no `favicon_url`. |
| Settings stay in Firefox and sync only through Firefox Sync | `lib/settings.js:37-60` uses `storage.sync`, the only storage API in the code. |
| Three permissions, and no host access | `manifest.json:25`: `storage`, `menus` and `activeTab`. |
| The manifest declares no data collection | `manifest.json:18` |
| The selection is read only when the toolbar button or shortcut is used | `background/background.js:41-50`: `activeTab` and `tabs.executeScript`. |
| The right-click menu gets the selection, link or page address from Firefox at the click | `lib/actions.js:16-21` |
| ChatGPT receives the text in the address of the page the extension opens | `lib/build-url.js:11` and `:45-47`; `background/open-chat.js:1-9` |
| Temporary chat and web search are only hints in that address | `lib/build-url.js:32-37` |
| Dialogs hold pending text in memory only | `background/dialogs.js:1` and `:20` |
| The `gpt` search sends nothing until Enter | `manifest.json:58-61` has no `suggest_url`. |
| No remote code | Manifest V2's default content security policy runs only the extension's own scripts. |
| Links to other sites open only when clicked | `options/options.html` |
## AMO's privacy policy field
- Unlike the license field, it renders a restricted Markdown. The field is
`privacy_policy = PurifiedMarkdownField(...)` in addons-server's
`addons/models.py`, as Mozilla's blog post of 3 March 2025 describes.
- The allowed tags are `a`, `abbr`, `acronym`, `b`, `blockquote`, `code`, `em`,
`i`, `li`, `ol`, `strong` and `ul`. Headings are dropped, so section labels
are bold lines.
- Tags are stripped before the Markdown is rendered, so anything in angle
brackets disappears.
- Bare URLs become links through Mozilla's redirector. The email address may
link to AMO's home page, as it does in the license.
## Publishing on AMO (untested, 2026-10-21)
Publish after the release that fixes #16.
1. In the Developer Hub, open the add-on's **Manage Authors & License** page,
where the license is set too.
2. Tick "This add-on has a Privacy Policy" and paste `PRIVACY.md` unchanged.
3. Save, then check:
```sh
curl -s https://addons.mozilla.org/api/v5/addons/addon/search-with-chatgpt/ | jq .has_privacy_policy
curl -s https://addons.mozilla.org/api/v5/addons/addon/search-with-chatgpt/eula_policy/ | jq .privacy_policy
```
The policy then appears at
https://addons.mozilla.org/firefox/addon/search-with-chatgpt/privacy/.
## Outside the policy
- Point the website's "Extension collects zero user information" at the new
policy.
- #17 is still open.
- The Chrome repository has no privacy policy.
+2 -1
View File
@@ -4,7 +4,7 @@
"manifest_version": 2, "manifest_version": 2,
"author": "Mathew Guest <mat@zavage.net>", "author": "Mathew Guest <mat@zavage.net>",
"homepage_url": "https://zavage-software.com/portfolio/search-with-ChatGPT", "homepage_url": "https://zavage-software.com/portfolio/search-with-ChatGPT",
"version": "1.3", "version": "1.2",
"icons": { "icons": {
"16": "icons/powered-by-openai-icon-16x16.png", "16": "icons/powered-by-openai-icon-16x16.png",
"32": "icons/powered-by-openai-icon-32x32.png", "32": "icons/powered-by-openai-icon-32x32.png",
@@ -59,6 +59,7 @@
"name": "ChatGPT", "name": "ChatGPT",
"search_url": "https://chatgpt.com?q={searchTerms}", "search_url": "https://chatgpt.com?q={searchTerms}",
"keyword": "gpt", "keyword": "gpt",
"favicon_url": "https://zavage-software.com/static/ChatGPT-favicon.png",
"is_default": false, "is_default": false,
"encoding": "UTF-8" "encoding": "UTF-8"
} }
+15 -349
View File
@@ -5,11 +5,6 @@
--border: #8f8f9d; --border: #8f8f9d;
--field-bg: #ffffff; --field-bg: #ffffff;
--accent: #0060df; --accent: #0060df;
/* Backdrop for the drawings and inline code. */
--surface: #f0f0f4;
--tooltip-bg: #15141a;
--tooltip-fg: #fbfbfe;
--danger: #d70022;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -20,10 +15,6 @@
--border: #8f8f9d; --border: #8f8f9d;
--field-bg: #2b2a33; --field-bg: #2b2a33;
--accent: #0df; --accent: #0df;
--surface: #1c1b22;
--tooltip-bg: #52525e;
--tooltip-fg: #fbfbfe;
--danger: #ff848b;
} }
} }
@@ -39,270 +30,7 @@ body {
} }
.intro { .intro {
margin: 0 0 0.75rem; margin: 0 0 1.5rem;
}
.ways {
margin: 0 0 0.75rem;
padding: 0;
list-style: none;
}
.ways > li {
margin-bottom: 0.9rem;
}
.inline-icon {
width: 16px;
height: 16px;
vertical-align: -3px;
}
/* Simplified drawings of Firefox's address bar and right-click menu. They are
not screenshots, so they follow the theme and never go out of date. */
.drawing {
max-width: 24rem;
margin: 0.45rem auto 0;
padding: 0.75rem;
border-radius: 8px;
background: var(--surface);
font-size: 0.85rem;
user-select: none;
}
.drawing img {
width: 16px;
height: 16px;
}
.drawing-urlbar {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.5rem;
border-radius: 6px;
background: var(--field-bg);
outline: 2px solid var(--accent);
}
.drawing-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.1rem 0.45rem;
border-radius: 4px;
background: color-mix(in srgb, var(--fg) 10%, var(--field-bg));
font-weight: 600;
white-space: nowrap;
}
.drawing-typed {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* The text cursor. */
.drawing-typed::after {
content: "";
display: inline-block;
width: 1px;
height: 1.1em;
margin-inline-start: 1px;
background: currentColor;
vertical-align: text-bottom;
}
.drawing-page {
margin: 0 0 0.3rem;
}
.drawing mark {
color: inherit;
background: color-mix(in srgb, var(--accent) 30%, transparent);
}
.drawing-toolbar {
position: relative;
display: flex;
align-items: center;
gap: 0.35rem;
}
.drawing-address {
flex: 1;
min-width: 0;
padding: 0.3rem 0.6rem;
border-radius: 6px;
background: var(--field-bg);
color: var(--fg-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.drawing-button {
position: relative;
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 4px;
}
.drawing-button svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.3;
stroke-linecap: round;
stroke-linejoin: round;
}
.drawing-button-hover {
background: color-mix(in srgb, var(--fg) 14%, transparent);
}
/* The mouse pointer resting on the hovered button. */
.drawing-button svg.drawing-pointer {
position: absolute;
top: 16px;
left: 17px;
width: 12px;
height: 18px;
fill: #fff;
stroke: #000;
stroke-width: 1;
}
.drawing-tooltip {
position: absolute;
top: calc(100% + 0.65rem);
right: 0;
padding: 0.2rem 0.5rem;
border-radius: 4px;
background: var(--tooltip-bg);
color: var(--tooltip-fg);
white-space: nowrap;
box-shadow: 0 1px 4px rgb(0 0 0 / 0.3);
}
/* Top padding keeps the heading clear of the tooltip at narrow widths. */
.drawing-content {
margin: 0.6rem 0 0;
padding: 1.9rem 0.75rem 1rem;
border-radius: 6px;
background: var(--field-bg);
font-size: 1.05rem;
font-weight: 700;
}
.drawing-menu {
width: 11rem;
/* Opens at the pointer, just under the selection. */
margin-inline-start: 3.5rem;
padding: 0.25rem 0;
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
border-radius: 6px;
background: var(--field-bg);
box-shadow: 0 2px 8px rgb(0 0 0 / 0.2);
}
.drawing-menu > div {
display: flex;
align-items: center;
gap: 0.45rem;
/* Line the plain items up with the text of the item that has an icon. */
padding: 0.2rem 0.75rem 0.2rem calc(0.75rem + 16px + 0.45rem);
}
.drawing-menu > .drawing-menu-active {
padding-inline-start: 0.75rem;
background: color-mix(in srgb, var(--accent) 20%, var(--field-bg));
}
.drawing-menu hr {
margin: 0.25rem 0;
border: 0;
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
}
/* The Advanced submenu, open beside the highlighted Ask ChatGPT item. */
.drawing-advanced {
margin-bottom: 1.15rem;
}
.drawing-menus {
display: flex;
align-items: flex-start;
justify-content: center;
}
.drawing-menus > .drawing-menu {
flex: none;
width: 10rem;
margin-inline-start: 0;
}
.drawing-menus > .drawing-submenu {
width: 8.5rem;
/* Lines its first item up with Ask ChatGPT, overlapping the menu edge. */
margin-top: 3.75rem;
margin-inline-start: -0.3rem;
}
/* Submenu items have no icon, so the highlighted one keeps the icon gap. */
.drawing-submenu > .drawing-menu-active {
padding-inline-start: calc(0.75rem + 16px + 0.45rem);
}
.drawing-submenu-arrow {
margin-inline-start: auto;
font-size: 1.2em;
line-height: 1;
}
/* The shortcut's keys, held down. shortcut.js fills these in. */
.drawing-keycaps {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.3rem 0;
color: var(--fg-dim);
font-size: 1rem;
}
.drawing-key {
min-width: 1.4rem;
padding: 0.35rem 0.65rem;
border: 1px solid color-mix(in srgb, var(--accent) 55%, var(--border));
border-bottom-width: 3px;
border-radius: 6px;
background: color-mix(in srgb, var(--accent) 12%, var(--field-bg));
color: var(--fg);
text-align: center;
transform: translateY(1px);
}
.hint.intro-hint {
margin: 0;
}
.usage,
.settings {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.hint.settings-scope {
margin: 0 0 1rem;
} }
.field { .field {
@@ -337,8 +65,7 @@ select {
input[type="text"]:focus-visible, input[type="text"]:focus-visible,
select:focus-visible, select:focus-visible,
button:focus-visible, button:focus-visible {
summary:focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 1px; outline-offset: 1px;
} }
@@ -349,30 +76,18 @@ summary:focus-visible {
font-size: 0.85rem; font-size: 0.85rem;
} }
summary { .tag {
cursor: pointer;
font-weight: 600;
color: var(--fg);
}
details[open] > summary {
margin-bottom: 0.75rem;
}
.more-options {
margin-bottom: 1.15rem;
}
.summary-detail {
font-weight: 400; font-weight: 400;
font-size: 0.75rem;
color: var(--fg-dim); color: var(--fg-dim);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0 0.3rem;
vertical-align: 1px;
} }
/* Reversed so the button stays at the right edge and "Saved" appears beside
it without moving it. */
.actions { .actions {
display: flex; display: flex;
flex-direction: row-reverse;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
flex-wrap: wrap; flex-wrap: wrap;
@@ -389,69 +104,35 @@ button {
cursor: pointer; cursor: pointer;
} }
/* Restore defaults discards every customised setting at once. */
button.danger {
color: var(--danger);
border-color: var(--danger);
}
button.danger:hover {
color: var(--bg);
background: var(--danger);
}
#status { #status {
color: var(--fg-dim); color: var(--fg-dim);
font-size: 0.85rem; font-size: 0.85rem;
} }
.note { .note {
margin-top: 1.25rem; margin-top: 1.75rem;
padding-top: 1rem; padding-top: 1rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
color: var(--fg-dim); color: var(--fg-dim);
font-size: 0.85rem; font-size: 0.85rem;
} }
.note summary { code,
font-size: 1rem; kbd {
}
code {
padding: 0 0.25em;
border-radius: 3px;
background: var(--surface);
font-family: monospace; font-family: monospace;
font-size: 0.9em; font-size: 0.9em;
} }
/* Written as <Ctrl>+L, so the keys read as words rather than code. */ .note h2 {
kbd {
font: inherit;
font-weight: 600;
white-space: nowrap;
}
.usage h2,
.settings h2 {
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
color: var(--fg); color: var(--fg);
font-size: 1rem; font-size: 1rem;
} }
.note p, .note p {
.note ol {
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
} }
.note ol {
padding-inline-start: 1.25rem;
}
.note li {
margin-bottom: 0.3rem;
}
.note code { .note code {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -459,9 +140,8 @@ kbd {
.project-links { .project-links {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center;
gap: 0.5rem 0.65rem; gap: 0.5rem 0.65rem;
margin-top: 1.5rem; margin-top: 1rem;
} }
.project-link { .project-link {
@@ -487,23 +167,9 @@ a:focus-visible {
/* The inline about:addons surface can be very narrow. */ /* The inline about:addons surface can be very narrow. */
@media (max-width: 26rem) { @media (max-width: 26rem) {
.actions { .actions {
align-items: flex-end; align-items: flex-start;
flex-direction: column; flex-direction: column;
} }
.drawing-menu {
margin-inline-start: 1rem;
}
/* Stack the links rather than wrap one onto a line that starts with "|". */
.project-links {
flex-direction: column;
align-items: center;
}
.link-separator {
display: none;
}
} }
[hidden] { display: none !important; } [hidden] { display: none !important; }
+57 -158
View File
@@ -7,120 +7,37 @@
<link rel="stylesheet" href="options.css" /> <link rel="stylesheet" href="options.css" />
<p class="intro"> <p class="intro">
This extension adds <a href="https://chatgpt.com/" target="_blank" rel="noopener noreferrer">ChatGPT</a> as a Firefox search engine. 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>
</p> </p>
<section class="usage" aria-labelledby="usage-title"> <p class="intro">
<h2 id="usage-title">Usage instructions</h2> Click the <strong>Ask ChatGPT</strong> toolbar button to ask about selected
text, or open a blank chat when nothing is selected. The button works in
<!-- Hotkeys are written <Ctrl>+L: named keys in angle brackets, letters plain. every context-menu mode and follows your settings below.
shortcut.js rewrites each [data-shortcut] for the user's platform. The
drawings are decorative; the text beside them carries the instructions. -->
<ul class="ways">
<li>
<strong>Address bar:</strong> press <kbd data-shortcut="Ctrl+L">&lt;Ctrl&gt;+L</kbd>,
type <code>gpt</code> and a space, then your question, and press
<kbd data-shortcut="Enter">&lt;Enter&gt;</kbd>.
<div class="drawing" aria-hidden="true">
<div class="drawing-urlbar">
<span class="drawing-chip"><img src="../icons/powered-by-openai-icon-16x16.png" srcset="../icons/powered-by-openai-icon-32x32.png 2x" alt="" />ChatGPT</span>
<span class="drawing-typed">how do magnets work?</span>
</div>
</div>
</li>
<li>
<strong>Right-click:</strong> select text on a webpage, right-click it, and
choose <strong>Ask ChatGPT</strong>.
<div class="drawing" aria-hidden="true">
<p class="drawing-page">Fridge <mark>magnets stick</mark> to steel because…</p>
<div class="drawing-menu">
<div>Copy</div>
<div>Select All</div>
<hr />
<div class="drawing-menu-active"><img src="../icons/powered-by-openai-icon-16x16.png" srcset="../icons/powered-by-openai-icon-32x32.png 2x" alt="" />Ask ChatGPT</div>
</div>
</div>
</li>
<li id="shortcut-intro" hidden>
<strong>Keyboard:</strong> select text and press <kbd id="shortcut-intro-key"></kbd>.
With nothing selected, it opens a blank chat.
<div class="drawing" aria-hidden="true">
<div class="drawing-keycaps" id="shortcut-keycaps"></div>
</div>
</li>
<li>
<strong>Toolbar:</strong> select text and click the
<img class="inline-icon" src="../icons/powered-by-openai-icon-16x16.png" srcset="../icons/powered-by-openai-icon-32x32.png 2x" alt="" />
<strong>Ask ChatGPT</strong> button. With nothing selected, it opens a
blank chat. Can’t see the button? Look in the puzzle-piece Extensions menu.
<div class="drawing" aria-hidden="true">
<div class="drawing-toolbar">
<span class="drawing-address">example.com</span>
<span class="drawing-button">
<svg viewBox="0 0 16 16"><path d="M2 5.5h3a2 2 0 1 1 3.2 0h3.3v2.8a2 2 0 1 1 0 3.2v3H2z" /></svg>
</span>
<span class="drawing-button drawing-button-hover">
<img src="../icons/powered-by-openai-icon-16x16.png" srcset="../icons/powered-by-openai-icon-32x32.png 2x" alt="" />
<svg class="drawing-pointer" viewBox="0 0 12 18"><path d="M1 1v14.5l3.6-3.4 2.6 5.6 2.2-1-2.6-5.5H11z" /></svg>
</span>
<span class="drawing-button">
<svg viewBox="0 0 16 16"><path d="M3 4.5h10M3 8h10M3 11.5h10" /></svg>
</span>
<span class="drawing-tooltip">Ask ChatGPT</span>
</div>
<p class="drawing-content"><mark>Why fridge magnets stick</mark></p>
</div>
</li>
</ul>
<p class="hint intro-hint">
ChatGPT opens with your text filled in. If it isn’t sent automatically, press
<kbd data-shortcut="Enter">&lt;Enter&gt;</kbd> or click Send. ChatGPT may ask you to sign in first.
</p> </p>
</section>
<section class="settings" aria-labelledby="settings-title"> <p class="intro">
<h2 id="settings-title">Settings</h2> ChatGPT may require you to sign in. If your prompt is not sent automatically,
<p class="hint settings-scope"> press <strong>Enter</strong> or click <strong>Send</strong> in ChatGPT.
These apply to the right-click menu, toolbar button, and keyboard shortcut.
Typing <code>gpt</code> in the address bar isn’t affected.
</p> </p>
<form id="settings" autocomplete="off"> <form id="settings" autocomplete="off">
<div class="field"> <div class="field">
<label for="contextMenuMode">Right-click menu</label> <label for="contextMenuMode">Context menu</label>
<select id="contextMenuMode"> <select id="contextMenuMode">
<option value="off">Off</option> <option value="off">Off</option>
<option value="simple">Simple</option> <option value="simple">Simple</option>
<option value="advanced">Advanced</option> <option value="advanced">Advanced</option>
</select> </select>
<p class="hint"> <p class="hint">Simple asks about selected text. Advanced adds Explain, Summarize, Debug code, and link/page actions.</p>
Simple shows Ask ChatGPT. Advanced adds Explain, Summarize, Debug code, <p class="hint" id="shortcut-hint">The keyboard shortcut always runs your default action.</p>
and more. With Off, the toolbar button and keyboard shortcut still work.
</p>
</div> </div>
<div id="advanced-settings" class="dependent-settings" hidden> <div id="advanced-settings" class="dependent-settings" hidden>
<!-- Shown only with Advanced, so the drawing previews what it adds. -->
<div class="drawing drawing-advanced" aria-hidden="true">
<div class="drawing-menus">
<div class="drawing-menu">
<div>Copy</div>
<div>Select All</div>
<hr />
<div class="drawing-menu-active"><img src="../icons/powered-by-openai-icon-16x16.png" srcset="../icons/powered-by-openai-icon-32x32.png 2x" alt="" />Ask ChatGPT<span class="drawing-submenu-arrow">›</span></div>
</div>
<div class="drawing-menu drawing-submenu">
<div>Ask normally</div>
<hr />
<div class="drawing-menu-active">Explain</div>
<div>Summarize</div>
<div>Debug code</div>
</div>
</div>
</div>
<div class="field checkbox"> <div class="field checkbox">
<label><input type="checkbox" id="showTranslate"> Show Translate in the Advanced menu</label> <label><input type="checkbox" id="showTranslate"> Show Translate in advanced menu</label>
</div> </div>
<div id="translation-settings" hidden> <div id="translation-settings" hidden>
<div class="field"> <div class="field">
@@ -139,8 +56,21 @@
</div> </div>
</div> </div>
<div class="field checkbox">
<label><input type="checkbox" id="temporaryChat" /> Request a temporary chat</label>
<p class="hint">
ChatGPT may not honour this. Check ChatGPT's own indicator before sending
anything sensitive.
</p>
</div>
<div class="field checkbox">
<label><input type="checkbox" id="webSearch" /> Ask ChatGPT to use web search (hint only)</label>
<p class="hint">A hint to ChatGPT's interface, not a guarantee.</p>
</div>
<div class="field"> <div class="field">
<label for="openIn">Open ChatGPT in</label> <label for="openIn">Open in</label>
<select id="openIn"> <select id="openIn">
<option value="new-tab">A new tab</option> <option value="new-tab">A new tab</option>
<option value="background-tab">A new background tab</option> <option value="background-tab">A new background tab</option>
@@ -148,77 +78,46 @@
</select> </select>
</div> </div>
<!-- Opens on load when one of these differs from its default (options.js), <div class="field">
so a customised setting never looks lost. --> <label for="promptTemplate">Prompt template</label>
<details id="more-options" class="more-options"> <input type="text" id="promptTemplate" spellcheck="false" maxlength="1000" />
<summary>More options: <span class="summary-detail">prompt template, temporary chat, web search</span></summary> <p class="hint"><code>{query}</code> is replaced with your selected text. Used by Ask normally, Simple mode, the toolbar button, and the keyboard shortcut. Other advanced actions use their own prompts. Up to 1,000 characters.</p>
</div>
<div class="field">
<label for="promptTemplate">Prompt template</label>
<input type="text" id="promptTemplate" spellcheck="false" maxlength="1000" />
<p class="hint">
<code>{query}</code> is replaced with your selected text, as in
<code>Explain simply: {query}</code>. Used by Ask ChatGPT, Ask normally,
the toolbar button, and the keyboard shortcut; other Advanced actions
such as Summarize use their own prompts. Up to 1,000 characters.
</p>
</div>
<div class="field checkbox">
<label><input type="checkbox" id="temporaryChat" /> Request a temporary chat</label>
<p class="hint">
ChatGPT may not honour this. Check ChatGPT's own indicator before sending
anything sensitive.
</p>
</div>
<div class="field checkbox">
<label><input type="checkbox" id="webSearch" /> Ask ChatGPT to use web search (hint only)</label>
<p class="hint">A hint to ChatGPT's interface, not a guarantee.</p>
</div>
</details>
<div class="actions"> <div class="actions">
<button type="button" id="restore" class="danger">Restore defaults</button> <button type="button" id="restore">Restore defaults</button>
<span id="status" role="status" aria-live="polite"></span> <span id="status" role="status" aria-live="polite"></span>
</div> </div>
</form> </form>
<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, toolbar button, 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>
<details class="note"> <section class="note" aria-labelledby="shortcut-title">
<summary>Use your own keyword or make ChatGPT your default search engine</summary> <h2 id="shortcut-title">Keyboard shortcut</h2>
<ol>
<li>
Open Firefox’s <strong>Settings → Search</strong>, or paste
<code>about:preferences#search</code> into the address bar.
</li>
<li>
Under <strong>Search Shortcuts</strong>, edit ChatGPT’s keyword to add
your own. <code>gpt</code> keeps working too.
</li>
<li>
Under <strong>Default Search Engine</strong>, choose ChatGPT to use it
without typing <code>gpt</code>. Choose your previous engine there to
switch back.
</li>
</ol>
<p>
These are Firefox settings, so <strong>Restore defaults</strong> doesn’t
change them.
<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>
</details>
<details class="note">
<summary>Change the keyboard shortcut</summary>
<p id="shortcut-current" role="status" aria-live="polite">Checking keyboard shortcut…</p> <p id="shortcut-current" role="status" aria-live="polite">Checking keyboard shortcut…</p>
<button type="button" id="change-shortcut" hidden>Change keyboard shortcut</button> <button type="button" id="change-shortcut" hidden>Change keyboard shortcut</button>
<p class="hint"> <p class="hint">
Or open Add-ons, click the gear menu, and choose You can also open Add-ons, then the gear menu →
<em>Manage Extension Shortcuts</em>. <strong>Restore defaults</strong> <em>Manage Extension Shortcuts</em> to change or remove the shortcut.
doesn’t change your shortcut.
</p> </p>
</details> </section>
<nav class="project-links" aria-label="Project links"> <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> <a href="https://zavage-software.com/portfolio/search-with-ChatGPT" target="_blank" rel="noopener noreferrer">Project website</a>
+4 -31
View File
@@ -1,27 +1,13 @@
import { DEFAULT_SETTINGS } from "../lib/defaults.js"; import { DEFAULT_SETTINGS } from "../lib/defaults.js";
import { load, save } from "../lib/settings.js"; import { load, save } from "../lib/settings.js";
import { initializeMenuSettings, MENU_SETTING_KEYS } from "./menu-settings.js"; import { initializeMenuSettings, MENU_SETTING_KEYS } from "./menu-settings.js";
import { initializeShortcut, renderStaticShortcuts } from "./shortcut.js"; import { initializeShortcut } from "./shortcut.js";
const SAVE_DEBOUNCE_MS = 400; const SAVE_DEBOUNCE_MS = 400;
const STATUS_CLEAR_MS = 1500; const STATUS_CLEAR_MS = 1500;
const field = (id) => document.getElementById(id); const field = (id) => document.getElementById(id);
// The settings inside the collapsed "More options" section, read from the page
// so moving a setting there needs no change here.
const MORE_OPTION_KEYS = [...field("more-options").querySelectorAll("[id]")]
.map(({ id }) => id)
.filter((id) => Object.hasOwn(DEFAULT_SETTINGS, id));
// A customised setting should never look lost inside a closed section. This
// only ever opens it, so it never undoes the user collapsing it.
function revealCustomised(settings, keys = MORE_OPTION_KEYS) {
if (keys.some((key) => settings[key] !== DEFAULT_SETTINGS[key])) {
field("more-options").open = true;
}
}
let saveTimer = null; let saveTimer = null;
let statusTimer = null; let statusTimer = null;
@@ -80,14 +66,8 @@ function scheduleSave() {
} }
// Module scripts are deferred, so the DOM is already parsed here. // Module scripts are deferred, so the DOM is already parsed here.
const [{ os }, initial] = await Promise.all([ void initializeShortcut({ commands: browser.commands, document, window });
browser.runtime.getPlatformInfo().catch(() => ({})), render(await load());
load(),
]);
renderStaticShortcuts(document, os);
void initializeShortcut({ commands: browser.commands, document, window, os });
render(initial);
revealCustomised(initial);
for (const id of Object.keys(DEFAULT_SETTINGS)) { for (const id of Object.keys(DEFAULT_SETTINGS)) {
if (MENU_SETTING_KEYS.includes(id)) continue; if (MENU_SETTING_KEYS.includes(id)) continue;
@@ -113,12 +93,5 @@ browser.storage.onChanged.addListener(async (changes, area) => {
if (area !== "sync") { if (area !== "sync") {
return; return;
} }
const settings = await load(); render(await load());
render(settings);
// Only keys whose value really changed, such as a sync from another device:
// this page saves every key at once, so checking them all would re-open the
// section whenever any other setting is edited.
revealCustomised(settings, MORE_OPTION_KEYS.filter(
(key) => key in changes && changes[key].newValue !== changes[key].oldValue,
));
}); });
+8 -42
View File
@@ -1,52 +1,18 @@
// Firefox stores Mac shortcuts with Command, MacCtrl, and Alt, and reads a
// manifest's Ctrl as Command there (ShortcutUtils' chromeModifierKeyMap).
const MAC_KEY_NAMES = { Ctrl: "Cmd", Command: "Cmd", MacCtrl: "Ctrl", Alt: "Option", Enter: "Return" };
const OTHER_KEY_NAMES = { Command: "Ctrl", MacCtrl: "Ctrl" };
// The keys of a Firefox shortcut such as "Alt+Shift+G", named for the platform.
export function shortcutKeys(shortcut, os) {
const names = os === "mac" ? MAC_KEY_NAMES : OTHER_KEY_NAMES;
return shortcut.split("+").map((key) => names[key] ?? key);
}
// Writes a shortcut the way the page does: named keys in angle brackets and
// single characters plain, "<Alt>+<Shift>+G".
export function formatShortcut(shortcut, os) {
return shortcutKeys(shortcut, os)
.map((name) => (name.length > 1 ? `<${name}>` : name))
.join("+");
}
// The page's static hotkeys are written for Windows and Linux.
export function renderStaticShortcuts(document, os) {
for (const element of document.querySelectorAll("[data-shortcut]")) {
element.textContent = formatShortcut(element.dataset.shortcut, os);
}
}
// Firefox owns shortcut preferences; never copy them into extension storage. // Firefox owns shortcut preferences; never copy them into extension storage.
export async function initializeShortcut({ commands, document, window, os }) { export async function initializeShortcut({ commands, document, window }) {
const field = (id) => document.getElementById(id); const field = (id) => document.getElementById(id);
let refreshId = 0; let refreshId = 0;
// One key cap per key for the drawing, joined by plain "+" text.
function keycaps(shortcut) {
return shortcutKeys(shortcut, os).flatMap((name, index) => {
const key = document.createElement("kbd");
key.className = "drawing-key";
key.textContent = name;
return index ? ["+", key] : [key];
});
}
function render(shortcut) { function render(shortcut) {
const keys = shortcut ? formatShortcut(shortcut, os) : ""; field("shortcut-intro").textContent = shortcut
field("shortcut-intro").hidden = !keys; ? ` Or press ${shortcut} to use your selection. With nothing selected, the shortcut opens a blank chat.`
field("shortcut-intro-key").textContent = keys; : "";
field("shortcut-keycaps").replaceChildren(...(keys ? keycaps(shortcut) : [])); 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 field("shortcut-current").textContent = shortcut === null
? "Could not read the keyboard shortcut. Check Manage Extension Shortcuts in Add-ons." ? "Could not read the keyboard shortcut. Check Manage Extension Shortcuts in Add-ons."
: keys ? `Current shortcut: ${keys}` : "No keyboard shortcut assigned."; : shortcut ? `Current shortcut: ${shortcut}` : "No keyboard shortcut assigned.";
} }
async function refresh() { async function refresh() {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "search-with-chatgpt-powered-by-openai-extension", "name": "search-with-chatgpt-powered-by-openai-extension",
"version": "1.3.0", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "search-with-chatgpt-powered-by-openai-extension", "name": "search-with-chatgpt-powered-by-openai-extension",
"version": "1.3.0", "version": "1.2.0",
"devDependencies": { "devDependencies": {
"web-ext": "10.5.0" "web-ext": "10.5.0"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "search-with-chatgpt-powered-by-openai-extension", "name": "search-with-chatgpt-powered-by-openai-extension",
"version": "1.3.0", "version": "1.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Development tooling for the Search with ChatGPT Firefox extension.", "description": "Development tooling for the Search with ChatGPT Firefox extension.",
+12 -70
View File
@@ -1,87 +1,32 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import { initializeShortcut } from "../options/shortcut.js";
import { formatShortcut, initializeShortcut, renderStaticShortcuts } from "../options/shortcut.js";
function setup(commands, os) { function setup(commands) {
const elements = Object.fromEntries([ const elements = Object.fromEntries([
"shortcut-intro", "shortcut-intro-key", "shortcut-keycaps", "shortcut-current", "change-shortcut", "shortcut-intro", "shortcut-hint", "shortcut-current", "change-shortcut",
].map((id) => [id, Object.assign(new EventTarget(), { ].map((id) => [id, Object.assign(new EventTarget(), { textContent: "", hidden: true })]));
textContent: "",
hidden: true,
children: [],
replaceChildren(...children) { this.children = children; },
})]));
const document = Object.assign(new EventTarget(), { const document = Object.assign(new EventTarget(), {
visibilityState: "visible", visibilityState: "visible",
getElementById: (id) => elements[id], getElementById: (id) => elements[id],
createElement: (tagName) => ({ tagName, className: "", textContent: "" }),
}); });
const window = new EventTarget(); const window = new EventTarget();
const ready = initializeShortcut({ commands, document, window, os }); const ready = initializeShortcut({ commands, document, window });
return { elements, document, window, ready }; return { elements, document, window, ready };
} }
const flush = () => new Promise((resolve) => setImmediate(resolve)); const flush = () => new Promise((resolve) => setImmediate(resolve));
const command = (shortcut) => ({ name: "ask-chatgpt", description: "Ask ChatGPT", shortcut }); const command = (shortcut) => ({ name: "ask-chatgpt", description: "Ask ChatGPT", shortcut });
// The drawing's key caps and the "+" text between them, in order.
const drawn = (element) => element.children.map((child) => child.textContent ?? child);
test("shows the configured command rather than another command or the default", async () => { test("shows the configured command rather than another command or the default", async () => {
const { elements, ready } = setup({ getAll: async () => [ const { elements, ready } = setup({ getAll: async () => [
{ name: "other", description: "Other", shortcut: "Alt+O" }, command("Ctrl+Shift+Y"), { name: "other", description: "Other", shortcut: "Alt+O" }, command("Ctrl+Shift+Y"),
] }); ] });
await ready; await ready;
assert.equal(elements["shortcut-intro"].hidden, false); for (const id of ["shortcut-intro", "shortcut-hint", "shortcut-current"]) {
assert.equal(elements["shortcut-intro-key"].textContent, "<Ctrl>+<Shift>+Y"); assert.match(elements[id].textContent, /Ctrl\+Shift\+Y/);
assert.equal(elements["shortcut-current"].textContent, "Current shortcut: <Ctrl>+<Shift>+Y"); assert.doesNotMatch(elements[id].textContent, /Alt\+Shift\+G|Alt\+O/);
assert.deepEqual(drawn(elements["shortcut-keycaps"]), ["Ctrl", "+", "Shift", "+", "Y"]);
assert.ok(elements["shortcut-keycaps"].children
.filter((child) => typeof child !== "string")
.every(({ tagName, className }) => tagName === "kbd" && className === "drawing-key"));
});
test("writes keys the way the page does, with Mac names on a Mac", () => {
assert.equal(formatShortcut("Alt+Shift+G", "linux"), "<Alt>+<Shift>+G");
assert.equal(formatShortcut("Ctrl+L", "win"), "<Ctrl>+L");
assert.equal(formatShortcut("Enter", undefined), "<Enter>");
assert.equal(formatShortcut("Ctrl+Shift+F5", "win"), "<Ctrl>+<Shift>+<F5>");
// Firefox names punctuation and media keys rather than using the character.
assert.equal(formatShortcut("Ctrl+Shift+Comma", "win"), "<Ctrl>+<Shift>+<Comma>");
assert.equal(formatShortcut("MediaPlayPause", "linux"), "<MediaPlayPause>");
assert.equal(formatShortcut("Alt+Shift+G", "mac"), "<Option>+<Shift>+G");
// Firefox reads a manifest's Ctrl as Command on a Mac, and stores shortcuts
// the user sets there with Command and MacCtrl.
assert.equal(formatShortcut("Ctrl+L", "mac"), "<Cmd>+L");
assert.equal(formatShortcut("Command+Shift+Y", "mac"), "<Cmd>+<Shift>+Y");
assert.equal(formatShortcut("MacCtrl+Alt+Y", "mac"), "<Ctrl>+<Option>+Y");
assert.equal(formatShortcut("Enter", "mac"), "<Return>");
});
test("a Mac shows its own key names for the configured shortcut", async () => {
const { elements, ready } = setup({ getAll: async () => [command("Alt+Shift+G")] }, "mac");
await ready;
assert.equal(elements["shortcut-intro-key"].textContent, "<Option>+<Shift>+G");
assert.deepEqual(drawn(elements["shortcut-keycaps"]), ["Option", "+", "Shift", "+", "G"]);
});
test("the page's written hotkeys match the formatter and are rewritten per platform", () => {
const html = readFileSync(new URL("../options/options.html", import.meta.url), "utf8");
const written = [...html.matchAll(/data-shortcut="([^"]+)">([^<]*)</g)];
assert.ok(written.length >= 2);
// Every hotkey must match the pattern above, or this test would skip it.
assert.equal(written.length, html.split("data-shortcut=").length - 1);
for (const [, shortcut, text] of written) {
assert.equal(text.replaceAll("&lt;", "<").replaceAll("&gt;", ">"), formatShortcut(shortcut, "linux"));
} }
const elements = written.map(([, shortcut]) => ({ dataset: { shortcut }, textContent: "" }));
renderStaticShortcuts({ querySelectorAll: () => elements }, "mac");
assert.deepEqual(
elements.map(({ textContent }) => textContent),
written.map(([, shortcut]) => formatShortcut(shortcut, "mac")),
);
assert.ok(elements.some(({ textContent }) => textContent === "<Cmd>+L"));
}); });
test("updates an open page when the shortcut is removed", async () => { test("updates an open page when the shortcut is removed", async () => {
@@ -96,9 +41,7 @@ test("updates an open page when the shortcut is removed", async () => {
changed({ name: "ask-chatgpt", newShortcut: "", oldShortcut: "Alt+Shift+G" }); changed({ name: "ask-chatgpt", newShortcut: "", oldShortcut: "Alt+Shift+G" });
await flush(); await flush();
assert.match(elements["shortcut-current"].textContent, /No keyboard shortcut assigned/); assert.match(elements["shortcut-current"].textContent, /No keyboard shortcut assigned/);
assert.equal(elements["shortcut-intro"].hidden, true); assert.doesNotMatch(elements["shortcut-intro"].textContent, /press|Alt\+Shift\+G/i);
assert.equal(elements["shortcut-intro-key"].textContent, "");
assert.deepEqual(elements["shortcut-keycaps"].children, []);
}); });
test("refreshes on focus and visibility without the change event API", async () => { test("refreshes on focus and visibility without the change event API", async () => {
@@ -108,11 +51,11 @@ test("refreshes on focus and visibility without the change event API", async ()
shortcut = "Ctrl+Shift+Y"; shortcut = "Ctrl+Shift+Y";
window.dispatchEvent(new Event("focus")); window.dispatchEvent(new Event("focus"));
await flush(); await flush();
assert.match(elements["shortcut-current"].textContent, /<Ctrl>\+<Shift>\+Y/); assert.match(elements["shortcut-current"].textContent, /Ctrl\+Shift\+Y/);
shortcut = "Alt+J"; shortcut = "Alt+J";
document.dispatchEvent(new Event("visibilitychange")); document.dispatchEvent(new Event("visibilitychange"));
await flush(); await flush();
assert.match(elements["shortcut-current"].textContent, /<Alt>\+J/); assert.match(elements["shortcut-current"].textContent, /Alt\+J/);
}); });
test("missing commands and failed reads never claim the default is assigned", async () => { test("missing commands and failed reads never claim the default is assigned", async () => {
@@ -121,8 +64,7 @@ test("missing commands and failed reads never claim the default is assigned", as
}]) { }]) {
const { elements, ready } = setup(commands); const { elements, ready } = setup(commands);
await ready; await ready;
assert.doesNotMatch(elements["shortcut-current"].textContent, /<Alt>\+<Shift>\+G|Alt\+Shift\+G/); assert.doesNotMatch(elements["shortcut-current"].textContent, /Alt\+Shift\+G/);
assert.equal(elements["shortcut-intro"].hidden, true);
assert.equal(elements["change-shortcut"].hidden, true); assert.equal(elements["change-shortcut"].hidden, true);
} }
}); });