Compare commits

..

62 commits

Author SHA1 Message Date
MengXi
bbacbf52a7
fix(options): preserve focused provider options drafts (#1419) 2026-04-28 22:08:00 -07:00
MengXi
281908a487
fix(page-translation): rewalk revealed accordion content (#1410) 2026-04-27 22:11:13 -07:00
github-actions[bot]
053aa9090d
chore(release): version packages (#1395)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-26 20:42:27 -07:00
MengXi
0bd869fd93
fix(extension): guard popup account avatar session (#1402) 2026-04-26 20:39:09 -07:00
byte92
6dfeb40b67
feat: add DeepSeek v4 models (#1396)
Co-authored-by: MengXi <z1219202167@outlook.com>
2026-04-26 20:35:38 -07:00
MengXi
c3debfbc0c
style(options): widen sync conflict dialog (#1400) 2026-04-26 20:19:39 -07:00
MengXi
466c1cefdb
feat(extension): open native side panel from floating button (#1397) 2026-04-26 20:18:01 -07:00
taiiiyang
619c83defd
feat(subtitles): inject translate button and settings panel for YouTube embed (#1394) 2026-04-26 22:21:01 +08:00
github-actions[bot]
37c0ced786
chore(release): version packages (#1386)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-25 23:14:53 -07:00
Yiou Li
afa7dee1b0
feat: add floating button controls (#1391)
Co-authored-by: Yiou Li <yli448@students.cs.ubc.ca>
Co-authored-by: MengXi <z1219202167@outlook.com>
2026-04-25 22:15:16 -07:00
taiiiyang
596bcf7248
feat(extension): support YouTube embed subtitles on third-party sites (#1392) 2026-04-26 11:46:02 +08:00
taiiiyang
69221554ff
feat(subtitles): add subtitle style settings panel (#1388)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 19:21:25 +08:00
MengXi
c25b299ca4
style(extension): align theme brand tokens (#1389) 2026-04-24 23:37:14 -07:00
MengXi
f05a0bda21
chore(codex): add worktree environment setup (#1387) 2026-04-24 22:28:48 -07:00
MengXi
18967cbab8
Merge pull request #1385 from mengxi-ream/codex/fix-defuddle-markdown 2026-04-24 22:12:17 -07:00
MengXi
746a3c5c3b fix(extension): ensure webpage context returns markdown 2026-04-24 22:01:17 -07:00
github-actions[bot]
b282ef87ed
chore(release): version packages (#1376) 2026-04-24 20:08:04 -07:00
MengXi
068bdecc8a
perf(extension): avoid heavy page detection on startup (#1382) 2026-04-24 19:59:35 -07:00
ananaBMaster
810623ba02
feat(popup): search languages in popup selectors (#1381) 2026-04-24 13:34:37 -07:00
MengXi
396dd0d36b
fix(auth): include credentials for API auth client (#1379) 2026-04-23 23:59:54 -07:00
MengXi
adfc89add6
fix(selection-toolbar): stabilize custom action provider switching (#1378) 2026-04-23 23:48:37 -07:00
MengXi
5b56df819a
perf(options): persist slider settings after drag commit (#1377) 2026-04-23 22:52:44 -07:00
GuaGua
4667e3eb40
fix(selection-toolbar): avoid opacity stacking context under modals (#1356)
Co-authored-by: Frog <frog@Frogs-MacBook-Pro.local>
Co-authored-by: MengXi <z1219202167@outlook.com>
2026-04-23 22:42:40 -07:00
MengXi
455584ff00
ci: fix stale on-hold label exemption (#1374) 2026-04-23 21:51:48 -07:00
github-actions[bot]
998f288cad
chore(release): version packages (#1322) 2026-04-23 20:08:09 -07:00
dependabot[bot]
14d4f2ed70
chore: bump the production-dependencies group across 1 directory with 5 updates (#1371)
Bumps the production-dependencies group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@orpc/client](https://github.com/middleapi/orpc/tree/HEAD/packages/client) | `1.13.14` | `1.14.0` |
| [@orpc/tanstack-query](https://github.com/middleapi/orpc/tree/HEAD/packages/tanstack-query) | `1.13.14` | `1.14.0` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.99.2` | `5.100.1` |
| [better-auth](https://github.com/better-auth/better-auth/tree/HEAD/packages/better-auth) | `1.6.6` | `1.6.8` |
| [posthog-js](https://github.com/PostHog/posthog-js) | `1.369.5` | `1.371.2` |



Updates `@orpc/client` from 1.13.14 to 1.14.0
- [Release notes](https://github.com/middleapi/orpc/releases)
- [Commits](https://github.com/middleapi/orpc/commits/v1.14.0/packages/client)

Updates `@orpc/tanstack-query` from 1.13.14 to 1.14.0
- [Release notes](https://github.com/middleapi/orpc/releases)
- [Commits](https://github.com/middleapi/orpc/commits/v1.14.0/packages/tanstack-query)

Updates `@tanstack/react-query` from 5.99.2 to 5.100.1
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query-devtools@5.100.1/packages/react-query)

Updates `better-auth` from 1.6.6 to 1.6.8
- [Release notes](https://github.com/better-auth/better-auth/releases)
- [Changelog](https://github.com/better-auth/better-auth/blob/main/packages/better-auth/CHANGELOG.md)
- [Commits](https://github.com/better-auth/better-auth/commits/better-auth@1.6.8/packages/better-auth)

Updates `posthog-js` from 1.369.5 to 1.371.2
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/compare/posthog-js@1.369.5...posthog-js@1.371.2)

---
updated-dependencies:
- dependency-name: "@orpc/client"
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: "@orpc/tanstack-query"
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.100.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: better-auth
  dependency-version: 1.6.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: posthog-js
  dependency-version: 1.371.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 12:05:03 +09:00
MengXi
d2c75ace5a
fix(dia): open options page via extension tab (#1373) 2026-04-23 19:51:35 -07:00
ishiko
090463d588
docs: update /tutorial references to /docs to match the website (#1372) 2026-04-23 19:41:37 -07:00
dependabot[bot]
56e3081d7d
chore: bump vite in the development-dependencies group (#1367)
Bumps the development-dependencies group with 1 update: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 8.0.9 to 8.0.10
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.10/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ishiko <ishiko732@gmail.com>
2026-04-23 15:36:46 +00:00
taiiiyang
26b06af870
refactor(subtitles): replace route-based navigation with flat panel navigator (#1368)
Replace the multi-file route system (routes/defs.ts, routes/config.tsx,
routes/index.ts) with a single-source-of-truth views/ directory using
flat ViewId strings instead of path-style RoutePath.

- Merge route definitions, metadata, and component map into views/index.tsx
- Make PanelShell purely presentational (header? + transition? props)
- Simplify translate button onClick to one-line reset + toggle
- Rename SubtitlesSettingsAction to SubpageMenuEntry
- Extract panel dismiss logic into dedicated hook

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 20:23:33 +08:00
dependabot[bot]
94c2f3b74d
chore: bump the development-dependencies group across 1 directory with 14 updates (#1364) 2026-04-22 01:21:54 -07:00
dependabot[bot]
068d5eea10
chore: bump the production-dependencies group across 1 directory with 11 updates (#1363) 2026-04-22 01:11:14 -07:00
dependabot[bot]
86c90ad506
chore: bump the ai-sdk group across 1 directory with 6 updates (#1362)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 00:14:03 -07:00
MengXi
01ccdd17a2
refactor(env): simplify extension env wiring (#1360) 2026-04-22 00:00:01 -07:00
taiiiyang
a49ab2790b
fix(subtitles): stabilize YouTube subtitle navigation and popup mounting (#1345) 2026-04-18 11:50:04 +08:00
taiiiyang
74f16a98d8
fix(subtitles): support stylized YouTube karaoke parsing and source export (#1336) 2026-04-15 23:07:22 +08:00
ananaBMaster
04da031715
build: update deps (#1341) 2026-04-14 16:53:24 -07:00
dependabot[bot]
e3c40e4717
chore: bump the development-dependencies group across 1 directory with 8 updates (#1340)
Bumps the development-dependencies group with 8 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/react-query-devtools) | `5.95.2` | `5.99.0` |
| [@vitest/coverage-istanbul](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-istanbul) | `4.1.2` | `4.1.4` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.4.27` | `10.5.0` |
| [jsdom](https://github.com/jsdom/jsdom) | `29.0.1` | `29.0.2` |
| [nx](https://github.com/nrwl/nx/tree/HEAD/packages/nx) | `22.6.2` | `22.6.5` |
| [postcss](https://github.com/postcss/postcss) | `8.5.8` | `8.5.9` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.5` | `8.0.8` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.2` | `4.1.4` |



Updates `@tanstack/react-query-devtools` from 5.95.2 to 5.99.0
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query-devtools@5.99.0/packages/react-query-devtools)

Updates `@vitest/coverage-istanbul` from 4.1.2 to 4.1.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/coverage-istanbul)

Updates `autoprefixer` from 10.4.27 to 10.5.0
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.4.27...10.5.0)

Updates `jsdom` from 29.0.1 to 29.0.2
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v29.0.1...v29.0.2)

Updates `nx` from 22.6.2 to 22.6.5
- [Release notes](https://github.com/nrwl/nx/releases)
- [Commits](https://github.com/nrwl/nx/commits/22.6.5/packages/nx)

Updates `postcss` from 8.5.8 to 8.5.9
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.9)

Updates `vite` from 8.0.5 to 8.0.8
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.8/packages/vite)

Updates `vitest` from 4.1.2 to 4.1.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query-devtools"
  dependency-version: 5.99.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@vitest/coverage-istanbul"
  dependency-version: 4.1.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: autoprefixer
  dependency-version: 10.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: jsdom
  dependency-version: 29.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: nx
  dependency-version: 22.6.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: postcss
  dependency-version: 8.5.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: vite
  dependency-version: 8.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: vitest
  dependency-version: 4.1.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 16:08:40 -07:00
ananaBMaster
4f081cd9fe
chore: upgrade antfu eslint v8 (#1339)
* build(eslint): upgrade antfu config to v8

* fix(tree): avoid render-time purity warnings
2026-04-14 15:50:01 -07:00
GuaGua
0f6bf631ad
chore(deps): upgrade wxt to 0.20.22 (#1325)
* chore(deps): upgrade wxt to 0.20.22

* chore: remove ascii patch

---------

Co-authored-by: frogGuaGuaGuaGua <268840081+frogGuaGuaGuaGua@users.noreply.github.com>
Co-authored-by: ananaBMaster <68643891+ananaBMaster@users.noreply.github.com>
2026-04-14 14:51:54 -07:00
ananaBMaster
373a2f214e
chore(zed): use workspace TypeScript SDK for vtsls (#1337) 2026-04-14 10:11:19 -07:00
ananaBMaster
fe2eeddc3d
Fix/1327 unsupported thinking options (#1335)
* fix(models): skip unsupported thinking options

* test(provider-options): update Kimi instruct expectations
2026-04-14 01:06:42 -07:00
GuaGua
dd68565a2c
docs(readme): remove outdated Afdian note (#1333) 2026-04-13 14:06:01 -07:00
GuaGua
08b40e82cd fix: keep floating button close menu aligned 2026-04-12 22:56:03 -07:00
MengXi
da2e94bb15
fix(selection-toolbar): add more cursor clearance after text selection (#1323) 2026-04-12 21:33:00 -07:00
MengXi
74f4219615 fix(selection-toolbar): derive custom action webpage context by session 2026-04-12 20:27:29 -07:00
yioulii
fb1937c437
fix: floating button style (#1321) 2026-04-12 17:50:11 -07:00
github-actions[bot]
3faa6afde2
chore(release): version packages (#1286)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-12 10:29:03 -07:00
taiiiyang
35008023c6
i18n(subtitles): rename zh-CN source subtitle download label (#1317) 2026-04-12 10:27:37 -07:00
taiiiyang
f344e0d7dd
refactor(subtitles): reuse prefetched source subtitles (#1312) 2026-04-12 19:35:23 +08:00
MengXi
cf35b8f099
Merge pull request #1314 from mengxi-ream/fix/preload-margin-max-10000 2026-04-12 01:03:12 -07:00
taiiiyang
19517361ce
perf: optimize youtube subtitle fetching with fast-path fallback (#1313) 2026-04-12 15:56:56 +08:00
GuaGua
788edfb5ce fix(extension): allow preload distance up to 10000px 2026-04-12 00:54:25 -07:00
taiiiyang
38be1edea9
feat(subtitles): add source subtitle download and improve download behavior (#1307) 2026-04-11 05:41:51 +00:00
ananaBMaster
3e9f374770
fix(page-translation): only prime webpage context for AI-aware title … (#1303) 2026-04-09 09:41:45 -07:00
dependabot[bot]
f606b7c56b
chore: bump the ai-sdk group with 22 updates (#1305) 2026-04-09 09:39:27 -07:00
GuaGua
f1d92569ba
fix(tts): change default English voice to Davis and colocate preview button (#1302)
* fix(tts): change default English voice to Davis and colocate preview button

Resolves #1101

* fix(tts): add changeset and freeze migration fixtures

* fix(tts): inline frozen migration fixture constants

---------

Co-authored-by: GuaGua <268840081+frogGuaGuaGuaGua@users.noreply.github.com>
2026-04-09 00:24:12 -07:00
ananaBMaster
75fafc5a3e
refactor(webpage-context): unify webpage translation context flow (#1295) 2026-04-08 16:46:05 -07:00
GuaGua
da8d9376e7
fix(theme): namespace shadow-root theme tokens (#1300) 2026-04-08 12:21:41 -07:00
GuaGua
1464d774fd
docs(skills): add edge extension testing workflow (#1299) 2026-04-08 09:20:07 -07:00
GuaGua
acdd296e19
fix(ui): soften page translation loading spinner (#1297) 2026-04-08 09:19:28 -07:00
dependabot[bot]
3b153a2353
chore: bump vite from 8.0.3 to 8.0.5 (#1288)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.3 to 8.0.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ishiko <ishiko732@gmail.com>
2026-04-07 18:18:51 +09:00
236 changed files with 14701 additions and 5831 deletions

View file

@ -1,67 +1,193 @@
---
name: extension-real-browser-testing
description: Test browser extensions in real browsers using built artifacts, unpacked installs, local repro pages, and browser DevTools automation. Use when reproducing or validating extension behavior in Chrome, Edge, Chromium, Brave, or Firefox, especially for content scripts, popups, options pages, hover/focus bugs, and browser-specific regressions.
description: Test browser extensions in real browsers using built artifacts, Edge + Playwright automation, runtime-message triggering, DOM debugging, and truthful screenshot capture.
metadata:
author: read-frog
version: "1.1.0"
---
# Extension Real Browser Testing
## When to Use
Use this skill when you need to validate extension behavior in a real browser instead of guessing from unit tests, jsdom, or static screenshots.
## When to use
Apply this skill when:
- The task requires validating an extension in a real browser, not just unit tests
- The bug only reproduces after `build`, `zip`, or unpacked installation
- The issue is browser-specific or version-specific
- You need to inspect live content script DOM, shadow DOM, popup state, or DevTools targets
- You need stronger evidence than component tests or jsdom can provide
- the task requires validating an extension in a real browser, not just unit tests
- the bug only reproduces after `build`, `zip`, or unpacked installation
- the issue is browser-specific or version-specific
- you need to inspect live content script DOM, shadow DOM, popup state, or service-worker targets
- you need stronger evidence than component tests or jsdom can provide
- a UI PR needs screenshots rendered from the real extension behavior
## Quick Reference
## Quick reference
| Topic | Reference |
|-------|-----------|
| Browser discovery, launching, unpacked extension loading | [references/launching.md](references/launching.md) |
| Real-browser verification workflow and debugging heuristics | [references/workflow.md](references/workflow.md) |
| Browser discovery, Edge launch patterns, unpacked extension loading | [references/launching.md](references/launching.md) |
| Real-browser verification workflow, debugging heuristics, read-frog capture recipe | [references/workflow.md](references/workflow.md) |
## Default Workflow
## General workflow
1. Build the extension artifact you will actually test.
2. Confirm the build output directory and manifest exist.
3. Start a minimal local repro page when the bug does not need a third-party site.
4. Launch a fresh browser profile with the unpacked extension loaded.
5. Verify the extension really loaded before testing:
Chromium-family: confirm the extension service worker or extension page appears in the DevTools target list.
6. Automate through DevTools when possible instead of relying on visual guesses.
7. Inspect actual DOM state after reproduction:
open/closed attributes, mounted/unmounted, opacity, visibility, pointer-events, relatedTarget, active element.
8. Clean up temporary browser profiles, servers, and debugging processes after the run.
5. Verify the extension really loaded before testing.
6. Trigger the real extension flow.
7. Inspect actual DOM/runtime state after reproduction.
8. Capture screenshots only after you can prove the claimed state.
9. Clean up temporary browser profiles, servers, and debugging processes after the run.
## Rules
- Test the built artifact that matches the user report. Do not assume `dev` behavior proves anything about `build`.
- Prefer unpacked extension installs from `.output/<browser>-mv3` or the equivalent build directory.
- Use a fresh `--user-data-dir` for browser launches so old extension state does not contaminate the result.
- Use a fresh profile for every automation run so old extension state does not contaminate the result.
- Keep browser-specific fixes and conclusions scoped. Do not generalize from one browser to all browsers without evidence.
- If the issue is isolated to extension overlays or content scripts, prefer fixing the extension-local wrapper layer before touching shared primitives used by options or unrelated pages.
- Node existence alone is weak evidence. A tooltip, popover, or dialog may remain mounted while already closed. Always inspect visual state too.
- When checking close behavior, distinguish these states:
- Trigger never received leave/blur
- Root state never closed
- Root closed, but visual state remained visible
- If browser automation conflicts with an already-running GUI session on macOS, relaunch with `open -na ... --args ...` to force a fresh instance.
- When screenshots are for review, raw before/after captures are the source of truth.
- Never present a stitched comparison graphic as if it were a raw browser screenshot.
## What to Capture
## Preferred automation path on this macOS setup
For this repository and machine, Edge + local Playwright was the most reliable path for unpacked-extension automation.
Important environment lessons:
- Chrome may appear to load an unpacked extension but still behave inconsistently under automation in this environment.
- Edge worked reliably with Playwright `launchPersistentContext` and a fresh profile.
- Reading `chrome.storage.local` from the Playwright service-worker target was unreliable here.
- Opening the real popup/options extension page and evaluating `chrome.*` there was reliable.
If Chrome behaves strangely, switch to Edge instead of continuing to guess.
## Read-frog page-translation workflow
For read-frog page translation, a reliable automation path was:
1. Open a real content page first.
2. Open `chrome-extension://<id>/popup.html`.
3. From the popup page, locate the actual content tab.
4. Explicitly set config in `chrome.storage.local` instead of assuming defaults.
5. Trigger the same runtime message used by the extension in production.
6. Wait for `.read-frog-spinner` nodes and record live DOM evidence.
7. Take the raw screenshot while spinner nodes are still present.
8. Keep waiting until translated wrapper nodes contain Chinese text to prove the run was real.
Example setup from the popup page:
```js
const setup = await popup.evaluate(async ({ targetUrl }) => {
const tab = (await chrome.tabs.query({})).find(item => item.url === targetUrl);
if (!tab?.id) throw new Error(`Could not find tab for ${targetUrl}`);
const current = await chrome.storage.local.get('config');
const config = current.config ?? {};
config.language = {
...(config.language ?? {}),
sourceCode: 'auto',
targetCode: 'cmn',
};
config.translate = {
...(config.translate ?? {}),
providerId: 'microsoft-translate-default',
mode: 'bilingual',
page: {
...(config.translate?.page ?? {}),
range: 'all',
},
requestQueueConfig: {
...(config.translate?.requestQueueConfig ?? {}),
rate: 1,
capacity: 1,
},
batchQueueConfig: {
...(config.translate?.batchQueueConfig ?? {}),
maxItemsPerBatch: 1,
maxCharactersPerBatch: 160,
},
};
await chrome.storage.local.set({ config });
await chrome.runtime.sendMessage({
id: Date.now(),
type: 'tryToSetEnablePageTranslationByTabId',
data: { tabId: tab.id, enabled: true },
timestamp: Date.now(),
});
return {
tabId: tab.id,
targetCode: config.language.targetCode,
};
}, { targetUrl: page.url() });
```
Good loading-time evidence for read-frog:
```js
await page.waitForFunction(
() => document.querySelectorAll('.read-frog-spinner').length >= 4,
null,
{ timeout: 45000 },
);
const loadingEvidence = await page.evaluate(() => ({
spinnerCount: document.querySelectorAll('.read-frog-spinner').length,
sampleSpinnerStyles: Array.from(document.querySelectorAll('.read-frog-spinner'))
.slice(0, 3)
.map(node => node.getAttribute('style')),
}));
```
Good completion-time evidence for read-frog:
```js
await page.waitForFunction(
() => {
const wrappers = Array.from(document.querySelectorAll('.read-frog-translated-content-wrapper'));
return wrappers.some(node => /[\u3400-\u9FFF]/.test(node.textContent || ''));
},
null,
{ timeout: 120000 },
);
```
## Screenshot policy
For UI PRs:
- keep raw before and raw after screenshots
- optionally add crops or a comparison board for reviewer convenience
- clearly label any composite as a composite built from raw screenshots
Important note from this workflow:
- a raw full-page screenshot can contain real loading spinners that are still hard to notice because they are tiny
- when reviewers need help seeing the difference, supplement the raw screenshots with zoomed crops, but do not replace the raw evidence
## What to capture
For reproducible browser bugs, collect at least:
- Browser name and version
- Extension version or build artifact path
- Whether the bug reproduces in `dev`, `build`, unpacked install, or store install
- Exact page URL or minimal repro page
- DOM evidence after reproduction
- If needed, a short event sequence: hover/focus/leave/openChange/close
- browser name and version
- extension build artifact path
- whether the bug reproduces in `dev`, `build`, unpacked install, or store install
- exact page URL or minimal repro page
- DOM/runtime evidence after reproduction
- screenshot paths or hosted URLs
- if needed, a short event sequence: hover/focus/leave/openChange/close
## Common Pitfalls
## Common pitfalls
- Testing the wrong profile and reading stale extension state
- Assuming a tooltip is still "open" just because the node still exists
- Assuming a popup is closed just because logic reported `open=false`
- Letting the tooltip or popup overlay itself become the mouse hit target during hover debugging
- Editing shared browser UI primitives when the bug is specific to extension content injected into pages
- testing the wrong profile and reading stale extension state
- assuming a popup or tooltip is still "open" just because the node still exists
- assuming a shortcut failure proves the feature is broken, when the runtime message path may still work
- using a fake or stitched screenshot and then describing it as raw evidence
- ignoring unrelated popup errors that may coexist with a still-working content-page flow
- pushing content-script-specific workarounds into shared primitives without evidence that every surface needs them
## References
- See `references/launching.md` for exact Edge launch patterns.
- See `references/workflow.md` for the real-browser checklist, debugging heuristics, and read-frog capture recipe.

View file

@ -1,8 +1,8 @@
# Browser Launching
## Chromium-Family Browsers
## Chromium-family browsers
Prefer Chrome, Edge, Chromium, or Brave when DevTools Protocol automation is needed.
Prefer Edge, Chrome, Chromium, or Brave when extension automation or DevTools Protocol access is needed.
### Discovery
@ -19,9 +19,49 @@ Typical Linux commands:
- `chromium`
- `brave-browser`
### Launch Pattern
## Preferred path here: Edge + Playwright
Use a fresh profile and load the unpacked build artifact directly.
Preferred browser for this workflow in this environment.
```js
import fs from 'node:fs';
import { chromium } from '/Users/frog/.hermes/hermes-agent/node_modules/playwright/index.mjs';
const extensionPath = '/ABS/PATH/TO/.output/chrome-mv3';
const userDataDir = '/tmp/extension-edge-profile';
fs.rmSync(userDataDir, { recursive: true, force: true });
const context = await chromium.launchPersistentContext(userDataDir, {
executablePath: '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
headless: false,
viewport: { width: 1440, height: 1200 },
args: [
'--no-first-run',
'--no-default-browser-check',
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
],
});
```
## Verify the MV3 worker
```js
let worker = context.serviceWorkers()[0];
if (!worker) {
worker = await context.waitForEvent('serviceworker', { timeout: 30000 });
}
const extensionId = new URL(worker.url()).host;
```
Good signals:
- the content page is present in `context.pages()`
- the MV3 service worker is present in `context.serviceWorkers()`
- the worker URL resolves to your extension ID
## Alternative manual launch
Useful when you want a browser window plus a remote debugging port for manual inspection:
```bash
open -na '/Applications/Microsoft Edge.app' --args \
@ -29,40 +69,26 @@ open -na '/Applications/Microsoft Edge.app' --args \
--user-data-dir=/tmp/ext-test-edge-profile \
--no-first-run \
--no-default-browser-check \
--disable-background-networking \
--disable-sync \
--disable-extensions-except='/abs/path/.output/edge-mv3' \
--load-extension='/abs/path/.output/edge-mv3' \
--disable-extensions-except='/abs/path/.output/chrome-mv3' \
--load-extension='/abs/path/.output/chrome-mv3' \
http://127.0.0.1:8123/
```
Use the same pattern for Chrome, Chromium, or Brave by swapping the app path.
### Verify the Extension Loaded
Query the DevTools target list:
Then inspect targets with:
```bash
curl -s http://127.0.0.1:9226/json/list
```
Good signals:
- target for your test page
- service worker target like `chrome-extension://<id>/background.js`
## Why not Browserbase/browser tool?
## Firefox
The browser tool cannot load a local unpacked extension, so it is not suitable for this workflow.
Firefox may not expose the same DevTools Protocol workflow. Prefer:
- manual repro with a fresh profile
- Playwright or browser-native debugging if the environment already supports it
## Why not Chrome first?
Still follow the same core principles:
- fresh profile
- built artifact
- verify the extension actually loaded
- collect DOM evidence, not only screenshots
Chrome can be fine in general, but on this machine Edge was more reliable for fresh-profile unpacked-extension testing. If Chrome behaves inconsistently, switch to Edge instead of brute-forcing it.
## Local Repro Pages
## Local repro pages
When the bug does not require a production site, prefer a minimal local page.
@ -72,8 +98,8 @@ Examples:
Benefits:
- removes third-party page variables
- makes selection and hover coordinates deterministic
- avoids network flakiness
- makes reproduction coordinates deterministic
- avoids unnecessary network flakiness
## Cleanup

View file

@ -1,34 +1,18 @@
# Workflow
## Real-Browser Repro Checklist
## Real-browser repro checklist
1. Build the browser-specific artifact.
2. Verify the manifest in the expected output directory.
3. Launch a fresh browser instance with the unpacked extension.
4. Confirm DevTools targets before continuing.
4. Confirm the worker/pages before continuing.
5. Reproduce the issue.
6. Read live DOM state after the repro, not just screenshots.
6. Read live DOM/runtime state after the repro, not just screenshots.
7. If needed, add temporary instrumentation to the extension-local layer and rebuild.
## DevTools Automation Pattern
## How to reason about UI bugs
For Chromium-family browsers, a minimal Node script using:
- `fetch` to read `/json/list`
- `WebSocket` to connect to `webSocketDebuggerUrl`
- `Runtime.evaluate`
- `Input.dispatchMouseEvent`
is usually enough.
Use it for:
- selecting text
- clicking toolbar buttons
- hovering triggers
- checking popup and tooltip state
## How to Reason About UI Bugs
### Tooltip / Popover Bugs
### Tooltip / popover bugs
Do not stop at "the node still exists."
@ -46,7 +30,7 @@ Important distinction:
- If `onOpenChange(false)` never fires, the event chain is wrong.
- If `onOpenChange(false)` fires but the element remains visible, the closed-state styling or unmount flow is wrong.
### Hover Bugs
### Hover bugs
Inspect `relatedTarget` on leave events.
@ -54,19 +38,70 @@ If the pointer leaves the trigger and lands on the tooltip's own overlay or posi
- the tooltip overlay may need `pointer-events-none`
- the extension-local tooltip wrapper may need a stronger closed-state style
### Build-Only Bugs
### Build-only bugs
When a bug appears only after `build`:
- reproduce against the built artifact first
- do not assume the dev server path is relevant
- compare dev/build only after you have real evidence from the built version
## Fix Scope Guidance
## Read-frog page-translation capture recipe
Prefer the narrowest layer that actually owns the problem:
1. Build the extension.
2. Launch Edge with a fresh profile and the unpacked build.
3. Open the target content page first.
4. Open `chrome-extension://<id>/popup.html`.
5. From the popup page, use `chrome.storage.local` to set:
- `language.targetCode = 'cmn'`
- `translate.providerId = 'microsoft-translate-default'`
- `translate.page.range = 'all'`
- `translate.requestQueueConfig = { rate: 1, capacity: 1 }`
- `translate.batchQueueConfig = { maxItemsPerBatch: 1, maxCharactersPerBatch: 160 }`
6. Trigger page translation by sending:
- Shared primitive only if the same bug exists across unrelated surfaces
- Extension-local wrapper if the bug is specific to content-script overlays or injected UI
- Feature-local component if only one trigger path is affected
```js
await chrome.runtime.sendMessage({
id: Date.now(),
type: 'tryToSetEnablePageTranslationByTabId',
data: { tabId, enabled: true },
timestamp: Date.now(),
});
```
When a shared primitive is used by safe surfaces like options or popup pages, be careful not to push a content-script-specific workaround into that shared layer unless you have evidence that every surface needs it.
7. Wait for loading evidence:
```js
await page.waitForFunction(
() => document.querySelectorAll('.read-frog-spinner').length >= 4,
null,
{ timeout: 45000 },
);
```
8. Record DOM evidence at capture time:
- spinner count
- sample inline spinner style strings
9. Take the raw screenshot.
10. Keep waiting until translated wrapper nodes contain Chinese text to prove the run was real.
## Honesty rules for screenshots
- Raw before and raw after screenshots are the source of truth.
- A crop is a crop, not a raw full-page screenshot.
- A stitched comparison board is a comparison graphic, not a raw screenshot.
- If the element is tiny and hard to see in a full-page shot, keep the raw shot and add a labeled crop as supplemental evidence.
## Common signals for read-frog
Loading-time signals:
- `.read-frog-spinner`
- inline spinner style strings
Completion-time signals:
- `.read-frog-translated-content-wrapper`
- Chinese characters in translated text
- translated page title
## Common pitfall seen in this workflow
The popup could show a recovery-mode error like `e?.trim is not a function` while the content-page translation flow still worked. Treat that as a separate issue and verify the actual target behavior directly.

View file

@ -1,5 +0,0 @@
---
"@read-frog/extension": patch
---
feat(subtitles): add a settings toggle menu for video subtitles

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
fix(options): preserve focused provider options drafts

View file

@ -1,5 +0,0 @@
---
"@read-frog/extension": patch
---
style: improve selection preview scrolling in the selection toolbar

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
fix(page-translation): re-walk revealed accordion content

View file

@ -0,0 +1,174 @@
---
description: Test browser extensions in a real browser using Edge + Playwright, including unpacked extension injection, runtime-message triggering, DOM debugging, and truthful screenshot capture.
allowed-tools: Bash(node:*), Bash(pnpm:*), Bash(git:*), Bash(python3:*), Bash(gh:*), Read, Glob, Write, Edit
---
# Extension Real Browser Testing
Use this skill when you need to debug or verify extension behavior in a real browser instead of guessing from unit tests or static screenshots.
## Core rules
1. Test the built artifact, not an imagined dev state.
2. Use local Playwright via `node`, not Browserbase/browser tool automation.
3. Prefer Edge on this macOS setup when Chrome behaves inconsistently.
4. Use a fresh browser profile every run.
5. Prove behavior with live DOM/runtime evidence in addition to screenshots.
6. Never present a stitched comparison graphic as if it were a raw browser screenshot.
## Workflow
### 1. Build the unpacked extension
```bash
pnpm build
```
Typical output:
```text
.output/chrome-mv3
```
### 2. Launch Edge with the unpacked extension
```js
import fs from 'node:fs';
import { chromium } from '/Users/frog/.hermes/hermes-agent/node_modules/playwright/index.mjs';
const extensionPath = '/ABS/PATH/TO/.output/chrome-mv3';
const userDataDir = '/tmp/extension-edge-profile';
fs.rmSync(userDataDir, { recursive: true, force: true });
const context = await chromium.launchPersistentContext(userDataDir, {
executablePath: '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
headless: false,
viewport: { width: 1440, height: 1200 },
args: [
'--no-first-run',
'--no-default-browser-check',
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
],
});
let worker = context.serviceWorkers()[0];
if (!worker) {
worker = await context.waitForEvent('serviceworker', { timeout: 30000 });
}
const extensionId = new URL(worker.url()).host;
```
### 3. Inspect or mutate state from popup/options
Important lesson from this workflow:
- reading `chrome.storage.local` from the service-worker target was unreliable here
- opening the real popup/options extension page and evaluating `chrome.*` there was reliable
```js
await popup.goto(`chrome-extension://${extensionId}/popup.html`)
await options.goto(`chrome-extension://${extensionId}/options.html`)
```
### 4. Trigger the real extension flow
For read-frog page translation, a reliable approach was:
- open a real content page first
- locate the real content tab from the popup page
- set `language.targetCode = 'cmn'`
- set the page-translation queue slow enough that loading is visible
- send the runtime message used by the extension
```js
const setup = await popup.evaluate(async ({ targetUrl }) => {
const tab = (await chrome.tabs.query({})).find(item => item.url === targetUrl);
if (!tab?.id) throw new Error(`Could not find tab for ${targetUrl}`);
const current = await chrome.storage.local.get('config');
const config = current.config ?? {};
config.language = {
...(config.language ?? {}),
sourceCode: 'auto',
targetCode: 'cmn',
};
config.translate = {
...(config.translate ?? {}),
providerId: 'microsoft-translate-default',
mode: 'bilingual',
page: {
...(config.translate?.page ?? {}),
range: 'all',
},
requestQueueConfig: {
...(config.translate?.requestQueueConfig ?? {}),
rate: 1,
capacity: 1,
},
batchQueueConfig: {
...(config.translate?.batchQueueConfig ?? {}),
maxItemsPerBatch: 1,
maxCharactersPerBatch: 160,
},
};
await chrome.storage.local.set({ config });
await chrome.runtime.sendMessage({
id: Date.now(),
type: 'tryToSetEnablePageTranslationByTabId',
data: { tabId: tab.id, enabled: true },
timestamp: Date.now(),
});
return {
tabId: tab.id,
targetCode: config.language.targetCode,
};
}, { targetUrl: page.url() });
```
### 5. Wait for live DOM evidence
```js
await page.waitForFunction(
() => document.querySelectorAll('.read-frog-spinner').length >= 4,
null,
{ timeout: 45000 },
);
const loadingEvidence = await page.evaluate(() => ({
spinnerCount: document.querySelectorAll('.read-frog-spinner').length,
sampleSpinnerStyles: Array.from(document.querySelectorAll('.read-frog-spinner'))
.slice(0, 3)
.map(node => node.getAttribute('style')),
}));
```
Then verify completion:
```js
await page.waitForFunction(
() => {
const wrappers = Array.from(document.querySelectorAll('.read-frog-translated-content-wrapper'));
return wrappers.some(node => /[\u3400-\u9FFF]/.test(node.textContent || ''));
},
null,
{ timeout: 120000 },
);
```
### 6. Screenshot rules
- Keep raw before and raw after screenshots.
- If the spinner is tiny in a full-page shot, add a crop as supplemental evidence.
- A crop is not a raw screenshot.
- A stitched board is not a raw screenshot.
## Pitfalls
- Chrome can behave inconsistently for unpacked-extension automation on this machine; switch to Edge when needed.
- Do not assume service-worker evaluation can read all needed state.
- A tiny spinner can be present in a correct raw full-page screenshot while still being hard to see at first glance.
- An extension page can show an unrelated recovery-mode or config error while the target content-script flow still works; verify the real target behavior directly.
- If keyboard shortcuts are flaky in automation, trigger the equivalent runtime message or popup action instead.

View file

@ -0,0 +1,48 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "read-frog"
[setup]
script = '''
set -eu
copy_env() {
rel="$1"
example_rel="$2"
src="$CODEX_SOURCE_TREE_PATH/$rel"
dst="$CODEX_WORKTREE_PATH/$rel"
example="$CODEX_SOURCE_TREE_PATH/$example_rel"
mkdir -p "$(dirname "$dst")"
if [ -f "$src" ]; then
cp "$src" "$dst"
echo "copied $rel from source tree"
elif [ -f "$example" ]; then
cp "$example" "$dst"
echo "created $rel from example"
else
echo "skipped $rel (no source or example found)"
fi
}
copy_source_file() {
rel="$1"
src="$CODEX_SOURCE_TREE_PATH/$rel"
dst="$CODEX_WORKTREE_PATH/$rel"
mkdir -p "$(dirname "$dst")"
if [ -f "$src" ]; then
cp "$src" "$dst"
echo "copied $rel from source tree"
else
echo "skipped $rel (no source found)"
fi
}
copy_env ".env.development" ".env.example"
copy_source_file "web-ext.config.ts"
'''

16
.env.example Normal file
View file

@ -0,0 +1,16 @@
# Optional overrides for the extension's runtime URLs/domains.
# By default the extension uses production readfrog.app values.
# When WXT_USE_LOCAL_PACKAGES=true, it instead falls back to localhost values.
# Values must already be canonical:
# - URLs must not end with a trailing slash
# - origins/domains are comma-separated with no spaces
#
# WXT_API_URL=https://api.readfrog.app
# WXT_WEBSITE_URL=https://www.readfrog.app
# WXT_OFFICIAL_SITE_ORIGINS=https://readfrog.app,https://www.readfrog.app
# WXT_AUTH_COOKIE_DOMAINS=readfrog.app
#
# Required in poduction:
# WXT_GOOGLE_CLIENT_ID=your-google-client-id
# WXT_POSTHOG_HOST=https://us.i.posthog.com
# WXT_POSTHOG_API_KEY=phc_your_posthog_key

View file

@ -5,6 +5,9 @@ on:
branches:
- main
env:
WXT_SKIP_ENV_VALIDATION: true
jobs:
test-and-build:
name: Test & Build

View file

@ -11,6 +11,11 @@ on:
required: true
type: string
env:
WXT_GOOGLE_CLIENT_ID: ${{ secrets.WXT_GOOGLE_CLIENT_ID || vars.WXT_GOOGLE_CLIENT_ID }}
WXT_POSTHOG_API_KEY: ${{ secrets.WXT_POSTHOG_API_KEY || vars.WXT_POSTHOG_API_KEY }}
WXT_POSTHOG_HOST: ${{ secrets.WXT_POSTHOG_HOST || vars.WXT_POSTHOG_HOST }}
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
@ -121,10 +126,6 @@ jobs:
- name: Build and Zip Extension
if: steps.changesets.outputs.published == 'true' || github.event_name == 'workflow_dispatch'
run: pnpm zip:all
env:
WXT_GOOGLE_CLIENT_ID: ${{ secrets.WXT_GOOGLE_CLIENT_ID || vars.WXT_GOOGLE_CLIENT_ID }}
WXT_POSTHOG_API_KEY: ${{ secrets.WXT_POSTHOG_API_KEY || vars.WXT_POSTHOG_API_KEY }}
WXT_POSTHOG_HOST: ${{ secrets.WXT_POSTHOG_HOST || vars.WXT_POSTHOG_HOST }}
- name: Upload Extension Zip to Release
if: steps.changesets.outputs.published == 'true' || github.event_name == 'workflow_dispatch'

View file

@ -18,9 +18,7 @@ jobs:
# ---- Issue settings ----
days-before-issue-stale: 30
days-before-issue-close: 60
exempt-issue-labels: |
pinned
on-hold
exempt-issue-labels: pinned,on-hold
stale-issue-message: |
This issue has been inactive for 30 days and is now marked as stale.
It will be automatically closed in 60 days if no further activity occurs.
@ -37,7 +35,4 @@ jobs:
close-pr-message: |
Since it has been a long time without updates, the PR has been automatically closed.
If you need to continue, please reopen or create a new PR.
exempt-pr-labels: |
pinned
WIP
on-hold
exempt-pr-labels: pinned,WIP,on-hold

View file

@ -13,6 +13,11 @@ on:
default: false
type: boolean
env:
WXT_GOOGLE_CLIENT_ID: ${{ secrets.WXT_GOOGLE_CLIENT_ID || vars.WXT_GOOGLE_CLIENT_ID }}
WXT_POSTHOG_API_KEY: ${{ secrets.WXT_POSTHOG_API_KEY || vars.WXT_POSTHOG_API_KEY }}
WXT_POSTHOG_HOST: ${{ secrets.WXT_POSTHOG_HOST || vars.WXT_POSTHOG_HOST }}
jobs:
submit:
name: Submit to Stores
@ -40,10 +45,6 @@ jobs:
- name: Build and Zip Extension
run: pnpm zip
env:
WXT_GOOGLE_CLIENT_ID: ${{ secrets.WXT_GOOGLE_CLIENT_ID || vars.WXT_GOOGLE_CLIENT_ID }}
WXT_POSTHOG_API_KEY: ${{ secrets.WXT_POSTHOG_API_KEY || vars.WXT_POSTHOG_API_KEY }}
WXT_POSTHOG_HOST: ${{ secrets.WXT_POSTHOG_HOST || vars.WXT_POSTHOG_HOST }}
- name: Upload Extension Zip to Release
run: gh release upload "${{ inputs.tag }}" .output/*-chrome.zip --clobber

1
.gitignore vendored
View file

@ -29,6 +29,7 @@ web-ext.config.ts
.env
.env.*
!.env.example
!.env.local.example
coverage/

View file

@ -28,6 +28,16 @@
// Add other languages as needed
},
"lsp": {
"vtsls": {
"settings": {
"typescript": {
"tsdk": "./node_modules/typescript/lib"
},
"vtsls": {
"autoUseWorkspaceTsdk": true
}
}
},
"eslint": {
"settings": {
// Remove after https://github.com/zed-industries/zed/issues/49387

View file

@ -1,5 +1,107 @@
# @read-frog/extension
## 1.33.1
### Patch Changes
- [#1394](https://github.com/mengxi-ream/read-frog/pull/1394) [`619c83d`](https://github.com/mengxi-ream/read-frog/commit/619c83defd417ad2c68c8e0c6258afe5e5d79b04) Thanks [@taiiiyang](https://github.com/taiiiyang)! - feat(subtitles): add embed translate button and settings panel injection
- [#1402](https://github.com/mengxi-ream/read-frog/pull/1402) [`0bd869f`](https://github.com/mengxi-ream/read-frog/commit/0bd869fd935738adcddae76f84c1232313168099) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - fix(extension): guard popup account avatar session state
- [#1397](https://github.com/mengxi-ream/read-frog/pull/1397) [`466c1ce`](https://github.com/mengxi-ream/read-frog/commit/466c1cefdb78726fd870d979ec90c41beafbaa38) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - feat(extension): open native side panel from floating button
- [#1400](https://github.com/mengxi-ream/read-frog/pull/1400) [`c3debfb`](https://github.com/mengxi-ream/read-frog/commit/c3debfbc0c2fe3ebf6c53937c63ca3d745ee4c0e) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - style(options): widen Google Drive sync conflict dialog
## 1.33.0
### Minor Changes
- [#1388](https://github.com/mengxi-ream/read-frog/pull/1388) [`6922155`](https://github.com/mengxi-ream/read-frog/commit/69221554ff0a4db662ce9dff3304ea8923f46c8e) Thanks [@taiiiyang](https://github.com/taiiiyang)! - feat(subtitles): add subtitle style settings panel with Trancy-inspired UI
- [#1392](https://github.com/mengxi-ream/read-frog/pull/1392) [`596bcf7`](https://github.com/mengxi-ream/read-frog/commit/596bcf7248ddeea7bea843143bcdab52b41a5048) Thanks [@taiiiyang](https://github.com/taiiiyang)! - feat(extension): support YouTube embed subtitles on third-party sites
### Patch Changes
- [#1385](https://github.com/mengxi-ream/read-frog/pull/1385) [`746a3c5`](https://github.com/mengxi-ream/read-frog/commit/746a3c5c3b71d83a4404db7c26a37c44acc031ae) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - fix(extension): ensure Defuddle webpage context returns Markdown
- [#1391](https://github.com/mengxi-ream/read-frog/pull/1391) [`afa7dee`](https://github.com/mengxi-ream/read-frog/commit/afa7dee1b0b8fcd26559d8a8590e51649166c3a9) Thanks [@li-yiou](https://github.com/li-yiou)! - feat: add floating button controls
- [#1389](https://github.com/mengxi-ream/read-frog/pull/1389) [`c25b299`](https://github.com/mengxi-ream/read-frog/commit/c25b299ca474f3c2baccf9e4a629d6e042dcfbcc) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - style(extension): align primary theme tokens and translation brand colors
## 1.32.4
### Patch Changes
- [#1382](https://github.com/mengxi-ream/read-frog/pull/1382) [`068bdec`](https://github.com/mengxi-ream/read-frog/commit/068bdecc8a3336f2e208a2caeb33412ae4fa45b1) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - perf: replace startup Readability parsing with lightweight page detection
- [#1379](https://github.com/mengxi-ream/read-frog/pull/1379) [`396dd0d`](https://github.com/mengxi-ream/read-frog/commit/396dd0d36b53e67d4815b83bd25418c99f67dac0) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - fix(auth): include credentials for API auth client
- [#1381](https://github.com/mengxi-ream/read-frog/pull/1381) [`810623b`](https://github.com/mengxi-ream/read-frog/commit/810623ba029911b7ab7d1e4db22a5ea3d6867cc5) Thanks [@ananaBMaster](https://github.com/ananaBMaster)! - feat(popup): search languages in popup selectors
- [#1377](https://github.com/mengxi-ream/read-frog/pull/1377) [`5b56df8`](https://github.com/mengxi-ream/read-frog/commit/5b56df819abb0e921e8426af97d26e6981b69d29) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - perf(options): persist slider settings after drag commit
- [#1356](https://github.com/mengxi-ream/read-frog/pull/1356) [`4667e3e`](https://github.com/mengxi-ream/read-frog/commit/4667e3eb406fa414cdb6d807682aea28368e548b) Thanks [@frogGuaGuaGuaGua](https://github.com/frogGuaGuaGuaGua)! - fix(selection-toolbar): keep modal selections visible when opacity is below 100%
- [#1378](https://github.com/mengxi-ream/read-frog/pull/1378) [`adfc89a`](https://github.com/mengxi-ream/read-frog/commit/adfc89add6f8b0b7d2f6adda5f232d2024e36e94) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - fix(selection-toolbar): keep custom AI action provider switches stable
## 1.32.3
### Patch Changes
- [#1323](https://github.com/mengxi-ream/read-frog/pull/1323) [`da2e94b`](https://github.com/mengxi-ream/read-frog/commit/da2e94bb151e1dca2ca2ac31d777df28210452af) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - fix(selection-toolbar): add more cursor clearance after text selection
- [#1318](https://github.com/mengxi-ream/read-frog/pull/1318) [`74f4219`](https://github.com/mengxi-ream/read-frog/commit/74f42196158be314dc65dc6e9c00b78ab021be23) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - fix(selection-toolbar): derive custom action webpage context by popover session
- [#1336](https://github.com/mengxi-ream/read-frog/pull/1336) [`74f16a9`](https://github.com/mengxi-ream/read-frog/commit/74f16a98d8d8e390ecf8aadc1a5a1db7990310e9) Thanks [@taiiiyang](https://github.com/taiiiyang)! - fix(subtitles): support stylized YouTube karaoke parsing and source export
- [#1324](https://github.com/mengxi-ream/read-frog/pull/1324) [`08b40e8`](https://github.com/mengxi-ream/read-frog/commit/08b40e82cd2c8d7b46e2cac8e1d87672c813fe0b) Thanks [@frogGuaGuaGuaGua](https://github.com/frogGuaGuaGuaGua)! - fix: keep floating button close menu aligned after reopening
- [#1335](https://github.com/mengxi-ream/read-frog/pull/1335) [`fe2eedd`](https://github.com/mengxi-ream/read-frog/commit/fe2eeddc3d49a5554d26454271a8ca27ea16245b) Thanks [@ananaBMaster](https://github.com/ananaBMaster)! - fix(models): skip unsupported thinking options for instruct variants
- [#1373](https://github.com/mengxi-ream/read-frog/pull/1373) [`d2c75ac`](https://github.com/mengxi-ream/read-frog/commit/d2c75ace5a4c5c8b6241a4211ac65f443c375c92) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - fix: open options page in Dia browser
- [#1345](https://github.com/mengxi-ream/read-frog/pull/1345) [`a49ab27`](https://github.com/mengxi-ream/read-frog/commit/a49ab2790bbb39112d67c08a1c8c5f8b22e4a1c8) Thanks [@taiiiyang](https://github.com/taiiiyang)! - fix(subtitles): stabilize YouTube subtitle navigation and popup mounting
- [#1360](https://github.com/mengxi-ream/read-frog/pull/1360) [`01ccdd1`](https://github.com/mengxi-ream/read-frog/commit/01ccdd17a226361eb436ab4fc498c6ac3aeb44c8) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - refactor(env): simplify extension env wiring
- [#1325](https://github.com/mengxi-ream/read-frog/pull/1325) [`0f6bf63`](https://github.com/mengxi-ream/read-frog/commit/0f6bf631ad61088f9c2c8fc27517754ef3dfe565) Thanks [@frogGuaGuaGuaGua](https://github.com/frogGuaGuaGuaGua)! - chore(deps): upgrade WXT to 0.20.22 and preserve extension-safe bundle output
- [#1321](https://github.com/mengxi-ream/read-frog/pull/1321) [`fb1937c`](https://github.com/mengxi-ream/read-frog/commit/fb1937c437bcba8ae1eacb181f367e61cc26c3db) Thanks [@yioulii](https://github.com/yioulii)! - fix: floating button style
- [#1372](https://github.com/mengxi-ream/read-frog/pull/1372) [`090463d`](https://github.com/mengxi-ream/read-frog/commit/090463d5887640df1fe4de83b1d40fd3a2175f94) Thanks [@ishiko732](https://github.com/ishiko732)! - docs: update `/tutorial` references to `/docs` to match the website
- [#1368](https://github.com/mengxi-ream/read-frog/pull/1368) [`26b06af`](https://github.com/mengxi-ream/read-frog/commit/26b06af8702ae32420d912666cd66d3348e26e4a) Thanks [@taiiiyang](https://github.com/taiiiyang)! - refactor(subtitles): replace route-based navigation with flat panel navigator
## 1.32.2
### Patch Changes
- [#1317](https://github.com/mengxi-ream/read-frog/pull/1317) [`3500802`](https://github.com/mengxi-ream/read-frog/commit/35008023c6adcabc60903787282c0906873dc107) Thanks [@taiiiyang](https://github.com/taiiiyang)! - i18n(subtitles): rename zh-CN source subtitle download label
- [#1283](https://github.com/mengxi-ream/read-frog/pull/1283) [`219a8d2`](https://github.com/mengxi-ream/read-frog/commit/219a8d29c6a093b822a56cec43e1c3336778e4c9) Thanks [@taiiiyang](https://github.com/taiiiyang)! - feat(subtitles): add a settings toggle menu for video subtitles
- [#1295](https://github.com/mengxi-ream/read-frog/pull/1295) [`75fafc5`](https://github.com/mengxi-ream/read-frog/commit/75fafc5a3e198667094bdbffa6b77858ce49d499) Thanks [@ananaBMaster](https://github.com/ananaBMaster)! - refactor: unify webpage translation context and prompt flow
- [#1287](https://github.com/mengxi-ream/read-frog/pull/1287) [`4a56faa`](https://github.com/mengxi-ream/read-frog/commit/4a56faa44c47f8157528fb9bd734a0e51712004c) Thanks [@ishiko732](https://github.com/ishiko732)! - style: improve selection preview scrolling in the selection toolbar
- [#1297](https://github.com/mengxi-ream/read-frog/pull/1297) [`acdd296`](https://github.com/mengxi-ream/read-frog/commit/acdd296e19681b4d2987f2edc2dbdbedd6cd57c8) Thanks [@frogGuaGuaGuaGua](https://github.com/frogGuaGuaGuaGua)! - fix(extension): soften page translation loading spinner with a thinner muted-gray arc
- [#1312](https://github.com/mengxi-ream/read-frog/pull/1312) [`f344e0d`](https://github.com/mengxi-ream/read-frog/commit/f344e0d7dd297d62b33d363ce710ee5ff8fccf1f) Thanks [@taiiiyang](https://github.com/taiiiyang)! - refactor(subtitles): reuse prefetched source subtitles across download and translation
- [#1300](https://github.com/mengxi-ream/read-frog/pull/1300) [`da8d937`](https://github.com/mengxi-ream/read-frog/commit/da8d9376e77939b2ac7be6e85653e467bbcb8019) Thanks [@frogGuaGuaGuaGua](https://github.com/frogGuaGuaGuaGua)! - fix(extension): namespace theme tokens to avoid shadow-root css collisions
- [#1299](https://github.com/mengxi-ream/read-frog/pull/1299) [`1464d77`](https://github.com/mengxi-ream/read-frog/commit/1464d774fddc34c950cf7f44852388af65e3e503) Thanks [@frogGuaGuaGuaGua](https://github.com/frogGuaGuaGuaGua)! - docs(extension): document the real-browser Edge extension testing workflow for UI verification
- [#1303](https://github.com/mengxi-ream/read-frog/pull/1303) [`3e9f374`](https://github.com/mengxi-ream/read-frog/commit/3e9f374770841d0c03aa66d817958aa5f8035485) Thanks [@ananaBMaster](https://github.com/ananaBMaster)! - fix(page-translation): only prime webpage context for AI-aware title translation
- [#1314](https://github.com/mengxi-ream/read-frog/pull/1314) [`788edfb`](https://github.com/mengxi-ream/read-frog/commit/788edfb5ce8ce09f6865726e65f1f67ee68f5433) Thanks [@frogGuaGuaGuaGua](https://github.com/frogGuaGuaGuaGua)! - fix(extension): allow the page pre-translate preload distance to be set up to 10000px
- [#1302](https://github.com/mengxi-ream/read-frog/pull/1302) [`f1d9256`](https://github.com/mengxi-ream/read-frog/commit/f1d92569ba224571fae5b221f0432804f1af9f1e) Thanks [@frogGuaGuaGuaGua](https://github.com/frogGuaGuaGuaGua)! - fix(tts): change default English voice to Davis and colocate preview button
- [#1313](https://github.com/mengxi-ream/read-frog/pull/1313) [`1951736`](https://github.com/mengxi-ream/read-frog/commit/19517361ceb546989d707bec5fe2675ba1840fb9) Thanks [@taiiiyang](https://github.com/taiiiyang)! - Optimize YouTube subtitle fetching by trying a fast timedtext fetch from the initial player data snapshot before falling back to the slower POT/wait flow.
- [#1307](https://github.com/mengxi-ream/read-frog/pull/1307) [`38be1ed`](https://github.com/mengxi-ream/read-frog/commit/38be1edea97040ecda6753bb557711a76c08aa35) Thanks [@taiiiyang](https://github.com/taiiiyang)! - feat(subtitles): add source subtitle download as SRT
## 1.32.1
### Patch Changes

192
CLAUDE.md
View file

@ -1,192 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Read Frog is an open-source AI-powered language learning browser extension that provides immersive translation, article analysis, and contextual learning features.
## Design Principles
- **SOLID**
- **S**ingle Responsibility: one reason to change per unit.
- **O**pen/Closed: extend via **composition**; avoid modifying stable core.
- **L**iskov: subtypes must be true drop-ins.
- **I**nterface Segregation: small, focused interfaces.
- **D**ependency Inversion: depend on abstractions; wire via DI.
- **DRY, KISS, YAGNI**
- No duplication; simplest thing that could work; dont build for imaginary futures.
- **Functional Lean**
- Prefer **pure functions**, **immutability**, and **clear boundaries** (ports & adapters / hexagonal).
## Requirements Confirmation Process
Whenever users express demands, must follow these steps:
1. **Thinking Prerequisites - Linus's Three Questions**
Before starting any analysis, ask yourself:
```text
1. "Is this a real problem or an imagined one?" - Reject over-engineering
2. "Is there a simpler way?" - Always seek the simplest solution
3. "Will it break anything?" - Backward compatibility is an iron law
```
2. **Requirements Understanding Confirmation**
```text
Based on existing information, I understand your requirement is: [Restate requirement using Linus's thinking communication style]
Please confirm if my understanding is accurate?
```
2. **Linus-style Problem Decomposition Thinking**
**Layer 1: Data Structure Analysis**
```text
"Bad programmers worry about the code. Good programmers worry about data structures."
- What is the core data? How are they related?
- Where does the data flow? Who owns it? Who modifies it?
- Are there unnecessary data copying or transformations?
```
**Layer 2: Special Case Identification**
```text
"Good code has no special cases"
- Find all if/else branches
- Which are real business logic? Which are patches for bad design?
- Can we redesign data structures to eliminate these branches?
```
**Layer 3: Complexity Review**
```text
"If implementation requires more than 3 levels of indentation, redesign it"
- What is the essence of this feature? (Explain in one sentence)
- How many concepts does the current solution use?
- Can we reduce it to half? Half again?
```
**Layer 4: Destructive Analysis**
```text
"Never break userspace" - Backward compatibility is an iron law
- List all existing features that might be affected
- Which dependencies will be broken?
- How to improve without breaking anything?
```
**Layer 5: Practicality Verification**
```text
"Theory and practice sometimes clash. Theory loses. Every single time."
- Does this problem really exist in production?
- How many users actually encounter this problem?
- Does the complexity of the solution match the severity of the problem?
```
3. **Decision Output Pattern**
After the above 5 layers of thinking, output must include (format can be prettier align with markdown of Github):
```text
【Core Judgment】
✅ Worth doing: [reason] / ❌ Not worth doing: [reason]
【Key Insights】
- Data structure: [most critical data relationships]
- Complexity: [complexity that can be eliminated]
- Risk points: [biggest destructive risks]
【Linus-style Solution】
If worth doing:
1. First step is always to simplify data structures
2. Eliminate all special cases
3. Implement in the stupidest but clearest way
4. Ensure zero destructiveness
If not worth doing:
"This is solving a non-existent problem. The real problem is [XXX]."
```
4. **Code Review Output**
When seeing code, immediately make three-layer judgment:
```text
【Taste Score】
🟢 Good taste / 🟡 Acceptable / 🔴 Poor quality
【Fatal Issues】
- [If any, directly point out the worst parts]
【Improvement Direction】
"Eliminate this special case"
"These 10 lines can become 3 lines"
"Data structure is wrong, should be..."
```
## Tech Stack
- **Framework**: [WXT](https://wxt.dev/) - Modern browser extension framework with Manifest V3
- **UI**: React 19, TailwindCSS 4, Radix UI, shadcn/ui components
- **Language**: TypeScript
- **State Management**: Jotai with custom storage adapter for extension storage sync
- **Database**: Dexie (IndexedDB wrapper) for local data persistence
- **AI Integration**: Vercel AI SDK with 20+ provider integrations
- **Testing**: Vitest with Istanbul coverage
- **Build**: Vite-based bundler (via WXT)
- **i18n**: @wxt-dev/i18n with YML locale files in `src/locales/`
## Development Commands
### Core Development
```bash
# Start development mode (Chrome)
pnpm dev
# Start development with local monorepo packages
pnpm dev:local
```
### Testing and Quality
```bash
# Run all tests
pnpm test
# Run tests in watch mode
pnpm test:watch
# Run tests with coverage
pnpm test:cov
# Type checking
pnpm type-check
# Linting
pnpm lint
pnpm lint:fix
```
## Commit Conventions
Follow Conventional Commits with these types:
Standard types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`
Custom types: `i18n`, `ai`
## Important Notes
As AI, you should be extremely concise. Sacrifice grammar for the sake of concision.

View file

@ -6,7 +6,7 @@ An open-source AI-powered language learning extension for browsers.<br/>
Supports immersive translation, article analysis, multiple AI models, and more.<br/>
Master languages effortlessly and deeply with AI, right in your browser.
**English** · [简体中文](./README.zh-CN.md) · [Official Website](https://readfrog.app) · [Tutorial](https://www.readfrog.app/tutorial) · [Changelog](https://www.readfrog.app/changelog) · [Blog](https://www.readfrog.app/blog)
**English** · [简体中文](./README.zh-CN.md) · [Official Website](https://readfrog.app) · [Tutorial](https://www.readfrog.app/docs) · [Changelog](https://www.readfrog.app/changelog) · [Blog](https://www.readfrog.app/blog)
<!-- SHIELD GROUP -->
@ -46,7 +46,6 @@ Master languages effortlessly and deeply with AI, right in your browser.
- [🤖 20+ AI Providers](#-20-ai-providers)
- [🎬 Subtitle Translation](#-subtitle-translation)
- [🔊 Text-to-Speech (TTS)](#-text-to-speech-tts)
- [📖 Read Article](#-read-article)
- [🤝 Contribute](#-contribute)
- [Contribute Code](#contribute-code)
- [📜 Commercial License Grant](#-commercial-license-grant)
@ -134,7 +133,7 @@ The extension automatically re-translates all visible content when you switch mo
### 🧠 [Context-Aware Translation][docs-tutorial]
Enable AI to understand the full context of what you're reading. When activated, Read Frog uses Mozilla's Readability library to extract the article's title and content, providing this context to the AI for more accurate, contextually-appropriate translations.
Enable AI to understand the full context of what you're reading. When activated, Read Frog extracts the page title and a concise Markdown version of the page content, providing this context to the AI for more accurate, contextually-appropriate translations.
This means technical terms get translated correctly within their domain, literary expressions maintain their nuance, and ambiguous phrases are interpreted based on the surrounding content rather than in isolation.
@ -226,20 +225,6 @@ Automatic language detection (basic or LLM-powered) with per-language voice mapp
</div>
<!-- ![][image-feat-read] -->
### 📖 [Read Article][docs-tutorial]
One-click deep article analysis. Read Frog extracts the main content using Mozilla's Readability, detects the source language, and generates a summary and introduction in your target language.
Then it provides sentence-by-sentence translations with vocabulary explanations tailored to your language level (beginner, intermediate, or advanced). Each sentence includes key word definitions, grammatical analysis, and contextual explanations. It's like having a personal language tutor analyze every article you read.
<div align="right">
[![Back to top][back-to-top]](#readme-top)
</div>
## 🤝 Contribute
Contributions of all types are more than welcome.
@ -254,7 +239,7 @@ Project Structure: [DeepWiki](https://deepwiki.com/mengxi-ream/read-frog)
Ask AI to understand the project: [Dosu](https://app.dosu.dev/29569286-71ba-47dd-b038-c7ab1b9d0df7/documents)
Check out the [Contribution Guide](https://readfrog.app/en/tutorial/code-contribution/contribution-guide) for more details.
Check out the [Contribution Guide](https://readfrog.app/en/docs/code-contribution/contribution-guide) for more details.
ReadFrog is dual-licensed under GPLv3 and a commercial license.
@ -308,8 +293,6 @@ Every donation helps us build a better language learning experience. Thank you f
[![Sponsors][sponsor-image]][sponsor-link]
(will support Afdian in the future)
<div align="right">
[![Back to top][back-to-top]](#readme-top)
@ -353,4 +336,4 @@ Every donation helps us build a better language learning experience. Thank you f
<!-- Feature docs link -->
[docs-tutorial]: https://readfrog.app/tutorial
[docs-tutorial]: https://readfrog.app/docs

View file

@ -6,7 +6,7 @@
支持沉浸式翻译、文章分析、多种 AI 模型等功能。<br/>
在浏览器中利用 AI 轻松深入地掌握语言。
[English](./README.md) · **简体中文** · [官方网站](https://readfrog.app) · [教程](https://www.readfrog.app/zh/tutorial) · [更新日志](https://www.readfrog.app/zh/changelog) · [博客](https://www.readfrog.app/zh/blog)
[English](./README.md) · **简体中文** · [官方网站](https://readfrog.app) · [教程](https://www.readfrog.app/zh/docs) · [更新日志](https://www.readfrog.app/zh/changelog) · [博客](https://www.readfrog.app/zh/blog)
<!-- SHIELD GROUP -->
@ -46,7 +46,6 @@
- [🤖 20+ AI 服务商](#-20-ai-服务商)
- [🎬 字幕翻译](#-字幕翻译)
- [🔊 文字转语音 (TTS)](#-文字转语音-tts)
- [📖 阅读文章](#-阅读文章)
- [🤝 贡献](#-贡献)
- [贡献代码](#贡献代码)
- [📜 商业授权](#-商业授权)
@ -134,7 +133,7 @@ Read Frog 的愿景是为各个级别的语言学习者提供易于使用、智
### 🧠 [上下文感知翻译][docs-tutorial]
让 AI 理解您正在阅读内容的完整上下文。启用后Read Frog 使用 Mozilla 的 Readability 库提取文章的标题和内容,将此上下文提供给 AI以获得更准确、更符合语境的翻译。
让 AI 理解您正在阅读内容的完整上下文。启用后Read Frog 会提取页面标题和简洁的 Markdown 页面内容,将此上下文提供给 AI以获得更准确、更符合语境的翻译。
这意味着技术术语会在其领域内被正确翻译,文学表达会保持其韵味,歧义短语会根据周围内容而非孤立地进行解释。
@ -226,20 +225,6 @@ Read Frog 的愿景是为各个级别的语言学习者提供易于使用、智
</div>
<!-- ![][image-feat-read] -->
### 📖 [阅读文章][docs-tutorial]
一键深度文章分析。Read Frog 使用 Mozilla 的 Readability 提取主要内容,检测源语言,并用您的目标语言生成摘要和导读。
然后提供逐句翻译,配合根据您的语言水平(初级、中级或高级)定制的词汇解释。每个句子都包含关键词定义、语法分析和上下文解释。就像有一位私人语言导师分析您阅读的每篇文章。
<div align="right">
[![Back to top][back-to-top]](#readme-top)
</div>
## 🤝 贡献
我们欢迎各种类型的贡献。
@ -252,7 +237,7 @@ Read Frog 的愿景是为各个级别的语言学习者提供易于使用、智
通过 AI 了解项目:[DeepWiki](https://deepwiki.com/mengxi-ream/read-frog)
查看[贡献指南](https://readfrog.app/zh/tutorial/code-contribution/contribution-guide)了解更多详情。
查看[贡献指南](https://readfrog.app/zh/docs/code-contribution/contribution-guide)了解更多详情。
ReadFrog 采用 GPLv3 和商业许可双重授权。
@ -349,4 +334,4 @@ ReadFrog 采用 GPLv3 和商业许可双重授权。
<!-- Feature docs link -->
[docs-tutorial]: https://readfrog.app/zh/tutorial
[docs-tutorial]: https://readfrog.app/zh/docs

View file

@ -98,4 +98,16 @@ export default antfu({
"test/no-identical-title": "error",
"test/prefer-hooks-on-top": "error",
},
}).append({
files: [
"**/__tests__/**/*.ts",
"**/__tests__/**/*.tsx",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts",
"**/*.spec.tsx",
],
rules: {
"react/component-hook-factories": "off",
},
})

View file

@ -1,89 +1,90 @@
{
"name": "@read-frog/extension",
"type": "module",
"version": "1.32.1",
"version": "1.33.1",
"private": true,
"packageManager": "pnpm@10.32.1",
"description": "Read Frog browser extension for language learning",
"scripts": {
"build": "DOTENV_CONFIG_QUIET=false wxt build",
"build:edge": "DOTENV_CONFIG_QUIET=false wxt build -b edge",
"build:firefox": "DOTENV_CONFIG_QUIET=false wxt build -b firefox --mv3",
"build": "wxt build",
"build:edge": "wxt build -b edge",
"build:firefox": "wxt build -b firefox --mv3",
"build:analyze": "wxt build --analyze",
"dev:local": "WXT_USE_LOCAL_PACKAGES=true wxt",
"dev": "DOTENV_CONFIG_QUIET=false wxt",
"dev:edge": "DOTENV_CONFIG_QUIET=false wxt -b edge",
"dev:firefox": "DOTENV_CONFIG_QUIET=false wxt -b firefox --mv3",
"dev": "wxt",
"dev:edge": "wxt -b edge",
"dev:firefox": "wxt -b firefox --mv3",
"lint": "eslint",
"lint:fix": "eslint --fix",
"postinstall": "wxt prepare",
"postinstall": "WXT_SKIP_ENV_VALIDATION=true wxt prepare",
"test": "vitest run",
"test:cov": "vitest run --coverage",
"test:watch": "vitest",
"type-check": "tsc --noEmit",
"scrape:ai-sdk-models": "npx tsx scripts/scrape-ai-sdk-provider-models.ts --out scripts/output/ai-sdk-provider-models.json",
"zip": "DOTENV_CONFIG_QUIET=false WXT_ZIP_MODE=true wxt zip",
"zip:edge": "DOTENV_CONFIG_QUIET=false WXT_ZIP_MODE=true wxt zip -b edge",
"zip:firefox": "DOTENV_CONFIG_QUIET=false WXT_ZIP_MODE=true wxt zip -b firefox --mv3",
"zip:all": "DOTENV_CONFIG_QUIET=false pnpm zip && pnpm zip:edge && pnpm zip:firefox",
"zip": "wxt zip",
"zip:edge": "wxt zip -b edge",
"zip:firefox": "wxt zip -b firefox --mv3",
"zip:all": "pnpm zip && pnpm zip:edge && pnpm zip:firefox",
"release": "changeset tag && git push origin --tags",
"prepare": "husky"
},
"dependencies": {
"@ai-sdk/alibaba": "^1.0.13",
"@ai-sdk/amazon-bedrock": "^4.0.87",
"@ai-sdk/anthropic": "^3.0.64",
"@ai-sdk/cerebras": "^2.0.41",
"@ai-sdk/cohere": "^3.0.27",
"@ai-sdk/deepinfra": "^2.0.41",
"@ai-sdk/deepseek": "^2.0.26",
"@ai-sdk/fireworks": "^2.0.42",
"@ai-sdk/google": "^3.0.55",
"@ai-sdk/groq": "^3.0.32",
"@ai-sdk/huggingface": "^1.0.39",
"@ai-sdk/mistral": "^3.0.27",
"@ai-sdk/moonshotai": "^2.0.12",
"@ai-sdk/openai": "^3.0.49",
"@ai-sdk/openai-compatible": "^2.0.37",
"@ai-sdk/perplexity": "^3.0.26",
"@ai-sdk/react": "^3.0.145",
"@ai-sdk/replicate": "^2.0.26",
"@ai-sdk/togetherai": "^2.0.41",
"@ai-sdk/vercel": "^2.0.39",
"@ai-sdk/xai": "^3.0.75",
"@base-ui/react": "^1.3.0",
"@ai-sdk/alibaba": "^1.0.17",
"@ai-sdk/amazon-bedrock": "^4.0.96",
"@ai-sdk/anthropic": "^3.0.71",
"@ai-sdk/cerebras": "^2.0.45",
"@ai-sdk/cohere": "^3.0.30",
"@ai-sdk/deepinfra": "^2.0.45",
"@ai-sdk/deepseek": "^2.0.29",
"@ai-sdk/fireworks": "^2.0.46",
"@ai-sdk/google": "^3.0.64",
"@ai-sdk/groq": "^3.0.35",
"@ai-sdk/huggingface": "^1.0.43",
"@ai-sdk/mistral": "^3.0.30",
"@ai-sdk/moonshotai": "^2.0.16",
"@ai-sdk/openai": "^3.0.53",
"@ai-sdk/openai-compatible": "^2.0.41",
"@ai-sdk/perplexity": "^3.0.29",
"@ai-sdk/react": "^3.0.170",
"@ai-sdk/replicate": "^2.0.29",
"@ai-sdk/togetherai": "^2.0.45",
"@ai-sdk/vercel": "^2.0.43",
"@ai-sdk/xai": "^3.0.83",
"@babel/runtime": "^7.29.2",
"@base-ui/react": "^1.4.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lint": "^6.9.5",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.41.0",
"@codemirror/view": "^6.41.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headless-tree/core": "^1.6.3",
"@headless-tree/react": "^1.6.3",
"@json-render/core": "^0.16.0",
"@json-render/react": "^0.16.0",
"@mozilla/readability": "^0.6.0",
"@openrouter/ai-sdk-provider": "^2.3.3",
"@orpc/client": "^1.13.13",
"@orpc/tanstack-query": "^1.13.13",
"@json-render/core": "^0.18.0",
"@json-render/react": "^0.18.0",
"@openrouter/ai-sdk-provider": "^2.8.0",
"@orpc/client": "^1.14.0",
"@orpc/tanstack-query": "^1.14.0",
"@radix-ui/react-slot": "^1.2.4",
"@read-frog/api-contract": "0.2.2",
"@read-frog/definitions": "0.1.2",
"@read-frog/api-contract": "0.4.1",
"@read-frog/definitions": "0.1.4",
"@remixicon/react": "^4.9.0",
"@t3-oss/env-core": "^0.13.11",
"@tabler/icons-react": "^3.41.1",
"@tanstack/hotkeys": "^0.7.1",
"@tanstack/react-form": "^1.28.6",
"@tanstack/react-form": "^1.29.1",
"@tanstack/react-hotkeys": "^0.9.1",
"@tanstack/react-query": "^5.96.1",
"@tanstack/react-query": "^5.100.1",
"@uiw/codemirror-extensions-color": "^4.25.9",
"@uiw/react-codemirror": "^4.25.9",
"@webext-core/messaging": "^2.3.0",
"@wxt-dev/i18n": "^0.2.5",
"ai": "^6.0.143",
"better-auth": "^1.5.6",
"ai": "^6.0.168",
"better-auth": "^1.6.8",
"case-anything": "^3.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -91,23 +92,24 @@
"css-tree": "^3.2.1",
"debounce": "^3.0.0",
"deepmerge-ts": "^7.1.5",
"defuddle": "^0.18.1",
"dequal": "^2.0.3",
"dexie": "^4.4.2",
"file-saver": "^2.0.5",
"franc": "^6.2.0",
"jotai": "^2.19.0",
"jotai": "^2.19.1",
"jotai-family": "^1.0.1",
"js-sha256": "^0.11.1",
"ollama-ai-provider-v2": "^3.5.0",
"posthog-js": "^1.364.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"posthog-js": "^1.371.2",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-error-boundary": "^6.1.1",
"react-markdown": "^10.1.0",
"react-rnd": "^10.5.3",
"react-router": "^7.14.0",
"react-router": "^7.14.2",
"recharts": "^3.8.1",
"shadcn": "^4.1.2",
"shadcn": "^4.4.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
@ -115,17 +117,17 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@antfu/eslint-config": "^7.7.3",
"@antfu/eslint-config": "^8.2.0",
"@changesets/changelog-github": "^0.6.0",
"@changesets/cli": "^2.30.0",
"@changesets/cli": "^2.31.0",
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@eslint-react/eslint-plugin": "^2.13.0",
"@eslint-react/eslint-plugin": "^3.0.0",
"@faker-js/faker": "^10.4.0",
"@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4.2.2",
"@tanstack/eslint-plugin-query": "^5.95.2",
"@tanstack/react-query-devtools": "^5.95.2",
"@tailwindcss/postcss": "^4.2.4",
"@tanstack/eslint-plugin-query": "^5.99.2",
"@tanstack/react-query-devtools": "^5.99.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@total-typescript/ts-reset": "^0.6.1",
@ -135,27 +137,26 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-istanbul": "^4.1.2",
"@vitest/coverage-istanbul": "^4.1.5",
"@wxt-dev/module-react": "^1.2.2",
"autoprefixer": "^10.4.27",
"autoprefixer": "^10.5.0",
"eruda": "^3.4.3",
"eslint": "^10.1.0",
"eslint": "^10.2.1",
"eslint-plugin-format": "^2.0.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"husky": "^9.1.7",
"jsdom": "^29.0.1",
"jsdom": "^29.0.2",
"lint-staged": "^16.4.0",
"nx": "^22.6.2",
"postcss": "^8.5.8",
"postcss-rem-to-responsive-pixel": "^7.0.1",
"nx": "^22.6.5",
"postcss": "^8.5.10",
"postcss-rem-to-responsive-pixel": "^7.0.4",
"postcss-value-parser": "^4.2.0",
"tailwindcss": "^4.2.2",
"type-fest": "^5.5.0",
"typescript": "^6.0.2",
"vite": "^8.0.3",
"vitest": "^4.1.2",
"wxt": "0.20.20"
"tailwindcss": "^4.2.4",
"type-fest": "^5.6.0",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vitest": "^4.1.5",
"wxt": "0.20.25"
},
"devEngines": {
"runtime": {

5257
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="m 8 1 c -1.65625 0 -3 1.34375 -3 3 s 1.34375 3 3 3 s 3 -1.34375 3 -3 s -1.34375 -3 -3 -3 z m -1.5 7 c -2.492188 0 -4.5 2.007812 -4.5 4.5 v 0.5 c 0 1.109375 0.890625 2 2 2 h 8 c 1.109375 0 2 -0.890625 2 -2 v -0.5 c 0 -2.492188 -2.007812 -4.5 -4.5 -4.5 z m 0 0" fill="#6a7282"/>
</svg>
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path transform="translate(8 8) scale(0.75) translate(-8 -8)" d="m 8 1 c -1.65625 0 -3 1.34375 -3 3 s 1.34375 3 3 3 s 3 -1.34375 3 -3 s -1.34375 -3 -3 -3 z m -1.5 7 c -2.492188 0 -4.5 2.007812 -4.5 4.5 v 0.5 c 0 1.109375 0.890625 2 2 2 h 8 c 1.109375 0 2 -0.890625 2 -2 v -0.5 c 0 -2.492188 -2.007812 -4.5 -4.5 -4.5 z m 0 0" fill="#6a7282"/>
</svg>

Before

Width:  |  Height:  |  Size: 510 B

After

Width:  |  Height:  |  Size: 565 B

Before After
Before After

View file

@ -1 +1 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M22.75 17.5C22.75 17.91 22.41 18.25 22 18.25H15V18.5C15 20 14.1 20.5 13 20.5H7C5.9 20.5 5 20 5 18.5V18.25H2C1.59 18.25 1.25 17.91 1.25 17.5C1.25 17.09 1.59 16.75 2 16.75H5V16.5C5 15 5.9 14.5 7 14.5H13C14.1 14.5 15 15 15 16.5V16.75H22C22.41 16.75 22.75 17.09 22.75 17.5Z" fill="oklch(69.6% 0.17 162.48)"></path> <path opacity="0.4" d="M22.75 6.5C22.75 6.91 22.41 7.25 22 7.25H19V7.5C19 9 18.1 9.5 17 9.5H11C9.9 9.5 9 9 9 7.5V7.25H2C1.59 7.25 1.25 6.91 1.25 6.5C1.25 6.09 1.59 5.75 2 5.75H9V5.5C9 4 9.9 3.5 11 3.5H17C18.1 3.5 19 4 19 5.5V5.75H22C22.41 5.75 22.75 6.09 22.75 6.5Z" fill="oklch(69.6% 0.17 162.48)"></path> </g></svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M22.75 17.5C22.75 17.91 22.41 18.25 22 18.25H15V18.5C15 20 14.1 20.5 13 20.5H7C5.9 20.5 5 20 5 18.5V18.25H2C1.59 18.25 1.25 17.91 1.25 17.5C1.25 17.09 1.59 16.75 2 16.75H5V16.5C5 15 5.9 14.5 7 14.5H13C14.1 14.5 15 15 15 16.5V16.75H22C22.41 16.75 22.75 17.09 22.75 17.5Z" fill="oklch(76.034% 0.12361 82.191)"></path> <path opacity="0.4" d="M22.75 6.5C22.75 6.91 22.41 7.25 22 7.25H19V7.5C19 9 18.1 9.5 17 9.5H11C9.9 9.5 9 9 9 7.5V7.25H2C1.59 7.25 1.25 6.91 1.25 6.5C1.25 6.09 1.59 5.75 2 5.75H9V5.5C9 4 9.9 3.5 11 3.5H17C18.1 3.5 19 4 19 5.5V5.75H22C22.41 5.75 22.75 6.09 22.75 6.5Z" fill="oklch(76.034% 0.12361 82.191)"></path> </g></svg>

Before

Width:  |  Height:  |  Size: 866 B

After

Width:  |  Height:  |  Size: 876 B

Before After
Before After

View file

@ -1,11 +0,0 @@
import { readFileSync } from "node:fs"
import { describe, expect, it } from "vitest"
const translationNodePresetCss = readFileSync(new URL("../translation-node-preset.css", import.meta.url), "utf8")
describe("translation-node-preset.css", () => {
it("keeps a float-wrap override for block translations", () => {
expect(translationNodePresetCss).toContain(".read-frog-translated-block-content[data-read-frog-float-wrap=\"true\"]")
expect(translationNodePresetCss).toContain("display: block !important;")
})
})

View file

@ -14,7 +14,7 @@
}
.read-frog-translated-block-content[data-read-frog-custom-translation-style="blockquote"] {
border-left: 4px solid var(--read-frog-primary);
border-left: 4px solid var(--read-frog-brand);
padding: 4px 0 4px 8px;
}
@ -24,22 +24,22 @@
}
[data-read-frog-custom-translation-style="dashedLine"] {
text-decoration: underline dashed var(--read-frog-primary) !important;
text-decoration: underline dashed var(--read-frog-brand) !important;
text-underline-offset: 5px;
}
[data-read-frog-custom-translation-style="border"] {
border: 1px solid var(--read-frog-primary);
border: 1px solid var(--read-frog-brand);
padding: 2px 4px;
border-radius: 4px;
}
[data-read-frog-custom-translation-style="textColor"] {
color: var(--read-frog-primary) !important;
color: var(--read-frog-brand) !important;
}
[data-read-frog-custom-translation-style="background"] {
background-color: color-mix(in srgb, var(--read-frog-primary) 15%, transparent);
background-color: color-mix(in srgb, var(--read-frog-brand) 15%, transparent);
padding: 2px 4px;
border-radius: 4px;
}

View file

@ -1,12 +1,16 @@
:root {
--read-frog-primary: oklch(76.5% 0.177 163.223);
--read-frog-primary: oklch(0.205 0 0);
--read-frog-brand: oklch(76.034% 0.12361 82.191);
--read-frog-foreground: oklch(0.985 0 0);
--read-frog-muted: oklch(0.97 0 0);
--read-frog-muted-foreground: oklch(0.556 0 0);
}
@media (prefers-color-scheme: dark) {
:root {
--read-frog-primary: oklch(59.6% 0.145 163.225);
--read-frog-primary: oklch(0.922 0 0);
--read-frog-brand: oklch(76.034% 0.12361 82.191);
--read-frog-foreground: oklch(0.205 0 0);
--read-frog-muted: oklch(0.269 0 0);
--read-frog-muted-foreground: oklch(0.708 0 0);
}

View file

@ -8,129 +8,139 @@
@custom-variant data-vertical (&[data-orientation="vertical"]);
@theme inline {
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
/* Use read-frog-prefixed theme tokens to avoid collisions with host-page custom properties inside shadow roots. */
--font-sans: var(--rf-font-sans);
--font-mono: var(--rf-font-mono);
--color-background: var(--rf-background);
--color-foreground: var(--rf-foreground);
--color-card: var(--rf-card);
--color-card-foreground: var(--rf-card-foreground);
--color-popover: var(--rf-popover);
--color-popover-foreground: var(--rf-popover-foreground);
--color-primary: var(--rf-primary);
--color-primary-foreground: var(--rf-primary-foreground);
--color-brand: var(--rf-brand);
--color-brand-foreground: var(--rf-brand-foreground);
--color-secondary: var(--rf-secondary);
--color-secondary-foreground: var(--rf-secondary-foreground);
--color-muted: var(--rf-muted);
--color-muted-foreground: var(--rf-muted-foreground);
--color-accent: var(--rf-accent);
--color-accent-foreground: var(--rf-accent-foreground);
--color-destructive: var(--rf-destructive);
--color-destructive-foreground: var(--rf-destructive-foreground);
--color-border: var(--rf-border);
--color-input: var(--rf-input);
--color-ring: var(--rf-ring);
--color-chart-1: var(--rf-chart-1);
--color-chart-2: var(--rf-chart-2);
--color-chart-3: var(--rf-chart-3);
--color-chart-4: var(--rf-chart-4);
--color-chart-5: var(--rf-chart-5);
--color-sidebar: var(--rf-sidebar);
--color-sidebar-foreground: var(--rf-sidebar-foreground);
--color-sidebar-primary: var(--rf-sidebar-primary);
--color-sidebar-primary-foreground: var(--rf-sidebar-primary-foreground);
--color-sidebar-accent: var(--rf-sidebar-accent);
--color-sidebar-accent-foreground: var(--rf-sidebar-accent-foreground);
--color-sidebar-border: var(--rf-sidebar-border);
--color-sidebar-ring: var(--rf-sidebar-ring);
--radius-sm: calc(var(--rf-radius) - 4px);
--radius-md: calc(var(--rf-radius) - 2px);
--radius-lg: var(--rf-radius);
--radius-xl: calc(var(--rf-radius) + 4px);
--radius-2xl: calc(var(--rf-radius) + 8px);
--radius-3xl: calc(var(--rf-radius) + 12px);
--radius-4xl: calc(var(--rf-radius) + 16px);
/* Additional theme variables */
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-link: var(--link);
--shadow-floating: var(--elevation-floating);
--color-warning: var(--rf-warning);
--color-warning-foreground: var(--rf-warning-foreground);
--color-link: var(--rf-link);
--shadow-floating: var(--rf-elevation-floating);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.693 0.17 162.48);
--primary-foreground: oklch(0.98 0.02 166);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.47 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.85 0.13 165);
--chart-2: oklch(0.77 0.15 163);
--chart-3: oklch(0.7 0.15 162);
--chart-4: oklch(0.6 0.13 163);
--chart-5: oklch(0.51 0.1 166);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.6 0.13 163);
--sidebar-primary-foreground: oklch(0.98 0.02 166);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--rf-font-sans:
ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--rf-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--rf-background: oklch(1 0 0);
--rf-foreground: oklch(0.141 0.005 285.823);
--rf-card: oklch(1 0 0);
--rf-card-foreground: oklch(0.141 0.005 285.823);
--rf-popover: oklch(1 0 0);
--rf-popover-foreground: oklch(0.141 0.005 285.823);
--rf-primary: oklch(0.205 0 0);
--rf-primary-foreground: oklch(0.985 0 0);
--rf-brand: oklch(76.034% 0.12361 82.191);
--rf-brand-foreground: oklch(0.985 0 0);
--rf-secondary: oklch(0.967 0.001 286.375);
--rf-secondary-foreground: oklch(0.47 0.006 285.885);
--rf-muted: oklch(0.967 0.001 286.375);
--rf-muted-foreground: oklch(0.552 0.016 285.938);
--rf-accent: oklch(0.967 0.001 286.375);
--rf-accent-foreground: oklch(0.21 0.006 285.885);
--rf-destructive: oklch(0.577 0.245 27.325);
--rf-border: oklch(0.92 0.004 286.32);
--rf-input: oklch(0.92 0.004 286.32);
--rf-ring: oklch(0.705 0.015 286.067);
--rf-chart-1: oklch(89.215% 0.05562 82.191);
--rf-chart-2: oklch(82.026% 0.09271 82.191);
--rf-chart-3: oklch(76.034% 0.12361 82.191);
--rf-chart-4: oklch(57.026% 0.09271 82.191);
--rf-chart-5: oklch(38.017% 0.06181 82.191);
--rf-radius: 0.625rem;
--rf-sidebar: oklch(0.985 0 0);
--rf-sidebar-foreground: oklch(0.141 0.005 285.823);
--rf-sidebar-primary: oklch(0.205 0 0);
--rf-sidebar-primary-foreground: oklch(0.985 0 0);
--rf-sidebar-accent: oklch(0.967 0.001 286.375);
--rf-sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--rf-sidebar-border: oklch(0.92 0.004 286.32);
--rf-sidebar-ring: oklch(0.705 0.015 286.067);
/* Additional theme variables */
--warning: oklch(97.3% 0.071 103.193);
--link: oklch(54.6% 0.245 262.881);
--elevation-floating: 0 8px 40px 0 rgb(0 0 0 / 0.08), 0 0 1px 0 rgb(0 0 0 / 0.08);
--rf-warning: oklch(97.3% 0.071 103.193);
--rf-link: oklch(54.6% 0.245 262.881);
--rf-elevation-floating: 0 8px 40px 0 rgb(0 0 0 / 0.08), 0 0 1px 0 rgb(0 0 0 / 0.08);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.693 0.17 162.48);
--primary-foreground: oklch(0.26 0.05 173);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.775 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.85 0.13 165);
--chart-2: oklch(0.77 0.15 163);
--chart-3: oklch(0.7 0.15 162);
--chart-4: oklch(0.6 0.13 163);
--chart-5: oklch(0.51 0.1 166);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.77 0.15 163);
--sidebar-primary-foreground: oklch(0.26 0.05 173);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--rf-background: oklch(0.141 0.005 285.823);
--rf-foreground: oklch(0.985 0 0);
--rf-card: oklch(0.21 0.006 285.885);
--rf-card-foreground: oklch(0.985 0 0);
--rf-popover: oklch(0.21 0.006 285.885);
--rf-popover-foreground: oklch(0.985 0 0);
--rf-primary: oklch(0.922 0 0);
--rf-primary-foreground: oklch(0.205 0 0);
--rf-brand: oklch(76.034% 0.12361 82.191);
--rf-brand-foreground: oklch(0.205 0 0);
--rf-secondary: oklch(0.274 0.006 286.033);
--rf-secondary-foreground: oklch(0.775 0 0);
--rf-muted: oklch(0.274 0.006 286.033);
--rf-muted-foreground: oklch(0.705 0.015 286.067);
--rf-accent: oklch(0.274 0.006 286.033);
--rf-accent-foreground: oklch(0.985 0 0);
--rf-destructive: oklch(0.704 0.191 22.216);
--rf-border: oklch(1 0 0 / 10%);
--rf-input: oklch(1 0 0 / 15%);
--rf-ring: oklch(0.552 0.016 285.938);
--rf-chart-1: oklch(38.017% 0.06181 82.191);
--rf-chart-2: oklch(57.026% 0.09271 82.191);
--rf-chart-3: oklch(76.034% 0.12361 82.191);
--rf-chart-4: oklch(82.026% 0.09271 82.191);
--rf-chart-5: oklch(89.215% 0.05562 82.191);
--rf-sidebar: oklch(0.21 0.006 285.885);
--rf-sidebar-foreground: oklch(0.985 0 0);
--rf-sidebar-primary: oklch(0.922 0 0);
--rf-sidebar-primary-foreground: oklch(0.205 0 0);
--rf-sidebar-accent: oklch(0.274 0.006 286.033);
--rf-sidebar-accent-foreground: oklch(0.985 0 0);
--rf-sidebar-border: oklch(1 0 0 / 10%);
--rf-sidebar-ring: oklch(0.552 0.016 285.938);
/* Additional theme variables */
--warning: oklch(42.1% 0.095 57.708);
--link: oklch(70.7% 0.165 254.624);
--elevation-floating: 0 8px 40px 0 rgb(0 0 0 / 0.28), 0 0 1px 0 rgb(0 0 0 / 0.28);
--rf-warning: oklch(42.1% 0.095 57.708);
--rf-link: oklch(70.7% 0.165 254.624);
--rf-elevation-floating: 0 8px 40px 0 rgb(0 0 0 / 0.28), 0 0 1px 0 rgb(0 0 0 / 0.28);
}
@layer base {

View file

@ -0,0 +1,115 @@
// @vitest-environment jsdom
import type { ComponentProps } from "react"
import { render, screen } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import guest from "@/assets/icons/avatars/guest.svg"
const { sessionState, useSessionMock } = vi.hoisted(() => ({
sessionState: {
data: null as unknown,
isPending: false,
},
useSessionMock: vi.fn(() => sessionState),
}))
vi.mock("@/env", () => ({
env: {
WXT_WEBSITE_URL: "https://readfrog.app",
},
}))
vi.mock("@/utils/auth/auth-client", () => ({
authClient: {
useSession: useSessionMock,
},
}))
vi.mock("@/components/ui/base-ui/avatar", () => ({
Avatar: ({
children,
className,
size = "default",
}: ComponentProps<"span"> & { size?: "default" | "sm" | "lg" }) => (
<span data-slot="avatar" data-size={size} className={className}>
{children}
</span>
),
AvatarImage: (props: ComponentProps<"img">) => props.src ? <img data-slot="avatar-image" {...props} /> : null,
AvatarFallback: ({ children }: ComponentProps<"span">) => (
<span data-slot="avatar-fallback">{children}</span>
),
}))
describe("user account", () => {
beforeEach(() => {
vi.clearAllMocks()
sessionState.data = null
sessionState.isPending = false
})
it("shows the guest image and login action when signed out", async () => {
const { UserAccount } = await import("../user-account")
render(<UserAccount />)
const image = screen.getByRole("img", { name: "Guest" })
expect(image).toHaveAttribute("src", guest)
expect(screen.getByText("Guest")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "Log in" })).toBeInTheDocument()
})
it("treats a session payload without user as signed out", async () => {
sessionState.data = { session: { id: "session-1" } }
const { UserAccount } = await import("../user-account")
expect(() => render(<UserAccount />)).not.toThrow()
const image = screen.getByRole("img", { name: "Guest" })
expect(image).toHaveAttribute("src", guest)
expect(screen.getByText("Guest")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "Log in" })).toBeInTheDocument()
})
it("shows the user's avatar and name when signed in with an image", async () => {
sessionState.data = {
user: {
name: "John Doe",
image: "https://cdn.example.com/john.png",
},
}
const { UserAccount } = await import("../user-account")
render(<UserAccount />)
expect(screen.getByRole("img", { name: "John Doe" })).toHaveAttribute("src", "https://cdn.example.com/john.png")
expect(screen.getByText("John Doe")).toBeInTheDocument()
expect(screen.queryByRole("button", { name: "Log in" })).not.toBeInTheDocument()
})
it("uses initials fallback for signed-in users without an image", async () => {
sessionState.data = {
user: {
name: "John Doe",
image: null,
},
}
const { UserAccount } = await import("../user-account")
render(<UserAccount />)
expect(screen.queryByRole("img", { name: "John Doe" })).not.toBeInTheDocument()
expect(screen.getByText("JD")).toBeInTheDocument()
expect(screen.getByText("John Doe")).toBeInTheDocument()
})
it("keeps the loading state without showing login", async () => {
sessionState.isPending = true
const { UserAccount } = await import("../user-account")
const view = render(<UserAccount />)
expect(screen.getByText("Loading...")).toBeInTheDocument()
expect(screen.queryByRole("button", { name: "Log in" })).not.toBeInTheDocument()
expect(view.container.querySelector("[data-slot='avatar']")).toHaveClass("animate-pulse")
})
})

View file

@ -13,7 +13,7 @@ export function APIConfigWarning({ className }: { className?: string }) {
{i18n.t("noAPIKeyConfig.warningWithLink.youMust")}
{" "}
<a
href="https://readfrog.app/tutorial/api-key"
href="https://readfrog.app/docs/api-key"
target="_blank"
rel="noreferrer"
className="underline"

View file

@ -16,10 +16,10 @@ import {
SheetTrigger,
} from "@/components/ui/base-ui/sheet"
import { QuickInsertableTextarea } from "@/components/ui/insertable-textarea"
import { DEFAULT_TRANSLATE_PROMPT_ID, getTokenCellText, TOKENS } from "@/utils/constants/prompt"
import { DEFAULT_TRANSLATE_PROMPT_ID } from "@/utils/constants/prompt"
import { getRandomUUID } from "@/utils/crypto-polyfill"
import { cn } from "@/utils/styles/utils"
import { usePromptAtoms } from "./context"
import { usePromptAtoms, usePromptInsertCells } from "./context"
export function ConfigurePrompt({
originPrompt,
@ -30,6 +30,7 @@ export function ConfigurePrompt({
className?: string
} & React.ComponentProps<"button">) {
const promptAtoms = usePromptAtoms()
const insertCells = usePromptInsertCells()
const [config, setConfig] = useAtom(promptAtoms.config)
const isExportMode = useAtomValue(promptAtoms.exportMode)
@ -118,10 +119,7 @@ export function ConfigurePrompt({
className="min-h-40 max-h-80"
disabled={isDefault}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setPrompt({ ...prompt, systemPrompt: e.target.value })}
insertCells={TOKENS.map(token => ({
text: getTokenCellText(token),
description: i18n.t(`options.translation.personalizedPrompts.editPrompt.promptCellInput.${token}`),
}))}
insertCells={insertCells}
/>
</Field>
<Field>
@ -131,10 +129,7 @@ export function ConfigurePrompt({
className="max-h-60"
disabled={isDefault}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setPrompt({ ...prompt, prompt: e.target.value })}
insertCells={TOKENS.map(token => ({
text: getTokenCellText(token),
description: i18n.t(`options.translation.personalizedPrompts.editPrompt.promptCellInput.${token}`),
}))}
insertCells={insertCells}
/>
</Field>
</FieldGroup>

View file

@ -4,6 +4,10 @@ import type { customPromptsConfigSchema } from "@/types/config/translate"
import { createContext, use } from "react"
export type CustomPromptsConfig = z.infer<typeof customPromptsConfigSchema>
export interface PromptInsertCell {
text: string
description: string
}
export interface PromptAtoms {
config: WritableAtom<CustomPromptsConfig, [CustomPromptsConfig], void>
@ -11,12 +15,25 @@ export interface PromptAtoms {
selectedPrompts: PrimitiveAtom<string[]>
}
export const PromptConfiguratorContext = createContext<PromptAtoms | null>(null)
export interface PromptConfiguratorContextValue {
promptAtoms: PromptAtoms
insertCells: PromptInsertCell[]
}
export const PromptConfiguratorContext = createContext<PromptConfiguratorContextValue | null>(null)
export function usePromptAtoms() {
const promptAtoms = use(PromptConfiguratorContext)
if (!promptAtoms) {
const promptConfigurator = use(PromptConfiguratorContext)
if (!promptConfigurator) {
throw new Error("usePromptAtoms must be used within PromptConfigurator")
}
return promptAtoms
return promptConfigurator.promptAtoms
}
export function usePromptInsertCells() {
const promptConfigurator = use(PromptConfiguratorContext)
if (!promptConfigurator) {
throw new Error("usePromptInsertCells must be used within PromptConfigurator")
}
return promptConfigurator.insertCells
}

View file

@ -1,4 +1,4 @@
import type { PromptAtoms } from "./context"
import type { PromptAtoms, PromptInsertCell } from "./context"
import { ConfigCard } from "@/entrypoints/options/components/config-card"
import { PromptConfiguratorContext } from "./context"
import { PromptList } from "./prompt-list"
@ -9,13 +9,14 @@ export { usePromptAtoms } from "./context"
interface PromptConfiguratorProps {
id?: string
promptAtoms: PromptAtoms
insertCells: PromptInsertCell[]
title: string
description: React.ReactNode
}
export function PromptConfigurator({ id, promptAtoms, title, description }: PromptConfiguratorProps) {
export function PromptConfigurator({ id, promptAtoms, insertCells, title, description }: PromptConfiguratorProps) {
return (
<PromptConfiguratorContext value={promptAtoms}>
<PromptConfiguratorContext value={{ promptAtoms, insertCells }}>
<ConfigCard id={id} className="lg:flex-col" title={title} description={description}>
<PromptList />
</ConfigCard>

View file

@ -33,7 +33,7 @@ export function PromptGrid({
const isExportMode = useAtomValue(promptAtoms.exportMode)
const patterns = config.patterns
const idPrefix = useId()
const checkboxBaseId = useId()
// Construct virtual default prompt object from code constant
const defaultPrompt: TranslatePromptObj = {
@ -90,7 +90,7 @@ export function PromptGrid({
{/* Checkbox: only show in export mode for custom prompts (not default) */}
<Activity mode={isExportMode && !isDefault ? "visible" : "hidden"}>
<Checkbox
id={`${idPrefix}-check-${pattern.id}`}
id={`${checkboxBaseId}-check-${pattern.id}`}
checked={selectedPrompts.includes(pattern.id)}
onClick={e => e.stopPropagation()}
onCheckedChange={(checked) => {
@ -103,7 +103,7 @@ export function PromptGrid({
/>
</Activity>
<Label
htmlFor={`${idPrefix}-check-${pattern.id}`}
htmlFor={`${checkboxBaseId}-check-${pattern.id}`}
className="flex-1 min-w-0 block truncate cursor-pointer"
title={pattern.name}
>

View file

@ -15,9 +15,11 @@ export const ThemeContext = createContext<ThemeContextI | undefined>(undefined)
export function ThemeProvider({
children,
container,
forcedTheme,
}: {
children: React.ReactNode
container?: HTMLElement
forcedTheme?: Theme
}) {
const [themeMode, setThemeMode] = useAtom(themeModeAtom)
@ -34,10 +36,12 @@ export function ThemeProvider({
() => !!window?.matchMedia?.("(prefers-color-scheme: dark)")?.matches,
)
const theme: Theme = themeMode === "system"
const resolvedTheme: Theme = themeMode === "system"
? (prefersDark ? "dark" : "light")
: themeMode
const theme: Theme = forcedTheme ?? resolvedTheme
// Apply theme to document or shadow root container
useLayoutEffect(() => {
const target = container ?? document.documentElement

View file

@ -0,0 +1,107 @@
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import * as React from "react"
import { cn } from "@/utils/styles/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className,
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className,
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className,
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className,
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className,
)}
{...props}
/>
)
}
export {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
}

View file

@ -5,27 +5,35 @@ import { cva } from "class-variance-authority"
import { cn } from "@/utils/styles/utils"
const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
"default": "bg-primary text-primary-foreground hover:bg-primary/80",
"outline": "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs",
"secondary": "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
"ghost": "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
"ghost-secondary": "hover:bg-secondary text-secondary-foreground dark:hover:bg-secondary/50 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
"destructive": "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
"outline":
"border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
"secondary":
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
"ghost":
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
"ghost-secondary":
"text-secondary-foreground hover:bg-secondary aria-expanded:bg-secondary aria-expanded:text-secondary-foreground dark:hover:bg-secondary/50",
"destructive":
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
"link": "text-primary underline-offset-4 hover:underline",
},
size: {
"default": "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
"xs": "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
"sm": "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
"lg": "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
"icon": "size-8",
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
"default":
"h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
"xs": "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
"sm": "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
"lg": "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
"icon": "size-9",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
"icon-lg": "size-10",
},
},
defaultVariants: {

View file

@ -1,12 +1,64 @@
import type { VariantProps } from "class-variance-authority"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react"
import { cva } from "class-variance-authority"
import * as React from "react"
import { SHARED_POPUP_CLOSED_STATE_CLASS } from "@/components/ui/base-ui/popup-animation-classes"
import { cn } from "@/utils/styles/utils"
const Select = SelectPrimitive.Root
const selectTriggerVariants = cva(
"gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "shadow-xs border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:ring-3 aria-invalid:ring-3 [&_[data-slot=select-icon]]:text-muted-foreground",
dark: "border-white/10 bg-white/6 text-white/92 hover:bg-white/10 focus-visible:border-white/18 focus-visible:ring-white/18 focus-visible:ring-1 [&_[data-slot=select-icon]]:text-white/62",
},
size: {
default: "h-8",
sm: "h-7 rounded-[min(var(--radius-md),10px)]",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
const selectContentVariants = cva(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 min-w-36 rounded-lg shadow-md duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none",
{
variants: {
variant: {
default: "bg-popover text-popover-foreground ring-foreground/10 ring-1",
dark: "bg-[rgba(24,26,30,0.96)] text-white/90 ring-white/10 ring-1 backdrop-blur-xl",
},
},
defaultVariants: {
variant: "default",
},
},
)
const selectItemVariants = cva(
"gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground",
dark: "focus:bg-white/10 focus:text-white",
},
},
defaultVariants: {
variant: "default",
},
},
)
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
@ -29,26 +81,22 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
function SelectTrigger({
className,
variant = "default",
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
}: SelectPrimitive.Trigger.Props & VariantProps<typeof selectTriggerVariants>) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"shadow-xs border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
className={cn(selectTriggerVariants({ variant, size, className }))}
{...props}
>
{children}
<SelectPrimitive.Icon
data-slot="select-icon"
render={
<IconChevronDown className="text-muted-foreground size-4 pointer-events-none" />
<IconChevronDown className="size-4 pointer-events-none" />
}
/>
</SelectPrimitive.Trigger>
@ -58,6 +106,7 @@ function SelectTrigger({
function SelectContent({
container,
className,
variant = "default",
children,
positionerClassName,
side = "bottom",
@ -68,6 +117,7 @@ function SelectContent({
...props
}: SelectPrimitive.Popup.Props
& Pick<SelectPrimitive.Portal.Props, "container">
& VariantProps<typeof selectContentVariants>
& {
positionerClassName?: string
}
@ -83,12 +133,12 @@ function SelectContent({
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className={cn("isolate z-50", positionerClassName)}
className={cn("pointer-events-auto isolate z-50", positionerClassName)}
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none", SHARED_POPUP_CLOSED_STATE_CLASS, className)}
className={cn(selectContentVariants({ variant }), SHARED_POPUP_CLOSED_STATE_CLASS, className)}
{...props}
>
<SelectScrollUpButton />
@ -115,16 +165,14 @@ function SelectLabel({
function SelectItem({
className,
variant = "default",
children,
...props
}: SelectPrimitive.Item.Props) {
}: SelectPrimitive.Item.Props & VariantProps<typeof selectItemVariants>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
className={cn(selectItemVariants({ variant, className }))}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 gap-2 shrink-0 whitespace-nowrap">

View file

@ -70,7 +70,7 @@ function SidebarProvider({
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
// eslint-disable-next-line react-naming-convention/use-state
// eslint-disable-next-line react/use-state
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(

View file

@ -430,6 +430,13 @@ describe("selectionPopover", () => {
})
})
it("applies configured opacity on the popover surface instead of the viewport host", () => {
const { element } = renderPopover()
expect(element.parentElement?.style.opacity).toBe("")
expect(element.style.opacity).toBe("var(--rf-selection-opacity, 1)")
})
it("keeps the body shrinkable so overflow can scroll after viewport changes", () => {
const { element } = renderPopover()

View file

@ -290,6 +290,7 @@ function SelectionPopoverShell({
style={{
display: "flex",
...style,
opacity: "var(--rf-selection-opacity, 1)",
maxWidth: "100vw",
maxHeight: "100vh",
}}

View file

@ -232,7 +232,7 @@ export function useSelectionPopoverLayout({
}
suppressResizeObserverRef.current = false
isDraggingRef.current = false
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
// eslint-disable-next-line react/set-state-in-effect
setPosition(null)
setDragging(false)
}, [])

View file

@ -118,8 +118,13 @@ function TreeItemLabel<T = any>({ item: propItem, children, className, ...props
const { currentItem, toggleIconType } = useTreeContext<T>()
const item = propItem || currentItem
React.useEffect(() => {
if (!item) {
console.warn("TreeItemLabel: No item provided via props or context")
}
}, [item])
if (!item) {
console.warn("TreeItemLabel: No item provided via props or context")
return null
}
@ -154,8 +159,13 @@ function TreeItemLabel<T = any>({ item: propItem, children, className, ...props
function TreeDragLine({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { tree } = useTreeContext()
React.useEffect(() => {
if (!tree || typeof tree.getDragLineStyle !== "function") {
console.warn("TreeDragLine: No tree provided via context or tree does not have getDragLineStyle method")
}
}, [tree])
if (!tree || typeof tree.getDragLineStyle !== "function") {
console.warn("TreeDragLine: No tree provided via context or tree does not have getDragLineStyle method")
return null
}

View file

@ -1,25 +1,43 @@
import guest from "@/assets/icons/avatars/guest.svg"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/base-ui/avatar"
import { Button } from "@/components/ui/base-ui/button"
import { env } from "@/env"
import { authClient } from "@/utils/auth/auth-client"
import { WEBSITE_URL } from "@/utils/constants/url"
import { cn } from "@/utils/styles/utils"
function getUserInitials(name: string | null | undefined) {
const normalizedName = name?.trim()
if (!normalizedName)
return "U"
const parts = normalizedName.split(/\s+/)
const initials = parts.length > 1
? `${parts[0]?.[0] ?? ""}${parts[parts.length - 1]?.[0] ?? ""}`
: Array.from(normalizedName).slice(0, 2).join("")
return initials.toUpperCase()
}
export function UserAccount() {
const { data, isPending } = authClient.useSession()
const user = data?.user
const displayName = user?.name?.trim() || "Guest"
const avatarSrc = user ? user.image : guest
const fallbackText = user ? getUserInitials(user.name) : "G"
return (
<div className="flex items-center gap-2">
<img
src={data?.user.image ?? guest}
alt="User"
className={cn("rounded-full border size-6", !data?.user.image && "p-1", isPending && "animate-pulse")}
/>
{isPending ? "Loading..." : data?.user.name ?? "Guest"}
{!isPending && !data && (
<Avatar size="sm" className={cn(isPending && "animate-pulse")}>
<AvatarImage src={avatarSrc || ""} alt={displayName} />
<AvatarFallback>{fallbackText}</AvatarFallback>
</Avatar>
{isPending ? "Loading..." : displayName}
{!isPending && !user && (
<Button
size="xs"
variant="outline"
onClick={() =>
window.open(`${WEBSITE_URL}/log-in`, "_blank")}
window.open(`${env.WXT_WEBSITE_URL}/log-in`, "_blank")}
>
Log in
</Button>

View file

@ -4,6 +4,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
const streamTextMock = vi.fn()
const outputObjectMock = vi.fn((params: Record<string, unknown>) => params)
const getModelByIdMock = vi.fn()
const loggerErrorMock = vi.fn()
const parsePartialJsonMock = vi.fn(async (text: string | undefined) => {
if (!text) {
return { state: "undefined-input", value: undefined }
}
try {
return { state: "successful-parse", value: JSON.parse(text) }
}
catch {
try {
return { state: "repaired-parse", value: JSON.parse(`${text}}`) }
}
catch {
return { state: "failed-parse", value: undefined }
}
}
})
class MockNoOutputGeneratedError extends Error {
static isInstance(error: unknown): error is MockNoOutputGeneratedError {
@ -13,6 +31,7 @@ class MockNoOutputGeneratedError extends Error {
vi.mock("ai", () => ({
streamText: streamTextMock,
parsePartialJson: parsePartialJsonMock,
NoOutputGeneratedError: MockNoOutputGeneratedError,
Output: {
object: outputObjectMock,
@ -25,7 +44,7 @@ vi.mock("@/utils/providers/model", () => ({
vi.mock("@/utils/logger", () => ({
logger: {
error: vi.fn(),
error: loggerErrorMock,
},
}))
@ -87,15 +106,16 @@ describe("background-stream", () => {
it("streams structured object output from background", async () => {
getModelByIdMock.mockResolvedValue("mock-model")
streamTextMock.mockReturnValue({
partialOutputStream: (async function* () {
yield { score: 97 }
yield { score: 97, summary: "Strong argument structure" }
fullStream: (async function* () {
yield { type: "text-delta", text: "{\"score\":97" }
yield { type: "text-delta", text: ",\"summary\":\"Strong argument structure\"}" }
})(),
output: Promise.resolve({
score: 97,
summary: "Strong argument structure",
}),
fullStream: (async function* () {})(),
get output() {
throw new Error("structured stream should not consume output separately")
},
get partialOutputStream() {
throw new Error("structured stream should not consume partialOutputStream separately")
},
})
const chunkSnapshots: BackgroundStructuredObjectStreamSnapshot[] = []
@ -237,7 +257,9 @@ describe("background-stream", () => {
options.onError?.({ error: rootCause })
return {
fullStream: (async function* () {})(),
output: Promise.reject(new MockNoOutputGeneratedError("No output generated. Check the stream for errors.")),
get output() {
throw new Error("text stream should not consume output separately")
},
}
})
@ -294,6 +316,50 @@ describe("background-stream", () => {
expect(mockPort.disconnect).toHaveBeenCalledTimes(1)
})
it("treats stream port disconnect aborts as expected cancellation", async () => {
getModelByIdMock.mockResolvedValue("mock-model")
let streamSignal: AbortSignal | undefined
streamTextMock.mockImplementation((options: { abortSignal?: AbortSignal }) => {
streamSignal = options.abortSignal
return {
fullStream: (async function* () {
await new Promise<void>((_resolve, reject) => {
options.abortSignal?.addEventListener("abort", () => {
reject(options.abortSignal?.reason ?? new DOMException("aborted", "AbortError"))
})
})
})(),
output: new Promise<string>(() => {}),
}
})
const { handleStreamTextPort } = await import("../background-stream")
const mockPort = createMockPort("stream-text")
handleStreamTextPort(mockPort.port as never)
const startPromise = mockPort.emitMessage({
type: "start",
requestId: "req-text-abort",
payload: {
providerId: "openai-default",
prompt: "Say hello",
},
})
await new Promise(resolve => setTimeout(resolve, 0))
expect(streamTextMock).toHaveBeenCalledTimes(1)
mockPort.emitDisconnect()
await startPromise
expect(streamSignal?.aborted).toBe(true)
expect(loggerErrorMock).not.toHaveBeenCalled()
expect(mockPort.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({
type: "error",
}))
})
it("returns error for invalid text start payload and disconnects", async () => {
const { handleStreamTextPort } = await import("../background-stream")
const mockPort = createMockPort("stream-text")

View file

@ -0,0 +1,279 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { createSidePanelWindowState, createToggleSidePanelHandler, getSidePanelApi, setupSidePanelMessageHandler } from "../side-panel"
function createLogger() {
return {
error: vi.fn(),
warn: vi.fn(),
}
}
const senderWindowMessage = {
sender: {
tab: {
id: 123,
windowId: 456,
},
},
}
function chromiumSidePanel<TApi>(api: TApi) {
return {
kind: "chromium-side-panel" as const,
api,
}
}
function firefoxSidebarAction<TApi>(api: TApi) {
return {
kind: "firefox-sidebar-action" as const,
api,
}
}
describe("background side panel", () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it("opens the global side panel synchronously so Chrome keeps the user gesture", async () => {
const logger = createLogger()
const calls: string[] = []
const sidePanel = {
setOptions: vi.fn(() => {
calls.push("setOptions")
}),
open: vi.fn((_options: { windowId: number }) => {
calls.push("open")
return Promise.resolve()
}),
}
const handler = createToggleSidePanelHandler({
getApi: () => chromiumSidePanel(sidePanel),
logger,
})
const result = handler(senderWindowMessage)
expect(sidePanel.open).toHaveBeenCalledWith({ windowId: 456 })
expect(sidePanel.open.mock.calls[0]?.[0]).not.toHaveProperty("tabId")
expect(sidePanel.setOptions).not.toHaveBeenCalled()
expect(calls).toEqual(["open"])
await expect(result).resolves.toEqual({ ok: true, action: "opened" })
})
it("closes the global side panel when the sender window is already open", async () => {
const logger = createLogger()
const windowState = createSidePanelWindowState()
const calls: string[] = []
const sidePanel = {
close: vi.fn((_options: { windowId: number }) => {
calls.push("close")
return Promise.resolve()
}),
open: vi.fn((_options: { windowId: number }) => {
calls.push("open")
return Promise.resolve()
}),
}
windowState.markOpened({ windowId: 456 })
const handler = createToggleSidePanelHandler({
getApi: () => chromiumSidePanel(sidePanel),
logger,
windowState,
})
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: true, action: "closed" })
expect(sidePanel.close).toHaveBeenCalledWith({ windowId: 456 })
expect(sidePanel.close.mock.calls[0]?.[0]).not.toHaveProperty("tabId")
expect(sidePanel.open).not.toHaveBeenCalled()
expect(calls).toEqual(["close"])
expect(windowState.isOpen(456)).toBe(false)
})
it("tracks browser side panel open and close events for toggle state", async () => {
const logger = createLogger()
const onOpenedListeners: Array<(info: { windowId?: number }) => void> = []
const onClosedListeners: Array<(info: { windowId?: number }) => void> = []
const registeredMessageHandlers = new Map<string, (message: typeof senderWindowMessage) => Promise<{ ok: true } | { ok: false, reason: string }>>()
const sidePanel = {
close: vi.fn().mockResolvedValue(undefined),
open: vi.fn().mockResolvedValue(undefined),
onClosed: {
addListener: vi.fn((listener) => {
onClosedListeners.push(listener)
}),
},
onOpened: {
addListener: vi.fn((listener) => {
onOpenedListeners.push(listener)
}),
},
}
setupSidePanelMessageHandler({
extensionBrowser: { sidePanel } as any,
logger,
registerMessageHandler: ((type: string, handler: (message: typeof senderWindowMessage) => Promise<{ ok: true } | { ok: false, reason: string }>) => {
registeredMessageHandlers.set(type, handler)
}) as any,
})
onOpenedListeners[0]?.({ windowId: 456 })
const handler = registeredMessageHandlers.get("toggleSidePanel")
if (!handler) {
throw new Error("toggleSidePanel handler was not registered")
}
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: true, action: "closed" })
expect(sidePanel.close).toHaveBeenCalledWith({ windowId: 456 })
onClosedListeners[0]?.({ windowId: 456 })
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: true, action: "opened" })
expect(sidePanel.open).toHaveBeenCalledWith({ windowId: 456 })
})
it("returns an unsupported result when closing is unavailable", async () => {
const logger = createLogger()
const windowState = createSidePanelWindowState()
const sidePanel = {
open: vi.fn(),
}
windowState.markOpened({ windowId: 456 })
const handler = createToggleSidePanelHandler({
getApi: () => chromiumSidePanel(sidePanel),
logger,
windowState,
})
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: false, reason: "unsupported" })
expect(logger.warn).toHaveBeenCalledWith("Side panel close API is unavailable in this browser")
expect(sidePanel.open).not.toHaveBeenCalled()
})
it("clears stale open state when Chrome rejects close", async () => {
const logger = createLogger()
const windowState = createSidePanelWindowState()
const error = new Error("No active global side panel")
const sidePanel = {
close: vi.fn().mockRejectedValue(error),
open: vi.fn().mockResolvedValue(undefined),
}
windowState.markOpened({ windowId: 456 })
const handler = createToggleSidePanelHandler({
getApi: () => chromiumSidePanel(sidePanel),
logger,
windowState,
})
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: false, reason: "toggle-failed" })
expect(logger.error).toHaveBeenCalledWith("Failed to close side panel", error)
expect(windowState.isOpen(456)).toBe(false)
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: true, action: "opened" })
expect(sidePanel.open).toHaveBeenCalledWith({ windowId: 456 })
})
it("returns an unsupported result when the side panel API is unavailable", async () => {
const logger = createLogger()
const handler = createToggleSidePanelHandler({
getApi: () => null,
logger,
})
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: false, reason: "unsupported" })
expect(logger.warn).toHaveBeenCalledWith("Side panel API is unavailable in this browser")
})
it("does not open the Firefox sidebar from a content-script message", async () => {
const logger = createLogger()
const sidebarAction = {
open: vi.fn(),
}
const handler = createToggleSidePanelHandler({
getApi: () => firefoxSidebarAction(sidebarAction),
logger,
})
await expect(handler(senderWindowMessage)).resolves.toEqual({
ok: false,
reason: "requires-extension-user-action",
})
expect(sidebarAction.open).not.toHaveBeenCalled()
expect(logger.warn).toHaveBeenCalledWith("Firefox sidebar requires an extension user action")
})
it("opens the Firefox sidebar when called from an extension user action", async () => {
const logger = createLogger()
const sidebarAction = {
open: vi.fn().mockResolvedValue(undefined),
}
const handler = createToggleSidePanelHandler({
getApi: () => firefoxSidebarAction(sidebarAction),
logger,
})
const result = handler({ data: { source: "extension-user-action" } })
expect(sidebarAction.open).toHaveBeenCalled()
await expect(result).resolves.toEqual({ ok: true, action: "opened" })
})
it("returns a missing-window result when the sender window id is unavailable", async () => {
const logger = createLogger()
const handler = createToggleSidePanelHandler({
getApi: () => chromiumSidePanel({ open: vi.fn() }),
logger,
})
await expect(handler({ sender: { tab: { id: 123 } } })).resolves.toEqual({ ok: false, reason: "missing-window" })
expect(logger.warn).toHaveBeenCalledWith(
"Cannot toggle side panel without a sender window",
{ sender: { tab: { id: 123 } } },
)
})
it("returns a toggle-failed result when Chrome rejects the open request", async () => {
const logger = createLogger()
const error = new Error("sidePanel.open() may only be called in response to a user gesture")
const handler = createToggleSidePanelHandler({
getApi: () => chromiumSidePanel({
open: vi.fn().mockRejectedValue(error),
}),
logger,
})
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: false, reason: "toggle-failed" })
expect(logger.error).toHaveBeenCalledWith("Failed to open side panel", error)
})
it("finds the Chrome sidePanel API when the WXT browser wrapper does not expose it", () => {
const sidePanel = {
open: vi.fn(),
}
vi.stubGlobal("chrome", {
sidePanel,
})
expect(getSidePanelApi({} as any)).toEqual({
kind: "chromium-side-panel",
api: sidePanel,
})
})
it("finds the Firefox sidebarAction API from the WXT browser wrapper", () => {
const sidebarAction = {
open: vi.fn(),
}
expect(getSidePanelApi({ sidebarAction } as any)).toEqual({
kind: "firefox-sidebar-action",
api: sidebarAction,
})
})
})

View file

@ -150,14 +150,70 @@ describe("translation queue helpers", () => {
expect.any(Function),
expect.objectContaining({
isBatch: true,
content: {
title: "Video title",
summary: "Ready summary",
context: {
videoTitle: "Video title",
videoSummary: "Ready summary",
},
}),
)
})
it("passes webpage context through the translation queue without generating a new summary", async () => {
const { setUpWebPageTranslationQueue } = await import("../translation-queues")
await setUpWebPageTranslationQueue()
const handler = getRegisteredMessageHandler("enqueueTranslateRequest")
const result = await handler({
data: {
text: "hello",
langConfig: DEFAULT_CONFIG.language,
providerConfig: llmProvider,
scheduleAt: Date.now(),
hash: "webpage-hash",
webTitle: "Page title",
webContent: "Page body",
webSummary: "Ready summary",
},
})
expect(result).toBe("translated subtitle")
expect(generateArticleSummaryMock).not.toHaveBeenCalled()
expect(executeTranslateMock).toHaveBeenCalledWith(
"hello",
DEFAULT_CONFIG.language,
llmProvider,
expect.any(Function),
expect.objectContaining({
context: {
webTitle: "Page title",
webContent: "Page body",
webSummary: "Ready summary",
},
}),
)
})
it("exposes webpage summary generation as a separate background handler", async () => {
const { setUpWebPageTranslationQueue } = await import("../translation-queues")
await setUpWebPageTranslationQueue()
const handler = getRegisteredMessageHandler("getOrGenerateWebPageSummary")
const result = await handler({
data: {
webTitle: "Page title",
webContent: "page body",
providerConfig: llmProvider,
},
})
expect(result).toBe("Generated summary")
expect(generateArticleSummaryMock).toHaveBeenCalledWith(
"Page title",
"page body",
llmProvider,
)
})
it("exposes subtitle summary generation as a separate background handler", async () => {
const { setUpSubtitlesTranslationQueue } = await import("../translation-queues")
await setUpSubtitlesTranslationQueue()
@ -179,7 +235,7 @@ describe("translation queue helpers", () => {
)
})
it("returns an empty string for invalid subtitle summary requests", async () => {
it("returns null for invalid subtitle summary requests", async () => {
const { setUpSubtitlesTranslationQueue } = await import("../translation-queues")
await setUpSubtitlesTranslationQueue()
@ -192,11 +248,11 @@ describe("translation queue helpers", () => {
},
})
expect(result).toBe("")
expect(result).toBeNull()
expect(generateArticleSummaryMock).not.toHaveBeenCalled()
})
it("returns an empty string when subtitle summary generation has no result", async () => {
it("returns null when subtitle summary generation has no result", async () => {
generateArticleSummaryMock.mockResolvedValue(null)
const { setUpSubtitlesTranslationQueue } = await import("../translation-queues")
@ -211,7 +267,7 @@ describe("translation queue helpers", () => {
},
})
expect(result).toBe("")
expect(result).toBeNull()
})
it("deduplicates concurrent subtitle summary generation requests", async () => {

View file

@ -2,6 +2,7 @@ import type { CaptureResult } from "posthog-js/dist/module.no-external"
import type { FeatureUsedEventProperties } from "@/types/analytics"
import { storage } from "#imports"
import posthog from "posthog-js/dist/module.no-external"
import { env } from "@/env"
import {
ANALYTICS_ENABLED_STORAGE_KEY,
ANALYTICS_FEATURE_USED_EVENT,
@ -58,12 +59,12 @@ export function resolveDistinctIdOverride(
function createDefaultRuntime(): BackgroundAnalyticsRuntime {
return {
apiHost: import.meta.env.WXT_POSTHOG_HOST,
apiKey: import.meta.env.WXT_POSTHOG_API_KEY,
apiHost: env.WXT_POSTHOG_HOST,
apiKey: env.WXT_POSTHOG_API_KEY,
createDistinctId: () => getRandomUUID(),
defaultAnalyticsEnabled: DEFAULT_ANALYTICS_ENABLED,
distinctIdOverride: resolveDistinctIdOverride(
import.meta.env.WXT_POSTHOG_TEST_UUID,
env.WXT_POSTHOG_TEST_UUID,
import.meta.env.DEV,
),
extensionVersion: EXTENSION_VERSION,

View file

@ -14,7 +14,7 @@ import type {
StreamRuntimeOptions,
ThinkingSnapshot,
} from "@/types/background-stream"
import { Output, streamText } from "ai"
import { Output, parsePartialJson, streamText } from "ai"
import { z } from "zod"
import { BACKGROUND_STREAM_PORTS } from "@/types/background-stream"
import { extractAISDKErrorMessage } from "@/utils/error/extract-message"
@ -23,6 +23,15 @@ import { getModelById } from "@/utils/providers/model"
const invalidStreamStartPayloadMessage = "Invalid stream start payload"
function createStreamAbortError(message: string) {
return new DOMException(message, "AbortError")
}
function isAbortLikeError(error: unknown) {
return (error instanceof DOMException && error.name === "AbortError")
|| (error instanceof Error && error.name === "AbortError")
}
const streamPortStartEnvelopeSchema = z.object({
type: z.literal("start"),
requestId: z.string().trim().min(1),
@ -128,7 +137,7 @@ function createStreamPortHandler<TSerializablePayload, TResponse>(
}
disconnectListener = () => {
abortController.abort()
abortController.abort(createStreamAbortError("stream port disconnected"))
cleanup()
}
@ -191,10 +200,12 @@ function createStreamPortHandler<TSerializablePayload, TResponse>(
}
catch (error) {
const finalError = streamError ?? error
logger.error("[Background] Stream Function failed", finalError)
if (!abortController.signal.aborted) {
safePost({ type: "error", error: { message: extractAISDKErrorMessage(finalError) } })
if (abortController.signal.aborted || isAbortLikeError(finalError)) {
return
}
logger.error("[Background] Stream Function failed", finalError)
safePost({ type: "error", error: { message: extractAISDKErrorMessage(finalError) } })
}
finally {
cleanup()
@ -225,6 +236,10 @@ function createStreamSnapshot<TOutput>(
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
}
export async function runStreamTextInBackground(
serializablePayload: BackgroundStreamTextSerializablePayload,
options: StreamRuntimeOptions<BackgroundTextStreamSnapshot> = {},
@ -279,16 +294,18 @@ export async function runStreamTextInBackground(
onChunk?.(createStreamSnapshot(cumulativeText, thinking))
break
}
case "error": {
throw part.error
}
}
}
const finalText = await result.output
thinking = {
...thinking,
status: "complete",
}
return createStreamSnapshot(finalText, thinking)
return createStreamSnapshot(cumulativeText, thinking)
}
export async function runStructuredObjectStreamInBackground(
@ -318,64 +335,60 @@ export async function runStructuredObjectStreamInBackground(
for (const field of outputSchema) {
schemaShape[field.name] = fieldTypeToZodSchema[field.type] ?? z.string().nullable()
}
const objectSchema = z.object(schemaShape).strict()
const result = streamText({
...(streamParams as Parameters<typeof streamText>[0]),
model,
output: Output.object({
schema: z.object(schemaShape).strict(),
schema: objectSchema,
}),
abortSignal: signal,
onError: ({ error }) => {
onError?.(error)
},
})
let cumulativeText = ""
const consumePartialOutput = async () => {
for await (const partial of result.partialOutputStream) {
if (signal?.aborted) {
throw new DOMException("stream aborted", "AbortError")
for await (const part of result.fullStream) {
if (signal?.aborted) {
throw new DOMException("stream aborted", "AbortError")
}
switch (part.type) {
case "text-delta": {
cumulativeText += part.text
const partial = await parsePartialJson(cumulativeText)
if (isRecord(partial.value)) {
cumulativeValue = { ...cumulativeValue, ...partial.value }
onChunk?.(createStreamSnapshot(cumulativeValue, thinking))
}
break
}
if (partial && typeof partial === "object" && !Array.isArray(partial)) {
cumulativeValue = { ...cumulativeValue, ...partial }
case "reasoning-delta": {
thinking = {
status: "thinking",
text: thinking.text + part.text,
}
onChunk?.(createStreamSnapshot(cumulativeValue, thinking))
break
}
case "reasoning-end": {
thinking = {
...thinking,
status: "complete",
}
onChunk?.(createStreamSnapshot(cumulativeValue, thinking))
break
}
case "error": {
throw part.error
}
}
}
const consumeFullStream = async () => {
for await (const part of result.fullStream) {
if (signal?.aborted) {
throw new DOMException("stream aborted", "AbortError")
}
switch (part.type) {
case "reasoning-delta": {
thinking = {
status: "thinking",
text: thinking.text + part.text,
}
onChunk?.(createStreamSnapshot(cumulativeValue, thinking))
break
}
case "reasoning-end": {
thinking = {
...thinking,
status: "complete",
}
onChunk?.(createStreamSnapshot(cumulativeValue, thinking))
break
}
}
}
}
const [finalValue] = await Promise.all([
result.output,
consumePartialOutput(),
consumeFullStream(),
])
const finalJson = await parsePartialJson(cumulativeText)
const finalValue = objectSchema.parse(finalJson.value)
thinking = {
...thinking,

View file

@ -1,8 +1,9 @@
import "@/utils/zod-config"
import { browser, defineBackground } from "#imports"
import { WEBSITE_URL } from "@/utils/constants/url"
import { env } from "@/env"
import { logger } from "@/utils/logger"
import { onMessage } from "@/utils/message"
import { openOptionsPage } from "@/utils/navigation"
import { SessionCacheGroupRegistry } from "@/utils/session-cache/session-cache-group-registry"
import { runAiSegmentSubtitles } from "./ai-segmentation"
import { setupAnalyticsMessageHandlers } from "./analytics"
@ -17,6 +18,7 @@ import { setupLLMGenerateTextMessageHandlers } from "./llm-generate-text"
import { initMockData } from "./mock-data"
import { newUserGuide } from "./new-user-guide"
import { proxyFetch } from "./proxy-fetch"
import { setupSidePanelMessageHandler } from "./side-panel"
import { setUpSubtitlesTranslationQueue, setUpWebPageTranslationQueue } from "./translation-queues"
import { translationMessage } from "./translation-signal"
import { setupTTSPlaybackMessageHandlers } from "./tts-playback"
@ -33,7 +35,7 @@ export default defineBackground({
// Open tutorial page when extension is installed
if (details.reason === "install") {
await browser.tabs.create({
url: `${WEBSITE_URL}/guide/step-1`,
url: `${env.WXT_WEBSITE_URL}/guide/step-1`,
})
}
@ -50,9 +52,15 @@ export default defineBackground({
await browser.tabs.create({ url, active: active ?? true })
})
onMessage("openOptionsPage", () => {
onMessage("openOptionsPage", async () => {
logger.info("openOptionsPage")
void browser.runtime.openOptionsPage()
await openOptionsPage()
})
setupSidePanelMessageHandler({
extensionBrowser: browser,
logger,
registerMessageHandler: onMessage,
})
onMessage("aiSegmentSubtitles", async (message) => {

View file

@ -1,5 +1,5 @@
import { browser } from "#imports"
import { OFFICIAL_SITE_URL_PATTERNS } from "@/utils/constants/url"
import { env } from "@/env"
import { onMessage, sendMessage } from "@/utils/message"
let lastIsPinned = false
@ -30,7 +30,7 @@ async function checkPinnedAndNotify() {
return
lastIsPinned = isOnToolbar
browser.tabs.query({ url: OFFICIAL_SITE_URL_PATTERNS }, (tabs) => {
browser.tabs.query({ url: env.WXT_OFFICIAL_SITE_ORIGINS.map((origin: string) => `${origin}/*`) }, (tabs) => {
for (const tab of tabs) {
void sendMessage("pinStateChanged", { isPinned: isOnToolbar }, tab.id)
}

View file

@ -1,6 +1,7 @@
import type { ProxyResponse } from "@/types/proxy-fetch"
import { browser } from "#imports"
import { AUTH_COOKIE_PATTERNS, AUTH_DOMAINS } from "@read-frog/definitions"
import { AUTH_COOKIE_PATTERNS } from "@read-frog/definitions"
import { env } from "@/env"
import { DEFAULT_PROXY_CACHE_TTL_MS } from "@/utils/constants/proxy-fetch"
import { logger } from "@/utils/logger"
@ -37,7 +38,7 @@ export function proxyFetch() {
browser.cookies.onChanged.addListener(async (changeInfo) => {
const { cookie, removed } = changeInfo
// Check if it's an auth-related cookie for monitored domains
if (cookie.domain && AUTH_DOMAINS.some(domain => cookie.domain.includes(domain))) {
if (cookie.domain && env.WXT_AUTH_COOKIE_DOMAINS.some((domain: string) => cookie.domain.includes(domain))) {
// Check against defined auth cookie patterns
if (AUTH_COOKIE_PATTERNS.some(name => cookie.name.includes(name))) {
// Get current cookie value for before/after comparison

View file

@ -0,0 +1,276 @@
import type { browser } from "#imports"
import type { onMessage } from "@/utils/message"
interface ChromiumSidePanelApi {
close?: (options: { windowId: number }) => Promise<void> | void
open: (options: { windowId: number }) => Promise<void> | void
onClosed?: SidePanelEvent<SidePanelStateInfo>
onOpened?: SidePanelEvent<SidePanelStateInfo>
}
interface FirefoxSidebarActionApi {
close?: () => Promise<void> | void
open?: () => Promise<void> | void
toggle?: () => Promise<void> | void
}
type BrowserSidePanelApi
= | { kind: "chromium-side-panel", api: ChromiumSidePanelApi }
| { kind: "firefox-sidebar-action", api: FirefoxSidebarActionApi }
interface SidePanelEvent<TInfo> {
addListener: (callback: (info: TInfo) => void) => void
}
interface SidePanelStateInfo {
windowId?: number
}
interface ToggleSidePanelMessage {
data?: {
source?: "content-script" | "extension-user-action"
}
sender?: {
tab?: {
id?: number
windowId?: number
}
}
}
type ToggleSidePanelResult
= | { ok: true, action: "opened" | "closed" }
| { ok: false, reason: "missing-window" | "unsupported" | "toggle-failed" | "requires-extension-user-action" }
interface SidePanelLogger {
error: (...args: any[]) => void
warn: (...args: any[]) => void
}
export function createSidePanelWindowState() {
const activeWindowIds = new Set<number>()
return {
isOpen(windowId: number) {
return activeWindowIds.has(windowId)
},
markClosed(info: SidePanelStateInfo) {
if (typeof info.windowId === "number") {
activeWindowIds.delete(info.windowId)
}
},
markOpened(info: SidePanelStateInfo) {
if (typeof info.windowId === "number") {
activeWindowIds.add(info.windowId)
}
},
}
}
function getToggleSource(message: ToggleSidePanelMessage) {
return message.data?.source ?? "content-script"
}
function toChromiumSidePanelApi(api: Partial<ChromiumSidePanelApi> | undefined): BrowserSidePanelApi | null {
if (typeof api?.open !== "function") {
return null
}
return {
kind: "chromium-side-panel",
api: api as ChromiumSidePanelApi,
}
}
function toFirefoxSidebarActionApi(api: Partial<FirefoxSidebarActionApi> | undefined): BrowserSidePanelApi | null {
if (typeof api?.open !== "function" && typeof api?.toggle !== "function") {
return null
}
return {
kind: "firefox-sidebar-action",
api: api as FirefoxSidebarActionApi,
}
}
export function getSidePanelApi(extensionBrowser: typeof browser): BrowserSidePanelApi | null {
const browserWithSidePanel = extensionBrowser as typeof extensionBrowser & { sidePanel?: Partial<ChromiumSidePanelApi> }
if (typeof browserWithSidePanel.sidePanel?.open === "function") {
return toChromiumSidePanelApi(browserWithSidePanel.sidePanel)
}
const globalWithChrome = globalThis as typeof globalThis & {
chrome?: { sidePanel?: Partial<ChromiumSidePanelApi> }
}
if (typeof globalWithChrome.chrome?.sidePanel?.open === "function") {
return toChromiumSidePanelApi(globalWithChrome.chrome.sidePanel)
}
const browserWithSidebarAction = extensionBrowser as typeof extensionBrowser & { sidebarAction?: Partial<FirefoxSidebarActionApi> }
const sidebarAction = toFirefoxSidebarActionApi(browserWithSidebarAction.sidebarAction)
if (sidebarAction) {
return sidebarAction
}
const globalWithBrowser = globalThis as typeof globalThis & {
browser?: { sidebarAction?: Partial<FirefoxSidebarActionApi> }
}
const globalSidebarAction = toFirefoxSidebarActionApi(globalWithBrowser.browser?.sidebarAction)
if (globalSidebarAction) {
return globalSidebarAction
}
return null
}
function toggleFirefoxSidebarAction({
api,
logger,
source,
}: {
api: FirefoxSidebarActionApi
logger: SidePanelLogger
source: ReturnType<typeof getToggleSource>
}): Promise<ToggleSidePanelResult> {
if (source !== "extension-user-action") {
logger.warn("Firefox sidebar requires an extension user action")
return Promise.resolve({ ok: false, reason: "requires-extension-user-action" } as const)
}
const openSidebar = typeof api.open === "function"
? () => api.open?.()
: typeof api.toggle === "function"
? () => api.toggle?.()
: null
if (!openSidebar) {
logger.warn("Firefox sidebar open API is unavailable in this browser")
return Promise.resolve({ ok: false, reason: "unsupported" } as const)
}
try {
const openResult = openSidebar()
return Promise.resolve(openResult)
.then(() => ({ ok: true, action: "opened" } as const))
.catch((error) => {
logger.error("Failed to open Firefox sidebar", error)
return { ok: false, reason: "toggle-failed" } as const
})
}
catch (error) {
logger.error("Failed to open Firefox sidebar", error)
return Promise.resolve({ ok: false, reason: "toggle-failed" } as const)
}
}
export function createToggleSidePanelHandler({
getApi,
logger,
windowState = createSidePanelWindowState(),
}: {
getApi: () => BrowserSidePanelApi | null
logger: SidePanelLogger
windowState?: ReturnType<typeof createSidePanelWindowState>
}) {
return (message: ToggleSidePanelMessage): Promise<ToggleSidePanelResult> => {
const browserSidePanel = getApi()
if (!browserSidePanel) {
logger.warn("Side panel API is unavailable in this browser")
return Promise.resolve({ ok: false, reason: "unsupported" } as const)
}
if (browserSidePanel.kind === "firefox-sidebar-action") {
return toggleFirefoxSidebarAction({
api: browserSidePanel.api,
logger,
source: getToggleSource(message),
})
}
const windowId = message.sender?.tab?.windowId
if (typeof windowId !== "number") {
logger.warn("Cannot toggle side panel without a sender window", message)
return Promise.resolve({ ok: false, reason: "missing-window" } as const)
}
const sidePanel = browserSidePanel.api
if (windowState.isOpen(windowId)) {
if (typeof sidePanel.close !== "function") {
logger.warn("Side panel close API is unavailable in this browser")
return Promise.resolve({ ok: false, reason: "unsupported" } as const)
}
try {
const closeResult = sidePanel.close({ windowId })
return Promise.resolve(closeResult)
.then(() => {
windowState.markClosed({ windowId })
return { ok: true, action: "closed" } as const
})
.catch((error) => {
windowState.markClosed({ windowId })
logger.error("Failed to close side panel", error)
return { ok: false, reason: "toggle-failed" } as const
})
}
catch (error) {
windowState.markClosed({ windowId })
logger.error("Failed to close side panel", error)
return Promise.resolve({ ok: false, reason: "toggle-failed" } as const)
}
}
try {
// Chrome requires sidePanel.open() to run directly in the user-gesture
// task. Do not await other async APIs before this call.
const openResult = sidePanel.open({ windowId })
return Promise.resolve(openResult)
.then(() => {
windowState.markOpened({ windowId })
return { ok: true, action: "opened" } as const
})
.catch((error) => {
logger.error("Failed to open side panel", error)
return { ok: false, reason: "toggle-failed" } as const
})
}
catch (error) {
logger.error("Failed to open side panel", error)
return Promise.resolve({ ok: false, reason: "toggle-failed" } as const)
}
}
}
export function setupSidePanelMessageHandler({
extensionBrowser,
logger,
registerMessageHandler,
}: {
extensionBrowser: typeof browser
logger: SidePanelLogger
registerMessageHandler: typeof onMessage
}) {
const windowState = createSidePanelWindowState()
const sidePanel = getSidePanelApi(extensionBrowser)
if (sidePanel?.kind !== "chromium-side-panel") {
registerMessageHandler("toggleSidePanel", createToggleSidePanelHandler({
getApi: () => getSidePanelApi(extensionBrowser),
logger,
windowState,
}))
return
}
sidePanel.api.onOpened?.addListener((info) => {
windowState.markOpened(info)
})
sidePanel.api.onClosed?.addListener((info) => {
windowState.markClosed(info)
})
registerMessageHandler("toggleSidePanel", createToggleSidePanelHandler({
getApi: () => getSidePanelApi(extensionBrowser),
logger,
windowState,
}))
}

View file

@ -1,7 +1,7 @@
import type { Config } from "@/types/config/config"
import type { LLMProviderConfig, ProviderConfig } from "@/types/config/provider"
import type { BatchQueueConfig, RequestQueueConfig } from "@/types/config/translate"
import type { ArticleContent } from "@/types/content"
import type { SubtitlePromptContext, WebPagePromptContext } from "@/types/content"
import type { PromptResolver } from "@/utils/host/translate/api/ai"
import { isLLMProviderConfig } from "@/types/config/provider"
import { putBatchRequestRecord } from "@/utils/batch-request-record"
@ -12,6 +12,7 @@ import { cleanText } from "@/utils/content/utils"
import { db } from "@/utils/db/dexie/db"
import { Sha256Hex } from "@/utils/hash"
import { executeTranslate } from "@/utils/host/translate/execute-translate"
import { normalizePromptContextValue } from "@/utils/host/translate/translate-text"
import { logger } from "@/utils/logger"
import { onMessage } from "@/utils/message"
import { getSubtitlesTranslatePrompt } from "@/utils/prompts/subtitles"
@ -28,27 +29,78 @@ export function shouldUseBatchQueue(providerConfig: ProviderConfig): boolean {
return isLLMProviderConfig(providerConfig)
}
export async function executeBatchTranslation(
dataList: TranslateBatchData[],
promptResolver: PromptResolver,
export async function executeBatchTranslation<TContext>(
dataList: TranslateBatchData<TContext>[],
promptResolver: PromptResolver<TContext>,
): Promise<string[]> {
const { langConfig, providerConfig, content } = dataList[0]
const { langConfig, providerConfig, context } = dataList[0]
const texts = dataList.map(d => d.text)
const batchText = texts.join(`\n\n${BATCH_SEPARATOR}\n\n`)
const result = await executeTranslate(batchText, langConfig, providerConfig, promptResolver, { isBatch: true, content })
const result = await executeTranslate(batchText, langConfig, providerConfig, promptResolver, { isBatch: true, context })
return parseBatchResult(result)
}
async function getOrGenerateSummary(
title: string,
textContent: string,
async function getOrGenerateWebPageSummary(
webTitle: string,
webContent: string,
providerConfig: LLMProviderConfig,
requestQueue: RequestQueue,
): Promise<string | undefined> {
const preparedText = cleanText(textContent)
): Promise<string | null> {
const preparedText = cleanText(webContent)
if (!preparedText) {
return undefined
return null
}
const textHash = Sha256Hex(preparedText)
const cacheKey = Sha256Hex(webTitle, textHash, JSON.stringify(providerConfig))
const cached = await db.articleSummaryCache.get(cacheKey)
if (cached) {
logger.info("Using cached summary")
return cached.summary
}
const thunk = async () => {
const cachedAgain = await db.articleSummaryCache.get(cacheKey)
if (cachedAgain) {
return cachedAgain.summary
}
const summary = await generateArticleSummary(webTitle, webContent, providerConfig)
if (!summary) {
return ""
}
await db.articleSummaryCache.put({
key: cacheKey,
summary,
createdAt: new Date(),
})
logger.info("Generated and cached new summary")
return summary
}
try {
const summary = await requestQueue.enqueue(thunk, Date.now(), cacheKey)
return summary || null
}
catch (error) {
logger.warn("Failed to get/generate summary:", error)
return null
}
}
async function getOrGenerateSubtitleSummary(
videoTitle: string,
subtitlesContext: string,
providerConfig: LLMProviderConfig,
requestQueue: RequestQueue,
): Promise<string | null> {
const preparedText = cleanText(subtitlesContext)
if (!preparedText) {
return null
}
const textHash = Sha256Hex(preparedText)
@ -66,7 +118,7 @@ async function getOrGenerateSummary(
return cachedAgain.summary
}
const summary = await generateArticleSummary(title, textContent, providerConfig)
const summary = await generateArticleSummary(videoTitle, subtitlesContext, providerConfig)
if (!summary) {
return ""
}
@ -83,30 +135,30 @@ async function getOrGenerateSummary(
try {
const summary = await requestQueue.enqueue(thunk, Date.now(), cacheKey)
return summary || undefined
return summary || null
}
catch (error) {
logger.warn("Failed to get/generate summary:", error)
return undefined
return null
}
}
export interface TranslateBatchData {
export interface TranslateBatchData<TContext = unknown> {
text: string
langConfig: Config["language"]
providerConfig: ProviderConfig
hash: string
scheduleAt: number
content?: ArticleContent
context?: TContext
}
interface TranslationQueueSetupConfig {
interface TranslationQueueSetupConfig<TContext = unknown> {
requestQueueConfig: RequestQueueConfig
batchQueueConfig: BatchQueueConfig
promptResolver: PromptResolver
promptResolver: PromptResolver<TContext>
}
async function createTranslationQueues(config: TranslationQueueSetupConfig) {
async function createTranslationQueues<TContext>(config: TranslationQueueSetupConfig<TContext>) {
const { rate, capacity } = config.requestQueueConfig
const { maxCharactersPerBatch, maxItemsPerBatch } = config.batchQueueConfig
const { promptResolver } = config
@ -119,7 +171,7 @@ async function createTranslationQueues(config: TranslationQueueSetupConfig) {
baseRetryDelayMs: 1_000,
})
const batchQueue = new BatchQueue<TranslateBatchData, string>({
const batchQueue = new BatchQueue<TranslateBatchData<TContext>, string>({
maxCharactersPerBatch,
maxItemsPerBatch,
batchDelay: 100,
@ -142,10 +194,10 @@ async function createTranslationQueues(config: TranslationQueueSetupConfig) {
return requestQueue.enqueue(batchThunk, earliestScheduleAt, hash)
},
executeIndividual: async (data) => {
const { text, langConfig, providerConfig, hash, scheduleAt, content } = data
const { text, langConfig, providerConfig, hash, scheduleAt, context } = data
const thunk = async () => {
await putBatchRequestRecord({ originalRequestCount: 1, providerConfig })
return executeTranslate(text, langConfig, providerConfig, promptResolver, { content })
return executeTranslate(text, langConfig, providerConfig, promptResolver, { context })
}
return requestQueue.enqueue(thunk, scheduleAt, hash)
},
@ -173,7 +225,7 @@ export async function setUpWebPageTranslationQueue() {
})
onMessage("enqueueTranslateRequest", async (message) => {
const { data: { text, langConfig, providerConfig, scheduleAt, hash, articleTitle, articleTextContent } } = message
const { data: { text, langConfig, providerConfig, scheduleAt, hash, webTitle, webContent, webSummary } } = message
// Check cache first
if (hash) {
@ -184,23 +236,14 @@ export async function setUpWebPageTranslationQueue() {
}
let result = ""
const content: ArticleContent = {
title: articleTitle ?? "",
const context: WebPagePromptContext = {
webTitle: normalizePromptContextValue(webTitle),
webContent: normalizePromptContextValue(webContent),
webSummary: normalizePromptContextValue(webSummary),
}
if (shouldUseBatchQueue(providerConfig)) {
// Generate or fetch cached summary if AI Content Aware is enabled
const config = await ensureInitializedConfig()
if (
isLLMProviderConfig(providerConfig)
&& config?.translate.enableAIContentAware
&& articleTitle != null
&& articleTextContent != null
) {
content.summary = await getOrGenerateSummary(articleTitle, articleTextContent, providerConfig, requestQueue)
}
const data = { text, langConfig, providerConfig, hash, scheduleAt, content }
const data = { text, langConfig, providerConfig, hash, scheduleAt, context }
result = await batchQueue.enqueue(data)
}
else {
@ -221,6 +264,16 @@ export async function setUpWebPageTranslationQueue() {
return result
})
onMessage("getOrGenerateWebPageSummary", async (message) => {
const { webTitle, webContent, providerConfig } = message.data
if (!isLLMProviderConfig(providerConfig) || !webTitle || !webContent) {
return null
}
return await getOrGenerateWebPageSummary(webTitle, webContent, providerConfig, requestQueue)
})
onMessage("setTranslateRequestQueueConfig", (message) => {
const { data } = message
requestQueue.setQueueOptions(data)
@ -256,13 +309,13 @@ export async function setUpSubtitlesTranslationQueue() {
}
let result = ""
const content: ArticleContent = {
title: videoTitle || "",
summary: summary || "",
const context: SubtitlePromptContext = {
videoTitle: normalizePromptContextValue(videoTitle),
videoSummary: normalizePromptContextValue(summary),
}
if (shouldUseBatchQueue(providerConfig)) {
const data = { text, langConfig, providerConfig, hash, scheduleAt, content }
const data = { text, langConfig, providerConfig, hash, scheduleAt, context }
result = await batchQueue.enqueue(data)
}
else {
@ -285,10 +338,10 @@ export async function setUpSubtitlesTranslationQueue() {
const { videoTitle, subtitlesContext, providerConfig } = message.data
if (!isLLMProviderConfig(providerConfig) || !videoTitle || !subtitlesContext) {
return ""
return null
}
return await getOrGenerateSummary(videoTitle, subtitlesContext, providerConfig, requestQueue) ?? ""
return await getOrGenerateSubtitleSummary(videoTitle, subtitlesContext, providerConfig, requestQueue)
})
onMessage("setSubtitlesRequestQueueConfig", (message) => {

View file

@ -1,14 +1,14 @@
import type { Config } from "@/types/config/config"
import { defineContentScript, storage } from "#imports"
import { kebabCase } from "case-anything"
import { env } from "@/env"
import { getLocalConfig } from "@/utils/config/storage"
import { APP_NAME } from "@/utils/constants/app"
import { CONFIG_STORAGE_KEY } from "@/utils/constants/config"
import { OFFICIAL_SITE_URL_PATTERNS } from "@/utils/constants/url"
import { onMessage, sendMessage } from "@/utils/message"
export default defineContentScript({
matches: OFFICIAL_SITE_URL_PATTERNS,
matches: env.WXT_OFFICIAL_SITE_ORIGINS.map((origin: string) => `${origin}/*`),
async main() {
onMessage("pinStateChanged", (msg) => {
window.postMessage({ source: `${kebabCase(APP_NAME)}-ext`, ...msg }, "*")

View file

@ -3,7 +3,7 @@ import type { LangCodeISO6393 } from "@read-frog/definitions"
import type { Config } from "@/types/config/config"
import { storage } from "#imports"
import { DEFAULT_CONFIG, DETECTED_CODE_STORAGE_KEY } from "@/utils/constants/config"
import { getDocumentInfo } from "@/utils/content/analyze"
import { detectPageLanguageLightweight } from "@/utils/content/page-language"
import { ensurePresetStyles } from "@/utils/host/translate/ui/style-injector"
import { logger } from "@/utils/logger"
import { onMessage, sendMessage } from "@/utils/message"
@ -55,7 +55,7 @@ export async function bootstrapHostContent(ctx: ContentScriptContext, initialCon
}
// Only the top frame should detect and set language to avoid race conditions from iframes
if (window === window.top) {
const { detectedCodeOrUnd } = await getDocumentInfo()
const { detectedCodeOrUnd } = await detectPageLanguageLightweight()
const detectedCode: LangCodeISO6393 = detectedCodeOrUnd === "und" ? "eng" : detectedCodeOrUnd
await storage.setItem<LangCodeISO6393>(`local:${DETECTED_CODE_STORAGE_KEY}`, detectedCode)
// Notify background script that URL has changed, let it decide whether to automatically enable translation
@ -92,7 +92,7 @@ export async function bootstrapHostContent(ctx: ContentScriptContext, initialCon
// Only the top frame should detect and set language to avoid race conditions from iframes
if (window === window.top) {
const { detectedCodeOrUnd } = await getDocumentInfo()
const { detectedCodeOrUnd } = await detectPageLanguageLightweight()
const initialDetectedCode: LangCodeISO6393 = detectedCodeOrUnd === "und" ? "eng" : detectedCodeOrUnd
await storage.setItem<LangCodeISO6393>(`local:${DETECTED_CODE_STORAGE_KEY}`, initialDetectedCode)

View file

@ -0,0 +1,307 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest"
import { DEFAULT_CONFIG } from "@/utils/constants/config"
import { PageTranslationManager } from "../page-translation"
const {
mockDeepQueryTopLevelSelector,
mockGetDetectedCodeFromStorage,
mockGetLocalConfig,
mockGetOrCreateWebPageContext,
mockHasNoWalkAncestor,
mockIsDontWalkIntoAndDontTranslateAsChildElement,
mockIsDontWalkIntoButTranslateAsChildElement,
mockRemoveAllTranslatedWrapperNodes,
mockSendMessage,
mockTranslateTextForPageTitle,
mockTranslateWalkedElement,
mockValidateTranslationConfigAndToast,
mockWalkAndLabelElement,
} = vi.hoisted(() => ({
mockGetDetectedCodeFromStorage: vi.fn(),
mockGetLocalConfig: vi.fn(),
mockGetOrCreateWebPageContext: vi.fn(),
mockDeepQueryTopLevelSelector: vi.fn(),
mockHasNoWalkAncestor: vi.fn(),
mockIsDontWalkIntoAndDontTranslateAsChildElement: vi.fn(),
mockIsDontWalkIntoButTranslateAsChildElement: vi.fn(),
mockWalkAndLabelElement: vi.fn(),
mockRemoveAllTranslatedWrapperNodes: vi.fn(),
mockTranslateWalkedElement: vi.fn(),
mockTranslateTextForPageTitle: vi.fn(),
mockValidateTranslationConfigAndToast: vi.fn(),
mockSendMessage: vi.fn(),
}))
vi.mock("@/utils/config/languages", () => ({
getDetectedCodeFromStorage: mockGetDetectedCodeFromStorage,
}))
vi.mock("@/utils/config/storage", () => ({
getLocalConfig: mockGetLocalConfig,
}))
vi.mock("@/utils/crypto-polyfill", () => ({
getRandomUUID: () => "walk-id",
}))
vi.mock("@/utils/host/dom/filter", () => ({
hasNoWalkAncestor: mockHasNoWalkAncestor,
isDontWalkIntoAndDontTranslateAsChildElement: mockIsDontWalkIntoAndDontTranslateAsChildElement,
isDontWalkIntoButTranslateAsChildElement: mockIsDontWalkIntoButTranslateAsChildElement,
isHTMLElement: (node: unknown) => node instanceof HTMLElement,
}))
vi.mock("@/utils/host/dom/find", () => ({
deepQueryTopLevelSelector: mockDeepQueryTopLevelSelector,
}))
vi.mock("@/utils/host/dom/traversal", () => ({
walkAndLabelElement: mockWalkAndLabelElement,
}))
vi.mock("@/utils/host/translate/node-manipulation", () => ({
removeAllTranslatedWrapperNodes: mockRemoveAllTranslatedWrapperNodes,
translateWalkedElement: mockTranslateWalkedElement,
}))
vi.mock("@/utils/host/translate/translate-text", () => ({
validateTranslationConfigAndToast: mockValidateTranslationConfigAndToast,
}))
vi.mock("@/utils/host/translate/translate-variants", () => ({
translateTextForPageTitle: mockTranslateTextForPageTitle,
}))
vi.mock("@/utils/host/translate/webpage-context", () => ({
getOrCreateWebPageContext: mockGetOrCreateWebPageContext,
}))
vi.mock("@/utils/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}))
vi.mock("@/utils/message", () => ({
sendMessage: mockSendMessage,
}))
const intersectionObservers: MockIntersectionObserver[] = []
class MockIntersectionObserver {
observe = vi.fn((target: Element) => {
this.targets.add(target)
})
unobserve = vi.fn((target: Element) => {
this.targets.delete(target)
})
disconnect = vi.fn(() => {
this.targets.clear()
})
private readonly targets = new Set<Element>()
constructor(
private readonly callback: IntersectionObserverCallback,
_options?: IntersectionObserverInit,
) {
intersectionObservers.push(this)
}
async triggerIntersect(target: Element): Promise<void> {
await this.callback([{
isIntersecting: true,
target,
} as IntersectionObserverEntry], this as unknown as IntersectionObserver)
}
}
async function flushDomUpdates(): Promise<void> {
await Promise.resolve()
await new Promise(resolve => setTimeout(resolve, 0))
await Promise.resolve()
}
function deepQueryTopLevelSelectorImpl(
root: Document | ShadowRoot | HTMLElement,
selectorFn: (element: HTMLElement) => boolean,
): HTMLElement[] {
if (root instanceof Document) {
return root.body ? deepQueryTopLevelSelectorImpl(root.body, selectorFn) : []
}
if (root instanceof HTMLElement && selectorFn(root)) {
return [root]
}
const result: HTMLElement[] = []
if (root instanceof HTMLElement && root.shadowRoot) {
result.push(...deepQueryTopLevelSelectorImpl(root.shadowRoot, selectorFn))
}
for (const child of root.children) {
if (child instanceof HTMLElement) {
result.push(...deepQueryTopLevelSelectorImpl(child, selectorFn))
}
}
return result
}
function isBlockedForTraversal(element: HTMLElement): boolean {
return Boolean(element.hidden)
|| element.getAttribute("aria-hidden") === "true"
|| element.classList.contains("closed")
}
function walkAndLabelVisibleParagraphs(element: HTMLElement, walkId: string) {
if (isBlockedForTraversal(element)) {
return {
forceBlock: false,
isInlineNode: false,
}
}
element.setAttribute("data-read-frog-walked", walkId)
for (const child of element.children) {
if (child instanceof HTMLElement) {
walkAndLabelVisibleParagraphs(child, walkId)
}
}
if (element.tagName === "P" && element.textContent?.trim()) {
element.setAttribute("data-read-frog-paragraph", "")
}
return {
forceBlock: false,
isInlineNode: false,
}
}
describe("pageTranslationManager mutation re-walk", () => {
beforeEach(() => {
vi.clearAllMocks()
intersectionObservers.length = 0
document.head.innerHTML = ""
document.body.innerHTML = ""
document.title = ""
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver)
mockGetDetectedCodeFromStorage.mockResolvedValue("eng")
mockGetLocalConfig.mockResolvedValue(DEFAULT_CONFIG)
mockGetOrCreateWebPageContext.mockResolvedValue({
url: window.location.href,
webTitle: "",
webContent: "",
})
mockHasNoWalkAncestor.mockReturnValue(false)
mockIsDontWalkIntoButTranslateAsChildElement.mockReturnValue(false)
mockIsDontWalkIntoAndDontTranslateAsChildElement.mockImplementation((element: HTMLElement) => isBlockedForTraversal(element))
mockDeepQueryTopLevelSelector.mockImplementation(deepQueryTopLevelSelectorImpl)
mockWalkAndLabelElement.mockImplementation((element: HTMLElement, walkId: string) => walkAndLabelVisibleParagraphs(element, walkId))
mockTranslateTextForPageTitle.mockResolvedValue("")
mockValidateTranslationConfigAndToast.mockReturnValue(true)
mockSendMessage.mockResolvedValue(undefined)
})
it("observes and translates hidden accordion content after it becomes visible", async () => {
document.body.innerHTML = `
<section id="accordion" hidden>
<p id="panel">Accordion body</p>
</section>
`
const manager = new PageTranslationManager()
await manager.start()
await flushDomUpdates()
const observer = intersectionObservers[0]
const accordion = document.getElementById("accordion") as HTMLElement
const panel = document.getElementById("panel") as HTMLElement
expect(observer.observe).not.toHaveBeenCalled()
accordion.removeAttribute("hidden")
await flushDomUpdates()
expect(observer.observe).toHaveBeenCalledWith(panel)
await observer.triggerIntersect(panel)
await flushDomUpdates()
expect(mockTranslateWalkedElement).toHaveBeenCalledWith(panel, "walk-id", DEFAULT_CONFIG)
manager.stop()
})
it("observes and translates aria-hidden accordion content after it becomes visible", async () => {
document.body.innerHTML = `
<section id="accordion" aria-hidden="true">
<p id="panel">Accordion body</p>
</section>
`
const manager = new PageTranslationManager()
await manager.start()
await flushDomUpdates()
const observer = intersectionObservers[0]
const accordion = document.getElementById("accordion") as HTMLElement
const panel = document.getElementById("panel") as HTMLElement
expect(observer.observe).not.toHaveBeenCalled()
accordion.setAttribute("aria-hidden", "false")
await flushDomUpdates()
expect(observer.observe).toHaveBeenCalledWith(panel)
await observer.triggerIntersect(panel)
await flushDomUpdates()
expect(mockTranslateWalkedElement).toHaveBeenCalledWith(panel, "walk-id", DEFAULT_CONFIG)
manager.stop()
})
it("keeps style/class based re-walk behavior for existing hidden panels", async () => {
document.body.innerHTML = `
<section id="accordion" class="closed">
<p id="panel">Accordion body</p>
</section>
`
const manager = new PageTranslationManager()
await manager.start()
await flushDomUpdates()
const observer = intersectionObservers[0]
const accordion = document.getElementById("accordion") as HTMLElement
const panel = document.getElementById("panel") as HTMLElement
expect(observer.observe).not.toHaveBeenCalled()
accordion.classList.remove("closed")
await flushDomUpdates()
expect(observer.observe).toHaveBeenCalledWith(panel)
await observer.triggerIntersect(panel)
await flushDomUpdates()
expect(mockTranslateWalkedElement).toHaveBeenCalledWith(panel, "walk-id", DEFAULT_CONFIG)
manager.stop()
})
})

View file

@ -8,7 +8,7 @@ const {
mockDeepQueryTopLevelSelector,
mockGetDetectedCodeFromStorage,
mockGetLocalConfig,
mockGetOrFetchArticleData,
mockGetOrCreateWebPageContext,
mockRemoveAllTranslatedWrapperNodes,
mockSendMessage,
mockTranslateTextForPageTitle,
@ -23,7 +23,7 @@ const {
mockRemoveAllTranslatedWrapperNodes: vi.fn(),
mockTranslateWalkedElement: vi.fn(),
mockTranslateTextForPageTitle: vi.fn(),
mockGetOrFetchArticleData: vi.fn(),
mockGetOrCreateWebPageContext: vi.fn(),
mockValidateTranslationConfigAndToast: vi.fn(),
mockSendMessage: vi.fn(),
}))
@ -38,6 +38,7 @@ vi.mock("@/utils/config/storage", () => ({
vi.mock("@/utils/host/dom/filter", () => ({
hasNoWalkAncestor: vi.fn().mockReturnValue(false),
isDontWalkIntoAndDontTranslateAsChildElement: vi.fn().mockReturnValue(false),
isDontWalkIntoButTranslateAsChildElement: vi.fn().mockReturnValue(false),
isHTMLElement: (node: unknown) => node instanceof HTMLElement,
}))
@ -59,8 +60,8 @@ vi.mock("@/utils/host/translate/translate-variants", () => ({
translateTextForPageTitle: mockTranslateTextForPageTitle,
}))
vi.mock("@/utils/host/translate/article-context", () => ({
getOrFetchArticleData: mockGetOrFetchArticleData,
vi.mock("@/utils/host/translate/webpage-context", () => ({
getOrCreateWebPageContext: mockGetOrCreateWebPageContext,
}))
vi.mock("@/utils/host/translate/translate-text", () => ({
@ -115,11 +116,47 @@ describe("pageTranslationManager title handling", () => {
mockGetDetectedCodeFromStorage.mockResolvedValue("eng")
mockGetLocalConfig.mockResolvedValue(DEFAULT_CONFIG)
mockDeepQueryTopLevelSelector.mockReturnValue([])
mockGetOrFetchArticleData.mockResolvedValue({ title: "Original Title" })
mockGetOrCreateWebPageContext.mockResolvedValue({
url: window.location.href,
webTitle: "Original Title",
webContent: "Article body",
})
mockValidateTranslationConfigAndToast.mockReturnValue(true)
mockSendMessage.mockResolvedValue(undefined)
})
it("does not prime webpage context on start for non-llm translation", async () => {
mockTranslateTextForPageTitle.mockResolvedValue("Translated Title")
const manager = new PageTranslationManager()
await manager.start()
await flushDomUpdates()
expect(mockGetOrCreateWebPageContext).not.toHaveBeenCalled()
manager.stop()
})
it("primes webpage context on start for AI-aware llm translation", async () => {
mockGetLocalConfig.mockResolvedValue({
...DEFAULT_CONFIG,
translate: {
...DEFAULT_CONFIG.translate,
providerId: "openai-default",
enableAIContentAware: true,
},
})
mockTranslateTextForPageTitle.mockResolvedValue("Translated Title")
const manager = new PageTranslationManager()
await manager.start()
await flushDomUpdates()
expect(mockGetOrCreateWebPageContext).toHaveBeenCalledTimes(1)
manager.stop()
})
it("translates the tab title on start and restores the latest source title on stop", async () => {
mockTranslateTextForPageTitle
.mockResolvedValueOnce("Translated Title")

View file

@ -1,17 +1,20 @@
import type { FeatureUsageContext } from "@/types/analytics"
import type { Config } from "@/types/config/config"
import { ANALYTICS_FEATURE, ANALYTICS_SURFACE } from "@/types/analytics"
import { isLLMProviderConfig } from "@/types/config/provider"
import { createFeatureUsageContext, trackFeatureUsed } from "@/utils/analytics"
import { getDetectedCodeFromStorage } from "@/utils/config/languages"
import { getLocalConfig } from "@/utils/config/storage"
import { CONTENT_WRAPPER_CLASS } from "@/utils/constants/dom-labels"
import { resolveProviderConfig } from "@/utils/constants/feature-providers"
import { getRandomUUID } from "@/utils/crypto-polyfill"
import { hasNoWalkAncestor, isDontWalkIntoButTranslateAsChildElement, isHTMLElement } from "@/utils/host/dom/filter"
import { hasNoWalkAncestor, isDontWalkIntoAndDontTranslateAsChildElement, isDontWalkIntoButTranslateAsChildElement, isHTMLElement } from "@/utils/host/dom/filter"
import { deepQueryTopLevelSelector } from "@/utils/host/dom/find"
import { walkAndLabelElement } from "@/utils/host/dom/traversal"
import { getOrFetchArticleData } from "@/utils/host/translate/article-context"
import { removeAllTranslatedWrapperNodes, translateWalkedElement } from "@/utils/host/translate/node-manipulation"
import { validateTranslationConfigAndToast } from "@/utils/host/translate/translate-text"
import { translateTextForPageTitle } from "@/utils/host/translate/translate-variants"
import { getOrCreateWebPageContext } from "@/utils/host/translate/webpage-context"
import { logger } from "@/utils/logger"
import { sendMessage } from "@/utils/message"
@ -57,7 +60,7 @@ export class PageTranslationManager implements IPageTranslationManager {
private mutationObservers: MutationObserver[] = []
private walkId: string | null = null
private intersectionOptions: IntersectionObserverInit
private dontWalkIntoElementsCache = new WeakSet<HTMLElement>()
private walkBlockedElementsCache = new WeakSet<HTMLElement>()
private titleObserver: MutationObserver | null = null
private lastSourceTitle: string | null = null
private lastAppliedTranslatedTitle: string | null = null
@ -117,12 +120,16 @@ export class PageTranslationManager implements IPageTranslationManager {
}
try {
const providerConfig = resolveProviderConfig(config, "translate")
await sendMessage("setAndNotifyPageTranslationStateChangedByManager", {
enabled: true,
})
this.isPageTranslating = true
await this.primeDocumentTitleContext(config.translate.enableAIContentAware)
await this.primeDocumentTitleContext(
config.translate.enableAIContentAware && isLLMProviderConfig(providerConfig),
)
this.startDocumentTitleTracking()
// Listen to existing elements when they enter the viewpoint
@ -147,8 +154,8 @@ export class PageTranslationManager implements IPageTranslationManager {
}, this.intersectionOptions)
// Initialize walkability state for existing elements
this.addDontWalkIntoElements(document.body)
await this.observerTopLevelParagraphs(document.body)
this.addWalkBlockedElements(document.body, config)
await this.observerTopLevelParagraphs(document.body, config)
// Start observing mutations from document.body and all shadow roots
this.observeMutations(document.body)
@ -183,7 +190,7 @@ export class PageTranslationManager implements IPageTranslationManager {
this.isPageTranslating = false
this.walkId = null
this.dontWalkIntoElementsCache = new WeakSet()
this.walkBlockedElementsCache = new WeakSet()
this.stopDocumentTitleTracking()
if (this.intersectionObserver) {
@ -261,16 +268,16 @@ export class PageTranslationManager implements IPageTranslationManager {
return window === window.top
}
private async primeDocumentTitleContext(enableAIContentAware: boolean): Promise<void> {
if (!this.shouldManageDocumentTitle()) {
private async primeDocumentTitleContext(shouldPrimeWebPageContext: boolean): Promise<void> {
if (!this.shouldManageDocumentTitle() || !shouldPrimeWebPageContext) {
return
}
try {
await getOrFetchArticleData(enableAIContentAware)
await getOrCreateWebPageContext()
}
catch (error) {
logger.warn("Failed to prime article context before translating document title:", error)
logger.warn("Failed to prime webpage context before translating document title:", error)
}
}
@ -380,12 +387,12 @@ export class PageTranslationManager implements IPageTranslationManager {
}
}
private async observerTopLevelParagraphs(container: HTMLElement): Promise<void> {
private async observerTopLevelParagraphs(container: HTMLElement, existingConfig?: Config): Promise<void> {
const observer = this.intersectionObserver
if (!this.walkId || !observer)
return
const config = await getLocalConfig()
const config = existingConfig ?? await getLocalConfig()
if (!config) {
logger.error("Global config is not initialized")
return
@ -448,33 +455,39 @@ export class PageTranslationManager implements IPageTranslationManager {
}
/**
* Handle style/class attribute changes and only trigger observation
* when element transitions from "don't walk into" to "walkable"
* Track the same blocked states that the traversal skips, so hidden accordion
* panels can be re-walked when the site reveals an existing subtree.
*/
private didChangeToWalkable(element: HTMLElement): boolean {
const wasDontWalkInto = this.dontWalkIntoElementsCache.has(element)
const isDontWalkIntoNow = isDontWalkIntoButTranslateAsChildElement(element)
private isWalkBlockedElement(element: HTMLElement, config: Config): boolean {
return isDontWalkIntoButTranslateAsChildElement(element)
|| isDontWalkIntoAndDontTranslateAsChildElement(element, config)
}
/**
* Handle attribute changes and only trigger observation
* when element transitions from blocked to walkable.
*/
private didChangeToWalkable(element: HTMLElement, config: Config): boolean {
const wasWalkBlocked = this.walkBlockedElementsCache.has(element)
const isWalkBlockedNow = this.isWalkBlockedElement(element, config)
// Update cache with current state
if (isDontWalkIntoNow) {
this.dontWalkIntoElementsCache.add(element)
if (isWalkBlockedNow) {
this.walkBlockedElementsCache.add(element)
}
else {
this.dontWalkIntoElementsCache.delete(element)
this.walkBlockedElementsCache.delete(element)
}
// Only trigger observation if element transitioned from "don't walk into" to "walkable"
// wasDontWalkInto === true means it was previously not walkable
// isDontWalkIntoNow === false means it's now walkable
return wasDontWalkInto === true && isDontWalkIntoNow === false
return wasWalkBlocked === true && isWalkBlockedNow === false
}
/**
* Initialize walkability state for an element and its descendants
*/
private addDontWalkIntoElements(element: HTMLElement): void {
const dontWalkIntoElements = deepQueryTopLevelSelector(element, isDontWalkIntoButTranslateAsChildElement)
dontWalkIntoElements.forEach(el => this.dontWalkIntoElementsCache.add(el))
private addWalkBlockedElements(element: HTMLElement, config: Config): void {
const walkBlockedElements = deepQueryTopLevelSelector(element, el => this.isWalkBlockedElement(el, config))
walkBlockedElements.forEach(el => this.walkBlockedElementsCache.add(el))
}
/**
@ -482,39 +495,54 @@ export class PageTranslationManager implements IPageTranslationManager {
*/
private observeMutations(container: HTMLElement): void {
const mutationObserver = new MutationObserver((records) => {
for (const rec of records) {
if (rec.type === "childList") {
rec.addedNodes.forEach((node) => {
if (isHTMLElement(node)) {
this.addDontWalkIntoElements(node)
void this.observerTopLevelParagraphs(node)
this.observeIsolatedDescendantsMutations(node)
}
})
}
else if (
rec.type === "attributes"
&& (rec.attributeName === "style" || rec.attributeName === "class")
) {
const el = rec.target
if (isHTMLElement(el) && this.didChangeToWalkable(el)) {
void this.observerTopLevelParagraphs(el)
}
}
}
void this.handleMutationRecords(records)
})
mutationObserver.observe(container, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["style", "class"],
attributeFilter: ["style", "class", "hidden", "aria-hidden"],
})
this.mutationObservers.push(mutationObserver)
this.observeIsolatedDescendantsMutations(container)
}
private async handleMutationRecords(records: MutationRecord[]): Promise<void> {
const config = await getLocalConfig()
if (!config) {
logger.error("Global config is not initialized")
return
}
for (const rec of records) {
if (rec.type === "childList") {
rec.addedNodes.forEach((node) => {
if (isHTMLElement(node)) {
this.addWalkBlockedElements(node, config)
void this.observerTopLevelParagraphs(node, config)
this.observeIsolatedDescendantsMutations(node)
}
})
}
else if (this.isWalkabilityAttributeMutation(rec)) {
const el = rec.target
if (isHTMLElement(el) && this.didChangeToWalkable(el, config)) {
void this.observerTopLevelParagraphs(el, config)
}
}
}
}
private isWalkabilityAttributeMutation(record: MutationRecord): boolean {
return record.type === "attributes"
&& (record.attributeName === "style"
|| record.attributeName === "class"
|| record.attributeName === "hidden"
|| record.attributeName === "aria-hidden")
}
/**
* Recursively find and observe shadow roots and iframes in an element and its descendants
* These can't be find as top level paragraph elements because isolated shadow roots and iframes are not

View file

@ -2,7 +2,8 @@ import { defineContentScript } from "#imports"
import { injectPlayerApi } from "./inject-player-api"
export default defineContentScript({
matches: ["*://*.youtube.com/*"],
matches: ["*://*.youtube.com/*", "*://*.youtube-nocookie.com/*"],
allFrames: true,
world: "MAIN",
runAt: "document_start",
main() {

View file

@ -37,10 +37,15 @@ declare global {
function findYoutubePlayer(): YouTubePlayer | null {
return document.querySelector(
".html5-video-player.playing-mode, .html5-video-player.paused-mode",
)
) ?? document.querySelector(".html5-video-player")
}
export function injectPlayerApi(): void {
if ((window as any).__READ_FROG_INTERCEPTOR_INJECTED__) {
return
}
;(window as any).__READ_FROG_INTERCEPTOR_INJECTED__ = true
setupTimedtextObserver()
window.addEventListener("message", handleMessage)
}

View file

@ -15,6 +15,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/base-ui/sidebar"
import { env } from "@/env"
import {
buildBilibiliEmbedUrl,
getBlogLocaleFromUILanguage,
@ -23,7 +24,6 @@ import {
hasNewBlogPost,
saveLastViewedBlogDate,
} from "@/utils/blog"
import { WEBSITE_URL } from "@/utils/constants/url"
import { version } from "../../../../package.json"
export function WhatsNewFooter() {
@ -38,7 +38,7 @@ export function WhatsNewFooter() {
const { data: latestBlogPost, isFetched: isLatestBlogPostFetched } = useQuery({
queryKey: ["latest-blog-post", blogLocale],
queryFn: () => getLatestBlogDate(`${WEBSITE_URL}/api/blog/latest`, blogLocale, version),
queryFn: () => getLatestBlogDate(`${env.WXT_WEBSITE_URL}/api/blog/latest`, blogLocale, version),
})
const markLatestBlogPostViewed = useEffectEvent(async () => {
@ -63,7 +63,7 @@ export function WhatsNewFooter() {
}, [])
const openPopover = useEffectEvent(() => {
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
// eslint-disable-next-line react/set-state-in-effect
setOpen(true)
})
@ -97,7 +97,7 @@ export function WhatsNewFooter() {
return null
}
const blogUrl = new URL(latestBlogPost.url, WEBSITE_URL).toString()
const blogUrl = new URL(latestBlogPost.url, env.WXT_WEBSITE_URL).toString()
const embedUrl = latestBlogPost.videoUrl ? buildBilibiliEmbedUrl(latestBlogPost.videoUrl) : null
return (

View file

@ -22,17 +22,23 @@ vi.mock("@/components/ui/json-code-editor", () => ({
JSONCodeEditor: ({
value,
onChange,
onBlur,
onFocus,
placeholder,
}: {
value?: string
onChange?: (value: string) => void
onBlur?: () => void
onFocus?: () => void
placeholder?: string
}) => (
<textarea
aria-label="provider-options-editor"
value={value}
placeholder={placeholder}
onBlur={onBlur}
onChange={event => onChange?.(event.target.value)}
onFocus={onFocus}
/>
),
}))
@ -53,16 +59,22 @@ const baseProviderConfig: APIProviderConfig = {
function ProviderOptionsFieldHarness({
initialConfig,
externalProviderOptions,
submitDelayMs = 0,
}: {
initialConfig: APIProviderConfig
externalProviderOptions?: Record<string, unknown>
submitDelayMs?: number
}) {
const [providerConfig, setProviderConfig] = useState(initialConfig)
const form = useAppForm({
...formOpts,
defaultValues: providerConfig,
onSubmit: async ({ value }) => {
setProviderConfig(value)
if (submitDelayMs > 0) {
await new Promise(resolve => window.setTimeout(resolve, submitDelayMs))
}
setProviderConfig(submitDelayMs > 0 ? structuredClone(value) : value)
},
})
@ -105,6 +117,7 @@ describe("providerOptionsField", () => {
render(<ProviderOptionsFieldHarness initialConfig={baseProviderConfig} />)
const editor = screen.getByLabelText("provider-options-editor")
fireEvent.focus(editor)
fireEvent.change(editor, { target: { value: "{\"reasoningEffort\":\"minimal\"}" } })
await act(async () => {
@ -115,6 +128,29 @@ describe("providerOptionsField", () => {
expect(screen.getByLabelText("provider-options-editor")).toHaveValue("{\"reasoningEffort\":\"minimal\"}")
})
it("keeps focused draft edits when a delayed autosave echo arrives", async () => {
render(<ProviderOptionsFieldHarness initialConfig={baseProviderConfig} submitDelayMs={100} />)
const editor = screen.getByLabelText("provider-options-editor")
fireEvent.focus(editor)
fireEvent.change(editor, { target: { value: "{\"reasoningEffort\":\"minimal\"}" } })
await act(async () => {
vi.advanceTimersByTime(500)
await Promise.resolve()
})
fireEvent.change(editor, { target: { value: "{\"reasoningEffort\":\"low\"}" } })
await act(async () => {
vi.advanceTimersByTime(100)
await Promise.resolve()
await Promise.resolve()
})
expect(screen.getByLabelText("provider-options-editor")).toHaveValue("{\"reasoningEffort\":\"low\"}")
})
it("shows the matched recommended provider options as the placeholder when the value is empty", () => {
render(<ProviderOptionsFieldHarness initialConfig={baseProviderConfig} />)
@ -180,7 +216,9 @@ describe("providerOptionsField", () => {
)
const editor = screen.getByLabelText("provider-options-editor")
fireEvent.focus(editor)
fireEvent.change(editor, { target: { value: "{" } })
fireEvent.blur(editor)
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "apply-external" }))
await Promise.resolve()

View file

@ -117,7 +117,7 @@ describe("providerOptionsRecommendationTrigger", () => {
expect(onApply).toHaveBeenCalledWith({ reasoningEffort: "none" })
})
it("renders Kimi recommendations based on model name alone", () => {
it("does not render Kimi instruct recommendations based on model name alone", () => {
render(
<ProviderOptionsRecommendationTrigger
providerId="provider-1"
@ -126,8 +126,8 @@ describe("providerOptionsRecommendationTrigger", () => {
/>,
)
expect(screen.getByRole("button", {
expect(screen.queryByRole("button", {
name: "options.apiProviders.form.providerOptionsRecommendationTrigger",
})).toBeInTheDocument()
})).not.toBeInTheDocument()
})
})

View file

@ -55,7 +55,7 @@ export function ConnectionTestButton({ providerConfig }: { providerConfig: APIPr
useEffect(() => {
mutation.reset()
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react/exhaustive-deps
}, [provider, apiKey, baseURL, connectionOptions])
const testResult = mutation.isSuccess ? "success" : mutation.isError ? "error" : null

View file

@ -38,17 +38,17 @@ export function ProviderOptionsRecommendationTrigger({
const previousMatchIndexRef = useRef<number | undefined>(undefined)
const closePopover = useEffectEvent(() => {
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
// eslint-disable-next-line react/set-state-in-effect
setOpen(false)
})
const startFlashing = useEffectEvent(() => {
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
// eslint-disable-next-line react/set-state-in-effect
setIsFlashing(true)
})
const stopFlashing = useEffectEvent(() => {
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
// eslint-disable-next-line react/set-state-in-effect
setIsFlashing(false)
})

View file

@ -2,8 +2,8 @@ import type { APIProviderTypes } from "@/types/config/provider"
import { i18n } from "#imports"
import ProviderIcon from "@/components/provider-icon"
import { useTheme } from "@/components/providers/theme-provider"
import { env } from "@/env"
import { PROVIDER_GROUPS, PROVIDER_ITEMS, SPECIFIC_TUTORIAL_PROVIDER_TYPES } from "@/utils/constants/providers"
import { WEBSITE_URL } from "@/utils/constants/url"
export function ConfigHeader({ providerType }: { providerType: APIProviderTypes }) {
const tutorialUrl = getHowToConfigureURL(providerType)
@ -31,13 +31,13 @@ export function ConfigHeader({ providerType }: { providerType: APIProviderTypes
function getHowToConfigureURL(providerType: APIProviderTypes): string | undefined {
if (SPECIFIC_TUTORIAL_PROVIDER_TYPES.includes(providerType as any)) {
return `${WEBSITE_URL}/tutorial/providers/${providerType}`
return `${env.WXT_WEBSITE_URL}/docs/providers/${providerType}`
}
const groupSlug = getProviderGroupSlug(providerType)
if (!groupSlug)
return undefined
return `${WEBSITE_URL}/tutorial/providers/${groupSlug}`
return `${env.WXT_WEBSITE_URL}/docs/providers/${groupSlug}`
}
function getProviderGroupSlug(providerType: APIProviderTypes): string | undefined {

View file

@ -22,7 +22,7 @@ export const ConnectionOptionsField = withForm({
// Sync local state when switching provider
const syncLocalOptions = useEffectEvent(() => {
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
// eslint-disable-next-line react/set-state-in-effect
setLocalOptions(providerConfig.connectionOptions ?? {})
})

View file

@ -39,9 +39,10 @@ export const ProviderOptionsField = withForm({
const [jsonInput, setJsonInput] = useState(() => externalJson)
const lastCommittedJsonRef = useRef(externalJson)
const pendingEditorCommitRef = useRef(false)
const editorFocusedRef = useRef(false)
const syncJsonInput = useEffectEvent((nextJson: string) => {
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
// eslint-disable-next-line react/set-state-in-effect
setJsonInput(nextJson)
})
@ -55,6 +56,18 @@ export const ProviderOptionsField = withForm({
return jsonInput
})
const handleJsonInputChange = useCallback((nextJson: string) => {
setJsonInput(nextJson)
}, [])
const handleEditorFocus = useCallback(() => {
editorFocusedRef.current = true
}, [])
const handleEditorBlur = useCallback(() => {
editorFocusedRef.current = false
}, [])
useEffect(() => {
resetSyncStateForProvider()
}, [providerConfig.id])
@ -66,9 +79,15 @@ export const ProviderOptionsField = withForm({
}
pendingEditorCommitRef.current = false
const currentJsonInput = readJsonInput()
if (editorFocusedRef.current && currentJsonInput !== lastCommittedJsonRef.current) {
return
}
lastCommittedJsonRef.current = externalJson
if (readJsonInput() !== externalJson) {
if (currentJsonInput !== externalJson) {
syncJsonInput(externalJson)
}
}, [providerConfig.providerOptions, externalJson])
@ -127,7 +146,9 @@ export const ProviderOptionsField = withForm({
</FieldLabel>
<JSONCodeEditor
value={jsonInput}
onChange={setJsonInput}
onChange={handleJsonInputChange}
onFocus={handleEditorFocus}
onBlur={handleEditorBlur}
placeholder={placeholderText}
hasError={!!jsonError}
height="150px"

View file

@ -94,7 +94,7 @@ function DialogContent({ onResolved, onCancelled }: DialogContentProps) {
const canConfirm = status.isValid && !isConfirming
return (
<AlertDialogContent className="w-[min(80rem,calc(100vw-2rem))] max-w-none max-h-[90vh] flex flex-col overflow-hidden">
<AlertDialogContent className="data-[size=default]:max-w-[calc(100vw-2rem)] data-[size=default]:md:max-w-2xl data-[size=default]:lg:max-w-4xl data-[size=default]:xl:max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Icon icon="mdi:alert" className="size-5 text-yellow-500" />

View file

@ -29,8 +29,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/base-ui/select"
import { env } from "@/env"
import { authClient } from "@/utils/auth/auth-client"
import { WEBSITE_URL } from "@/utils/constants/url"
import {
createNotebaseMapping,
isNotebaseMappingCompatible,
@ -326,7 +326,7 @@ export const NotebaseConnectionField = withForm({
type="button"
size="sm"
variant="outline"
onClick={() => window.open(`${WEBSITE_URL}/log-in`, "_blank")}
onClick={() => window.open(`${env.WXT_WEBSITE_URL}/log-in`, "_blank")}
>
{t("loginAction")}
</Button>
@ -352,7 +352,7 @@ export const NotebaseConnectionField = withForm({
type="button"
size="sm"
variant="outline"
onClick={() => window.open(`${WEBSITE_URL}/notebase`, "_blank")}
onClick={() => window.open(`${env.WXT_WEBSITE_URL}/notebase`, "_blank")}
>
{t("openNotebaseAction")}
</Button>
@ -419,7 +419,7 @@ export const NotebaseConnectionField = withForm({
type="button"
size="sm"
variant="outline"
onClick={() => window.open(`${WEBSITE_URL}/notebase`, "_blank")}
onClick={() => window.open(`${env.WXT_WEBSITE_URL}/notebase`, "_blank")}
>
{t("openNotebaseAction")}
</Button>

View file

@ -1,3 +1,4 @@
import type { FloatingButtonClickAction as FloatingButtonClickActionValue } from "@/types/config/floating-button"
import { i18n } from "#imports"
import { useAtom } from "jotai"
import {
@ -8,13 +9,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/base-ui/select"
import { floatingButtonClickActionSchema } from "@/types/config/floating-button"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { ConfigCard } from "../../components/config-card"
const items = [
{ value: "panel", label: i18n.t("options.floatingButtonAndToolbar.floatingButton.clickAction.panel") },
{ value: "translate", label: i18n.t("options.floatingButtonAndToolbar.floatingButton.clickAction.translate") },
]
] satisfies Array<{ value: FloatingButtonClickActionValue, label: string }>
export function FloatingButtonClickAction() {
const [floatingButton, setFloatingButton] = useAtom(
@ -32,9 +34,10 @@ export function FloatingButtonClickAction() {
items={items}
value={floatingButton.clickAction}
onValueChange={(value) => {
if (!value)
const parsedValue = floatingButtonClickActionSchema.safeParse(value)
if (!parsedValue.success)
return
void setFloatingButton({ ...floatingButton, clickAction: value })
void setFloatingButton({ ...floatingButton, clickAction: parsedValue.data })
}}
>
<SelectTrigger className="w-[180px]">

View file

@ -1,5 +1,6 @@
import { i18n } from "#imports"
import { useAtom } from "jotai"
import { useEffect, useState } from "react"
import { Slider } from "@/components/ui/base-ui/slider"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { MAX_SELECTION_OVERLAY_OPACITY, MIN_SELECTION_OVERLAY_OPACITY } from "@/utils/constants/selection"
@ -7,6 +8,12 @@ import { ConfigCard } from "../../components/config-card"
export function SelectionToolbarOpacity() {
const [selectionToolbar, setSelectionToolbar] = useAtom(configFieldsAtomMap.selectionToolbar)
const [draftOpacity, setDraftOpacity] = useState(selectionToolbar.opacity)
useEffect(() => {
// eslint-disable-next-line react/set-state-in-effect
setDraftOpacity(selectionToolbar.opacity)
}, [selectionToolbar.opacity])
return (
<ConfigCard
@ -19,14 +26,17 @@ export function SelectionToolbarOpacity() {
min={MIN_SELECTION_OVERLAY_OPACITY}
max={MAX_SELECTION_OVERLAY_OPACITY}
step={1}
value={selectionToolbar.opacity}
value={draftOpacity}
onValueChange={(value) => {
setDraftOpacity(value as number)
}}
onValueCommitted={(value) => {
void setSelectionToolbar({ opacity: value as number })
}}
className="flex-1"
/>
<span className="w-10 text-sm text-right">
{selectionToolbar.opacity}
{draftOpacity}
%
</span>
</div>

View file

@ -79,12 +79,6 @@ export function TtsConfig() {
function TtsDefaultVoiceField() {
const [ttsConfig, setTtsConfig] = useAtom(configFieldsAtomMap.tts)
const { play, isFetching, isPlaying } = useTextToSpeech(ANALYTICS_SURFACE.TTS_SETTINGS)
const isFetchingOrPlaying = isFetching || isPlaying
const handlePreview = async () => {
void play(i18n.t("options.tts.voice.previewSample"), ttsConfig)
}
return (
<Field>
@ -123,18 +117,6 @@ function TtsDefaultVoiceField() {
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="outline"
className="sm:w-auto"
onClick={handlePreview}
disabled={isFetchingOrPlaying}
>
{isFetchingOrPlaying
? <IconLoader2 className="mr-2 size-4 animate-spin" />
: <IconPlayerPlayFilled className="mr-2 size-4" />}
{i18n.t("options.tts.voice.preview")}
</Button>
</div>
</Field>
)
@ -143,6 +125,8 @@ function TtsDefaultVoiceField() {
function TtsLanguageVoiceField() {
const [ttsConfig, setTtsConfig] = useAtom(configFieldsAtomMap.tts)
const [selectedLanguage, setSelectedLanguage] = useState<LangCodeISO6393>("eng")
const { play, isFetching, isPlaying } = useTextToSpeech(ANALYTICS_SURFACE.TTS_SETTINGS)
const isFetchingOrPlaying = isFetching || isPlaying
const selectedLanguageVoice = ttsConfig.languageVoices[selectedLanguage] ?? ttsConfig.defaultVoice
const defaultLanguageVoice = getDefaultTTSVoiceForLanguage(selectedLanguage, ttsConfig.defaultVoice)
@ -156,6 +140,12 @@ function TtsLanguageVoiceField() {
})
}
const handlePreview = async () => {
void play(i18n.t("options.tts.voice.previewSample"), ttsConfig, {
forcedVoice: selectedLanguageVoice,
})
}
const resetLanguageVoice = () => {
updateLanguageVoice(defaultLanguageVoice)
}
@ -205,15 +195,29 @@ function TtsLanguageVoiceField() {
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="outline"
className="sm:w-auto"
onClick={resetLanguageVoice}
disabled={selectedLanguageVoice === defaultLanguageVoice}
>
{i18n.t("options.tts.languageVoice.reset")}
</Button>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
type="button"
variant="outline"
className="sm:w-auto"
onClick={handlePreview}
disabled={isFetchingOrPlaying}
>
{isFetchingOrPlaying
? <IconLoader2 className="mr-2 size-4 animate-spin" />
: <IconPlayerPlayFilled className="mr-2 size-4" />}
{i18n.t("options.tts.voice.preview")}
</Button>
<Button
type="button"
variant="outline"
className="sm:w-auto"
onClick={resetLanguageVoice}
disabled={selectedLanguageVoice === defaultLanguageVoice}
>
{i18n.t("options.tts.languageVoice.reset")}
</Button>
</div>
</div>
</div>
<FieldDescription>

View file

@ -13,10 +13,10 @@ import { Activity, useMemo, useState } from "react"
import { Button } from "@/components/ui/base-ui/button"
import { Field, FieldLabel } from "@/components/ui/base-ui/field"
import { CSSCodeEditor } from "@/components/ui/css-code-editor"
import { env } from "@/env"
import { useDebouncedValue } from "@/hooks/use-debounced-value"
import { MAX_CUSTOM_CSS_LENGTH } from "@/types/config/translate"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { WEBSITE_URL } from "@/utils/constants/url"
import { lintCSS } from "@/utils/css/lint-css"
import { cn } from "@/utils/styles/utils"
@ -67,7 +67,7 @@ export function CSSEditor() {
<FieldLabel htmlFor="css-editor" data-invalid>
{i18n.t("options.translation.translationStyle.cssEditor")}
</FieldLabel>
<a href={`${WEBSITE_URL}/tutorial/custom-css`} className="text-xs text-link hover:opacity-90" target="_blank" rel="noreferrer">
<a href={`${env.WXT_WEBSITE_URL}/docs/custom-css`} className="text-xs text-link hover:opacity-90" target="_blank" rel="noreferrer">
{i18n.t("options.apiProviders.howToConfigure")}
</a>
</div>

View file

@ -1,12 +1,19 @@
import { i18n } from "#imports"
import { PromptConfigurator } from "@/components/prompt-configurator"
import { getTokenCellText, WEB_PAGE_PROMPT_TOKENS } from "@/utils/constants/prompt"
import { promptAtoms } from "./atoms"
export function PersonalizedPrompts() {
const insertCells = WEB_PAGE_PROMPT_TOKENS.map(token => ({
text: getTokenCellText(token),
description: i18n.t(`options.translation.personalizedPrompts.editPrompt.promptCellInput.${token}`),
}))
return (
<PromptConfigurator
id="personalized-prompts"
promptAtoms={promptAtoms}
insertCells={insertCells}
title={i18n.t("options.translation.personalizedPrompts.title")}
description={(
<p>

View file

@ -1,12 +1,19 @@
import { i18n } from "#imports"
import { PromptConfigurator } from "@/components/prompt-configurator"
import { getTokenCellText, SUBTITLE_PROMPT_TOKENS } from "@/utils/constants/prompt"
import { promptAtoms } from "./atoms"
export function SubtitlesCustomPrompts() {
const insertCells = SUBTITLE_PROMPT_TOKENS.map(token => ({
text: getTokenCellText(token),
description: i18n.t(`options.videoSubtitles.customPrompts.editPrompt.promptCellInput.${token}` as never),
}))
return (
<PromptConfigurator
id="subtitles-custom-prompts"
promptAtoms={promptAtoms}
insertCells={insertCells}
title={i18n.t("options.videoSubtitles.customPrompts.title")}
description={i18n.t("options.videoSubtitles.customPrompts.description")}
/>

View file

@ -3,6 +3,7 @@ import { i18n } from "#imports"
import { Icon } from "@iconify/react"
import { deepmerge } from "deepmerge-ts"
import { useAtom } from "jotai"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/base-ui/button"
import { Card } from "@/components/ui/base-ui/card"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/base-ui/field"
@ -20,6 +21,12 @@ const SLIDER_LABEL_CLASS_NAME = "text-sm whitespace-nowrap @xs/field-group:min-w
export function GeneralSettings() {
const [videoSubtitlesConfig, setVideoSubtitlesConfig] = useAtom(configFieldsAtomMap.videoSubtitles)
const { displayMode, translationPosition, container } = videoSubtitlesConfig.style
const [draftBackgroundOpacity, setDraftBackgroundOpacity] = useState(container.backgroundOpacity)
useEffect(() => {
// eslint-disable-next-line react/set-state-in-effect
setDraftBackgroundOpacity(container.backgroundOpacity)
}, [container.backgroundOpacity])
const handleDisplayModeChange = (value: SubtitlesDisplayMode | null) => {
if (!value)
@ -123,12 +130,13 @@ export function GeneralSettings() {
min={MIN_BACKGROUND_OPACITY}
max={MAX_BACKGROUND_OPACITY}
step={5}
value={container.backgroundOpacity}
onValueChange={value => handleContainerChange({ backgroundOpacity: value as number })}
value={draftBackgroundOpacity}
onValueChange={value => setDraftBackgroundOpacity(value as number)}
onValueCommitted={value => handleContainerChange({ backgroundOpacity: value as number })}
className="flex-1"
/>
<span className="w-10 text-sm text-right">
{container.backgroundOpacity}
{draftBackgroundOpacity}
%
</span>
</div>

View file

@ -2,6 +2,7 @@ import type { SubtitlesFontFamily, SubtitleTextStyle } from "@/types/config/subt
import { i18n } from "#imports"
import { deepmerge } from "deepmerge-ts"
import { useAtom } from "jotai"
import { useEffect, useState } from "react"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/base-ui/field"
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/base-ui/select"
import { Slider } from "@/components/ui/base-ui/slider"
@ -26,6 +27,18 @@ interface SubtitlesTextStyleFormProps {
export function SubtitlesTextStyleForm({ type }: SubtitlesTextStyleFormProps) {
const [videoSubtitlesConfig, setVideoSubtitlesConfig] = useAtom(configFieldsAtomMap.videoSubtitles)
const textStyle = videoSubtitlesConfig.style[type]
const [draftFontScale, setDraftFontScale] = useState(textStyle.fontScale)
const [draftFontWeight, setDraftFontWeight] = useState(textStyle.fontWeight)
useEffect(() => {
// eslint-disable-next-line react/set-state-in-effect
setDraftFontScale(textStyle.fontScale)
}, [textStyle.fontScale])
useEffect(() => {
// eslint-disable-next-line react/set-state-in-effect
setDraftFontWeight(textStyle.fontWeight)
}, [textStyle.fontWeight])
const handleChange = (style: Partial<SubtitleTextStyle>) => {
void setVideoSubtitlesConfig(deepmerge(videoSubtitlesConfig, { style: { [type]: style } }))
@ -71,12 +84,13 @@ export function SubtitlesTextStyleForm({ type }: SubtitlesTextStyleFormProps) {
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step={10}
value={textStyle.fontScale}
onValueChange={value => handleChange({ fontScale: value as number })}
value={draftFontScale}
onValueChange={value => setDraftFontScale(value as number)}
onValueCommitted={value => handleChange({ fontScale: value as number })}
className="flex-1"
/>
<span className="w-10 text-sm text-right">
{textStyle.fontScale}
{draftFontScale}
%
</span>
</div>
@ -91,11 +105,12 @@ export function SubtitlesTextStyleForm({ type }: SubtitlesTextStyleFormProps) {
min={MIN_FONT_WEIGHT}
max={MAX_FONT_WEIGHT}
step={100}
value={textStyle.fontWeight}
onValueChange={value => handleChange({ fontWeight: value as number })}
value={draftFontWeight}
onValueChange={value => setDraftFontWeight(value as number)}
onValueCommitted={value => handleChange({ fontWeight: value as number })}
className="flex-1"
/>
<span className="w-10 text-sm text-right">{textStyle.fontWeight}</span>
<span className="w-10 text-sm text-right">{draftFontWeight}</span>
</div>
</div>
</Field>

View file

@ -1,6 +1,7 @@
import { browser, i18n } from "#imports"
import { i18n } from "#imports"
import { Icon } from "@iconify/react"
import { UserAccount } from "@/components/user-account"
import { openOptionsPage } from "@/utils/navigation"
import { version } from "../../../package.json"
import { AISmartContext } from "./components/ai-smart-context"
import { AlwaysTranslate } from "./components/always-translate"
@ -44,7 +45,9 @@ function App() {
<button
type="button"
className="flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 hover:bg-neutral-300 dark:hover:bg-neutral-700"
onClick={() => browser.runtime.openOptionsPage()}
onClick={() => {
void openOptionsPage()
}}
>
<Icon icon="tabler:settings" className="size-4" strokeWidth={1.6} />
<span className="text-[13px] font-medium">

View file

@ -3,8 +3,8 @@ import { Icon } from "@iconify/react/dist/iconify.js"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Button } from "@/components/ui/base-ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/base-ui/tooltip"
import { env } from "@/env"
import { getBlogLocaleFromUILanguage, getLastViewedBlogDate, getLatestBlogDate, hasNewBlogPost, saveLastViewedBlogDate } from "@/utils/blog"
import { WEBSITE_URL } from "@/utils/constants/url"
import { version } from "../../../../package.json"
export default function BlogNotification() {
@ -18,7 +18,7 @@ export default function BlogNotification() {
const { data: latestBlogPost } = useQuery({
queryKey: ["latest-blog-post", blogLocale],
queryFn: () => getLatestBlogDate(`${WEBSITE_URL}/api/blog/latest`, blogLocale, version),
queryFn: () => getLatestBlogDate(`${env.WXT_WEBSITE_URL}/api/blog/latest`, blogLocale, version),
})
const handleClick = async () => {
@ -29,8 +29,8 @@ export default function BlogNotification() {
// Open the latest blog post URL directly, or fallback to /blog if not available
// Convert relative URL to absolute URL
const blogUrl = latestBlogPost?.url
? `${WEBSITE_URL}${latestBlogPost.url}`
: `${WEBSITE_URL}/blog`
? `${env.WXT_WEBSITE_URL}${latestBlogPost.url}`
: `${env.WXT_WEBSITE_URL}/blog`
window.open(blogUrl, "_blank")
}

View file

@ -1,20 +1,26 @@
import type { LangCodeISO6393 } from "@read-frog/definitions"
import type { LanguageItem } from "@/components/language-combobox-options"
import { i18n } from "#imports"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { Icon } from "@iconify/react"
import {
LANG_CODE_TO_EN_NAME,
LANG_CODE_TO_LOCALE_NAME,
langCodeISO6393Schema,
} from "@read-frog/definitions"
import { IconChevronDown } from "@tabler/icons-react"
import { useAtom, useAtomValue } from "jotai"
import { useMemo } from "react"
import { filterLanguage } from "@/components/language-combobox-options"
import { Button } from "@/components/ui/base-ui/button"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/base-ui/select"
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/base-ui/combobox"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { detectedCodeAtom } from "@/utils/atoms/detected-code"
@ -22,82 +28,154 @@ function langCodeLabel(langCode: LangCodeISO6393) {
return `${LANG_CODE_TO_EN_NAME[langCode]} (${LANG_CODE_TO_LOCALE_NAME[langCode]})`
}
const langSelectorTriggerClasses = "!h-14 w-30 rounded-lg shadow-xs pr-2 gap-1"
function createLanguageItem(code: LangCodeISO6393): LanguageItem<LangCodeISO6393> {
return {
value: code,
label: langCodeLabel(code),
name: LANG_CODE_TO_EN_NAME[code],
}
}
const langSelectorTriggerClasses = "!h-14 w-30 rounded-lg shadow-xs pr-2 gap-1 justify-between bg-transparent"
const langSelectorContentClasses = "flex flex-col items-start text-base font-medium min-w-0 flex-1"
function LanguageComboboxTrigger({
label,
subtitle,
ariaLabel,
}: {
label: string
subtitle: string
ariaLabel: string
}) {
return (
<ComboboxPrimitive.Trigger
render={(
<Button
type="button"
variant="outline"
className={langSelectorTriggerClasses}
aria-label={ariaLabel}
title={label}
/>
)}
>
<div className={langSelectorContentClasses}>
<span className="truncate w-full text-left">{label}</span>
<span className="text-sm text-neutral-500">{subtitle}</span>
</div>
<IconChevronDown className="size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
)
}
export default function LanguageOptionsSelector() {
const [language, setLanguage] = useAtom(configFieldsAtomMap.language)
const detectedCode = useAtomValue(detectedCodeAtom)
const targetLanguageItems = useMemo(
() => langCodeISO6393Schema.options.map(createLanguageItem),
[],
)
const sourceLanguageItems = useMemo<LanguageItem[]>(
() => [
{
value: "auto",
label: langCodeLabel(detectedCode),
name: LANG_CODE_TO_EN_NAME[detectedCode],
},
...targetLanguageItems,
],
[detectedCode, targetLanguageItems],
)
const currentSourceItem = useMemo(
() => sourceLanguageItems.find(item => item.value === language.sourceCode) ?? sourceLanguageItems[0] ?? null,
[language.sourceCode, sourceLanguageItems],
)
const currentTargetItem = useMemo(
() => targetLanguageItems.find(item => item.value === language.targetCode) ?? null,
[language.targetCode, targetLanguageItems],
)
const handleSourceLangChange = (newLangCode: LangCodeISO6393 | "auto" | null) => {
if (!newLangCode)
const handleSourceLangChange = (item: LanguageItem | null) => {
if (!item || item.value === language.sourceCode)
return
void setLanguage({ sourceCode: newLangCode })
void setLanguage({ sourceCode: item.value })
}
const handleTargetLangChange = (newLangCode: LangCodeISO6393 | null) => {
if (!newLangCode)
const handleTargetLangChange = (item: LanguageItem | null) => {
if (!item || item.value === "auto" || item.value === language.targetCode)
return
void setLanguage({ targetCode: newLangCode })
void setLanguage({ targetCode: item.value })
}
const sourceLangLabel
= language.sourceCode === "auto"
? `${langCodeLabel(detectedCode)} (auto)`
: langCodeLabel(language.sourceCode)
? `${currentSourceItem?.label ?? langCodeLabel(detectedCode)} (auto)`
: currentSourceItem?.label ?? langCodeLabel(language.sourceCode)
const targetLangLabel = langCodeLabel(language.targetCode)
const targetLangLabel = currentTargetItem?.label ?? langCodeLabel(language.targetCode)
return (
<div className="flex items-center justify-between">
<Select value={language.sourceCode} onValueChange={handleSourceLangChange}>
<SelectTrigger className={langSelectorTriggerClasses}>
<div className={langSelectorContentClasses}>
<SelectValue render={<span className="truncate w-full" />}>
{sourceLangLabel}
</SelectValue>
<span className="text-sm text-neutral-500">
{language.sourceCode === "auto"
? i18n.t("popup.autoLang")
: i18n.t("popup.sourceLang")}
</span>
</div>
</SelectTrigger>
<SelectContent className="rounded-lg shadow-md w-72">
<SelectGroup>
<SelectItem value="auto">
{langCodeLabel(detectedCode)}
<AutoLangCell />
</SelectItem>
{langCodeISO6393Schema.options.map(key => (
<SelectItem key={key} value={key}>
{langCodeLabel(key)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Combobox
value={currentSourceItem}
onValueChange={handleSourceLangChange}
items={sourceLanguageItems}
filter={filterLanguage}
autoHighlight
>
<LanguageComboboxTrigger
label={sourceLangLabel}
subtitle={language.sourceCode === "auto"
? i18n.t("popup.autoLang")
: i18n.t("popup.sourceLang")}
ariaLabel={i18n.t("popup.sourceLang")}
/>
<ComboboxContent className="rounded-lg shadow-md w-72">
<ComboboxInput
showTrigger={false}
placeholder={i18n.t("translationHub.searchLanguages")}
/>
<ComboboxList>
{(item: LanguageItem) => (
<ComboboxItem key={item.value} value={item}>
{item.label}
{item.value === "auto" && <AutoLangCell />}
</ComboboxItem>
)}
</ComboboxList>
<ComboboxEmpty>{i18n.t("translationHub.noLanguagesFound")}</ComboboxEmpty>
</ComboboxContent>
</Combobox>
<Icon icon="tabler:arrow-right" className="h-4 w-4 text-neutral-500" />
<Select value={language.targetCode} onValueChange={handleTargetLangChange}>
<SelectTrigger className={langSelectorTriggerClasses}>
<div className={langSelectorContentClasses}>
<SelectValue render={<span className="truncate w-full" />}>
{targetLangLabel}
</SelectValue>
<span className="text-sm text-neutral-500">{i18n.t("popup.targetLang")}</span>
</div>
</SelectTrigger>
<SelectContent className="rounded-lg shadow-md w-72">
<SelectGroup>
{langCodeISO6393Schema.options.map(key => (
<SelectItem key={key} value={key}>
{langCodeLabel(key)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Combobox
value={currentTargetItem}
onValueChange={handleTargetLangChange}
items={targetLanguageItems}
filter={filterLanguage}
autoHighlight
>
<LanguageComboboxTrigger
label={targetLangLabel}
subtitle={i18n.t("popup.targetLang")}
ariaLabel={i18n.t("popup.targetLang")}
/>
<ComboboxContent className="rounded-lg shadow-md w-72">
<ComboboxInput
showTrigger={false}
placeholder={i18n.t("translationHub.searchLanguages")}
/>
<ComboboxList>
{(item: LanguageItem<LangCodeISO6393>) => (
<ComboboxItem key={item.value} value={item}>
{item.label}
</ComboboxItem>
)}
</ComboboxList>
<ComboboxEmpty>{i18n.t("translationHub.noLanguagesFound")}</ComboboxEmpty>
</ComboboxContent>
</Combobox>
</div>
)
}

View file

@ -72,7 +72,7 @@ export function MoreMenu() {
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => window.open("https://readfrog.app/tutorial/", "_blank", "noopener,noreferrer")}
onClick={() => window.open("https://readfrog.app/docs/", "_blank", "noopener,noreferrer")}
className="cursor-pointer"
>
<Icon icon="tabler:help-circle" className="size-4" strokeWidth={1.6} />

View file

@ -33,12 +33,6 @@ function HydrateAtoms({
return children
}
const selectionContentCss = `
body {
opacity: var(--rf-selection-opacity, 1);
}
`
// eslint-disable-next-line import/no-mutable-exports
export let shadowWrapper: HTMLElement | null = null
@ -57,7 +51,6 @@ async function mountSelectionUI(ctx: ContentScriptContext) {
name: `${kebabCase(APP_NAME)}-selection`,
position: "overlay",
anchor: "body",
css: selectionContentCss,
onMount: (container, shadow, shadowHost) => {
const wrapper = insertShadowRootUIWrapperInto(container)
shadowWrapper = wrapper

View file

@ -44,14 +44,15 @@ function showSpinner(element: HTMLElement): () => void {
spinner.id = SPINNER_ID
// Use the same border spinner style as page translation
// Colors: primary green (#4ade80 / oklch(76.5% 0.177 163.223)) and muted gray
// Colors: brand yellow (oklch(76.034% 0.12361 82.191)) and muted gray
spinner.style.cssText = `
--rf-brand: oklch(76.034% 0.12361 82.191);
position: absolute !important;
display: inline-block !important;
width: 10px !important;
height: 10px !important;
border: 3px solid #e5e5e5 !important;
border-top: 3px solid #4ade80 !important;
border-top: 3px solid var(--rf-brand) !important;
border-radius: 50% !important;
box-sizing: content-box !important;
z-index: 999999 !important;
@ -76,9 +77,9 @@ function showSpinner(element: HTMLElement): () => void {
)
}
else {
// For reduced motion, keep the spinner static but preserve the primary
// For reduced motion, keep the spinner static but preserve the brand
// segment so the loading state remains visible without animation.
spinner.style.borderTopColor = "#4ade80"
spinner.style.borderTopColor = "var(--rf-brand)"
}
// Calculate position - vertically centered relative to the element

View file

@ -7,6 +7,7 @@ describe("replaceSelectionToolbarCustomActionPromptTokens", () => {
paragraphs: "hello world paragraph",
targetLanguage: "English",
webTitle: "Test Page",
webContent: "Test page content",
}
it("replaces selection and paragraphs tokens", () => {
@ -18,13 +19,13 @@ describe("replaceSelectionToolbarCustomActionPromptTokens", () => {
expect(result).toBe("selection=hello, paragraphs=hello world paragraph")
})
it("replaces targetLanguage and webTitle tokens", () => {
it("replaces targetLanguage, webTitle, and webContent tokens", () => {
const result = replaceSelectionToolbarCustomActionPromptTokens(
"Target language: {{targetLanguage}}, Page: {{webTitle}}",
"Target language: {{targetLanguage}}, Page: {{webTitle}}, Content: {{webContent}}",
baseTokens,
)
expect(result).toBe("Target language: English, Page: Test Page")
expect(result).toBe("Target language: English, Page: Test Page, Content: Test page content")
})
it("leaves unrelated text unchanged", () => {
@ -43,6 +44,7 @@ describe("buildSelectionToolbarCustomActionSystemPrompt", () => {
paragraphs: "hello world paragraph",
targetLanguage: "English",
webTitle: "Test Page",
webContent: "Test page content",
}
it("appends structured output contract with resolved fields and defaults", () => {

View file

@ -8,7 +8,7 @@ import type { Config } from "@/types/config/config"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { createStore, Provider } from "jotai"
import { afterEach, describe, expect, it, vi } from "vitest"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { TooltipProvider } from "@/components/ui/base-ui/tooltip"
import { isLLMProviderConfig, isTranslateProviderConfig } from "@/types/config/provider"
import { configAtom } from "@/utils/atoms/config"
@ -28,7 +28,8 @@ import { SelectionTranslationProvider } from "../translate-button/provider"
const streamBackgroundTextMock = vi.fn()
const streamBackgroundStructuredObjectMock = vi.fn()
const translateTextCoreMock = vi.fn()
const getOrFetchArticleDataMock = vi.fn()
const getOrCreateWebPageContextMock = vi.fn().mockResolvedValue(null)
const getOrGenerateWebPageSummaryMock = vi.fn()
const toastErrorMock = vi.fn()
const onMessageMock = vi.fn()
const originalGetSelection = window.getSelection
@ -236,8 +237,12 @@ vi.mock("@/utils/host/translate/translate-text", () => ({
translateTextCore: (...args: unknown[]) => translateTextCoreMock(...args),
}))
vi.mock("@/utils/host/translate/article-context", () => ({
getOrFetchArticleData: (...args: unknown[]) => getOrFetchArticleDataMock(...args),
vi.mock("@/utils/host/translate/webpage-context", () => ({
getOrCreateWebPageContext: (...args: unknown[]) => getOrCreateWebPageContextMock(...args),
}))
vi.mock("@/utils/host/translate/webpage-summary", () => ({
getOrGenerateWebPageSummary: (...args: unknown[]) => getOrGenerateWebPageSummaryMock(...args),
}))
vi.mock("sonner", () => ({
@ -417,6 +422,11 @@ async function openTooltip(trigger: HTMLElement) {
}
describe("selection toolbar requests", () => {
beforeEach(() => {
getOrCreateWebPageContextMock.mockResolvedValue(null)
getOrGenerateWebPageSummaryMock.mockResolvedValue(undefined)
})
afterEach(() => {
cleanup()
document.body.innerHTML = ""
@ -426,7 +436,7 @@ describe("selection toolbar requests", () => {
it("does not rerun translation on passive config refresh, but reruns when request values change", async () => {
translateTextCoreMock.mockResolvedValue("translated once")
getOrFetchArticleDataMock.mockResolvedValue(null)
getOrCreateWebPageContextMock.mockResolvedValue(null)
const store = createStore()
store.set(configAtom, cloneConfig(DEFAULT_CONFIG))
@ -494,7 +504,7 @@ describe("selection toolbar requests", () => {
it("renders the translation tooltip as non-interactive and closes it on hover leave", async () => {
translateTextCoreMock.mockResolvedValue("translated once")
getOrFetchArticleDataMock.mockResolvedValue(null)
getOrCreateWebPageContextMock.mockResolvedValue(null)
const store = createStore()
store.set(configAtom, cloneConfig(DEFAULT_CONFIG))
@ -517,7 +527,7 @@ describe("selection toolbar requests", () => {
it("opts out of focus restoration when closing the translation popover", async () => {
translateTextCoreMock.mockResolvedValue("translated once")
getOrFetchArticleDataMock.mockResolvedValue(null)
getOrCreateWebPageContextMock.mockResolvedValue(null)
const store = createStore()
store.set(configAtom, cloneConfig(DEFAULT_CONFIG))
@ -538,7 +548,7 @@ describe("selection toolbar requests", () => {
translateTextCoreMock
.mockImplementationOnce(() => firstRun.promise)
.mockImplementationOnce(() => secondRun.promise)
getOrFetchArticleDataMock.mockResolvedValue(null)
getOrCreateWebPageContextMock.mockResolvedValue(null)
const store = createStore()
store.set(configAtom, cloneConfig(DEFAULT_CONFIG))
@ -578,7 +588,7 @@ describe("selection toolbar requests", () => {
it("keeps the original page selection session when selecting text inside the translation popover", async () => {
translateTextCoreMock.mockResolvedValue("Overlay panel content")
getOrFetchArticleDataMock.mockResolvedValue(null)
getOrCreateWebPageContextMock.mockResolvedValue(null)
const paragraph = document.createElement("p")
paragraph.textContent = "Original page paragraph with surrounding context."
@ -691,11 +701,57 @@ describe("selection toolbar requests", () => {
expect(screen.queryByTestId("translation-content")).toBeNull()
})
it("does not start llm streaming after the popover closes while webpage context is still loading", async () => {
const pendingContext = createDeferredPromise<{
url: string
webTitle: string
webContent: string
} | null>()
getOrCreateWebPageContextMock.mockImplementation(() => pendingContext.promise)
streamBackgroundTextMock.mockResolvedValue({
output: "Should not stream",
thinking: {
status: "complete",
text: "",
},
})
const store = createStore()
const updatedConfig = cloneConfig(DEFAULT_CONFIG)
setSelectionToolbarTranslateProvider(updatedConfig, "openai-default")
store.set(configAtom, updatedConfig)
setSelectionState(store, { text: "Selected text" })
renderWithProviders(<TranslateButton />, store)
fireEvent.click(screen.getByRole("button", { name: "action.translation" }))
await waitFor(() => {
expect(getOrCreateWebPageContextMock).toHaveBeenCalled()
})
fireEvent.click(screen.getByRole("button", { name: "Close" }))
await act(async () => {
pendingContext.resolve({
url: "https://example.com/article",
webTitle: "Article title",
webContent: "Article body",
})
await Promise.resolve()
})
expect(streamBackgroundTextMock).not.toHaveBeenCalled()
expect(toastErrorMock).not.toHaveBeenCalled()
expect(screen.queryByRole("alert")).toBeNull()
expect(screen.queryByTestId("translation-content")).toBeNull()
})
it("renders translate errors inline and clears them after a successful rerun", async () => {
translateTextCoreMock
.mockRejectedValueOnce(new Error("Standard translation failed"))
.mockResolvedValueOnce("Recovered translation")
getOrFetchArticleDataMock.mockResolvedValue(null)
getOrCreateWebPageContextMock.mockResolvedValue(null)
const store = createStore()
store.set(configAtom, cloneConfig(DEFAULT_CONFIG))
@ -745,7 +801,7 @@ describe("selection toolbar requests", () => {
it("shows translations identical to the original text", async () => {
translateTextCoreMock.mockResolvedValue("Selected text")
getOrFetchArticleDataMock.mockResolvedValue(null)
getOrCreateWebPageContextMock.mockResolvedValue(null)
const store = createStore()
store.set(configAtom, cloneConfig(DEFAULT_CONFIG))
@ -767,7 +823,7 @@ describe("selection toolbar requests", () => {
it("opens selection translation from the context menu and tracks the context-menu surface", async () => {
translateTextCoreMock.mockResolvedValue("Context menu result")
getOrFetchArticleDataMock.mockResolvedValue(null)
getOrCreateWebPageContextMock.mockResolvedValue(null)
const paragraph = document.createElement("p")
paragraph.textContent = "Selected text inside a paragraph."
@ -815,7 +871,7 @@ describe("selection toolbar requests", () => {
it("reuses the same captured session for cross-node context-menu translation", async () => {
translateTextCoreMock.mockResolvedValue("Cross-node result")
getOrFetchArticleDataMock.mockResolvedValue(null)
getOrCreateWebPageContextMock.mockResolvedValue(null)
const container = document.createElement("div")
const firstBlock = document.createElement("div")
@ -1075,6 +1131,56 @@ describe("selection toolbar requests", () => {
})
})
it("keeps a pending custom action request alive across a passive config refresh", async () => {
const pendingRun = createDeferredPromise<BackgroundStructuredObjectStreamSnapshot>()
const signals: AbortSignal[] = []
streamBackgroundStructuredObjectMock.mockImplementationOnce((_payload, options: { signal?: AbortSignal }) => {
signals.push(options.signal as AbortSignal)
return pendingRun.promise
})
const paragraph = document.createElement("p")
paragraph.textContent = "Selected text inside a paragraph."
document.body.appendChild(paragraph)
const store = createStore()
store.set(configAtom, cloneConfig(DEFAULT_CONFIG))
setSelectionState(store, { text: "Selected text", range: createRangeFor(paragraph) })
renderWithProviders(<SelectionToolbarCustomActionButtons />, store)
const actionName = DEFAULT_CONFIG.selectionToolbar.customActions[0]?.name
if (!actionName) {
throw new Error("Default custom action is missing")
}
fireEvent.click(screen.getByRole("button", { name: actionName }))
await waitFor(() => {
expect(streamBackgroundStructuredObjectMock).toHaveBeenCalledTimes(1)
})
act(() => {
store.set(configAtom, cloneConfig(store.get(configAtom)))
})
await act(async () => {
await Promise.resolve()
})
expect(streamBackgroundStructuredObjectMock).toHaveBeenCalledTimes(1)
expect(signals[0]?.aborted).toBe(false)
await act(async () => {
pendingRun.resolve(createStructuredObjectSnapshot({ summary: "still alive" }))
await Promise.resolve()
})
await waitFor(() => {
expect(screen.getByText("{\"summary\":\"still alive\"}")).toBeInTheDocument()
})
})
it("reruns custom action requests from the footer and aborts the previous run", async () => {
const firstRun = createDeferredPromise<BackgroundStructuredObjectStreamSnapshot>()
const secondRun = createDeferredPromise<BackgroundStructuredObjectStreamSnapshot>()
@ -1131,6 +1237,79 @@ describe("selection toolbar requests", () => {
})
})
it("switches a custom action provider from the footer without aborting the replacement request", async () => {
const firstRun = createDeferredPromise<BackgroundStructuredObjectStreamSnapshot>()
const secondRun = createDeferredPromise<BackgroundStructuredObjectStreamSnapshot>()
const signals: AbortSignal[] = []
streamBackgroundStructuredObjectMock
.mockImplementationOnce((_payload, options: { signal?: AbortSignal }) => {
signals.push(options.signal as AbortSignal)
options.signal?.addEventListener("abort", () => {
firstRun.reject(new DOMException("aborted", "AbortError"))
})
return firstRun.promise
})
.mockImplementationOnce((_payload, options: { signal?: AbortSignal }) => {
signals.push(options.signal as AbortSignal)
return secondRun.promise
})
const paragraph = document.createElement("p")
paragraph.textContent = "Selected text inside a paragraph."
document.body.appendChild(paragraph)
const store = createStore()
store.set(configAtom, cloneConfig(DEFAULT_CONFIG))
setSelectionState(store, { text: "Selected text", range: createRangeFor(paragraph) })
renderWithProviders(<SelectionToolbarCustomActionButtons />, store)
const action = DEFAULT_CONFIG.selectionToolbar.customActions[0]
if (!action) {
throw new Error("Default custom action is missing")
}
const nextProviderId = findAlternateLLMProviderId(store.get(configAtom), action.providerId)
if (!nextProviderId) {
throw new Error("No alternate LLM provider available for custom action provider switch test")
}
fireEvent.click(screen.getByRole("button", { name: action.name }))
await waitFor(() => {
expect(streamBackgroundStructuredObjectMock).toHaveBeenCalledTimes(1)
})
fireEvent.click(screen.getByRole("button", { name: "Change provider" }))
await waitFor(() => {
expect(streamBackgroundStructuredObjectMock).toHaveBeenCalledTimes(2)
})
expect(streamBackgroundStructuredObjectMock.mock.calls[1]?.[0]).toMatchObject({
providerId: nextProviderId,
})
expect(signals[0]?.aborted).toBe(true)
await act(async () => {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
expect(signals[1]?.aborted).toBe(false)
await act(async () => {
secondRun.resolve(createStructuredObjectSnapshot({ summary: "provider switched" }))
await Promise.resolve()
})
await waitFor(() => {
expect(screen.getByText("{\"summary\":\"provider switched\"}")).toBeInTheDocument()
})
expect(screen.queryByRole("alert")).toBeNull()
expect(store.get(configAtom).selectionToolbar.customActions[0]?.providerId).toBe(nextProviderId)
})
it("shows a precheck alert when a custom action has no selected text", async () => {
const paragraph = document.createElement("p")
paragraph.textContent = "Selected text inside a paragraph."

View file

@ -131,6 +131,17 @@ describe("selectionToolbar - isInputOrTextarea logic", () => {
expect(document.querySelector(".absolute.z-2147483647")).toHaveClass("opacity-0")
}
const getToolbar = () => document.querySelector(".absolute.z-2147483647") as HTMLElement | null
const getToolbarSurface = () => document.querySelector("[data-slot='selection-toolbar-surface']") as HTMLElement | null
it("applies configured opacity on the toolbar surface instead of the overlay host", () => {
render(<SelectionToolbar />)
expect(getToolbar()?.style.opacity).toBe("")
expect(getToolbarSurface()?.style.opacity).toBe("var(--rf-selection-opacity, 1)")
})
it("should show toolbar when selecting text in a normal div element", async () => {
render(
<div>
@ -682,6 +693,27 @@ describe("selectionToolbar - positioning logic", () => {
})
})
it("should keep the bottom-right toolbar below the cursor to reduce accidental clicks", async () => {
render(
<div>
<SelectionToolbar />
<div data-testid="test-element">Test content</div>
</div>,
)
const target = screen.getByTestId("test-element")
const toolbar = getToolbarElement()
mockToolbarDimensions(toolbar, 200, 50)
await triggerMouseDownAndUp(target, 100, 100, 200, 200)
await waitFor(() => {
const topValue = Number.parseInt(toolbar.style.top)
expect(topValue - 200).toBeGreaterThanOrEqual(20)
})
})
it("should position toolbar at bottom-left when selecting from top-right to bottom-left", async () => {
render(
<div>

View file

@ -1,9 +1,53 @@
import type { CachedWebPageContext } from "@/utils/host/translate/webpage-context"
// @vitest-environment jsdom
import { describe, expect, it } from "vitest"
import { act, render, screen, waitFor } from "@testing-library/react"
import { createElement } from "react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { isLLMProviderConfig } from "@/types/config/provider"
import { DEFAULT_CONFIG } from "@/utils/constants/config"
import { CUSTOM_ACTION_CONTEXT_CHAR_LIMIT } from "../../../utils"
import { buildCustomActionExecutionPlan } from "../use-custom-action-execution"
import { buildCustomActionExecutionPlan, useCustomActionWebPageContext } from "../use-custom-action-execution"
const getOrCreateWebPageContextMock = vi.fn()
vi.mock("@/utils/host/translate/webpage-context", async () => {
const actual = await vi.importActual<typeof import("@/utils/host/translate/webpage-context")>(
"@/utils/host/translate/webpage-context",
)
return {
...actual,
getOrCreateWebPageContext: (...args: unknown[]) => getOrCreateWebPageContextMock(...args),
}
})
function createDeferredPromise<T>() {
let resolve!: (value: T) => void
let reject!: (error?: unknown) => void
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve
reject = nextReject
})
return { promise, resolve, reject }
}
function WebPageContextProbe({
open,
popoverSessionKey,
}: {
open: boolean
popoverSessionKey: number
}) {
const webPageContext = useCustomActionWebPageContext(open, popoverSessionKey)
const status = webPageContext === undefined
? "pending"
: webPageContext === null
? "null"
: webPageContext.webTitle
return createElement("div", { "data-testid": "web-page-context" }, status)
}
function createCustomActionRequest() {
const action = DEFAULT_CONFIG.selectionToolbar.customActions[0]
@ -26,19 +70,122 @@ function createCustomActionRequest() {
}
}
describe("useCustomActionWebPageContext", () => {
beforeEach(() => {
getOrCreateWebPageContextMock.mockReset()
})
afterEach(() => {
vi.clearAllMocks()
})
it("ignores stale pending results after the popover closes and reopens", async () => {
const firstRequest = createDeferredPromise<CachedWebPageContext | null>()
const secondRequest = createDeferredPromise<CachedWebPageContext | null>()
getOrCreateWebPageContextMock
.mockImplementationOnce(() => firstRequest.promise)
.mockImplementationOnce(() => secondRequest.promise)
const { rerender } = render(createElement(WebPageContextProbe, { open: true, popoverSessionKey: 0 }))
expect(screen.getByTestId("web-page-context")).toHaveTextContent("pending")
rerender(createElement(WebPageContextProbe, { open: false, popoverSessionKey: 0 }))
rerender(createElement(WebPageContextProbe, { open: true, popoverSessionKey: 1 }))
expect(screen.getByTestId("web-page-context")).toHaveTextContent("pending")
await act(async () => {
firstRequest.resolve({
url: "https://example.com/first",
webTitle: "First page",
webContent: "First content",
})
await Promise.resolve()
})
expect(screen.getByTestId("web-page-context")).toHaveTextContent("pending")
await act(async () => {
secondRequest.resolve({
url: "https://example.com/second",
webTitle: "Second page",
webContent: "Second content",
})
await Promise.resolve()
})
await waitFor(() => {
expect(screen.getByTestId("web-page-context")).toHaveTextContent("Second page")
})
})
it("returns to loading for a new popover session even when the previous session already resolved", async () => {
const firstRequest = createDeferredPromise<CachedWebPageContext | null>()
const secondRequest = createDeferredPromise<CachedWebPageContext | null>()
getOrCreateWebPageContextMock
.mockImplementationOnce(() => firstRequest.promise)
.mockImplementationOnce(() => secondRequest.promise)
const { rerender } = render(createElement(WebPageContextProbe, { open: true, popoverSessionKey: 0 }))
await act(async () => {
firstRequest.resolve({
url: "https://example.com/first",
webTitle: "First page",
webContent: "First content",
})
await Promise.resolve()
})
await waitFor(() => {
expect(screen.getByTestId("web-page-context")).toHaveTextContent("First page")
})
rerender(createElement(WebPageContextProbe, { open: false, popoverSessionKey: 0 }))
rerender(createElement(WebPageContextProbe, { open: true, popoverSessionKey: 1 }))
expect(screen.getByTestId("web-page-context")).toHaveTextContent("pending")
await act(async () => {
secondRequest.resolve({
url: "https://example.com/second",
webTitle: "Second page",
webContent: "Second content",
})
await Promise.resolve()
})
await waitFor(() => {
expect(screen.getByTestId("web-page-context")).toHaveTextContent("Second page")
})
})
})
describe("buildCustomActionExecutionPlan", () => {
it("truncates long context tokens before passing them into custom action prompts", () => {
it("truncates paragraph context tokens and trusts the canonical webpage content", () => {
const contextText = "x".repeat(CUSTOM_ACTION_CONTEXT_CHAR_LIMIT + 128)
const webPageContext: CachedWebPageContext = {
url: "https://example.com/article",
webTitle: "Example page",
webContent: "y".repeat(CUSTOM_ACTION_CONTEXT_CHAR_LIMIT),
}
const plan = buildCustomActionExecutionPlan(
createCustomActionRequest(),
"Selected text",
contextText,
webPageContext,
)
expect(plan.error).toBeNull()
expect(plan.executionContext?.promptTokens.paragraphs).toBe(
contextText.slice(0, CUSTOM_ACTION_CONTEXT_CHAR_LIMIT),
)
expect(plan.executionContext?.promptTokens.webContent).toBe(
webPageContext.webContent,
)
expect(plan.executionContext?.promptTokens.selection).toBe("Selected text")
})
})

View file

@ -28,6 +28,7 @@ import { SaveToNotebaseButton } from "./save-to-notebase-button"
import {
buildCustomActionExecutionPlan,
useCustomActionExecution,
useCustomActionWebPageContext,
} from "./use-custom-action-execution"
interface SelectionCustomActionPendingOpenRequest {
@ -96,7 +97,8 @@ export function SelectionCustomActionProvider({
return activeSession?.contextSnapshot.text || cleanSelection
}, [activeSession?.contextSnapshot.text, cleanSelection])
const titleText = document.title || null
const webPageContext = useCustomActionWebPageContext(isOpen, popoverSessionKey)
const titleText = (webPageContext?.webTitle ?? document.title) || null
const activeAction = useMemo(
() => selectionToolbarConfig.customActions.find(action =>
action.enabled !== false && action.id === activeActionId,
@ -115,8 +117,8 @@ export function SelectionCustomActionProvider({
[providersConfig],
)
const executionPlan = useMemo(
() => buildCustomActionExecutionPlan(customActionRequest, cleanSelection, paragraphsText),
[cleanSelection, customActionRequest, paragraphsText],
() => buildCustomActionExecutionPlan(customActionRequest, cleanSelection, paragraphsText, webPageContext),
[cleanSelection, customActionRequest, paragraphsText, webPageContext],
)
const {
error,
@ -134,7 +136,7 @@ export function SelectionCustomActionProvider({
})
const displayedResult = executionPlan.executionContext ? result : null
const displayedError = error ?? executionPlan.error
const displayedIsRunning = executionPlan.executionContext ? isRunning : false
const displayedIsRunning = (isOpen && webPageContext === undefined) || (executionPlan.executionContext ? isRunning : false)
const displayedThinking = executionPlan.executionContext ? thinking : null
const resetPopoverSession = useCallback((options?: { clearAnchor?: boolean }) => {

View file

@ -64,7 +64,7 @@ function SaveToNotebaseButtonEnabled({
},
}))
const saveMutation = useMutation(orpc.row.add.mutationOptions({
const saveMutation = useMutation(orpc.row.create.mutationOptions({
meta: {
suppressToast: true,
},
@ -73,7 +73,7 @@ function SaveToNotebaseButtonEnabled({
description: connection?.tableNameSnapshot,
})
},
onError: (error) => {
onError: (error: unknown) => {
if (isORPCUnauthorizedError(error)) {
toast.error(i18n.t("action.saveToNotebaseLoginRequired"))
return

View file

@ -1,3 +1,4 @@
import type { JSONValue } from "ai"
import type { RefObject } from "react"
import type { SelectionToolbarCustomActionRequestSlice } from "../atoms"
import type { SelectionToolbarInlineError } from "../inline-error"
@ -5,12 +6,14 @@ import type { AnalyticsSurface } from "@/types/analytics"
import type { BackgroundStructuredObjectStreamSnapshot, ThinkingSnapshot } from "@/types/background-stream"
import type { LLMProviderConfig } from "@/types/config/provider"
import type { SelectionToolbarCustomAction } from "@/types/config/selection-toolbar"
import type { CachedWebPageContext } from "@/utils/host/translate/webpage-context"
import { LANG_CODE_TO_EN_NAME } from "@read-frog/definitions"
import { useCallback, useEffect, useRef, useState } from "react"
import { ANALYTICS_FEATURE } from "@/types/analytics"
import { isLLMProviderConfig } from "@/types/config/provider"
import { createFeatureUsageContext, trackFeatureUsed } from "@/utils/analytics"
import { streamBackgroundStructuredObject } from "@/utils/content-script/background-stream-client"
import { getOrCreateWebPageContext } from "@/utils/host/translate/webpage-context"
import { resolveModelId } from "@/utils/providers/model-id"
import { getProviderOptionsWithOverride } from "@/utils/providers/options"
import { truncateContextTextForCustomAction } from "../../utils"
@ -29,6 +32,7 @@ export interface CustomActionExecutionContext {
paragraphs: string
targetLanguage: string
webTitle: string
webContent: string
}
}
@ -37,6 +41,28 @@ interface CustomActionExecutionPlan {
executionContext: CustomActionExecutionContext | null
}
interface ResolvedWebPageContext {
popoverSessionKey: number
value: CachedWebPageContext | null
}
interface CustomActionExecutionRequest {
analytics: {
actionId: string
actionName: string
surface: AnalyticsSurface
}
key: string
payload: {
outputSchema: Array<{ name: string, type: SelectionToolbarCustomAction["outputSchema"][number]["type"] }>
prompt: string
providerId: string
providerOptions?: Record<string, Record<string, JSONValue>>
system: string
temperature?: number
}
}
function scrollSelectionPopoverBodyToBottom(ref: RefObject<HTMLDivElement | null>) {
requestAnimationFrame(() => {
if (ref.current) {
@ -45,10 +71,32 @@ function scrollSelectionPopoverBodyToBottom(ref: RefObject<HTMLDivElement | null
})
}
function normalizeExecutionKeyValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(normalizeExecutionKeyValue)
}
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter(([, nestedValue]) => nestedValue !== undefined)
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
.map(([key, nestedValue]) => [key, normalizeExecutionKeyValue(nestedValue)]),
)
}
return value
}
function stringifyExecutionRequestKey(value: Record<string, unknown>) {
return JSON.stringify(normalizeExecutionKeyValue(value))
}
export function buildCustomActionExecutionPlan(
customActionRequest: SelectionToolbarCustomActionRequestSlice,
cleanSelection: string,
contextText: string,
webPageContext?: CachedWebPageContext | null,
): CustomActionExecutionPlan {
const action = customActionRequest.action
@ -81,6 +129,13 @@ export function buildCustomActionExecutionPlan(
}
}
if (webPageContext === undefined) {
return {
error: null,
executionContext: null,
}
}
return {
error: null,
executionContext: {
@ -90,12 +145,111 @@ export function buildCustomActionExecutionPlan(
selection: cleanSelection,
paragraphs: truncateContextTextForCustomAction(contextText || cleanSelection),
targetLanguage: LANG_CODE_TO_EN_NAME[customActionRequest.language.targetCode],
webTitle: document.title,
webTitle: webPageContext?.webTitle ?? document.title,
webContent: webPageContext?.webContent || "",
},
},
}
}
export function useCustomActionWebPageContext(open: boolean, popoverSessionKey: number) {
const [resolvedWebPageContext, setResolvedWebPageContext] = useState<ResolvedWebPageContext | null>(null)
useEffect(() => {
if (!open) {
return
}
let isCancelled = false
void getOrCreateWebPageContext()
.then((nextContext) => {
if (!isCancelled) {
setResolvedWebPageContext({
popoverSessionKey,
value: nextContext,
})
}
})
.catch(() => {
if (!isCancelled) {
setResolvedWebPageContext({
popoverSessionKey,
value: null,
})
}
})
return () => {
isCancelled = true
}
}, [open, popoverSessionKey])
if (!open || resolvedWebPageContext?.popoverSessionKey !== popoverSessionKey) {
return undefined
}
return resolvedWebPageContext.value
}
function buildCustomActionExecutionRequest({
analyticsSurface,
executionContext,
popoverSessionKey,
rerunNonce,
}: {
analyticsSurface: AnalyticsSurface
executionContext: CustomActionExecutionContext
popoverSessionKey: number
rerunNonce: number
}): CustomActionExecutionRequest {
const { action, providerConfig, promptTokens } = executionContext
const systemPrompt = buildSelectionToolbarCustomActionSystemPrompt(
action.systemPrompt,
promptTokens,
action.outputSchema,
)
const prompt = replaceSelectionToolbarCustomActionPromptTokens(action.prompt, promptTokens)
const modelName = resolveModelId(providerConfig.model) ?? ""
const providerOptions = getProviderOptionsWithOverride(
modelName,
providerConfig.provider,
providerConfig.providerOptions,
)
const outputSchema = action.outputSchema.map(({ name, type }) => ({ name, type }))
return {
analytics: {
actionId: action.id,
actionName: action.name,
surface: analyticsSurface,
},
key: stringifyExecutionRequestKey({
actionId: action.id,
analyticsSurface,
model: providerConfig.model,
outputSchema: action.outputSchema.map(({ description, name, type }) => ({ description, name, type })),
popoverSessionKey,
prompt,
promptTokens,
provider: providerConfig.provider,
providerId: providerConfig.id,
providerOptions,
rerunNonce,
system: systemPrompt,
temperature: providerConfig.temperature,
}),
payload: {
providerId: providerConfig.id,
system: systemPrompt,
prompt,
outputSchema,
providerOptions,
temperature: providerConfig.temperature,
},
}
}
export function useCustomActionExecution({
analyticsSurface,
bodyRef,
@ -116,6 +270,19 @@ export function useCustomActionExecution({
const [error, setError] = useState<SelectionToolbarInlineError | null>(null)
const [thinking, setThinking] = useState<ThinkingSnapshot | null>(null)
const lastRunKeyRef = useRef<string | null>(null)
const bodyRefRef = useRef(bodyRef)
bodyRefRef.current = bodyRef
const executionRequest = executionContext
? buildCustomActionExecutionRequest({
analyticsSurface,
executionContext,
popoverSessionKey,
rerunNonce,
})
: null
const executionRequestRef = useRef<CustomActionExecutionRequest | null>(null)
executionRequestRef.current = executionRequest
const executionRequestKey = executionRequest?.key ?? null
const resetSessionState = useCallback(() => {
setIsRunning(false)
@ -125,52 +292,34 @@ export function useCustomActionExecution({
}, [])
useEffect(() => {
if (!open || !executionContext) {
if (!open || !executionRequestKey) {
return
}
const nextRunKey = JSON.stringify({
actionId: executionContext.action.id,
paragraphs: executionContext.promptTokens.paragraphs,
popoverSessionKey,
providerId: executionContext.providerConfig.id,
rerunNonce,
selection: executionContext.promptTokens.selection,
targetLanguage: executionContext.promptTokens.targetLanguage,
webTitle: executionContext.promptTokens.webTitle,
})
if (lastRunKeyRef.current === nextRunKey) {
const request = executionRequestRef.current
if (!request || request.key !== executionRequestKey) {
return
}
lastRunKeyRef.current = nextRunKey
if (lastRunKeyRef.current === executionRequestKey) {
return
}
lastRunKeyRef.current = executionRequestKey
let isCancelled = false
const abortController = new AbortController()
const { action, providerConfig, promptTokens } = executionContext
const analyticsContext = createFeatureUsageContext(
ANALYTICS_FEATURE.CUSTOM_AI_ACTION,
analyticsSurface,
request.analytics.surface,
Date.now(),
{
action_id: action.id,
action_name: action.name,
action_id: request.analytics.actionId,
action_name: request.analytics.actionName,
},
)
const run = async () => {
const systemPrompt = buildSelectionToolbarCustomActionSystemPrompt(
action.systemPrompt,
promptTokens,
action.outputSchema,
)
const prompt = replaceSelectionToolbarCustomActionPromptTokens(action.prompt, promptTokens)
const modelName = resolveModelId(providerConfig.model) ?? ""
const providerOptions = getProviderOptionsWithOverride(
modelName,
providerConfig.provider,
providerConfig.providerOptions,
)
setIsRunning(true)
setResult(null)
setError(null)
@ -181,14 +330,7 @@ export function useCustomActionExecution({
try {
const finalResult = await streamBackgroundStructuredObject(
{
providerId: providerConfig.id,
system: systemPrompt,
prompt,
outputSchema: action.outputSchema.map(({ name, type }) => ({ name, type })),
providerOptions,
temperature: providerConfig.temperature,
},
request.payload,
{
signal: abortController.signal,
onChunk: (partial: BackgroundStructuredObjectStreamSnapshot) => {
@ -198,7 +340,7 @@ export function useCustomActionExecution({
setResult(partial.output)
setThinking(partial.thinking)
scrollSelectionPopoverBodyToBottom(bodyRef)
scrollSelectionPopoverBodyToBottom(bodyRefRef.current)
},
},
)
@ -243,7 +385,7 @@ export function useCustomActionExecution({
isCancelled = true
abortController.abort()
}
}, [analyticsSurface, bodyRef, executionContext, open, popoverSessionKey, rerunNonce])
}, [executionRequestKey, open])
useEffect(() => {
if (!open) {

View file

@ -6,6 +6,7 @@ export interface SelectionToolbarCustomActionPromptTokens {
paragraphs: string
targetLanguage: string
webTitle: string
webContent: string
}
export function replaceSelectionToolbarCustomActionPromptTokens(
@ -17,6 +18,7 @@ export function replaceSelectionToolbarCustomActionPromptTokens(
.replaceAll(getSelectionToolbarCustomActionTokenCellText("paragraphs"), tokens.paragraphs)
.replaceAll(getSelectionToolbarCustomActionTokenCellText("targetLanguage"), tokens.targetLanguage)
.replaceAll(getSelectionToolbarCustomActionTokenCellText("webTitle"), tokens.webTitle)
.replaceAll(getSelectionToolbarCustomActionTokenCellText("webContent"), tokens.webContent)
}
type StructuredOutputField = Pick<SelectionToolbarCustomActionOutputField, "name" | "type" | "description">

View file

@ -199,18 +199,18 @@ function applyDirectionOffset(
tooltipWidth: number,
tooltipHeight: number,
): { x: number, y: number } {
const MARGIN = 12
const CURSOR_CLEARANCE = 20
switch (direction) {
case SelectionDirection.BOTTOM_RIGHT:
return { x: baseX - MARGIN, y: baseY + MARGIN }
return { x: baseX, y: baseY + CURSOR_CLEARANCE }
case SelectionDirection.BOTTOM_LEFT:
return { x: baseX - tooltipWidth + MARGIN, y: baseY + MARGIN }
return { x: baseX - tooltipWidth, y: baseY + CURSOR_CLEARANCE }
case SelectionDirection.TOP_RIGHT:
return { x: baseX - MARGIN, y: baseY - tooltipHeight - MARGIN }
return { x: baseX, y: baseY - tooltipHeight - CURSOR_CLEARANCE }
case SelectionDirection.TOP_LEFT:
return { x: baseX - tooltipWidth + MARGIN, y: baseY - tooltipHeight - MARGIN }
return { x: baseX - tooltipWidth, y: baseY - tooltipHeight - CURSOR_CLEARANCE }
default:
return { x: baseX - MARGIN, y: baseY + MARGIN }
return { x: baseX, y: baseY + CURSOR_CLEARANCE }
}
}
@ -444,16 +444,22 @@ export function SelectionToolbar() {
ref={tooltipRef}
inert={!isSelectionToolbarVisible}
className={cn(
`group absolute ${SELECTION_CONTENT_OVERLAY_LAYERS.selectionOverlay} bg-popover rounded-sm shadow-floating border border-border/50 overflow-visible flex items-center transition-opacity`,
`group absolute ${SELECTION_CONTENT_OVERLAY_LAYERS.selectionOverlay} overflow-visible transition-opacity`,
isSelectionToolbarVisible ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0",
)}
>
<div className="flex items-center overflow-x-auto overflow-y-hidden rounded-sm max-w-105 no-scrollbar">
{features.translate.enabled && <TranslateButton />}
{!isFirefox && features.speak.enabled && <SpeakButton />}
<SelectionToolbarCustomActionButtons />
<div
data-slot="selection-toolbar-surface"
className="bg-popover rounded-sm shadow-floating border border-border/50 flex items-center"
style={{ opacity: "var(--rf-selection-opacity, 1)" }}
>
<div className="flex items-center overflow-x-auto overflow-y-hidden rounded-sm max-w-105 no-scrollbar">
{features.translate.enabled && <TranslateButton />}
{!isFirefox && features.speak.enabled && <SpeakButton />}
<SelectionToolbarCustomActionButtons />
</div>
<CloseButton />
</div>
<CloseButton />
</div>
)}
</div>

View file

@ -15,9 +15,10 @@ import { configFieldsAtomMap, writeConfigAtom } from "@/utils/atoms/config"
import { filterEnabledProvidersConfig } from "@/utils/config/helpers"
import { buildFeatureProviderPatch } from "@/utils/constants/feature-providers"
import { streamBackgroundText } from "@/utils/content-script/background-stream-client"
import { getOrFetchArticleData } from "@/utils/host/translate/article-context"
import { prepareTranslationText } from "@/utils/host/translate/text-preparation"
import { translateTextCore } from "@/utils/host/translate/translate-text"
import { getOrCreateWebPageContext } from "@/utils/host/translate/webpage-context"
import { getOrGenerateWebPageSummary } from "@/utils/host/translate/webpage-summary"
import { onMessage } from "@/utils/message"
import { getTranslatePromptFromConfig } from "@/utils/prompts/translate"
import { resolveModelId } from "@/utils/providers/model-id"
@ -46,6 +47,23 @@ interface SelectionTranslatePendingOpenRequest {
surface: typeof ANALYTICS_SURFACE.SELECTION_TOOLBAR | typeof ANALYTICS_SURFACE.CONTEXT_MENU
}
async function getSelectionWebPagePromptContext(
providerConfig: ProviderConfig,
enableAIContentAware: boolean,
) {
const webPageContext = await getOrCreateWebPageContext()
if (!webPageContext) {
return undefined
}
const webSummary = await getOrGenerateWebPageSummary(webPageContext, providerConfig, enableAIContentAware)
return {
webTitle: webPageContext.webTitle,
webContent: webPageContext.webContent,
webSummary: webSummary ?? undefined,
}
}
async function translateWithLlm({
preparedText,
providerConfig,
@ -68,15 +86,32 @@ async function translateWithLlm({
} = providerConfig
const modelName = resolveModelId(providerConfig.model)
const providerOptions = getProviderOptionsWithOverride(modelName ?? "", provider, userProviderOptions)
const abortController = new AbortController()
registerAbortController(abortController)
const throwIfAborted = () => {
if (abortController.signal.aborted) {
throw new DOMException("aborted", "AbortError")
}
}
const webPageContext = await getSelectionWebPagePromptContext(providerConfig, translateRequest.enableAIContentAware)
throwIfAborted()
const { systemPrompt, prompt } = getTranslatePromptFromConfig(
{ customPromptsConfig: translateRequest.customPromptsConfig },
targetLangName,
preparedText,
webPageContext
? {
context: {
webTitle: webPageContext.webTitle,
webContent: webPageContext.webContent,
webSummary: webPageContext.webSummary,
},
}
: undefined,
)
const abortController = new AbortController()
registerAbortController(abortController)
const translatedText = await streamBackgroundText(
{
providerId,
@ -103,14 +138,14 @@ async function translateWithStandardProvider({
providerConfig: ProviderConfig
translateRequest: SelectionToolbarTranslateRequestSlice
}) {
const articleData = await getOrFetchArticleData(translateRequest.enableAIContentAware)
const webPageContext = await getSelectionWebPagePromptContext(providerConfig, translateRequest.enableAIContentAware)
const translatedText = await translateTextCore({
text,
langConfig: translateRequest.language,
providerConfig,
enableAIContentAware: translateRequest.enableAIContentAware,
extraHashTags: ["selectionTranslation"],
articleContext: articleData ?? undefined,
webPageContext,
})
return translatedText

View file

@ -1,12 +1,10 @@
import FrogToast from "@/components/frog-toast"
import FloatingButton from "./components/floating-button"
import SideContent from "./components/side-content"
export default function App() {
return (
<>
<FloatingButton />
<SideContent />
<FrogToast />
</>
)

View file

@ -0,0 +1,391 @@
// @vitest-environment jsdom
import type { FloatingButtonConfig } from "@/types/config/floating-button"
import { act, fireEvent, render, screen } from "@testing-library/react"
import { atom, createStore, Provider } from "jotai"
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { sendMessage } from "@/utils/message"
import FloatingButton from ".."
const toastInfoMock = vi.fn()
vi.mock("#imports", () => ({
browser: {
runtime: {
getURL: (path = "") => `chrome-extension://test-extension${path}`,
},
},
i18n: {
t: (key: string) => key,
},
}))
vi.mock("@/utils/atoms/config", () => {
const floatingButtonBaseAtom = atom<FloatingButtonConfig>({
enabled: true,
position: 0.66,
side: "right",
clickAction: "panel",
disabledFloatingButtonPatterns: [],
locked: false,
})
const floatingButtonAtom = atom(
get => get(floatingButtonBaseAtom),
(get, set, patch: Partial<FloatingButtonConfig>) => {
set(floatingButtonBaseAtom, {
...get(floatingButtonBaseAtom),
...patch,
})
},
)
return {
configFieldsAtomMap: {
floatingButton: floatingButtonAtom,
sideContent: atom({ width: 360 }),
},
}
})
vi.mock("../../../atoms", () => ({
enablePageTranslationAtom: atom({ enabled: false }),
isDraggingButtonAtom: atom(false),
isSideOpenAtom: atom(false),
}))
vi.mock("../../../index", () => ({
shadowWrapper: document.body,
}))
vi.mock("@/utils/message", () => ({
sendMessage: vi.fn(),
}))
vi.mock("sonner", () => ({
toast: {
info: (...args: unknown[]) => toastInfoMock(...args),
},
}))
beforeAll(() => {
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal("ResizeObserver", ResizeObserverMock)
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
vi.mocked(sendMessage).mockReset()
toastInfoMock.mockReset()
setViewport(1024, 768)
})
function setViewport(width: number, height: number) {
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: width,
})
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: height,
})
}
function renderFloatingButton(
floatingButtonOverrides: Partial<FloatingButtonConfig> = {},
) {
const store = createStore()
void store.set(configFieldsAtomMap.floatingButton, floatingButtonOverrides)
return {
store,
...render(
<Provider store={store}>
<FloatingButton />
</Provider>,
),
}
}
function getMainButton() {
return screen.getByTestId("floating-main-button")
}
function getFloatingButtonConfig(store: ReturnType<typeof createStore>) {
return store.get(configFieldsAtomMap.floatingButton)
}
function mockRect(element: Element, rect: Partial<DOMRect>) {
const left = rect.left ?? 0
const top = rect.top ?? 0
const width = rect.width ?? 0
const height = rect.height ?? 0
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
x: left,
y: top,
left,
top,
width,
height,
right: rect.right ?? left + width,
bottom: rect.bottom ?? top + height,
toJSON: () => {},
} as DOMRect)
}
describe("floatingButton controls", () => {
it("shows the close trigger only after entering the main floating button", () => {
renderFloatingButton()
const closeTrigger = screen.getByRole("button", { name: "Close floating button" })
const mainButton = getMainButton()
expect(mainButton).toHaveClass("transition-transform")
expect(mainButton).toHaveClass("duration-300")
expect(closeTrigger).toHaveClass("-top-1")
expect(closeTrigger).toHaveClass("left-0")
expect(closeTrigger).toHaveClass("invisible")
expect(closeTrigger).toHaveClass("pointer-events-none")
expect(closeTrigger).toHaveClass("text-neutral-300")
expect(closeTrigger).toHaveClass("hover:scale-110")
expect(closeTrigger).toHaveClass("active:scale-90")
expect(closeTrigger).toHaveClass("hover:text-neutral-500")
expect(closeTrigger).toHaveClass("active:text-neutral-500")
fireEvent.mouseEnter(mainButton)
expect(closeTrigger).toHaveClass("visible")
expect(closeTrigger).toHaveClass("pointer-events-auto")
expect(closeTrigger).toHaveClass("-left-6")
})
it("renders a lock trigger at the lower-left corner and keeps controls expanded after entering the main button", () => {
renderFloatingButton()
const lockTrigger = screen.getByRole("button", { name: "Lock floating button" })
const mainButton = getMainButton()
const floatingButtonContainer = screen.getByTestId("floating-button-container")
expect(lockTrigger).toHaveClass("left-0")
expect(lockTrigger).toHaveClass("-bottom-1")
expect(lockTrigger).toHaveClass("invisible")
expect(lockTrigger).toHaveClass("pointer-events-none")
expect(lockTrigger).toHaveClass("text-neutral-300")
expect(lockTrigger).toHaveClass("hover:scale-110")
expect(lockTrigger).toHaveClass("active:scale-90")
expect(lockTrigger).toHaveClass("hover:text-neutral-500")
expect(lockTrigger).toHaveClass("active:text-neutral-500")
expect(mainButton).toHaveClass("translate-x-6")
fireEvent.mouseEnter(mainButton)
expect(lockTrigger).toHaveClass("visible")
expect(lockTrigger).toHaveClass("pointer-events-auto")
expect(lockTrigger).toHaveClass("-left-6")
expect(mainButton).toHaveClass("translate-x-0")
fireEvent.click(lockTrigger)
const unlockTrigger = screen.getByRole("button", { name: "Unlock floating button" })
expect(unlockTrigger).toHaveClass("text-neutral-300")
expect(unlockTrigger).toHaveClass("-left-6")
expect(mainButton).toHaveClass("translate-x-0")
expect(mainButton).toHaveClass("opacity-100")
expect(mainButton).not.toHaveClass("translate-x-6")
fireEvent.mouseLeave(floatingButtonContainer)
expect(mainButton).toHaveClass("translate-x-0")
expect(mainButton).toHaveClass("opacity-60")
fireEvent.mouseEnter(mainButton)
expect(mainButton).toHaveClass("opacity-100")
})
it("forces the close trigger visible while the dropdown is open", () => {
renderFloatingButton()
const closeTrigger = screen.getByRole("button", { name: "Close floating button" })
const mainButton = getMainButton()
fireEvent.mouseEnter(mainButton)
fireEvent.click(closeTrigger)
expect(closeTrigger).toHaveClass("visible")
expect(closeTrigger).toHaveClass("pointer-events-auto")
expect(screen.getByText("options.floatingButtonAndToolbar.floatingButton.closeMenu.disableForSite")).toBeInTheDocument()
})
it("toggles the browser side panel on a normal panel click", () => {
vi.useFakeTimers()
renderFloatingButton({ clickAction: "panel" })
const mainButton = getMainButton()
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
vi.advanceTimersByTime(349)
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
expect(sendMessage).toHaveBeenCalledWith("toggleSidePanel", undefined)
})
it("shows a Firefox sidebar help link when the browser requires an extension user action", async () => {
vi.useFakeTimers()
vi.mocked(sendMessage).mockResolvedValue({
ok: false,
reason: "requires-extension-user-action",
})
renderFloatingButton({ clickAction: "panel" })
const mainButton = getMainButton()
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
vi.advanceTimersByTime(349)
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
await act(async () => {
await Promise.resolve()
})
const toastContent = toastInfoMock.mock.calls[0]?.[0]
expect(toastContent).toBeDefined()
render(<>{toastContent}</>)
expect(screen.getByText("sidePanel.firefoxUserActionHint")).toBeInTheDocument()
const link = screen.getByRole("link", { name: "sidePanel.firefoxUserActionHelpText" })
expect(link).toHaveAttribute(
"href",
"sidePanel.firefoxUserActionHelpUrl",
)
expect(link).toHaveAttribute("target", "_blank")
expect(link).toHaveAttribute("rel", "noopener noreferrer")
})
it("keeps translate as a normal click action", () => {
vi.useFakeTimers()
renderFloatingButton({ clickAction: "translate" })
const mainButton = getMainButton()
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
vi.advanceTimersByTime(349)
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
expect(sendMessage).toHaveBeenCalledWith(
"tryToSetEnablePageTranslationOnContentScript",
expect.objectContaining({ enabled: true }),
)
})
it("turns the frog into the only visible control after a long press", () => {
vi.useFakeTimers()
renderFloatingButton()
const mainButton = getMainButton()
expect(screen.getAllByRole("button")).toHaveLength(4)
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
act(() => {
vi.advanceTimersByTime(350)
})
expect(mainButton).toHaveClass("rounded-full")
expect(screen.queryAllByRole("button")).toHaveLength(0)
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
expect(sendMessage).not.toHaveBeenCalled()
})
it("starts dragging before the long-press delay after enough pointer movement", () => {
vi.useFakeTimers()
renderFloatingButton()
const mainButton = getMainButton()
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
fireEvent.pointerMove(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 908, clientY: 500 })
expect(mainButton).toHaveClass("rounded-full")
expect(screen.queryAllByRole("button")).toHaveLength(0)
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 908, clientY: 500 })
expect(sendMessage).not.toHaveBeenCalled()
})
it("persists the left side and vertical position after dragging to the left half", () => {
vi.useFakeTimers()
setViewport(1000, 1000)
const { store } = renderFloatingButton({ position: 0.6, side: "right" })
const mainButton = getMainButton()
const floatingButtonContainer = screen.getByTestId("floating-button-container")
mockRect(floatingButtonContainer, { left: 956, top: 600, width: 44, height: 120 })
mockRect(mainButton, { left: 956, top: 640, width: 44, height: 40 })
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 978, clientY: 660 })
act(() => {
vi.advanceTimersByTime(350)
})
fireEvent.pointerMove(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 120, clientY: 520 })
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 120, clientY: 520 })
expect(getFloatingButtonConfig(store).side).toBe("left")
expect(getFloatingButtonConfig(store).position).toBeCloseTo(0.46)
})
it("persists the right side and vertical position after dragging to the right half", () => {
vi.useFakeTimers()
setViewport(1000, 1000)
const { store } = renderFloatingButton({ position: 0.6, side: "left" })
const mainButton = getMainButton()
const floatingButtonContainer = screen.getByTestId("floating-button-container")
mockRect(floatingButtonContainer, { left: 0, top: 600, width: 44, height: 120 })
mockRect(mainButton, { left: 0, top: 640, width: 44, height: 40 })
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 22, clientY: 660 })
act(() => {
vi.advanceTimersByTime(350)
})
fireEvent.pointerMove(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 520 })
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 520 })
expect(getFloatingButtonConfig(store).side).toBe("right")
expect(getFloatingButtonConfig(store).position).toBeCloseTo(0.46)
})
it("mirrors the controls when attached to the left edge", () => {
renderFloatingButton({ side: "left" })
const closeTrigger = screen.getByRole("button", { name: "Close floating button" })
const lockTrigger = screen.getByRole("button", { name: "Lock floating button" })
const mainButton = getMainButton()
const hiddenButtons = screen.getAllByRole("button").filter(button => (
button !== closeTrigger && button !== lockTrigger
))
expect(mainButton).toHaveClass("rounded-r-full")
expect(mainButton).toHaveClass("-translate-x-6")
expect(closeTrigger).toHaveClass("right-0")
expect(lockTrigger).toHaveClass("right-0")
for (const hiddenButton of hiddenButtons) {
expect(hiddenButton).toHaveClass("-translate-x-12")
}
fireEvent.mouseEnter(mainButton)
expect(mainButton).toHaveClass("translate-x-0")
expect(closeTrigger).toHaveClass("-right-6")
expect(lockTrigger).toHaveClass("-right-6")
for (const hiddenButton of hiddenButtons) {
expect(hiddenButton).toHaveClass("translate-x-0")
}
})
})

View file

@ -1,3 +1,4 @@
import type { FloatingButtonSide } from "@/types/config/floating-button"
import { cn } from "@/utils/styles/utils"
export default function HiddenButton({
@ -5,17 +6,25 @@ export default function HiddenButton({
onClick,
children,
className,
side = "right",
expanded = false,
}: {
icon: React.ReactNode
onClick: () => void
children?: React.ReactNode
className?: string
side?: FloatingButtonSide
expanded?: boolean
}) {
return (
<button
type="button"
className={cn(
"border-border mr-2 translate-x-12 cursor-pointer rounded-full border bg-white p-1.5 text-neutral-600 dark:text-neutral-400 transition-transform duration-300 group-hover:translate-x-0 hover:bg-neutral-100 dark:bg-neutral-900 dark:hover:bg-neutral-800",
"border-border cursor-pointer rounded-full border bg-white shadow-lg p-1.5 text-neutral-600 dark:text-neutral-400 transition-transform duration-300 hover:bg-neutral-100 dark:bg-neutral-900 dark:hover:bg-neutral-800",
side === "right" ? "mr-2" : "ml-2",
expanded
? "translate-x-0"
: side === "right" ? "translate-x-12" : "-translate-x-12",
className,
)}
onClick={onClick}

View file

@ -1,7 +1,9 @@
import type { FloatingButtonSide } from "@/types/config/floating-button"
import { browser, i18n } from "#imports"
import { IconSettings, IconX } from "@tabler/icons-react"
import { IconLock, IconLockOpen, IconSettings, IconX } from "@tabler/icons-react"
import { useAtom, useAtomValue } from "jotai"
import { useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import readFrogLogo from "@/assets/icons/read-frog.png?url&no-inline"
import {
DropdownMenu,
@ -16,194 +18,513 @@ import { APP_NAME } from "@/utils/constants/app"
import { sendMessage } from "@/utils/message"
import { cn } from "@/utils/styles/utils"
import { matchDomainPattern } from "@/utils/url"
import { enablePageTranslationAtom, isDraggingButtonAtom, isSideOpenAtom } from "../../atoms"
import { enablePageTranslationAtom, isDraggingButtonAtom } from "../../atoms"
import { shadowWrapper } from "../../index"
import HiddenButton from "./components/hidden-button"
import TranslateButton from "./translate-button"
const readFrogLogoUrl = new URL(readFrogLogo, browser.runtime.getURL("/")).href
const LONG_PRESS_DELAY_MS = 350
const DRAG_START_DISTANCE_PX = 6
const MIN_FLOATING_CONTAINER_TOP_PX = 30
const FLOATING_CONTAINER_BOTTOM_CLEARANCE_PX = 200
interface DragPoint {
x: number
y: number
}
interface PendingDragState {
pointerId: number
startClientX: number
startClientY: number
currentClientX: number
currentClientY: number
pointerOffsetX: number
pointerOffsetY: number
mainOffsetY: number
buttonWidth: number
buttonHeight: number
hasActiveDrag: boolean
longPressTimerId: number
}
const floatingButtonControlClassName = cn(
"absolute invisible cursor-pointer pointer-events-none flex size-6 items-center justify-center",
"text-neutral-300 transition-[color,left,right,transform] duration-300 hover:scale-110 hover:text-neutral-500 active:scale-90 active:text-neutral-500",
"dark:text-neutral-700 dark:hover:text-neutral-500 dark:active:text-neutral-500",
)
const floatingButtonControlOffsetClassNames = {
right: {
collapsed: "left-0",
expanded: "-left-6",
},
left: {
collapsed: "right-0",
expanded: "-right-6",
},
} satisfies Record<FloatingButtonSide, { collapsed: string, expanded: string }>
function FirefoxSidebarHelpToast() {
return (
<span>
{i18n.t("sidePanel.firefoxUserActionHint")}
{" "}
<a
href={i18n.t("sidePanel.firefoxUserActionHelpUrl")}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
{i18n.t("sidePanel.firefoxUserActionHelpText")}
</a>
</span>
)
}
function getFloatingButtonSide(side: string | undefined): FloatingButtonSide {
return side === "left" ? "left" : "right"
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value))
}
function getPointerDistance(startX: number, startY: number, currentX: number, currentY: number) {
return Math.hypot(currentX - startX, currentY - startY)
}
function getDragPreviewPosition(pendingDrag: PendingDragState): DragPoint {
return {
x: clamp(
pendingDrag.currentClientX - pendingDrag.pointerOffsetX,
0,
Math.max(0, window.innerWidth - pendingDrag.buttonWidth),
),
y: clamp(
pendingDrag.currentClientY - pendingDrag.pointerOffsetY,
0,
Math.max(0, window.innerHeight - pendingDrag.buttonHeight),
),
}
}
function getNormalizedFloatingContainerTop(mainButtonTop: number, mainOffsetY: number) {
const viewportHeight = Math.max(1, window.innerHeight)
const maxTop = Math.max(
MIN_FLOATING_CONTAINER_TOP_PX,
viewportHeight - FLOATING_CONTAINER_BOTTOM_CLEARANCE_PX,
)
const containerTop = clamp(
mainButtonTop - mainOffsetY,
MIN_FLOATING_CONTAINER_TOP_PX,
maxTop,
)
return containerTop / viewportHeight
}
export default function FloatingButton() {
const [floatingButton, setFloatingButton] = useAtom(
configFieldsAtomMap.floatingButton,
)
const sideContent = useAtomValue(configFieldsAtomMap.sideContent)
const translationState = useAtomValue(enablePageTranslationAtom)
const [isSideOpen, setIsSideOpen] = useAtom(isSideOpenAtom)
const [isDraggingButton, setIsDraggingButton] = useAtom(isDraggingButtonAtom)
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
const [dragPosition, setDragPosition] = useState<number | null>(null)
const initialClientYRef = useRef<number | null>(null)
const [isHitAreaExpanded, setIsHitAreaExpanded] = useState(false)
const [dragPreviewPosition, setDragPreviewPosition] = useState<DragPoint | null>(null)
const containerRef = useRef<HTMLDivElement | null>(null)
const mainButtonRef = useRef<HTMLDivElement | null>(null)
const pendingDragRef = useRef<PendingDragState | null>(null)
const lastDragPreviewRef = useRef<DragPoint | null>(null)
const isFloatingButtonLocked = floatingButton.locked
const floatingButtonSide = getFloatingButtonSide(floatingButton.side)
const isFloatingButtonExpanded = isHitAreaExpanded || isDropdownOpen
const isMainButtonAttached = isFloatingButtonLocked || isFloatingButtonExpanded
// 按钮拖动处理
useEffect(() => {
const initialClientY = initialClientYRef.current
if (!isDraggingButton || !initialClientY || !floatingButton)
if (!isDraggingButton)
return
const handleMouseMove = (e: MouseEvent) => {
const initialY = floatingButton.position * window.innerHeight
const newY = Math.max(
30,
Math.min(
window.innerHeight - 200,
initialY + e.clientY - initialClientY,
),
)
const newPosition = newY / window.innerHeight
setDragPosition(newPosition)
}
const handleMouseUp = () => {
setIsDraggingButton(false)
}
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
const previousUserSelect = document.body.style.userSelect
const previousCursor = document.body.style.cursor
document.body.style.userSelect = "none"
document.body.style.cursor = "grabbing"
return () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
document.body.style.userSelect = ""
document.body.style.userSelect = previousUserSelect
document.body.style.cursor = previousCursor
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDraggingButton])
// 拖拽结束时写入 storage
useEffect(() => {
if (!isDraggingButton && dragPosition !== null) {
void setFloatingButton({ position: dragPosition })
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setDragPosition(null)
}
}, [isDraggingButton, dragPosition, setFloatingButton])
const handleButtonDragStart = (e: React.MouseEvent) => {
// 记录初始位置,用于后续判断是点击还是拖动
initialClientYRef.current = e.clientY
let hasMoved = false // 标记是否发生了移动
e.preventDefault()
setIsDraggingButton(true)
// 创建一个监听器检测移动
const handleMouseMove = (moveEvent: MouseEvent) => {
const moveDistance = Math.abs(moveEvent.clientY - e.clientY)
// 如果移动距离大于阈值,标记为已移动
if (moveDistance > 5) {
hasMoved = true
return () => {
const pendingDrag = pendingDragRef.current
if (pendingDrag) {
window.clearTimeout(pendingDrag.longPressTimerId)
}
}
}, [])
// 在鼠标释放时,只有未移动才触发点击事件
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
// 只有未移动过才触发点击
if (!hasMoved) {
if (floatingButton.clickAction === "translate") {
const nextEnabled = !translationState.enabled
void sendMessage("tryToSetEnablePageTranslationOnContentScript", {
enabled: nextEnabled,
analyticsContext: nextEnabled
? createFeatureUsageContext(ANALYTICS_FEATURE.PAGE_TRANSLATION, ANALYTICS_SURFACE.FLOATING_BUTTON)
: undefined,
})
}
else {
setIsSideOpen(o => !o)
}
}
const handleFloatingButtonClick = () => {
if (floatingButton.clickAction === "translate") {
const nextEnabled = !translationState.enabled
void sendMessage("tryToSetEnablePageTranslationOnContentScript", {
enabled: nextEnabled,
analyticsContext: nextEnabled
? createFeatureUsageContext(ANALYTICS_FEATURE.PAGE_TRANSLATION, ANALYTICS_SURFACE.FLOATING_BUTTON)
: undefined,
})
return
}
document.addEventListener("mouseup", handleMouseUp)
document.addEventListener("mousemove", handleMouseMove)
void Promise.resolve(sendMessage("toggleSidePanel", undefined)).then((result) => {
if (result?.ok === false && result.reason === "requires-extension-user-action") {
toast.info(<FirefoxSidebarHelpToast />)
}
})
}
const attachSideClassName = isDraggingButton || isSideOpen || isDropdownOpen ? "translate-x-0" : ""
const startActiveDrag = () => {
const pendingDrag = pendingDragRef.current
if (!pendingDrag || pendingDrag.hasActiveDrag)
return
pendingDrag.hasActiveDrag = true
const nextPreviewPosition = getDragPreviewPosition(pendingDrag)
lastDragPreviewRef.current = nextPreviewPosition
setDragPreviewPosition(nextPreviewPosition)
setIsHitAreaExpanded(false)
setIsDropdownOpen(false)
setIsDraggingButton(true)
}
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.pointerType === "mouse" && e.button !== 0)
return
const mainButton = mainButtonRef.current ?? e.currentTarget
const mainButtonRect = mainButton.getBoundingClientRect()
const containerRect = containerRef.current?.getBoundingClientRect()
const mainOffsetY = containerRect
? mainButtonRect.top - containerRect.top
: 0
e.preventDefault()
if (typeof e.currentTarget.setPointerCapture === "function") {
e.currentTarget.setPointerCapture(e.pointerId)
}
pendingDragRef.current = {
pointerId: e.pointerId,
startClientX: e.clientX,
startClientY: e.clientY,
currentClientX: e.clientX,
currentClientY: e.clientY,
pointerOffsetX: e.clientX - mainButtonRect.left,
pointerOffsetY: e.clientY - mainButtonRect.top,
mainOffsetY,
buttonWidth: mainButtonRect.width || 40,
buttonHeight: mainButtonRect.height || 40,
hasActiveDrag: false,
longPressTimerId: window.setTimeout(startActiveDrag, LONG_PRESS_DELAY_MS),
}
}
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const pendingDrag = pendingDragRef.current
if (!pendingDrag || pendingDrag.pointerId !== e.pointerId)
return
pendingDrag.currentClientX = e.clientX
pendingDrag.currentClientY = e.clientY
if (!pendingDrag.hasActiveDrag) {
const pointerDistance = getPointerDistance(
pendingDrag.startClientX,
pendingDrag.startClientY,
e.clientX,
e.clientY,
)
if (pointerDistance > DRAG_START_DISTANCE_PX) {
startActiveDrag()
}
}
if (pendingDrag.hasActiveDrag) {
const nextPreviewPosition = getDragPreviewPosition(pendingDrag)
lastDragPreviewRef.current = nextPreviewPosition
setDragPreviewPosition(nextPreviewPosition)
}
}
const finishPointerInteraction = (
e: React.PointerEvent<HTMLDivElement>,
shouldTriggerClick: boolean,
) => {
const pendingDrag = pendingDragRef.current
if (!pendingDrag || pendingDrag.pointerId !== e.pointerId)
return
window.clearTimeout(pendingDrag.longPressTimerId)
pendingDragRef.current = null
if (typeof e.currentTarget.releasePointerCapture === "function") {
e.currentTarget.releasePointerCapture(e.pointerId)
}
if (pendingDrag.hasActiveDrag) {
const finalPreviewPosition = lastDragPreviewRef.current
?? getDragPreviewPosition(pendingDrag)
const finalCenterX = finalPreviewPosition.x + pendingDrag.buttonWidth / 2
const nextSide: FloatingButtonSide = finalCenterX < window.innerWidth / 2
? "left"
: "right"
const nextPosition = getNormalizedFloatingContainerTop(
finalPreviewPosition.y,
pendingDrag.mainOffsetY,
)
lastDragPreviewRef.current = null
setDragPreviewPosition(null)
void setFloatingButton({ position: nextPosition, side: nextSide })
setIsDraggingButton(false)
return
}
lastDragPreviewRef.current = null
setDragPreviewPosition(null)
setIsDraggingButton(false)
if (shouldTriggerClick) {
handleFloatingButtonClick()
}
}
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
finishPointerInteraction(e, true)
}
const handlePointerCancel = (e: React.PointerEvent<HTMLDivElement>) => {
finishPointerInteraction(e, false)
}
const handleMouseEnter = () => {
if (!isDraggingButton) {
setIsHitAreaExpanded(true)
}
}
const handleMouseLeave = () => {
if (!isDropdownOpen && !isDraggingButton) {
setIsHitAreaExpanded(false)
}
}
if (!floatingButton.enabled || floatingButton.disabledFloatingButtonPatterns.some(pattern => matchDomainPattern(window.location.href, pattern))) {
return null
}
const containerStyle: React.CSSProperties = isDraggingButton && dragPreviewPosition
? {
left: `${dragPreviewPosition.x}px`,
right: "auto",
top: `${dragPreviewPosition.y}px`,
}
: {
left: floatingButtonSide === "left" ? "0px" : undefined,
right: floatingButtonSide === "right"
? "var(--removed-body-scroll-bar-size, 0px)"
: undefined,
top: `${floatingButton.position * 100}vh`,
}
return (
<div
className="group fixed z-2147483647 flex flex-col items-end gap-2 print:hidden"
style={{
right: isSideOpen
? `calc(${sideContent.width}px + var(--removed-body-scroll-bar-size, 0px))`
: "var(--removed-body-scroll-bar-size, 0px)",
top: `${(dragPosition ?? floatingButton.position) * 100}vh`,
}}
ref={containerRef}
data-testid="floating-button-container"
className={cn(
"fixed z-2147483647 flex flex-col gap-2 print:hidden",
isDraggingButton
? "items-center"
: floatingButtonSide === "right" ? "items-end" : "items-start",
!isDraggingButton && isFloatingButtonExpanded && (
floatingButtonSide === "right" ? "pl-6" : "pr-6"
),
)}
style={containerStyle}
onMouseLeave={handleMouseLeave}
>
<TranslateButton className={attachSideClassName} />
<div
className={cn(
"border-border flex h-10 w-15 items-center rounded-l-full border border-r-0 bg-white opacity-60 shadow-lg group-hover:opacity-100 dark:bg-neutral-900",
"translate-x-5 transition-transform duration-300 group-hover:translate-x-0",
(isSideOpen || isDropdownOpen) && "opacity-100",
isDraggingButton ? "cursor-move" : "cursor-pointer",
attachSideClassName,
)}
onMouseDown={handleButtonDragStart}
>
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
<DropdownMenuTrigger
render={(
<button
type="button"
title="Close floating button"
className={cn(
"border-border absolute -top-1 -left-1 hidden cursor-pointer rounded-full border bg-neutral-100 dark:bg-neutral-900",
"group-hover:block",
isDropdownOpen && "block",
)}
onMouseDown={e => e.stopPropagation()} // 父级不会收到 mousedown
/>
)}
>
<IconX className="h-3 w-3 text-neutral-400 dark:text-neutral-600" />
</DropdownMenuTrigger>
<DropdownMenuContent container={shadowWrapper} align="start" side="left" className="z-2147483647 w-fit! whitespace-nowrap">
<DropdownMenuItem
onMouseDown={e => e.stopPropagation()}
onClick={() => {
const currentDomain = window.location.hostname
const currentPatterns = floatingButton.disabledFloatingButtonPatterns || []
void setFloatingButton({
...floatingButton,
disabledFloatingButtonPatterns: [...currentPatterns, currentDomain],
})
}}
>
{i18n.t("options.floatingButtonAndToolbar.floatingButton.closeMenu.disableForSite")}
</DropdownMenuItem>
<DropdownMenuItem
onMouseDown={e => e.stopPropagation()}
onClick={() => {
void setFloatingButton({ ...floatingButton, enabled: false })
}}
>
{i18n.t("options.floatingButtonAndToolbar.floatingButton.closeMenu.disableGlobally")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<img
src={readFrogLogoUrl}
alt={APP_NAME}
className="ml-[5px] h-8 w-8 rounded-full"
{!isDraggingButton && (
<TranslateButton
side={floatingButtonSide}
expanded={isFloatingButtonExpanded}
/>
)}
<div className="relative">
<div
ref={mainButtonRef}
data-testid="floating-main-button"
className={cn(
"border-border relative flex h-10 items-center bg-white shadow-lg transition-transform duration-300 dark:bg-neutral-900",
isDraggingButton
? "w-10 touch-none justify-center rounded-full border cursor-grabbing opacity-100"
: floatingButtonSide === "right"
? "w-11 justify-start rounded-l-full border border-r-0"
: "w-11 justify-end rounded-r-full border border-l-0",
!isDraggingButton && (isMainButtonAttached
? "translate-x-0"
: floatingButtonSide === "right" ? "translate-x-6" : "-translate-x-6"),
!isDraggingButton && (isFloatingButtonExpanded ? "opacity-100" : "opacity-60"),
!isDraggingButton && "cursor-pointer",
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onMouseEnter={handleMouseEnter}
>
<img
src={readFrogLogoUrl}
alt={APP_NAME}
className={cn(
"h-8 w-8 rounded-full",
!isDraggingButton && (floatingButtonSide === "right" ? "ml-1" : "mr-1"),
)}
/>
</div>
{!isDraggingButton && (
<>
<FloatingButtonCloseMenu
expanded={isFloatingButtonExpanded}
side={floatingButtonSide}
onDropdownOpenChange={setIsDropdownOpen}
/>
<FloatingButtonLockControl
expanded={isFloatingButtonExpanded}
side={floatingButtonSide}
/>
</>
)}
</div>
<HiddenButton
className={attachSideClassName}
icon={<IconSettings className="h-5 w-5" />}
onClick={() => {
void sendMessage("openOptionsPage", undefined)
}}
/>
{!isDraggingButton && (
<HiddenButton
side={floatingButtonSide}
expanded={isFloatingButtonExpanded}
icon={<IconSettings className="h-5 w-5" />}
onClick={() => {
void sendMessage("openOptionsPage", undefined)
}}
/>
)}
</div>
)
}
interface FloatingButtonCloseMenuProps {
expanded: boolean
side: FloatingButtonSide
onDropdownOpenChange: (open: boolean) => void
}
function FloatingButtonCloseMenu({
expanded,
side,
onDropdownOpenChange,
}: FloatingButtonCloseMenuProps) {
const [floatingButton, setFloatingButton] = useAtom(configFieldsAtomMap.floatingButton)
const [open, setOpen] = useState(false)
const controlOffsetClassName = !floatingButton.locked && !expanded
? floatingButtonControlOffsetClassNames[side].collapsed
: floatingButtonControlOffsetClassNames[side].expanded
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
onDropdownOpenChange(nextOpen)
}
const handleDisableForSite = () => {
const currentDomain = window.location.hostname
const currentPatterns = floatingButton.disabledFloatingButtonPatterns || []
void setFloatingButton({
...floatingButton,
disabledFloatingButtonPatterns: [...currentPatterns, currentDomain],
})
}
const handleDisableGlobally = () => {
void setFloatingButton({ ...floatingButton, enabled: false })
}
return (
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger
render={(
<button
type="button"
aria-label="Close floating button"
className={cn(
floatingButtonControlClassName,
"-top-1",
controlOffsetClassName,
expanded && "visible pointer-events-auto",
open && "visible pointer-events-auto",
)}
/>
)}
>
<IconX className="h-3 w-3" strokeWidth={3} />
</DropdownMenuTrigger>
<DropdownMenuContent container={shadowWrapper} align="start" side={side === "right" ? "left" : "right"} className="z-2147483647 w-fit! whitespace-nowrap">
<DropdownMenuItem
onMouseDown={e => e.stopPropagation()}
onClick={handleDisableForSite}
>
{i18n.t("options.floatingButtonAndToolbar.floatingButton.closeMenu.disableForSite")}
</DropdownMenuItem>
<DropdownMenuItem
onMouseDown={e => e.stopPropagation()}
onClick={handleDisableGlobally}
>
{i18n.t("options.floatingButtonAndToolbar.floatingButton.closeMenu.disableGlobally")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
interface FloatingButtonLockControlProps {
expanded: boolean
side: FloatingButtonSide
}
function FloatingButtonLockControl({ expanded, side }: FloatingButtonLockControlProps) {
const [floatingButton, setFloatingButton] = useAtom(configFieldsAtomMap.floatingButton)
const locked = floatingButton.locked
const controlOffsetClassName = !locked && !expanded
? floatingButtonControlOffsetClassNames[side].collapsed
: floatingButtonControlOffsetClassNames[side].expanded
const handleToggleLocked = () => {
void setFloatingButton({ ...floatingButton, locked: !locked })
}
return (
<button
type="button"
aria-label={locked ? "Unlock floating button" : "Lock floating button"}
className={cn(
floatingButtonControlClassName,
"-bottom-1",
controlOffsetClassName,
expanded && "visible pointer-events-auto",
)}
onClick={handleToggleLocked}
>
{locked
? <IconLock className="h-3 w-3" strokeWidth={3} />
: <IconLockOpen className="h-3 w-3" strokeWidth={3} />}
</button>
)
}

Some files were not shown because too many files have changed in this diff Show more