mirror of
https://github.com/mengxi-ream/read-frog.git
synced 2026-04-30 01:56:46 +00:00
Compare commits
61 commits
fix/connec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbacbf52a7 |
||
|
|
281908a487 |
||
|
|
053aa9090d |
||
|
|
0bd869fd93 |
||
|
|
6dfeb40b67 |
||
|
|
c3debfbc0c |
||
|
|
466c1cefdb |
||
|
|
619c83defd |
||
|
|
37c0ced786 |
||
|
|
afa7dee1b0 |
||
|
|
596bcf7248 |
||
|
|
69221554ff |
||
|
|
c25b299ca4 |
||
|
|
f05a0bda21 |
||
|
|
18967cbab8 |
||
|
|
746a3c5c3b | ||
|
|
b282ef87ed |
||
|
|
068bdecc8a |
||
|
|
810623ba02 |
||
|
|
396dd0d36b |
||
|
|
adfc89add6 |
||
|
|
5b56df819a |
||
|
|
4667e3eb40 |
||
|
|
455584ff00 |
||
|
|
998f288cad |
||
|
|
14d4f2ed70 |
||
|
|
d2c75ace5a |
||
|
|
090463d588 |
||
|
|
56e3081d7d |
||
|
|
26b06af870 |
||
|
|
94c2f3b74d |
||
|
|
068d5eea10 |
||
|
|
86c90ad506 |
||
|
|
01ccdd17a2 |
||
|
|
a49ab2790b |
||
|
|
74f16a98d8 |
||
|
|
04da031715 |
||
|
|
e3c40e4717 |
||
|
|
4f081cd9fe |
||
|
|
0f6bf631ad |
||
|
|
373a2f214e |
||
|
|
fe2eeddc3d |
||
|
|
dd68565a2c |
||
|
|
08b40e82cd | ||
|
|
da2e94bb15 |
||
|
|
74f4219615 | ||
|
|
fb1937c437 |
||
|
|
3faa6afde2 |
||
|
|
35008023c6 |
||
|
|
f344e0d7dd |
||
|
|
cf35b8f099 |
||
|
|
19517361ce |
||
|
|
788edfb5ce | ||
|
|
38be1edea9 |
||
|
|
3e9f374770 |
||
|
|
f606b7c56b |
||
|
|
f1d92569ba |
||
|
|
75fafc5a3e |
||
|
|
da8d9376e7 |
||
|
|
1464d774fd |
||
|
|
acdd296e19 |
236 changed files with 14703 additions and 5722 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
feat(subtitles): add a settings toggle menu for video subtitles
|
||||
5
.changeset/focused-provider-options.md
Normal file
5
.changeset/focused-provider-options.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
fix(options): preserve focused provider options drafts
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
style: improve selection preview scrolling in the selection toolbar
|
||||
5
.changeset/rare-accordions-rewalk.md
Normal file
5
.changeset/rare-accordions-rewalk.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
fix(page-translation): re-walk revealed accordion content
|
||||
174
.claude/skills/extension-real-browser-testing.md
Normal file
174
.claude/skills/extension-real-browser-testing.md
Normal 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.
|
||||
48
.codex/environments/environment.toml
Normal file
48
.codex/environments/environment.toml
Normal 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
16
.env.example
Normal 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
|
||||
3
.github/workflows/pr-test.yml
vendored
3
.github/workflows/pr-test.yml
vendored
|
|
@ -5,6 +5,9 @@ on:
|
|||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
WXT_SKIP_ENV_VALIDATION: true
|
||||
|
||||
jobs:
|
||||
test-and-build:
|
||||
name: Test & Build
|
||||
|
|
|
|||
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
9
.github/workflows/stale-issue-pr.yml
vendored
9
.github/workflows/stale-issue-pr.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
9
.github/workflows/submit.yml
vendored
9
.github/workflows/submit.yml
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -29,6 +29,7 @@ web-ext.config.ts
|
|||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.local.example
|
||||
|
||||
coverage/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
102
CHANGELOG.md
102
CHANGELOG.md
|
|
@ -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
192
CLAUDE.md
|
|
@ -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; don’t 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.
|
||||
25
README.md
25
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
})
|
||||
|
|
|
|||
147
package.json
147
package.json
|
|
@ -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.5",
|
||||
"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": {
|
||||
|
|
|
|||
5263
pnpm-lock.yaml
generated
5263
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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;")
|
||||
})
|
||||
})
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
115
src/components/__tests__/user-account.test.tsx
Normal file
115
src/components/__tests__/user-account.test.tsx
Normal 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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
107
src/components/ui/base-ui/avatar.tsx
Normal file
107
src/components/ui/base-ui/avatar.tsx
Normal 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,
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -290,6 +290,7 @@ function SelectionPopoverShell({
|
|||
style={{
|
||||
display: "flex",
|
||||
...style,
|
||||
opacity: "var(--rf-selection-opacity, 1)",
|
||||
maxWidth: "100vw",
|
||||
maxHeight: "100vh",
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}, [])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
279
src/entrypoints/background/__tests__/side-panel.test.ts
Normal file
279
src/entrypoints/background/__tests__/side-panel.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
276
src/entrypoints/background/side-panel.ts
Normal file
276
src/entrypoints/background/side-panel.ts
Normal 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,
|
||||
}))
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 }, "*")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ?? {})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue