Compare commits

..

2 commits

Author SHA1 Message Date
MengXi
f51039851a fix(options): use typed options path 2026-04-23 19:23:56 -07:00
MengXi
13d54192ad fix(options): open options tab directly 2026-04-23 19:21:59 -07:00
137 changed files with 2197 additions and 4922 deletions

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
fix(selection-toolbar): add more cursor clearance after text selection

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
fix(selection-toolbar): derive custom action webpage context by popover session

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
fix(subtitles): support stylized YouTube karaoke parsing and source export

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
fix: keep floating button close menu aligned after reopening

View file

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

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
fix(options): open options tab directly for Dia compatibility

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
fix(models): skip unsupported thinking options for instruct variants

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
fix(subtitles): stabilize YouTube subtitle navigation and popup mounting

View file

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

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
refactor(env): simplify extension env wiring

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
chore(deps): upgrade WXT to 0.20.22 and preserve extension-safe bundle output

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
fix: floating button style

View file

@ -1,48 +0,0 @@
# 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"
'''

View file

@ -18,7 +18,9 @@ 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.
@ -35,4 +37,7 @@ jobs:
close-pr-message: |
Since it has been a long time without updates, the PR has been automatically closed.
If you need to continue, please reopen or create a new PR.
exempt-pr-labels: pinned,WIP,on-hold
exempt-pr-labels: |
pinned
WIP
on-hold

View file

@ -1,77 +1,5 @@
# @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

View file

@ -6,7 +6,7 @@ An open-source AI-powered language learning extension for browsers.<br/>
Supports immersive translation, article analysis, multiple AI models, and more.<br/>
Master languages effortlessly and deeply with AI, right in your browser.
**English** · [简体中文](./README.zh-CN.md) · [Official Website](https://readfrog.app) · [Tutorial](https://www.readfrog.app/docs) · [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/tutorial) · [Changelog](https://www.readfrog.app/changelog) · [Blog](https://www.readfrog.app/blog)
<!-- SHIELD GROUP -->
@ -46,6 +46,7 @@ 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)
@ -133,7 +134,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 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.
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.
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.
@ -225,6 +226,20 @@ 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.
@ -239,7 +254,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/docs/code-contribution/contribution-guide) for more details.
Check out the [Contribution Guide](https://readfrog.app/en/tutorial/code-contribution/contribution-guide) for more details.
ReadFrog is dual-licensed under GPLv3 and a commercial license.
@ -336,4 +351,4 @@ Every donation helps us build a better language learning experience. Thank you f
<!-- Feature docs link -->
[docs-tutorial]: https://readfrog.app/docs
[docs-tutorial]: https://readfrog.app/tutorial

View file

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

View file

@ -1,7 +1,7 @@
{
"name": "@read-frog/extension",
"type": "module",
"version": "1.33.1",
"version": "1.32.2",
"private": true,
"packageManager": "pnpm@10.32.1",
"description": "Read Frog browser extension for language learning",
@ -16,7 +16,7 @@
"dev:firefox": "wxt -b firefox --mv3",
"lint": "eslint",
"lint:fix": "eslint --fix",
"postinstall": "WXT_SKIP_ENV_VALIDATION=true wxt prepare",
"postinstall": "wxt prepare",
"test": "vitest run",
"test:cov": "vitest run --coverage",
"test:watch": "vitest",
@ -51,7 +51,6 @@
"@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",
@ -66,9 +65,10 @@
"@headless-tree/react": "^1.6.3",
"@json-render/core": "^0.18.0",
"@json-render/react": "^0.18.0",
"@mozilla/readability": "^0.6.0",
"@openrouter/ai-sdk-provider": "^2.8.0",
"@orpc/client": "^1.14.0",
"@orpc/tanstack-query": "^1.14.0",
"@orpc/client": "^1.13.14",
"@orpc/tanstack-query": "^1.13.14",
"@radix-ui/react-slot": "^1.2.4",
"@read-frog/api-contract": "0.4.1",
"@read-frog/definitions": "0.1.4",
@ -78,13 +78,13 @@
"@tanstack/hotkeys": "^0.7.1",
"@tanstack/react-form": "^1.29.1",
"@tanstack/react-hotkeys": "^0.9.1",
"@tanstack/react-query": "^5.100.1",
"@tanstack/react-query": "^5.99.2",
"@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.168",
"better-auth": "^1.6.8",
"better-auth": "^1.6.6",
"case-anything": "^3.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -92,7 +92,6 @@
"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",
@ -101,7 +100,7 @@
"jotai-family": "^1.0.1",
"js-sha256": "^0.11.1",
"ollama-ai-provider-v2": "^3.5.0",
"posthog-js": "^1.371.2",
"posthog-js": "^1.369.5",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-error-boundary": "^6.1.1",
@ -154,7 +153,7 @@
"tailwindcss": "^4.2.4",
"type-fest": "^5.6.0",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vite": "^8.0.9",
"vitest": "^4.1.5",
"wxt": "0.20.25"
},

544
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path 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>
<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>

Before

Width:  |  Height:  |  Size: 565 B

After

Width:  |  Height:  |  Size: 510 B

Before After
Before After

View file

@ -1 +1 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M22.75 17.5C22.75 17.91 22.41 18.25 22 18.25H15V18.5C15 20 14.1 20.5 13 20.5H7C5.9 20.5 5 20 5 18.5V18.25H2C1.59 18.25 1.25 17.91 1.25 17.5C1.25 17.09 1.59 16.75 2 16.75H5V16.5C5 15 5.9 14.5 7 14.5H13C14.1 14.5 15 15 15 16.5V16.75H22C22.41 16.75 22.75 17.09 22.75 17.5Z" fill="oklch(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>
<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>

Before

Width:  |  Height:  |  Size: 876 B

After

Width:  |  Height:  |  Size: 866 B

Before After
Before After

View file

@ -0,0 +1,23 @@
import { readFileSync } from "node:fs"
import { describe, expect, it } from "vitest"
const themeCss = readFileSync(new URL("../theme.css", import.meta.url), "utf8")
describe("theme.css", () => {
it("namespaces theme tokens with read-frog prefixes", () => {
const normalizedThemeCss = themeCss.replace(/\s+/g, " ")
expect(normalizedThemeCss).toContain("--font-sans: var(--rf-font-sans);")
expect(normalizedThemeCss).toContain("--font-mono: var(--rf-font-mono);")
expect(normalizedThemeCss).toContain("--color-border: var(--rf-border);")
expect(normalizedThemeCss).toContain("--color-background: var(--rf-background);")
expect(normalizedThemeCss).toContain("--shadow-floating: var(--rf-elevation-floating);")
expect(normalizedThemeCss).toContain("--rf-font-sans: ui-sans-serif, system-ui, sans-serif")
expect(normalizedThemeCss).toContain("--rf-font-mono: ui-monospace, SFMono-Regular, Menlo")
expect(normalizedThemeCss).toContain("--rf-border: oklch(0.92 0.004 286.32);")
expect(normalizedThemeCss).not.toContain("--font-sans: var(--font-sans);")
expect(normalizedThemeCss).not.toContain("--font-mono: var(--font-mono);")
expect(normalizedThemeCss).not.toContain("--color-border: var(--border);")
expect(normalizedThemeCss).not.toContain("--color-background: var(--background);")
})
})

View file

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

View file

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

View file

@ -1,16 +1,12 @@
:root {
--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-primary: oklch(76.5% 0.177 163.223);
--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(0.922 0 0);
--read-frog-brand: oklch(76.034% 0.12361 82.191);
--read-frog-foreground: oklch(0.205 0 0);
--read-frog-primary: oklch(59.6% 0.145 163.225);
--read-frog-muted: oklch(0.269 0 0);
--read-frog-muted-foreground: oklch(0.708 0 0);
}

View file

@ -19,8 +19,6 @@
--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);
@ -69,10 +67,8 @@
--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-primary: oklch(0.693 0.17 162.48);
--rf-primary-foreground: oklch(0.98 0.02 166);
--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);
@ -83,16 +79,16 @@
--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-chart-1: oklch(0.85 0.13 165);
--rf-chart-2: oklch(0.77 0.15 163);
--rf-chart-3: oklch(0.7 0.15 162);
--rf-chart-4: oklch(0.6 0.13 163);
--rf-chart-5: oklch(0.51 0.1 166);
--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-primary: oklch(0.6 0.13 163);
--rf-sidebar-primary-foreground: oklch(0.98 0.02 166);
--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);
@ -110,10 +106,8 @@
--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-primary: oklch(0.693 0.17 162.48);
--rf-primary-foreground: oklch(0.26 0.05 173);
--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);
@ -124,15 +118,15 @@
--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-chart-1: oklch(0.85 0.13 165);
--rf-chart-2: oklch(0.77 0.15 163);
--rf-chart-3: oklch(0.7 0.15 162);
--rf-chart-4: oklch(0.6 0.13 163);
--rf-chart-5: oklch(0.51 0.1 166);
--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-primary: oklch(0.77 0.15 163);
--rf-sidebar-primary-foreground: oklch(0.26 0.05 173);
--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%);

View file

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

View file

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

View file

@ -15,11 +15,9 @@ 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)
@ -36,12 +34,10 @@ export function ThemeProvider({
() => !!window?.matchMedia?.("(prefers-color-scheme: dark)")?.matches,
)
const resolvedTheme: Theme = themeMode === "system"
const theme: Theme = themeMode === "system"
? (prefersDark ? "dark" : "light")
: themeMode
const theme: Theme = forcedTheme ?? resolvedTheme
// Apply theme to document or shadow root container
useLayoutEffect(() => {
const target = container ?? document.documentElement

View file

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

View file

@ -5,35 +5,27 @@ import { cva } from "class-variance-authority"
import { cn } from "@/utils/styles/utils"
const buttonVariants = cva(
"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",
"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",
{
variants: {
variant: {
"default": "bg-primary text-primary-foreground hover:bg-primary/80",
"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",
"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",
"link": "text-primary underline-offset-4 hover:underline",
},
size: {
"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",
"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",
},
},
defaultVariants: {

View file

@ -1,64 +1,12 @@
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
@ -81,22 +29,26 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
function SelectTrigger({
className,
variant = "default",
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & VariantProps<typeof selectTriggerVariants>) {
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
className={cn(selectTriggerVariants({ variant, size, className }))}
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,
)}
{...props}
>
{children}
<SelectPrimitive.Icon
data-slot="select-icon"
render={
<IconChevronDown className="size-4 pointer-events-none" />
<IconChevronDown className="text-muted-foreground size-4 pointer-events-none" />
}
/>
</SelectPrimitive.Trigger>
@ -106,7 +58,6 @@ function SelectTrigger({
function SelectContent({
container,
className,
variant = "default",
children,
positionerClassName,
side = "bottom",
@ -117,7 +68,6 @@ function SelectContent({
...props
}: SelectPrimitive.Popup.Props
& Pick<SelectPrimitive.Portal.Props, "container">
& VariantProps<typeof selectContentVariants>
& {
positionerClassName?: string
}
@ -133,12 +83,12 @@ function SelectContent({
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className={cn("pointer-events-auto isolate z-50", positionerClassName)}
className={cn("isolate z-50", positionerClassName)}
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(selectContentVariants({ variant }), SHARED_POPUP_CLOSED_STATE_CLASS, className)}
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)}
{...props}
>
<SelectScrollUpButton />
@ -165,14 +115,16 @@ function SelectLabel({
function SelectItem({
className,
variant = "default",
children,
...props
}: SelectPrimitive.Item.Props & VariantProps<typeof selectItemVariants>) {
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(selectItemVariants({ variant, className }))}
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,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 gap-2 shrink-0 whitespace-nowrap">

View file

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

View file

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

View file

@ -1,38 +1,20 @@
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 { 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">
<Avatar size="sm" className={cn(isPending && "animate-pulse")}>
<AvatarImage src={avatarSrc || ""} alt={displayName} />
<AvatarFallback>{fallbackText}</AvatarFallback>
</Avatar>
{isPending ? "Loading..." : displayName}
{!isPending && !user && (
<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 && (
<Button
size="xs"
variant="outline"

View file

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

View file

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

View file

@ -14,7 +14,7 @@ import type {
StreamRuntimeOptions,
ThinkingSnapshot,
} from "@/types/background-stream"
import { Output, parsePartialJson, streamText } from "ai"
import { Output, streamText } from "ai"
import { z } from "zod"
import { BACKGROUND_STREAM_PORTS } from "@/types/background-stream"
import { extractAISDKErrorMessage } from "@/utils/error/extract-message"
@ -23,15 +23,6 @@ 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),
@ -137,7 +128,7 @@ function createStreamPortHandler<TSerializablePayload, TResponse>(
}
disconnectListener = () => {
abortController.abort(createStreamAbortError("stream port disconnected"))
abortController.abort()
cleanup()
}
@ -200,12 +191,10 @@ function createStreamPortHandler<TSerializablePayload, TResponse>(
}
catch (error) {
const finalError = streamError ?? error
if (abortController.signal.aborted || isAbortLikeError(finalError)) {
return
}
logger.error("[Background] Stream Function failed", finalError)
safePost({ type: "error", error: { message: extractAISDKErrorMessage(finalError) } })
if (!abortController.signal.aborted) {
safePost({ type: "error", error: { message: extractAISDKErrorMessage(finalError) } })
}
}
finally {
cleanup()
@ -236,10 +225,6 @@ 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> = {},
@ -294,18 +279,16 @@ 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(cumulativeText, thinking)
return createStreamSnapshot(finalText, thinking)
}
export async function runStructuredObjectStreamInBackground(
@ -335,60 +318,64 @@ 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: objectSchema,
schema: z.object(schemaShape).strict(),
}),
abortSignal: signal,
onError: ({ error }) => {
onError?.(error)
},
})
let cumulativeText = ""
for await (const part of result.fullStream) {
if (signal?.aborted) {
throw new DOMException("stream aborted", "AbortError")
}
const consumePartialOutput = async () => {
for await (const partial of result.partialOutputStream) {
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
}
case "reasoning-delta": {
thinking = {
status: "thinking",
text: thinking.text + part.text,
}
if (partial && typeof partial === "object" && !Array.isArray(partial)) {
cumulativeValue = { ...cumulativeValue, ...partial }
onChunk?.(createStreamSnapshot(cumulativeValue, thinking))
break
}
case "reasoning-end": {
thinking = {
...thinking,
status: "complete",
}
onChunk?.(createStreamSnapshot(cumulativeValue, thinking))
break
}
case "error": {
throw part.error
}
}
}
const finalJson = await parsePartialJson(cumulativeText)
const finalValue = objectSchema.parse(finalJson.value)
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(),
])
thinking = {
...thinking,

View file

@ -18,7 +18,6 @@ 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"
@ -57,12 +56,6 @@ export default defineBackground({
await openOptionsPage()
})
setupSidePanelMessageHandler({
extensionBrowser: browser,
logger,
registerMessageHandler: onMessage,
})
onMessage("aiSegmentSubtitles", async (message) => {
try {
return await runAiSegmentSubtitles(message.data)

View file

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

View file

@ -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 { detectPageLanguageLightweight } from "@/utils/content/page-language"
import { getDocumentInfo } from "@/utils/content/analyze"
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 detectPageLanguageLightweight()
const { detectedCodeOrUnd } = await getDocumentInfo()
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 detectPageLanguageLightweight()
const { detectedCodeOrUnd } = await getDocumentInfo()
const initialDetectedCode: LangCodeISO6393 = detectedCodeOrUnd === "und" ? "eng" : detectedCodeOrUnd
await storage.setItem<LangCodeISO6393>(`local:${DETECTED_CODE_STORAGE_KEY}`, initialDetectedCode)

View file

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

View file

@ -38,7 +38,6 @@ 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,
}))

View file

@ -1,5 +1,4 @@
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"
@ -8,7 +7,7 @@ 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, isDontWalkIntoAndDontTranslateAsChildElement, isDontWalkIntoButTranslateAsChildElement, isHTMLElement } from "@/utils/host/dom/filter"
import { hasNoWalkAncestor, isDontWalkIntoButTranslateAsChildElement, isHTMLElement } from "@/utils/host/dom/filter"
import { deepQueryTopLevelSelector } from "@/utils/host/dom/find"
import { walkAndLabelElement } from "@/utils/host/dom/traversal"
import { removeAllTranslatedWrapperNodes, translateWalkedElement } from "@/utils/host/translate/node-manipulation"
@ -60,7 +59,7 @@ export class PageTranslationManager implements IPageTranslationManager {
private mutationObservers: MutationObserver[] = []
private walkId: string | null = null
private intersectionOptions: IntersectionObserverInit
private walkBlockedElementsCache = new WeakSet<HTMLElement>()
private dontWalkIntoElementsCache = new WeakSet<HTMLElement>()
private titleObserver: MutationObserver | null = null
private lastSourceTitle: string | null = null
private lastAppliedTranslatedTitle: string | null = null
@ -154,8 +153,8 @@ export class PageTranslationManager implements IPageTranslationManager {
}, this.intersectionOptions)
// Initialize walkability state for existing elements
this.addWalkBlockedElements(document.body, config)
await this.observerTopLevelParagraphs(document.body, config)
this.addDontWalkIntoElements(document.body)
await this.observerTopLevelParagraphs(document.body)
// Start observing mutations from document.body and all shadow roots
this.observeMutations(document.body)
@ -190,7 +189,7 @@ export class PageTranslationManager implements IPageTranslationManager {
this.isPageTranslating = false
this.walkId = null
this.walkBlockedElementsCache = new WeakSet()
this.dontWalkIntoElementsCache = new WeakSet()
this.stopDocumentTitleTracking()
if (this.intersectionObserver) {
@ -387,12 +386,12 @@ export class PageTranslationManager implements IPageTranslationManager {
}
}
private async observerTopLevelParagraphs(container: HTMLElement, existingConfig?: Config): Promise<void> {
private async observerTopLevelParagraphs(container: HTMLElement): Promise<void> {
const observer = this.intersectionObserver
if (!this.walkId || !observer)
return
const config = existingConfig ?? await getLocalConfig()
const config = await getLocalConfig()
if (!config) {
logger.error("Global config is not initialized")
return
@ -455,39 +454,33 @@ export class PageTranslationManager implements IPageTranslationManager {
}
/**
* Track the same blocked states that the traversal skips, so hidden accordion
* panels can be re-walked when the site reveals an existing subtree.
* Handle style/class attribute changes and only trigger observation
* when element transitions from "don't walk into" to "walkable"
*/
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)
private didChangeToWalkable(element: HTMLElement): boolean {
const wasDontWalkInto = this.dontWalkIntoElementsCache.has(element)
const isDontWalkIntoNow = isDontWalkIntoButTranslateAsChildElement(element)
// Update cache with current state
if (isWalkBlockedNow) {
this.walkBlockedElementsCache.add(element)
if (isDontWalkIntoNow) {
this.dontWalkIntoElementsCache.add(element)
}
else {
this.walkBlockedElementsCache.delete(element)
this.dontWalkIntoElementsCache.delete(element)
}
return wasWalkBlocked === true && isWalkBlockedNow === false
// 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
}
/**
* Initialize walkability state for an element and its descendants
*/
private addWalkBlockedElements(element: HTMLElement, config: Config): void {
const walkBlockedElements = deepQueryTopLevelSelector(element, el => this.isWalkBlockedElement(el, config))
walkBlockedElements.forEach(el => this.walkBlockedElementsCache.add(el))
private addDontWalkIntoElements(element: HTMLElement): void {
const dontWalkIntoElements = deepQueryTopLevelSelector(element, isDontWalkIntoButTranslateAsChildElement)
dontWalkIntoElements.forEach(el => this.dontWalkIntoElementsCache.add(el))
}
/**
@ -495,54 +488,39 @@ export class PageTranslationManager implements IPageTranslationManager {
*/
private observeMutations(container: HTMLElement): void {
const mutationObserver = new MutationObserver((records) => {
void this.handleMutationRecords(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)
}
}
}
})
mutationObserver.observe(container, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["style", "class", "hidden", "aria-hidden"],
attributeFilter: ["style", "class"],
})
this.mutationObservers.push(mutationObserver)
this.observeIsolatedDescendantsMutations(container)
}
private async handleMutationRecords(records: MutationRecord[]): Promise<void> {
const config = await getLocalConfig()
if (!config) {
logger.error("Global config is not initialized")
return
}
for (const rec of records) {
if (rec.type === "childList") {
rec.addedNodes.forEach((node) => {
if (isHTMLElement(node)) {
this.addWalkBlockedElements(node, config)
void this.observerTopLevelParagraphs(node, config)
this.observeIsolatedDescendantsMutations(node)
}
})
}
else if (this.isWalkabilityAttributeMutation(rec)) {
const el = rec.target
if (isHTMLElement(el) && this.didChangeToWalkable(el, config)) {
void this.observerTopLevelParagraphs(el, config)
}
}
}
}
private isWalkabilityAttributeMutation(record: MutationRecord): boolean {
return record.type === "attributes"
&& (record.attributeName === "style"
|| record.attributeName === "class"
|| record.attributeName === "hidden"
|| record.attributeName === "aria-hidden")
}
/**
* Recursively find and observe shadow roots and iframes in an element and its descendants
* These can't be find as top level paragraph elements because isolated shadow roots and iframes are not

View file

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

View file

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

View file

@ -22,23 +22,17 @@ 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}
/>
),
}))
@ -59,22 +53,16 @@ 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 }) => {
if (submitDelayMs > 0) {
await new Promise(resolve => window.setTimeout(resolve, submitDelayMs))
}
setProviderConfig(submitDelayMs > 0 ? structuredClone(value) : value)
setProviderConfig(value)
},
})
@ -117,7 +105,6 @@ 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 () => {
@ -128,29 +115,6 @@ 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} />)
@ -216,9 +180,7 @@ describe("providerOptionsField", () => {
)
const editor = screen.getByLabelText("provider-options-editor")
fireEvent.focus(editor)
fireEvent.change(editor, { target: { value: "{" } })
fireEvent.blur(editor)
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "apply-external" }))
await Promise.resolve()

View file

@ -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 `${env.WXT_WEBSITE_URL}/docs/providers/${providerType}`
return `${env.WXT_WEBSITE_URL}/tutorial/providers/${providerType}`
}
const groupSlug = getProviderGroupSlug(providerType)
if (!groupSlug)
return undefined
return `${env.WXT_WEBSITE_URL}/docs/providers/${groupSlug}`
return `${env.WXT_WEBSITE_URL}/tutorial/providers/${groupSlug}`
}
function getProviderGroupSlug(providerType: APIProviderTypes): string | undefined {

View file

@ -39,7 +39,6 @@ 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/set-state-in-effect
@ -56,18 +55,6 @@ 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])
@ -79,15 +66,9 @@ export const ProviderOptionsField = withForm({
}
pendingEditorCommitRef.current = false
const currentJsonInput = readJsonInput()
if (editorFocusedRef.current && currentJsonInput !== lastCommittedJsonRef.current) {
return
}
lastCommittedJsonRef.current = externalJson
if (currentJsonInput !== externalJson) {
if (readJsonInput() !== externalJson) {
syncJsonInput(externalJson)
}
}, [providerConfig.providerOptions, externalJson])
@ -146,9 +127,7 @@ export const ProviderOptionsField = withForm({
</FieldLabel>
<JSONCodeEditor
value={jsonInput}
onChange={handleJsonInputChange}
onFocus={handleEditorFocus}
onBlur={handleEditorBlur}
onChange={setJsonInput}
placeholder={placeholderText}
hasError={!!jsonError}
height="150px"

View file

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

View file

@ -1,4 +1,3 @@
import type { FloatingButtonClickAction as FloatingButtonClickActionValue } from "@/types/config/floating-button"
import { i18n } from "#imports"
import { useAtom } from "jotai"
import {
@ -9,14 +8,13 @@ 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(
@ -34,10 +32,9 @@ export function FloatingButtonClickAction() {
items={items}
value={floatingButton.clickAction}
onValueChange={(value) => {
const parsedValue = floatingButtonClickActionSchema.safeParse(value)
if (!parsedValue.success)
if (!value)
return
void setFloatingButton({ ...floatingButton, clickAction: parsedValue.data })
void setFloatingButton({ ...floatingButton, clickAction: value })
}}
>
<SelectTrigger className="w-[180px]">

View file

@ -1,6 +1,5 @@
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"
@ -8,12 +7,6 @@ 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
@ -26,17 +19,14 @@ export function SelectionToolbarOpacity() {
min={MIN_SELECTION_OVERLAY_OPACITY}
max={MAX_SELECTION_OVERLAY_OPACITY}
step={1}
value={draftOpacity}
value={selectionToolbar.opacity}
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">
{draftOpacity}
{selectionToolbar.opacity}
%
</span>
</div>

View file

@ -67,7 +67,7 @@ export function CSSEditor() {
<FieldLabel htmlFor="css-editor" data-invalid>
{i18n.t("options.translation.translationStyle.cssEditor")}
</FieldLabel>
<a href={`${env.WXT_WEBSITE_URL}/docs/custom-css`} className="text-xs text-link hover:opacity-90" target="_blank" rel="noreferrer">
<a href={`${env.WXT_WEBSITE_URL}/tutorial/custom-css`} className="text-xs text-link hover:opacity-90" target="_blank" rel="noreferrer">
{i18n.t("options.apiProviders.howToConfigure")}
</a>
</div>

View file

@ -3,7 +3,6 @@ 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"
@ -21,12 +20,6 @@ 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)
@ -130,13 +123,12 @@ export function GeneralSettings() {
min={MIN_BACKGROUND_OPACITY}
max={MAX_BACKGROUND_OPACITY}
step={5}
value={draftBackgroundOpacity}
onValueChange={value => setDraftBackgroundOpacity(value as number)}
onValueCommitted={value => handleContainerChange({ backgroundOpacity: value as number })}
value={container.backgroundOpacity}
onValueChange={value => handleContainerChange({ backgroundOpacity: value as number })}
className="flex-1"
/>
<span className="w-10 text-sm text-right">
{draftBackgroundOpacity}
{container.backgroundOpacity}
%
</span>
</div>

View file

@ -2,7 +2,6 @@ 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"
@ -27,18 +26,6 @@ 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 } }))
@ -84,13 +71,12 @@ export function SubtitlesTextStyleForm({ type }: SubtitlesTextStyleFormProps) {
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step={10}
value={draftFontScale}
onValueChange={value => setDraftFontScale(value as number)}
onValueCommitted={value => handleChange({ fontScale: value as number })}
value={textStyle.fontScale}
onValueChange={value => handleChange({ fontScale: value as number })}
className="flex-1"
/>
<span className="w-10 text-sm text-right">
{draftFontScale}
{textStyle.fontScale}
%
</span>
</div>
@ -105,12 +91,11 @@ export function SubtitlesTextStyleForm({ type }: SubtitlesTextStyleFormProps) {
min={MIN_FONT_WEIGHT}
max={MAX_FONT_WEIGHT}
step={100}
value={draftFontWeight}
onValueChange={value => setDraftFontWeight(value as number)}
onValueCommitted={value => handleChange({ fontWeight: value as number })}
value={textStyle.fontWeight}
onValueChange={value => handleChange({ fontWeight: value as number })}
className="flex-1"
/>
<span className="w-10 text-sm text-right">{draftFontWeight}</span>
<span className="w-10 text-sm text-right">{textStyle.fontWeight}</span>
</div>
</div>
</Field>

View file

@ -1,26 +1,20 @@
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 {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/base-ui/combobox"
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/base-ui/select"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { detectedCodeAtom } from "@/utils/atoms/detected-code"
@ -28,154 +22,82 @@ function langCodeLabel(langCode: LangCodeISO6393) {
return `${LANG_CODE_TO_EN_NAME[langCode]} (${LANG_CODE_TO_LOCALE_NAME[langCode]})`
}
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 langSelectorTriggerClasses = "!h-14 w-30 rounded-lg shadow-xs pr-2 gap-1"
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 = (item: LanguageItem | null) => {
if (!item || item.value === language.sourceCode)
const handleSourceLangChange = (newLangCode: LangCodeISO6393 | "auto" | null) => {
if (!newLangCode)
return
void setLanguage({ sourceCode: item.value })
void setLanguage({ sourceCode: newLangCode })
}
const handleTargetLangChange = (item: LanguageItem | null) => {
if (!item || item.value === "auto" || item.value === language.targetCode)
const handleTargetLangChange = (newLangCode: LangCodeISO6393 | null) => {
if (!newLangCode)
return
void setLanguage({ targetCode: item.value })
void setLanguage({ targetCode: newLangCode })
}
const sourceLangLabel
= language.sourceCode === "auto"
? `${currentSourceItem?.label ?? langCodeLabel(detectedCode)} (auto)`
: currentSourceItem?.label ?? langCodeLabel(language.sourceCode)
? `${langCodeLabel(detectedCode)} (auto)`
: langCodeLabel(language.sourceCode)
const targetLangLabel = currentTargetItem?.label ?? langCodeLabel(language.targetCode)
const targetLangLabel = langCodeLabel(language.targetCode)
return (
<div className="flex items-center justify-between">
<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>
<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>
<Icon icon="tabler:arrow-right" className="h-4 w-4 text-neutral-500" />
<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>
<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>
</div>
)
}

View file

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

View file

@ -33,6 +33,12 @@ 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
@ -51,6 +57,7 @@ async function mountSelectionUI(ctx: ContentScriptContext) {
name: `${kebabCase(APP_NAME)}-selection`,
position: "overlay",
anchor: "body",
css: selectionContentCss,
onMount: (container, shadow, shadowHost) => {
const wrapper = insertShadowRootUIWrapperInto(container)
shadowWrapper = wrapper

View file

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

View file

@ -1131,56 +1131,6 @@ 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>()
@ -1237,79 +1187,6 @@ describe("selection toolbar requests", () => {
})
})
it("switches a custom action provider from the footer without aborting the replacement request", async () => {
const firstRun = createDeferredPromise<BackgroundStructuredObjectStreamSnapshot>()
const secondRun = createDeferredPromise<BackgroundStructuredObjectStreamSnapshot>()
const signals: AbortSignal[] = []
streamBackgroundStructuredObjectMock
.mockImplementationOnce((_payload, options: { signal?: AbortSignal }) => {
signals.push(options.signal as AbortSignal)
options.signal?.addEventListener("abort", () => {
firstRun.reject(new DOMException("aborted", "AbortError"))
})
return firstRun.promise
})
.mockImplementationOnce((_payload, options: { signal?: AbortSignal }) => {
signals.push(options.signal as AbortSignal)
return secondRun.promise
})
const paragraph = document.createElement("p")
paragraph.textContent = "Selected text inside a paragraph."
document.body.appendChild(paragraph)
const store = createStore()
store.set(configAtom, cloneConfig(DEFAULT_CONFIG))
setSelectionState(store, { text: "Selected text", range: createRangeFor(paragraph) })
renderWithProviders(<SelectionToolbarCustomActionButtons />, store)
const action = DEFAULT_CONFIG.selectionToolbar.customActions[0]
if (!action) {
throw new Error("Default custom action is missing")
}
const nextProviderId = findAlternateLLMProviderId(store.get(configAtom), action.providerId)
if (!nextProviderId) {
throw new Error("No alternate LLM provider available for custom action provider switch test")
}
fireEvent.click(screen.getByRole("button", { name: action.name }))
await waitFor(() => {
expect(streamBackgroundStructuredObjectMock).toHaveBeenCalledTimes(1)
})
fireEvent.click(screen.getByRole("button", { name: "Change provider" }))
await waitFor(() => {
expect(streamBackgroundStructuredObjectMock).toHaveBeenCalledTimes(2)
})
expect(streamBackgroundStructuredObjectMock.mock.calls[1]?.[0]).toMatchObject({
providerId: nextProviderId,
})
expect(signals[0]?.aborted).toBe(true)
await act(async () => {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
expect(signals[1]?.aborted).toBe(false)
await act(async () => {
secondRun.resolve(createStructuredObjectSnapshot({ summary: "provider switched" }))
await Promise.resolve()
})
await waitFor(() => {
expect(screen.getByText("{\"summary\":\"provider switched\"}")).toBeInTheDocument()
})
expect(screen.queryByRole("alert")).toBeNull()
expect(store.get(configAtom).selectionToolbar.customActions[0]?.providerId).toBe(nextProviderId)
})
it("shows a precheck alert when a custom action has no selected text", async () => {
const paragraph = document.createElement("p")
paragraph.textContent = "Selected text inside a paragraph."

View file

@ -131,17 +131,6 @@ 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>

View file

@ -1,4 +1,3 @@
import type { JSONValue } from "ai"
import type { RefObject } from "react"
import type { SelectionToolbarCustomActionRequestSlice } from "../atoms"
import type { SelectionToolbarInlineError } from "../inline-error"
@ -46,23 +45,6 @@ interface ResolvedWebPageContext {
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) {
@ -71,27 +53,6 @@ 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,
@ -192,64 +153,6 @@ export function useCustomActionWebPageContext(open: boolean, popoverSessionKey:
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,
@ -270,19 +173,6 @@ 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)
@ -292,34 +182,53 @@ export function useCustomActionExecution({
}, [])
useEffect(() => {
if (!open || !executionRequestKey) {
if (!open || !executionContext) {
return
}
const request = executionRequestRef.current
if (!request || request.key !== executionRequestKey) {
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,
webContent: executionContext.promptTokens.webContent,
webTitle: executionContext.promptTokens.webTitle,
})
if (lastRunKeyRef.current === nextRunKey) {
return
}
if (lastRunKeyRef.current === executionRequestKey) {
return
}
lastRunKeyRef.current = executionRequestKey
lastRunKeyRef.current = nextRunKey
let isCancelled = false
const abortController = new AbortController()
const { action, providerConfig, promptTokens } = executionContext
const analyticsContext = createFeatureUsageContext(
ANALYTICS_FEATURE.CUSTOM_AI_ACTION,
request.analytics.surface,
analyticsSurface,
Date.now(),
{
action_id: request.analytics.actionId,
action_name: request.analytics.actionName,
action_id: action.id,
action_name: action.name,
},
)
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)
@ -330,7 +239,14 @@ export function useCustomActionExecution({
try {
const finalResult = await streamBackgroundStructuredObject(
request.payload,
{
providerId: providerConfig.id,
system: systemPrompt,
prompt,
outputSchema: action.outputSchema.map(({ name, type }) => ({ name, type })),
providerOptions,
temperature: providerConfig.temperature,
},
{
signal: abortController.signal,
onChunk: (partial: BackgroundStructuredObjectStreamSnapshot) => {
@ -340,7 +256,7 @@ export function useCustomActionExecution({
setResult(partial.output)
setThinking(partial.thinking)
scrollSelectionPopoverBodyToBottom(bodyRefRef.current)
scrollSelectionPopoverBodyToBottom(bodyRef)
},
},
)
@ -385,7 +301,7 @@ export function useCustomActionExecution({
isCancelled = true
abortController.abort()
}
}, [executionRequestKey, open])
}, [analyticsSurface, bodyRef, executionContext, open, popoverSessionKey, rerunNonce])
useEffect(() => {
if (!open) {

View file

@ -444,22 +444,16 @@ export function SelectionToolbar() {
ref={tooltipRef}
inert={!isSelectionToolbarVisible}
className={cn(
`group absolute ${SELECTION_CONTENT_OVERLAY_LAYERS.selectionOverlay} overflow-visible transition-opacity`,
`group absolute ${SELECTION_CONTENT_OVERLAY_LAYERS.selectionOverlay} bg-popover rounded-sm shadow-floating border border-border/50 overflow-visible flex items-center transition-opacity`,
isSelectionToolbarVisible ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0",
)}
>
<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 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>
)}
</div>

View file

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

View file

@ -1,14 +1,9 @@
// @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 { fireEvent, render, screen } from "@testing-library/react"
import { atom } from "jotai"
import { beforeAll, describe, expect, it, vi } from "vitest"
import FloatingButton from ".."
const toastInfoMock = vi.fn()
vi.mock("#imports", () => ({
browser: {
runtime: {
@ -20,32 +15,17 @@ vi.mock("#imports", () => ({
},
}))
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("@/utils/atoms/config", () => ({
configFieldsAtomMap: {
floatingButton: atom({
enabled: true,
position: 0.66,
clickAction: "panel",
disabledFloatingButtonPatterns: [],
}),
sideContent: atom({ width: 360 }),
},
}))
vi.mock("../../../atoms", () => ({
enablePageTranslationAtom: atom({ enabled: false }),
@ -57,14 +37,20 @@ vi.mock("../../../index", () => ({
shadowWrapper: document.body,
}))
vi.mock("@/utils/message", () => ({
sendMessage: vi.fn(),
vi.mock("../translate-button", () => ({
default: ({ className }: { className?: string }) => (
<div data-testid="translate-button" className={className} />
),
}))
vi.mock("sonner", () => ({
toast: {
info: (...args: unknown[]) => toastInfoMock(...args),
},
vi.mock("../components/hidden-button", () => ({
default: ({ className, onClick }: { className?: string, onClick: () => void }) => (
<button type="button" data-testid="hidden-button" className={className} onClick={onClick} />
),
}))
vi.mock("@/utils/message", () => ({
sendMessage: vi.fn(),
}))
beforeAll(() => {
@ -77,315 +63,25 @@ beforeAll(() => {
vi.stubGlobal("ResizeObserver", ResizeObserverMock)
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
vi.mocked(sendMessage).mockReset()
toastInfoMock.mockReset()
setViewport(1024, 768)
})
describe("floatingButton close trigger", () => {
it("keeps the close trigger in the layout with visibility classes instead of display:none", () => {
render(<FloatingButton />)
function setViewport(width: number, height: number) {
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: width,
})
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: height,
})
}
const closeTrigger = screen.getByTitle("Close floating button")
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")
expect(closeTrigger).toHaveClass("group-hover:visible")
expect(closeTrigger).not.toHaveClass("hidden")
expect(closeTrigger).not.toHaveClass("group-hover:block")
})
it("forces the close trigger visible while the dropdown is open", () => {
renderFloatingButton()
render(<FloatingButton />)
const closeTrigger = screen.getByRole("button", { name: "Close floating button" })
const mainButton = getMainButton()
fireEvent.mouseEnter(mainButton)
const closeTrigger = screen.getByTitle("Close floating button")
fireEvent.click(closeTrigger)
expect(closeTrigger).toHaveClass("visible")
expect(closeTrigger).toHaveClass("pointer-events-auto")
expect(screen.getByText("options.floatingButtonAndToolbar.floatingButton.closeMenu.disableForSite")).toBeInTheDocument()
})
it("toggles the browser side panel on a normal panel click", () => {
vi.useFakeTimers()
renderFloatingButton({ clickAction: "panel" })
const mainButton = getMainButton()
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
vi.advanceTimersByTime(349)
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
expect(sendMessage).toHaveBeenCalledWith("toggleSidePanel", undefined)
})
it("shows a Firefox sidebar help link when the browser requires an extension user action", async () => {
vi.useFakeTimers()
vi.mocked(sendMessage).mockResolvedValue({
ok: false,
reason: "requires-extension-user-action",
})
renderFloatingButton({ clickAction: "panel" })
const mainButton = getMainButton()
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
vi.advanceTimersByTime(349)
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
await act(async () => {
await Promise.resolve()
})
const toastContent = toastInfoMock.mock.calls[0]?.[0]
expect(toastContent).toBeDefined()
render(<>{toastContent}</>)
expect(screen.getByText("sidePanel.firefoxUserActionHint")).toBeInTheDocument()
const link = screen.getByRole("link", { name: "sidePanel.firefoxUserActionHelpText" })
expect(link).toHaveAttribute(
"href",
"sidePanel.firefoxUserActionHelpUrl",
)
expect(link).toHaveAttribute("target", "_blank")
expect(link).toHaveAttribute("rel", "noopener noreferrer")
})
it("keeps translate as a normal click action", () => {
vi.useFakeTimers()
renderFloatingButton({ clickAction: "translate" })
const mainButton = getMainButton()
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
vi.advanceTimersByTime(349)
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
expect(sendMessage).toHaveBeenCalledWith(
"tryToSetEnablePageTranslationOnContentScript",
expect.objectContaining({ enabled: true }),
)
})
it("turns the frog into the only visible control after a long press", () => {
vi.useFakeTimers()
renderFloatingButton()
const mainButton = getMainButton()
expect(screen.getAllByRole("button")).toHaveLength(4)
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
act(() => {
vi.advanceTimersByTime(350)
})
expect(mainButton).toHaveClass("rounded-full")
expect(screen.queryAllByRole("button")).toHaveLength(0)
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
expect(sendMessage).not.toHaveBeenCalled()
})
it("starts dragging before the long-press delay after enough pointer movement", () => {
vi.useFakeTimers()
renderFloatingButton()
const mainButton = getMainButton()
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
fireEvent.pointerMove(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 908, clientY: 500 })
expect(mainButton).toHaveClass("rounded-full")
expect(screen.queryAllByRole("button")).toHaveLength(0)
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 908, clientY: 500 })
expect(sendMessage).not.toHaveBeenCalled()
})
it("persists the left side and vertical position after dragging to the left half", () => {
vi.useFakeTimers()
setViewport(1000, 1000)
const { store } = renderFloatingButton({ position: 0.6, side: "right" })
const mainButton = getMainButton()
const floatingButtonContainer = screen.getByTestId("floating-button-container")
mockRect(floatingButtonContainer, { left: 956, top: 600, width: 44, height: 120 })
mockRect(mainButton, { left: 956, top: 640, width: 44, height: 40 })
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 978, clientY: 660 })
act(() => {
vi.advanceTimersByTime(350)
})
fireEvent.pointerMove(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 120, clientY: 520 })
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 120, clientY: 520 })
expect(getFloatingButtonConfig(store).side).toBe("left")
expect(getFloatingButtonConfig(store).position).toBeCloseTo(0.46)
})
it("persists the right side and vertical position after dragging to the right half", () => {
vi.useFakeTimers()
setViewport(1000, 1000)
const { store } = renderFloatingButton({ position: 0.6, side: "left" })
const mainButton = getMainButton()
const floatingButtonContainer = screen.getByTestId("floating-button-container")
mockRect(floatingButtonContainer, { left: 0, top: 600, width: 44, height: 120 })
mockRect(mainButton, { left: 0, top: 640, width: 44, height: 40 })
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 22, clientY: 660 })
act(() => {
vi.advanceTimersByTime(350)
})
fireEvent.pointerMove(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 520 })
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 520 })
expect(getFloatingButtonConfig(store).side).toBe("right")
expect(getFloatingButtonConfig(store).position).toBeCloseTo(0.46)
})
it("mirrors the controls when attached to the left edge", () => {
renderFloatingButton({ side: "left" })
const closeTrigger = screen.getByRole("button", { name: "Close floating button" })
const lockTrigger = screen.getByRole("button", { name: "Lock floating button" })
const mainButton = getMainButton()
const hiddenButtons = screen.getAllByRole("button").filter(button => (
button !== closeTrigger && button !== lockTrigger
))
expect(mainButton).toHaveClass("rounded-r-full")
expect(mainButton).toHaveClass("-translate-x-6")
expect(closeTrigger).toHaveClass("right-0")
expect(lockTrigger).toHaveClass("right-0")
for (const hiddenButton of hiddenButtons) {
expect(hiddenButton).toHaveClass("-translate-x-12")
}
fireEvent.mouseEnter(mainButton)
expect(mainButton).toHaveClass("translate-x-0")
expect(closeTrigger).toHaveClass("-right-6")
expect(lockTrigger).toHaveClass("-right-6")
for (const hiddenButton of hiddenButtons) {
expect(hiddenButton).toHaveClass("translate-x-0")
}
})
})

View file

@ -1,4 +1,3 @@
import type { FloatingButtonSide } from "@/types/config/floating-button"
import { cn } from "@/utils/styles/utils"
export default function HiddenButton({
@ -6,25 +5,17 @@ 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 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",
"border-border mr-2 translate-x-12 cursor-pointer rounded-full border bg-white shadow-lg 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",
className,
)}
onClick={onClick}

View file

@ -1,9 +1,7 @@
import type { FloatingButtonSide } from "@/types/config/floating-button"
import { browser, i18n } from "#imports"
import { IconLock, IconLockOpen, IconSettings, IconX } from "@tabler/icons-react"
import { 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,
@ -18,513 +16,194 @@ 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 } from "../../atoms"
import { enablePageTranslationAtom, isDraggingButtonAtom, isSideOpenAtom } 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 [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
const [dragPosition, setDragPosition] = useState<number | null>(null)
const initialClientYRef = useRef<number | null>(null)
// 按钮拖动处理
useEffect(() => {
if (!isDraggingButton)
const initialClientY = initialClientYRef.current
if (!isDraggingButton || !initialClientY || !floatingButton)
return
const previousUserSelect = document.body.style.userSelect
const previousCursor = document.body.style.cursor
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)
document.body.style.userSelect = "none"
document.body.style.cursor = "grabbing"
return () => {
document.body.style.userSelect = previousUserSelect
document.body.style.cursor = previousCursor
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
document.body.style.userSelect = ""
}
// eslint-disable-next-line react/exhaustive-deps
}, [isDraggingButton])
// 拖拽结束时写入 storage
useEffect(() => {
return () => {
const pendingDrag = pendingDragRef.current
if (pendingDrag) {
window.clearTimeout(pendingDrag.longPressTimerId)
}
if (!isDraggingButton && dragPosition !== null) {
void setFloatingButton({ position: dragPosition })
// eslint-disable-next-line react/set-state-in-effect
setDragPosition(null)
}
}, [])
}, [isDraggingButton, dragPosition, setFloatingButton])
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
}
void Promise.resolve(sendMessage("toggleSidePanel", undefined)).then((result) => {
if (result?.ok === false && result.reason === "requires-extension-user-action") {
toast.info(<FirefoxSidebarHelpToast />)
}
})
}
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
const handleButtonDragStart = (e: React.MouseEvent) => {
// 记录初始位置,用于后续判断是点击还是拖动
initialClientYRef.current = e.clientY
let hasMoved = false // 标记是否发生了移动
e.preventDefault()
if (typeof e.currentTarget.setPointerCapture === "function") {
e.currentTarget.setPointerCapture(e.pointerId)
}
setIsDraggingButton(true)
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()
// 创建一个监听器检测移动
const handleMouseMove = (moveEvent: MouseEvent) => {
const moveDistance = Math.abs(moveEvent.clientY - e.clientY)
// 如果移动距离大于阈值,标记为已移动
if (moveDistance > 5) {
hasMoved = true
}
}
if (pendingDrag.hasActiveDrag) {
const nextPreviewPosition = getDragPreviewPosition(pendingDrag)
lastDragPreviewRef.current = nextPreviewPosition
setDragPreviewPosition(nextPreviewPosition)
}
}
// 在鼠标释放时,只有未移动才触发点击事件
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
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 (!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)
}
}
}
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()
}
document.addEventListener("mouseup", handleMouseUp)
document.addEventListener("mousemove", handleMouseMove)
}
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)
}
}
const attachSideClassName = isDraggingButton || isSideOpen || isDropdownOpen ? "translate-x-0" : ""
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
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}
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`,
}}
>
{!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}
/>
</>
<TranslateButton className={attachSideClassName} />
<div
className={cn(
"border-border flex h-10 w-11 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-6 transition-transform duration-300 group-hover:translate-x-0",
(isSideOpen || isDropdownOpen) && "opacity-100",
isDraggingButton ? "cursor-move" : "cursor-pointer",
attachSideClassName,
)}
</div>
{!isDraggingButton && (
<HiddenButton
side={floatingButtonSide}
expanded={isFloatingButtonExpanded}
icon={<IconSettings className="h-5 w-5" />}
onClick={() => {
void sendMessage("openOptionsPage", undefined)
}}
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 invisible cursor-pointer rounded-full border bg-neutral-100 dark:bg-neutral-900",
"group-hover:visible",
isDropdownOpen && "visible",
)}
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-1 h-8 w-8 rounded-full"
/>
)}
</div>
<HiddenButton
className={attachSideClassName}
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>
)
}

View file

@ -1,4 +1,3 @@
import type { FloatingButtonSide } from "@/types/config/floating-button"
import { RiTranslate } from "@remixicon/react"
import { IconCheck } from "@tabler/icons-react"
import { useAtomValue } from "jotai"
@ -7,15 +6,7 @@ import { cn } from "@/utils/styles/utils"
import { enablePageTranslationAtom } from "../../atoms"
import HiddenButton from "./components/hidden-button"
export default function TranslateButton({
className,
side = "right",
expanded = false,
}: {
className?: string
side?: FloatingButtonSide
expanded?: boolean
}) {
export default function TranslateButton({ className }: { className: string }) {
const translationState = useAtomValue(enablePageTranslationAtom)
const isEnabled = translationState.enabled
@ -23,8 +14,6 @@ export default function TranslateButton({
<HiddenButton
icon={<RiTranslate className="h-5 w-5" />}
className={className}
side={side}
expanded={expanded}
onClick={() => {
void sendMessage("tryToSetEnablePageTranslationOnContentScript", { enabled: !isEnabled })
}}

View file

@ -0,0 +1,116 @@
import { kebabCase } from "case-anything"
import { useAtom, useAtomValue } from "jotai"
import { useEffect, useState } from "react"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { APP_NAME } from "@/utils/constants/app"
import { MIN_SIDE_CONTENT_WIDTH } from "@/utils/constants/side"
import { cn } from "@/utils/styles/utils"
import { isSideOpenAtom } from "../../atoms"
export default function SideContent() {
const isSideOpen = useAtomValue(isSideOpenAtom)
const [sideContent, setSideContent] = useAtom(configFieldsAtomMap.sideContent)
const [isResizing, setIsResizing] = useState(false)
// const providersConfig = useAtomValue(configFieldsAtomMap.providersConfig)
// Setup resize handlers
useEffect(() => {
if (!isResizing)
return
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing)
return
const windowWidth = window.innerWidth
const newWidth = windowWidth - e.clientX
const clampedWidth = Math.max(MIN_SIDE_CONTENT_WIDTH, newWidth)
void setSideContent({ width: clampedWidth })
}
const handleMouseUp = () => {
setIsResizing(false)
}
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
document.body.style.userSelect = "none"
return () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
document.body.style.userSelect = ""
}
}, [isResizing, setSideContent])
// HTML width adjustment
useEffect(() => {
const styleId = `shrink-origin-for-${kebabCase(APP_NAME)}-side-content`
let styleTag = document.getElementById(styleId)
if (isSideOpen) {
if (!styleTag) {
styleTag = document.createElement("style")
styleTag.id = styleId
document.head.appendChild(styleTag)
}
styleTag.textContent = `
html {
width: calc(100% - ${sideContent.width}px) !important;
position: relative !important;
min-height: 100vh !important;
}
`
}
else {
if (styleTag) {
document.head.removeChild(styleTag)
}
}
return () => {
if (styleTag && document.head.contains(styleTag)) {
document.head.removeChild(styleTag)
}
}
}, [isSideOpen, sideContent.width])
const handleResizeStart = (e: React.MouseEvent) => {
e.preventDefault()
setIsResizing(true)
}
return (
<>
<div
className={cn(
"bg-background fixed top-0 right-0 z-[2147483647] h-full pr-[var(--removed-body-scroll-bar-size,0px)]",
isSideOpen
? "border-border translate-x-0 border-l"
: "translate-x-full",
)}
style={{
width: `calc(${sideContent.width}px + var(--removed-body-scroll-bar-size, 0px))`,
}}
>
{/* Resize handle */}
<div
className="absolute top-0 left-0 z-10 h-full w-2 cursor-ew-resize justify-center bg-transparent"
onMouseDown={handleResizeStart}
>
</div>
<div className="flex h-full flex-col gap-y-2 py-3 items-center justify-center">
The function is being upgraded
</div>
</div>
{/* Transparent overlay to prevent other events during resizing */}
{isResizing && (
<div className="fixed inset-0 z-[2147483647] cursor-ew-resize bg-transparent" />
)}
</>
)
}

View file

@ -0,0 +1,190 @@
const TRAILING_PUNCTUATION_RE = /[.!?,:;'"…)}\]]$/
const WHITESPACE_RUN_RE = /\s+/g
/**
* "块级叶子"
* @param {Node} root -
* @returns {string[]}
*/
export function flattenToParagraphs(root: Node) {
// —— 1. 定义哪些标签(或 computedStyle算"块级"
const semanticBlocks = new Set([
"p",
"article",
"section",
"figure",
"figcaption",
"blockquote",
"pre",
"ul",
"ol",
"li",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"div",
"header",
"footer",
"main",
"nav",
])
function isBlockLevel(node: Node): boolean {
// 只有元素节点才能是块级
if (node.nodeType !== Node.ELEMENT_NODE)
return false
const el = node as Element
// 如果标签名在列表里,或者 computedStyle.display=block
if (semanticBlocks.has(el.tagName.toLowerCase()))
return true
const disp = window.getComputedStyle(el).display
return disp === "block" || disp === "list-item"
}
function hasBlockDescendant(node: Node): boolean {
// 非元素节点不会有块级后代
if (node.nodeType !== Node.ELEMENT_NODE)
return false
const el = node as Element
// 检查子孙是否存在任一块级元素
for (let i = 0; i < el.children.length; i++) {
const child = el.children[i]
if (isBlockLevel(child) || hasBlockDescendant(child)) {
return true
}
}
return false
}
const paragraphs: string[] = []
// 获取元素的文本内容,同时考虑内联元素之间的空格
function getTextWithSpaces(element: Element): string {
let text = ""
// 为每个子节点递归处理
for (const child of element.childNodes) {
let childText = ""
if (child.nodeType === Node.TEXT_NODE) {
childText = child.textContent || ""
}
else if (child.nodeType === Node.ELEMENT_NODE) {
childText = getTextWithSpaces(child as Element)
}
if (
text.length > 0
&& !text.endsWith(" ")
&& !TRAILING_PUNCTUATION_RE.test(childText)
) {
text += " "
}
text += childText
// if (child.nodeType === Node.TEXT_NODE) {
// // 文本节点直接添加
// text += child.textContent || "";
// } else if (child.nodeType === Node.ELEMENT_NODE) {
// const childEl = child as Element;
// // 防止在已有空格的地方添加额外空格
// if (text.length > 0 && !text.endsWith(" ")) {
// text += " ";
// }
// text += getTextWithSpaces(childEl);
// // 在内联元素后添加空格,如果不是已以空格结尾且不是标点符号结尾
// if (!text.endsWith(" ") && !/[.!?,:;'"…)}\]]$/.test(text)) {
// text += " ";
// }
// }
}
return text
}
function walk(node: Node) {
// 跳过注释节点、处理指令等非内容节点
if (
node.nodeType !== Node.ELEMENT_NODE
&& node.nodeType !== Node.TEXT_NODE
) {
return
}
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element
// 如果它是一个"块级叶子",就提取成段落;否则下降
if (isBlockLevel(element) && !hasBlockDescendant(element)) {
// 使用新的方法获取文本,保留内联元素之间的空格
const raw = getTextWithSpaces(element).replace(WHITESPACE_RUN_RE, " ").trim()
if (raw?.length && raw.length > 20) {
// 可根据需求调整最小长度过滤
paragraphs.push(raw)
}
}
else {
// 继续遍历子节点
for (const child of element.childNodes) {
walk(child)
}
}
}
// 如果是文本节点,且其父容器也不是"块级叶子"时,可以视作一个独立段落
else if (node.nodeType === Node.TEXT_NODE) {
const txt = node.textContent?.replace(WHITESPACE_RUN_RE, " ").trim()
if (txt?.length && txt.length > 20) {
paragraphs.push(txt)
}
}
}
// 从 root 开始遍历
walk(root)
// 返回段落数组
return paragraphs
}
export function extractSeoInfo(doc: Document) {
const seoInfo = {
title: doc.title || "",
metaDescription:
doc.querySelector("meta[name=\"description\"]")?.getAttribute("content")
|| "",
metaKeywords:
doc.querySelector("meta[name=\"keywords\"]")?.getAttribute("content") || "",
canonicalUrl:
doc.querySelector("link[rel=\"canonical\"]")?.getAttribute("href") || "",
ogTitle:
doc.querySelector("meta[property=\"og:title\"]")?.getAttribute("content")
|| "",
ogDescription:
doc
.querySelector("meta[property=\"og:description\"]")
?.getAttribute("content") || "",
ogImage:
doc.querySelector("meta[property=\"og:image\"]")?.getAttribute("content")
|| "",
twitterCard:
doc.querySelector("meta[name=\"twitter:card\"]")?.getAttribute("content")
|| "",
twitterTitle:
doc
.querySelector("meta[name=\"twitter:title\"]")
?.getAttribute("content") || "",
h1Tags: Array.from(doc.querySelectorAll("h1"), h => h.textContent?.trim() || ""),
structuredData: Array.from(doc.querySelectorAll("script[type=\"application/ld+json\"]"), (script) => {
try {
return JSON.parse(script.textContent || "{}")
}
catch (e) {
console.error("Error parsing structured data:", e)
return {}
}
}),
}
return seoInfo
}

View file

@ -0,0 +1,75 @@
import type { ArticleExplanation, ArticleWord } from "@/types/content"
import type { DOWNLOAD_FILE_ITEMS } from "@/utils/constants/side"
import { saveAs } from "file-saver"
import { toast } from "sonner"
import { AST_TEMPLATE, MARKDOWN_TEMPLATE_TOKEN, PARAGRAPH_DEPTH, SENTENCE_TEMPLATE, WORDS_TEMPLATE } from "@/utils/constants/side"
export type DOWNLOAD_FILE_TYPES = keyof typeof DOWNLOAD_FILE_ITEMS
type ExplanationDataList = Array<ArticleExplanation["paragraphs"]>
type DOWNLOADER_MAP = Record<DOWNLOAD_FILE_TYPES, (explainDataList: ExplanationDataList, opts?: object) => void>
class Downloader {
title = document.title ?? "Untitled"
downloader: DOWNLOADER_MAP = {
md: this.downloadMarkdown,
}
download(explainDataList: ExplanationDataList, fileType: DOWNLOAD_FILE_TYPES, opts?: object) {
this.downloader[fileType].call(this, explainDataList, opts)
}
downloadMarkdown(explainDataList: ExplanationDataList) {
try {
const article = this.markdownParser(explainDataList)
const blob = new Blob([article], {
type: "text/plain",
})
saveAs(blob, `${this.title}.md`)
}
catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
else {
toast.error("Something went wrong when exporting...")
}
}
}
markdownParser(explainDataList: ExplanationDataList = []) {
const sentence = this.parseSentence(explainDataList)
return AST_TEMPLATE
.replace(MARKDOWN_TEMPLATE_TOKEN.title, this.title)
.replace(MARKDOWN_TEMPLATE_TOKEN.sentence, sentence)
}
parseSentence(explainDataList: ExplanationDataList = []) {
const list = explainDataList.flat(PARAGRAPH_DEPTH)
return list.reduce((sentence, paragraph, pIndex) => {
const words = paragraph.words ?? []
return sentence + SENTENCE_TEMPLATE
.replace(MARKDOWN_TEMPLATE_TOKEN.originalSentence, paragraph.originalSentence)
.replace(MARKDOWN_TEMPLATE_TOKEN.translatedSentence, paragraph.translatedSentence)
.replace(MARKDOWN_TEMPLATE_TOKEN.words, this.parseWords(words))
.replace(MARKDOWN_TEMPLATE_TOKEN.explanation, paragraph.explanation)
.replace(MARKDOWN_TEMPLATE_TOKEN.globalIndex, (pIndex + 1).toString())
}, "")
}
parseWords(words: ArticleWord[]) {
return words.reduce((text, word, wIndex) => {
return text + WORDS_TEMPLATE
.replace(MARKDOWN_TEMPLATE_TOKEN.wIndex, (wIndex + 1).toString())
.replace(MARKDOWN_TEMPLATE_TOKEN.word, word.word)
.replace(MARKDOWN_TEMPLATE_TOKEN.syntacticCategory, word.syntacticCategory)
.replace(MARKDOWN_TEMPLATE_TOKEN.explanation, word.explanation)
}, "")
}
}
export default new Downloader()

View file

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Side Panel | Read Frog</title>
<meta name="manifest.open_at_install" content="false" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View file

@ -1,66 +0,0 @@
import "@/utils/zod-config"
import type { ThemeMode } from "@/types/config/theme"
import { Provider as JotaiProvider } from "jotai"
import { useHydrateAtoms } from "jotai/utils"
import readFrogLogo from "@/assets/icons/read-frog.png?url&no-inline"
import { ThemeProvider } from "@/components/providers/theme-provider"
import { baseThemeModeAtom } from "@/utils/atoms/theme"
import { APP_NAME } from "@/utils/constants/app"
import { renderPersistentReactRoot } from "@/utils/react-root"
import { getLocalThemeMode } from "@/utils/theme"
import "@/assets/styles/text-small.css"
import "@/assets/styles/theme.css"
function HydrateAtoms({
initialValues,
children,
}: {
initialValues: [
[typeof baseThemeModeAtom, ThemeMode],
]
children: React.ReactNode
}) {
useHydrateAtoms(initialValues)
return children
}
function SidePanelShell() {
return (
<main className="bg-background text-foreground flex min-h-screen flex-col px-5 py-6">
<section className="flex flex-1 flex-col items-center justify-center gap-4 text-center">
<img
src={readFrogLogo}
alt={APP_NAME}
className="size-16 rounded-full"
/>
<div className="space-y-2">
<h1 className="text-xl font-semibold tracking-tight">
{APP_NAME}
</h1>
<p className="text-muted-foreground text-sm">
Side Panel is coming soon.
</p>
</div>
</section>
</main>
)
}
async function initApp() {
const root = document.getElementById("root")!
root.className = "min-h-screen bg-background text-base antialiased"
const themeMode = await getLocalThemeMode()
renderPersistentReactRoot(root, (
<JotaiProvider>
<HydrateAtoms initialValues={[[baseThemeModeAtom, themeMode]]}>
<ThemeProvider>
<SidePanelShell />
</ThemeProvider>
</HydrateAtoms>
</JotaiProvider>
))
}
void initApp()

View file

@ -1,10 +1,8 @@
import type { ViewId } from "./ui/subtitles-settings-panel/views"
import type { StateData, SubtitlesFragment, SubtitlesState } from "@/utils/subtitles/types"
import { atom, createStore } from "jotai"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { DEFAULT_SUBTITLE_POSITION } from "@/utils/constants/subtitles"
import { hasRenderableSubtitleByMode, isAwaitingTranslation } from "@/utils/subtitles/display-rules"
import { ROOT_VIEW } from "./ui/subtitles-settings-panel/views"
export const subtitlesStore = createStore()
@ -18,8 +16,6 @@ export const subtitlesVisibleAtom = atom<boolean>(false)
export const subtitlesSettingsPanelOpenAtom = atom<boolean>(false)
export const subtitlesSettingsPanelViewAtom = atom<ViewId>(ROOT_VIEW)
export interface SubtitlePosition {
percent: number
anchor: "top" | "bottom"

View file

@ -9,8 +9,7 @@ declare global {
}
export default defineContentScript({
matches: ["*://*.youtube.com/*", "*://*.youtube-nocookie.com/*"],
allFrames: true,
matches: ["*://*.youtube.com/*"],
cssInjectionMode: "manifest",
async main(ctx) {
if (window.__READ_FROG_SUBTITLES_INJECTED__)

View file

@ -1,33 +1,22 @@
import { YOUTUBE_EMBED_PATH_PATTERN, YOUTUBE_NAVIGATE_FINISH_EVENT, YOUTUBE_WATCH_URL_PATTERN } from "@/utils/constants/subtitles"
import { YOUTUBE_NAVIGATE_FINISH_EVENT, YOUTUBE_WATCH_URL_PATTERN } from "@/utils/constants/subtitles"
import { setupYoutubeSubtitles } from "./platforms/youtube"
import { getYoutubeConfig } from "./platforms/youtube/config"
import { youtubeConfig } from "./platforms/youtube/config"
import { mountSubtitlesUI } from "./renderer/mount-subtitles-ui"
function isYoutubeWatch(): boolean {
return window.location.href.includes(YOUTUBE_WATCH_URL_PATTERN)
}
function isYoutubeEmbed(): boolean {
return YOUTUBE_EMBED_PATH_PATTERN.test(window.location.pathname)
}
export function initYoutubeSubtitles() {
let initialized = false
let mountedAdapter: ReturnType<typeof setupYoutubeSubtitles> | null = null
const embedded = isYoutubeEmbed()
const config = getYoutubeConfig({ embedded })
const tryInit = async () => {
if (!isYoutubeWatch() && !embedded) {
if (!window.location.href.includes(YOUTUBE_WATCH_URL_PATTERN)) {
return
}
if (!mountedAdapter) {
mountedAdapter = setupYoutubeSubtitles(config)
mountedAdapter = setupYoutubeSubtitles()
}
await mountSubtitlesUI({ adapter: mountedAdapter, config })
await mountSubtitlesUI({ adapter: mountedAdapter, config: youtubeConfig })
if (initialized) {
return
@ -39,7 +28,5 @@ export function initYoutubeSubtitles() {
void tryInit()
if (!embedded) {
window.addEventListener(YOUTUBE_NAVIGATE_FINISH_EVENT, () => void tryInit())
}
window.addEventListener(YOUTUBE_NAVIGATE_FINISH_EVENT, () => void tryInit())
}

View file

@ -1,12 +1,9 @@
export interface ControlsConfig {
findVideoContainer?: () => HTMLElement | null
measureHeight: (container: HTMLElement) => number
checkVisibility: (container: HTMLElement) => boolean
}
export interface PlatformConfig {
embedded?: boolean
selectors: {
video: string
playerContainer: string
@ -14,10 +11,10 @@ export interface PlatformConfig {
nativeSubtitles: string
}
events: {
navigateStart?: string
navigateFinish?: string
}
events: {
navigateStart?: string
navigateFinish?: string
}
controls?: ControlsConfig

View file

@ -6,57 +6,32 @@ import {
YOUTUBE_NAVIGATE_START_EVENT,
} from "@/utils/constants/subtitles"
import { getYoutubeVideoId } from "@/utils/subtitles/video-id"
export const youtubeConfig: PlatformConfig = {
selectors: {
video: "video.html5-main-video",
playerContainer: "#movie_player.html5-video-player",
controlsBar: "#movie_player .ytp-right-controls",
nativeSubtitles: YOUTUBE_NATIVE_SUBTITLES_CLASS,
},
interface YoutubeConfigOptions {
embedded?: boolean
}
export function getYoutubeConfig(options: YoutubeConfigOptions = {}): PlatformConfig {
const { embedded } = options
return {
embedded,
selectors: {
video: "video.html5-main-video",
playerContainer: "#movie_player.html5-video-player",
controlsBar: embedded ? ".quick-actions-wrapper" : "#movie_player .ytp-right-controls",
nativeSubtitles: YOUTUBE_NATIVE_SUBTITLES_CLASS,
},
events: embedded
? {}
: {
navigateStart: YOUTUBE_NAVIGATE_START_EVENT,
navigateFinish: YOUTUBE_NAVIGATE_FINISH_EVENT,
},
controls: embedded
? {
findVideoContainer: () => document.querySelector<HTMLElement>("#movie_player"),
measureHeight: () => {
const wrapper = document.querySelector(".quick-actions-wrapper")
const player = document.querySelector("#movie_player")
const progressBar = player?.querySelector(".ytp-progress-bar-container")
if (!wrapper || !progressBar)
return DEFAULT_CONTROLS_HEIGHT
return wrapper.getBoundingClientRect().top - progressBar.getBoundingClientRect().top
},
checkVisibility: () => true,
}
: {
measureHeight: (container) => {
const player = container.closest(".html5-video-player")
const progressBar = player?.querySelector(".ytp-progress-bar-container")
const controlsBar = progressBar?.parentElement
return controlsBar?.getBoundingClientRect().height ?? DEFAULT_CONTROLS_HEIGHT
},
checkVisibility: (container) => {
const player = container.closest(".html5-video-player")
return !!player && !player.classList.contains("ytp-autohide")
},
},
getVideoId: getYoutubeVideoId,
}
}
events: {
navigateStart: YOUTUBE_NAVIGATE_START_EVENT,
navigateFinish: YOUTUBE_NAVIGATE_FINISH_EVENT,
},
controls: {
measureHeight: (container) => {
const player = container.closest(".html5-video-player")
const progressBar = player?.querySelector(".ytp-progress-bar-container")
const controlsBar = progressBar?.parentElement
return controlsBar?.getBoundingClientRect().height ?? DEFAULT_CONTROLS_HEIGHT
},
checkVisibility: (container) => {
const player = container.closest(".html5-video-player")
return !!player && !player.classList.contains("ytp-autohide")
},
},
getVideoId: getYoutubeVideoId,
}

View file

@ -1,12 +1,12 @@
import type { PlatformConfig } from "@/entrypoints/subtitles.content/platforms"
import { YoutubeSubtitlesFetcher } from "@/utils/subtitles/fetchers"
import { UniversalVideoAdapter } from "../../universal-adapter"
import { youtubeConfig } from "./config"
export function setupYoutubeSubtitles(config: PlatformConfig) {
export function setupYoutubeSubtitles() {
const subtitlesFetcher = new YoutubeSubtitlesFetcher()
return new UniversalVideoAdapter({
config,
config: youtubeConfig,
subtitlesFetcher,
})
}

View file

@ -1,5 +1,6 @@
import type { SubtitlesProvidersAdapter } from "../ui/subtitles-ui-context"
import type { UniversalVideoAdapter } from "../universal-adapter"
import type { PlatformConfig } from "@/entrypoints/subtitles.content/platforms"
import { Provider as JotaiProvider } from "jotai"
import ReactDOM from "react-dom/client"
import { Toaster } from "sonner"
import themeCSS from "@/assets/styles/theme.css?inline"
@ -8,13 +9,19 @@ import { REACT_SHADOW_HOST_CLASS } from "@/utils/constants/dom-labels"
import { waitForElement } from "@/utils/dom/wait-for-element"
import { ShadowWrapperContext } from "@/utils/react-shadow-host/create-shadow-host"
import { ShadowHostBuilder } from "@/utils/react-shadow-host/shadow-host-builder"
import { subtitlesStore } from "../atoms"
import { SubtitlesContainer } from "../ui/subtitles-container"
import { SubtitlesProviders } from "../ui/subtitles-ui-context"
import { SubtitlesUIContext } from "../ui/subtitles-ui-context"
const SUBTITLES_UI_HOST_ID = "read-frog-subtitles-ui-host"
type MountSubtitlesUIAdapter = Pick<
UniversalVideoAdapter,
"downloadSourceSubtitles" | "getControlsConfig" | "toggleSubtitlesManually"
>
interface MountSubtitlesUIOptions {
adapter: SubtitlesProvidersAdapter
adapter: MountSubtitlesUIAdapter
config: Pick<PlatformConfig, "selectors">
}
@ -83,14 +90,22 @@ export async function mountSubtitlesUI(
parentEl.appendChild(shadowHost)
const app = (
<ShadowWrapperContext value={reactContainer}>
<ThemeProvider container={reactContainer}>
<SubtitlesProviders adapter={adapter}>
<SubtitlesContainer />
<Toaster richColors className="z-2147483647 notranslate" />
</SubtitlesProviders>
</ThemeProvider>
</ShadowWrapperContext>
<JotaiProvider store={subtitlesStore}>
<ShadowWrapperContext value={reactContainer}>
<ThemeProvider container={reactContainer}>
<SubtitlesUIContext
value={{
toggleSubtitles: adapter.toggleSubtitlesManually,
downloadSourceSubtitles: adapter.downloadSourceSubtitles,
controlsConfig: adapter.getControlsConfig(),
}}
>
<SubtitlesContainer />
<Toaster richColors className="z-2147483647 notranslate" />
</SubtitlesUIContext>
</ThemeProvider>
</ShadowWrapperContext>
</JotaiProvider>
)
reactRoot.render(app)

View file

@ -0,0 +1,44 @@
import * as React from "react"
import themeCSS from "@/assets/styles/theme.css?inline"
import { TRANSLATE_BUTTON_CONTAINER_ID } from "@/utils/constants/subtitles"
import { createReactShadowHost } from "@/utils/react-shadow-host/create-shadow-host"
import { SubtitlesTranslateButton } from "../ui/subtitles-translate-button"
const wrapperCSS = `
:host {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
height: 100%;
margin: 0;
padding: 0;
}
.light, .dark {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
`
export function renderSubtitlesTranslateButton(): HTMLDivElement {
const existingContainer = document.querySelector<HTMLDivElement>(`#${TRANSLATE_BUTTON_CONTAINER_ID}`)
if (existingContainer) {
return existingContainer
}
const component = React.createElement(SubtitlesTranslateButton)
const shadowHost = createReactShadowHost(component, {
position: "inline",
inheritStyles: false,
cssContent: [themeCSS, wrapperCSS],
}) as HTMLDivElement
shadowHost.id = TRANSLATE_BUTTON_CONTAINER_ID
return shadowHost
}

View file

@ -1,73 +0,0 @@
import type { SubtitlesProvidersAdapter } from "../ui/subtitles-ui-context"
import themeCSS from "@/assets/styles/theme.css?inline"
import { TRANSLATE_BUTTON_CONTAINER_ID } from "@/utils/constants/subtitles"
import { createReactShadowHost } from "@/utils/react-shadow-host/create-shadow-host"
import { SubtitlesSettingsPanel } from "../ui/subtitles-settings-panel"
import { SubtitlesTranslateButton } from "../ui/subtitles-translate-button"
import { SubtitlesProviders } from "../ui/subtitles-ui-context"
const wrapperCSS = `
:host {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
height: 100%;
margin: 0;
padding: 0;
}
.light, .dark {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
`
const embedWrapperCSS = `
:host {
display: inline-flex;
align-items: center;
position: relative;
height: 100%;
}
.light, .dark {
display: flex;
align-items: center;
height: 100%;
position: relative;
}
`
export function renderSubtitlesTranslateButton(adapter: SubtitlesProvidersAdapter): HTMLDivElement {
const existingContainer = document.querySelector<HTMLDivElement>(`#${TRANSLATE_BUTTON_CONTAINER_ID}`)
if (existingContainer)
return existingContainer
const component = adapter.embedded
? (
<SubtitlesProviders adapter={adapter}>
<SubtitlesTranslateButton />
<SubtitlesSettingsPanel />
</SubtitlesProviders>
)
: <SubtitlesTranslateButton />
const shadowHost = createReactShadowHost(component, {
position: "inline",
inheritStyles: false,
cssContent: [themeCSS, adapter.embedded ? embedWrapperCSS : wrapperCSS],
...(adapter.embedded && { style: { position: "relative" }, forcedTheme: "dark" as const }),
}) as HTMLDivElement
shadowHost.id = TRANSLATE_BUTTON_CONTAINER_ID
if (adapter.embedded) {
for (const eventType of ["click", "mousedown", "pointerdown", "dblclick"]) {
shadowHost.addEventListener(eventType, e => e.stopPropagation())
}
}
return shadowHost
}

View file

@ -1,16 +1,13 @@
import { useAtomValue } from "jotai"
import { use } from "react"
import { subtitlesDisplayAtom, subtitlesShowContentAtom, subtitlesShowStateAtom } from "../atoms"
import { StateMessage } from "./state-message"
import { SubtitlesSettingsPanel } from "./subtitles-settings-panel"
import { SubtitlesUIContext } from "./subtitles-ui-context"
import { SubtitlesView } from "./subtitles-view"
export function SubtitlesContainer() {
const { stateData, isVisible } = useAtomValue(subtitlesDisplayAtom)
const showState = useAtomValue(subtitlesShowStateAtom)
const showContent = useAtomValue(subtitlesShowContentAtom)
const ui = use(SubtitlesUIContext)
return (
<div className="absolute inset-0 pointer-events-none overflow-visible">
@ -23,11 +20,9 @@ export function SubtitlesContainer() {
)}
</div>
{!ui?.embedded && (
<div className="absolute inset-0 z-40 overflow-visible">
<SubtitlesSettingsPanel />
</div>
)}
<div className="absolute inset-0 z-40 overflow-visible">
<SubtitlesSettingsPanel />
</div>
</div>
)
}

View file

@ -43,10 +43,11 @@ export function DownloadSourceSubtitles() {
size="icon-sm"
onClick={downloadSubtitles}
disabled={isDownloading}
className="border border-white/10 bg-white/6 text-white/88 hover:bg-white/10 focus-visible:border-white/18 focus-visible:ring-white/18"
>
{isDownloading
? <IconLoader2 className="size-3.5 animate-spin" />
: <IconDownload className="size-3.5" />}
? <IconLoader2 className="size-3.5 animate-spin text-white/80" />
: <IconDownload className="size-3.5 text-white/72" />}
</Button>
</SubtitlesSettingsItem>
)

View file

@ -0,0 +1,92 @@
import { useAtom } from "jotai"
import { Activity, useEffect, useEffectEvent, useMemo, useRef } from "react"
import { TRANSLATE_BUTTON_CONTAINER_ID } from "@/utils/constants/subtitles"
import { cn } from "@/utils/styles/utils"
import { subtitlesSettingsPanelOpenAtom } from "../../../atoms"
import { useSubtitlesUI } from "../../subtitles-ui-context"
import { useControlsInfo } from "../../use-controls-visible"
interface SettingsPanelShellProps {
children: React.ReactNode
}
export function SettingsPanelShell({
children,
}: SettingsPanelShellProps) {
const rootRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const [isOpen, setPanelOpen] = useAtom(subtitlesSettingsPanelOpenAtom)
const { controlsConfig } = useSubtitlesUI()
const { controlsHeight, controlsVisible } = useControlsInfo(rootRef, controlsConfig)
const bottomOffset = useMemo(
() => (controlsVisible ? controlsHeight + 18 : 22),
[controlsHeight, controlsVisible],
)
const onPointerDown = useEffectEvent((event: PointerEvent) => {
if (!isOpen) {
return
}
const path = event.composedPath()
const triggerHost = document.getElementById(TRANSLATE_BUTTON_CONTAINER_ID)
const clickedInsidePanel = !!panelRef.current && path.includes(panelRef.current)
const clickedTrigger = !!triggerHost && path.includes(triggerHost)
if (clickedInsidePanel || clickedTrigger) {
return
}
setPanelOpen(false)
})
const onKeyDown = useEffectEvent((event: KeyboardEvent) => {
if (!isOpen) {
return
}
if (event.key === "Escape") {
setPanelOpen(false)
}
})
useEffect(() => {
document.addEventListener("pointerdown", onPointerDown, true)
document.addEventListener("keydown", onKeyDown, true)
return () => {
document.removeEventListener("pointerdown", onPointerDown, true)
document.removeEventListener("keydown", onKeyDown, true)
}
}, [])
return (
<div
ref={rootRef}
className="absolute inset-0 z-40 pointer-events-none overflow-visible font-light"
>
<Activity mode={isOpen ? "visible" : "hidden"}>
<div
className={cn(
"absolute right-4 z-40 transition-[bottom,opacity,transform] duration-200 ease-out",
isOpen ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2",
)}
style={{ bottom: `${bottomOffset}px` }}
>
<div
ref={panelRef}
data-slot="subtitles-settings-panel"
className="pointer-events-auto relative isolate z-40 w-[min(17rem,calc(100vw-2rem))] overflow-hidden rounded-[14px] border border-white/10 bg-[linear-gradient(180deg,rgba(24,25,29,0.82)_0%,rgba(13,14,18,0.78)_100%)] text-white shadow-[0_18px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.07)] backdrop-blur-xl"
>
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-white/14" />
<div className="pointer-events-none absolute -right-12 -bottom-14 size-32 rounded-full bg-[#d8a94b]/9 blur-3xl" />
<div>
{children}
</div>
</div>
</div>
</Activity>
</div>
)
}

View file

@ -1,37 +0,0 @@
import type { ReactNode } from "react"
import { Button } from "@/components/ui/base-ui/button"
import { Label } from "@/components/ui/base-ui/label"
import { cn } from "@/utils/styles/utils"
interface SubpageMenuEntryProps {
icon?: ReactNode
label: string
onClick: () => void
}
export function SubpageMenuEntry({
icon,
label,
onClick,
}: SubpageMenuEntryProps) {
return (
<Button
type="button"
variant="ghost"
onClick={onClick}
className={cn("h-auto w-full justify-start rounded-[14px] px-2 py-2 text-left")}
>
<div className="flex items-center gap-3">
<div className="text-muted-foreground shrink-0">
{icon}
</div>
<div className="min-w-0 flex-1">
<Label className="font-light! cursor-pointer text-left text-[13px] leading-5">
{label}
</Label>
</div>
</div>
</Button>
)
}

View file

@ -14,12 +14,12 @@ export function SubtitlesSettingsItem({
children,
}: SubtitlesSettingsItemProps) {
return (
<div className="hover:bg-muted/50 flex items-center gap-3 rounded-[14px] px-2 py-2 transition-colors">
<div className="flex items-center gap-3 rounded-[14px] px-2 py-2 transition-colors hover:bg-white/4.5">
<Label
htmlFor={labelFor}
className="font-light! flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md py-0.5 text-left text-[13px] leading-5 transition-colors"
className="font-light! flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md py-0.5 text-left text-[13px] leading-5 text-white/96 transition-colors hover:text-white"
>
<div className="text-muted-foreground shrink-0">
<div className="shrink-0 text-white/82">
{icon}
</div>
<div className="min-w-0 flex-1">

View file

@ -25,6 +25,7 @@ export function SubtitlesToggle() {
checked={isVisible}
onCheckedChange={checked => toggleSubtitles(checked)}
aria-label={title}
className="data-checked:bg-[#d8a94b] data-unchecked:bg-white/14 border-white/12 shadow-none"
/>
</SubtitlesSettingsItem>
)

View file

@ -1,65 +0,0 @@
import type { RefObject } from "react"
import { useEffect, useEffectEvent } from "react"
import { TRANSLATE_BUTTON_CLASS, TRANSLATE_BUTTON_CONTAINER_ID } from "@/utils/constants/subtitles"
function isElement(value: EventTarget | null): value is Element {
return value instanceof Element
}
function isTranslateTriggerTarget(path: EventTarget[]) {
return path.some(target =>
isElement(target)
&& (target.id === TRANSLATE_BUTTON_CONTAINER_ID || target.classList.contains(TRANSLATE_BUTTON_CLASS)),
)
}
interface UseSubtitlesPanelDismissOptions {
enabled: boolean
onClose: () => void
panelRef: RefObject<HTMLElement | null>
}
export function useSubtitlesPanelDismiss({
enabled,
onClose,
panelRef,
}: UseSubtitlesPanelDismissOptions) {
const onPointerDown = useEffectEvent((event: PointerEvent) => {
if (!enabled) {
return
}
const path = event.composedPath()
const clickedInsidePanel = !!panelRef.current && path.includes(panelRef.current)
const clickedTrigger = isTranslateTriggerTarget(path)
const clickedPanelPopup = path.some(target =>
isElement(target) && target.matches("[data-slot='select-content']"),
)
if (clickedInsidePanel || clickedTrigger || clickedPanelPopup) {
return
}
onClose()
})
const onKeyDown = useEffectEvent((event: KeyboardEvent) => {
if (!enabled) {
return
}
if (event.key === "Escape") {
onClose()
}
})
useEffect(() => {
document.addEventListener("pointerdown", onPointerDown, true)
document.addEventListener("keydown", onKeyDown, true)
return () => {
document.removeEventListener("pointerdown", onPointerDown, true)
document.removeEventListener("keydown", onKeyDown, true)
}
}, [])
}

View file

@ -1,49 +1,12 @@
import type { ViewId } from "./views"
import { useAtom } from "jotai"
import { useRef } from "react"
import {
subtitlesSettingsPanelOpenAtom,
subtitlesSettingsPanelViewAtom,
} from "../../atoms"
import { PanelShell } from "./panel-shell"
import { MainMenu, ROOT_VIEW, SUBPAGE_MAP } from "./views"
import { DownloadSourceSubtitles } from "./components/download-source-subtitles"
import { SettingsPanelShell } from "./components/settings-panel-shell"
import { SubtitlesToggle } from "./components/subtitles-toggle"
export function SubtitlesSettingsPanel() {
const [isOpen, setPanelOpen] = useAtom(subtitlesSettingsPanelOpenAtom)
const [view, setView] = useAtom(subtitlesSettingsPanelViewAtom)
const prevViewRef = useRef<ViewId>(ROOT_VIEW)
const subpage = view !== ROOT_VIEW ? SUBPAGE_MAP.get(view) : undefined
const navigateTo = (id: ViewId) => {
prevViewRef.current = view
setView(id)
}
const navigateBack = () => {
prevViewRef.current = view
setView(ROOT_VIEW)
}
const close = () => {
setPanelOpen(false)
setView(ROOT_VIEW)
prevViewRef.current = ROOT_VIEW
}
return (
<PanelShell
open={isOpen}
onClose={close}
header={subpage ? { title: subpage.title, onBack: navigateBack } : undefined}
transition={subpage
? {
key: view,
direction: prevViewRef.current === ROOT_VIEW ? "forward" : "back",
}
: undefined}
>
{subpage ? <subpage.component /> : <MainMenu onNavigate={navigateTo} />}
</PanelShell>
<SettingsPanelShell>
<SubtitlesToggle />
<DownloadSourceSubtitles />
</SettingsPanelShell>
)
}

View file

@ -1,151 +0,0 @@
import { IconChevronLeft } from "@tabler/icons-react"
import { Activity, useMemo, useRef } from "react"
import { Button } from "@/components/ui/base-ui/button"
import { cn } from "@/utils/styles/utils"
import { useSubtitlesUI } from "../subtitles-ui-context"
import { useControlsInfo } from "../use-controls-visible"
import { useSubtitlesPanelDismiss } from "./components/use-subtitles-panel-dismiss"
type TransitionDirection = "back" | "forward"
interface PanelShellProps {
children: React.ReactNode
open: boolean
onClose: () => void
header?: { title: string, onBack: () => void }
transition?: { key: string, direction: TransitionDirection }
}
function TransitionContent({
children,
direction,
transitionKey,
}: {
children: React.ReactNode
direction: TransitionDirection
transitionKey: string
}) {
return (
<div
key={transitionKey}
data-direction={direction}
className={cn(
"duration-200 ease-out animate-in fade-in-0",
direction === "forward" ? "slide-in-from-right-3" : "slide-in-from-left-3",
)}
>
{children}
</div>
)
}
function PanelContent({
children,
panelRef,
header,
transition,
maxHeight,
}: {
children: React.ReactNode
panelRef: React.RefObject<HTMLDivElement | null>
header?: PanelShellProps["header"]
transition?: PanelShellProps["transition"]
maxHeight?: string
}) {
return (
<div
ref={panelRef}
data-slot="subtitles-settings-panel"
className="bg-popover text-popover-foreground border-border pointer-events-auto relative isolate z-40 flex w-[min(19rem,calc(100vw-2rem))] flex-col overflow-hidden rounded-[20px] border shadow-floating backdrop-blur-2xl"
style={{ maxHeight }}
>
<Activity mode={header ? "visible" : "hidden"}>
<div className="border-border flex items-center gap-3 border-b px-4 pt-3 pb-3">
<Button
type="button"
variant="ghost-secondary"
size="icon-sm"
aria-label="Back to subtitles menu"
onClick={header?.onBack}
className="rounded-full"
>
<IconChevronLeft className="size-4" />
</Button>
<div className="min-w-0 truncate text-xs font-medium">
{header?.title}
</div>
</div>
</Activity>
<div className="min-h-0 flex-1 overflow-y-auto">
{transition
? (
<TransitionContent direction={transition.direction} transitionKey={transition.key}>
{children}
</TransitionContent>
)
: children}
</div>
</div>
)
}
export function PanelShell({
children,
open,
onClose,
header,
transition,
}: PanelShellProps) {
const rootRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const { controlsConfig, embedded } = useSubtitlesUI()
const { controlsHeight, controlsVisible } = useControlsInfo(rootRef, controlsConfig)
const bottomOffset = useMemo(
() => (controlsVisible ? controlsHeight + 18 : 22),
[controlsHeight, controlsVisible],
)
useSubtitlesPanelDismiss({
enabled: open,
onClose,
panelRef,
})
const rootClassName = embedded
? "relative z-40 pointer-events-none font-light h-full"
: "absolute inset-0 z-40 pointer-events-none overflow-visible font-light [container-type:size]"
const positionClassName = cn(
"absolute z-40 transition-[bottom,opacity,transform] duration-200 ease-out",
embedded ? "bottom-full right-0" : "right-4",
open ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0",
)
const positionStyle = embedded
? { marginBottom: `${bottomOffset}px` }
: { bottom: `${bottomOffset}px` }
const maxHeight = embedded
? "min(24rem, 60vh)"
: `calc(100cqh - ${bottomOffset}px - 1rem)`
return (
<div ref={rootRef} className={rootClassName}>
<Activity mode={open ? "visible" : "hidden"}>
<div className={positionClassName} style={positionStyle}>
<PanelContent
panelRef={panelRef}
header={header}
transition={transition}
maxHeight={maxHeight}
>
{children}
</PanelContent>
</div>
</Activity>
</div>
)
}

View file

@ -1,30 +0,0 @@
import type { ComponentType, ReactNode } from "react"
import { i18n } from "#imports"
import { IconAdjustmentsHorizontal } from "@tabler/icons-react"
import { StyleView } from "./style"
export type ViewId = "main" | "style"
export const ROOT_VIEW = "main" satisfies ViewId
export interface SubpageConfig {
id: Exclude<ViewId, "main">
title: string
icon: ReactNode
component: ComponentType
hidden?: boolean
}
export const SUBPAGES: SubpageConfig[] = [
{
id: "style",
title: i18n.t("options.videoSubtitles.style.title"),
icon: <IconAdjustmentsHorizontal className="size-4" />,
component: StyleView,
},
]
export const VISIBLE_SUBPAGES = SUBPAGES.filter(p => !p.hidden)
export const SUBPAGE_MAP = new Map(SUBPAGES.map(p => [p.id, p]))
export { MainMenu } from "./main-menu"

View file

@ -1,25 +0,0 @@
import type { ViewId } from "."
import { VISIBLE_SUBPAGES } from "."
import { DownloadSourceSubtitles } from "../components/download-source-subtitles"
import { SubpageMenuEntry } from "../components/subpage-menu-entry"
import { SubtitlesToggle } from "../components/subtitles-toggle"
export function MainMenu({ onNavigate }: { onNavigate: (id: ViewId) => void }) {
return (
<div className="px-2 py-2.5">
<div className="space-y-1.5">
<SubtitlesToggle />
<DownloadSourceSubtitles />
{VISIBLE_SUBPAGES.map(page => (
<SubpageMenuEntry
key={page.id}
icon={page.icon}
label={page.title}
onClick={() => onNavigate(page.id)}
/>
))}
</div>
</div>
)
}

View file

@ -1,248 +0,0 @@
import type { ReactNode } from "react"
import type { SubtitlesDisplayMode, SubtitlesFontFamily, SubtitlesTranslationPosition, SubtitleTextStyle } from "@/types/config/subtitles"
import { i18n } from "#imports"
import { IconLanguage, IconRefresh, IconSettings, IconSubtitles } from "@tabler/icons-react"
import { deepmerge } from "deepmerge-ts"
import { useAtom } from "jotai"
import { Activity, use } from "react"
import { Button } from "@/components/ui/base-ui/button"
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/base-ui/select"
import { Slider } from "@/components/ui/base-ui/slider"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import {
DEFAULT_BACKGROUND_OPACITY,
DEFAULT_DISPLAY_MODE,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SCALE,
DEFAULT_FONT_WEIGHT,
DEFAULT_SUBTITLE_COLOR,
DEFAULT_TRANSLATION_POSITION,
MAX_BACKGROUND_OPACITY,
MAX_FONT_SCALE,
MAX_FONT_WEIGHT,
MIN_BACKGROUND_OPACITY,
MIN_FONT_SCALE,
MIN_FONT_WEIGHT,
SUBTITLE_FONT_FAMILIES,
} from "@/utils/constants/subtitles"
import { ShadowWrapperContext } from "@/utils/react-shadow-host/create-shadow-host"
import { subtitlesStore } from "../../../atoms"
const SELECT_TRIGGER_CLASS = "min-w-[5.5rem] text-[13px] [&_[data-slot=select-value]]:text-white/92"
const SELECT_CONTENT_CLASS = "[&_[role=option]]:text-[13px]"
const SLIDER_CLASS = "[&_[role=slider]]:border-0 [&_[role=slider]]:shadow-[0_2px_4px_rgba(0,0,0,0.3)]"
const FONT_FAMILY_OPTIONS = Object.keys(SUBTITLE_FONT_FAMILIES) as SubtitlesFontFamily[]
function SettingsGroup({ icon, title, onReset, children }: {
icon: ReactNode
title: string
onReset: () => void
children: ReactNode
}) {
return (
<div className="mb-4">
<div className="mb-1.5 flex items-center justify-between px-0.5">
<div className="flex items-center gap-1.5 text-[13px] font-medium text-white/90">
{icon}
{title}
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onReset}
className="cursor-pointer text-white/62 hover:bg-white/8 hover:text-white/90"
>
<IconRefresh className="size-3.5" />
</Button>
</div>
<div className="divide-y divide-white/6 rounded-xl bg-white/[0.04]">
{children}
</div>
</div>
)
}
function SettingRow({ label, children }: { label: string, children: ReactNode }) {
return (
<div className="flex items-center justify-between px-3 py-2.5">
<span className="text-[13px] text-white/92">{label}</span>
{children}
</div>
)
}
function SliderRow({ label, value, display, min, max, step, onChange }: {
label: string
value: number
display: string
min: number
max: number
step: number
onChange: (v: number) => void
}) {
return (
<div className="px-3 py-2.5">
<div className="mb-3.5 flex items-center justify-between">
<span className="text-[13px] text-white/92">{label}</span>
<span className="text-[12px] text-white/62">{display}</span>
</div>
<Slider
min={min}
max={max}
step={step}
value={value}
onValueChange={v => onChange(v as number)}
className={SLIDER_CLASS}
/>
</div>
)
}
function TextStyleGroup({ icon, title, textStyle, onChange, onReset, portalContainer }: {
icon: ReactNode
title: string
textStyle: SubtitleTextStyle
onChange: (patch: Partial<SubtitleTextStyle>) => void
onReset: () => void
portalContainer: HTMLElement | null
}) {
return (
<SettingsGroup icon={icon} title={title} onReset={onReset}>
<SliderRow
label={i18n.t("options.videoSubtitles.style.fontScale")}
value={textStyle.fontScale}
display={`${textStyle.fontScale}%`}
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step={10}
onChange={v => onChange({ fontScale: v })}
/>
<SettingRow label={i18n.t("options.videoSubtitles.style.color")}>
<input
type="color"
value={textStyle.color}
onChange={e => onChange({ color: e.target.value })}
className="h-6 w-6 cursor-pointer rounded border border-white/15 bg-transparent p-0.5"
/>
</SettingRow>
<SettingRow label={i18n.t("options.videoSubtitles.style.fontFamily")}>
<Select value={textStyle.fontFamily} onValueChange={v => v && onChange({ fontFamily: v as SubtitlesFontFamily })}>
<SelectTrigger variant="dark" size="sm" className={SELECT_TRIGGER_CLASS}>
<SelectValue>{textStyle.fontFamily}</SelectValue>
</SelectTrigger>
<SelectContent variant="dark" container={portalContainer} className={SELECT_CONTENT_CLASS}>
<SelectGroup>
{FONT_FAMILY_OPTIONS.map(key => (
<SelectItem key={key} variant="dark" value={key}>{key}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</SettingRow>
<SliderRow
label={i18n.t("options.videoSubtitles.style.fontWeight")}
value={textStyle.fontWeight}
display={String(textStyle.fontWeight)}
min={MIN_FONT_WEIGHT}
max={MAX_FONT_WEIGHT}
step={100}
onChange={v => onChange({ fontWeight: v })}
/>
</SettingsGroup>
)
}
const DEFAULT_TEXT_STYLE: SubtitleTextStyle = {
fontFamily: DEFAULT_FONT_FAMILY,
fontScale: DEFAULT_FONT_SCALE,
color: DEFAULT_SUBTITLE_COLOR,
fontWeight: DEFAULT_FONT_WEIGHT,
}
export function StyleView() {
const [config, setConfig] = useAtom(configFieldsAtomMap.videoSubtitles, { store: subtitlesStore })
const portalContainer = use(ShadowWrapperContext)
const { displayMode, translationPosition, container } = config.style
const updateStyle = (patch: Record<string, unknown>) => {
void setConfig(deepmerge(config, { style: patch }))
}
return (
<div className="min-h-[calc(100cqh-6rem)] px-3 pb-4 pt-3">
<SettingsGroup
icon={<IconSettings className="size-3.5" />}
title={i18n.t("options.videoSubtitles.style.generalSettings")}
onReset={() => updateStyle({
displayMode: DEFAULT_DISPLAY_MODE,
translationPosition: DEFAULT_TRANSLATION_POSITION,
container: { backgroundOpacity: DEFAULT_BACKGROUND_OPACITY },
})}
>
<SettingRow label={i18n.t("options.videoSubtitles.style.displayMode.title")}>
<Select value={displayMode} onValueChange={(v: SubtitlesDisplayMode | null) => v && updateStyle({ displayMode: v })}>
<SelectTrigger variant="dark" size="sm" className={SELECT_TRIGGER_CLASS}>
<SelectValue>{i18n.t(`options.videoSubtitles.style.displayMode.${displayMode}`)}</SelectValue>
</SelectTrigger>
<SelectContent variant="dark" container={portalContainer} className={SELECT_CONTENT_CLASS}>
<SelectGroup>
<SelectItem variant="dark" value="bilingual">{i18n.t("options.videoSubtitles.style.displayMode.bilingual")}</SelectItem>
<SelectItem variant="dark" value="originalOnly">{i18n.t("options.videoSubtitles.style.displayMode.originalOnly")}</SelectItem>
<SelectItem variant="dark" value="translationOnly">{i18n.t("options.videoSubtitles.style.displayMode.translationOnly")}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</SettingRow>
<Activity mode={displayMode === "bilingual" ? "visible" : "hidden"}>
<SettingRow label={i18n.t("options.videoSubtitles.style.translationPosition.title")}>
<Select value={translationPosition} onValueChange={(v: SubtitlesTranslationPosition | null) => v && updateStyle({ translationPosition: v })}>
<SelectTrigger variant="dark" size="sm" className={SELECT_TRIGGER_CLASS}>
<SelectValue>{i18n.t(`options.videoSubtitles.style.translationPosition.${translationPosition}`)}</SelectValue>
</SelectTrigger>
<SelectContent variant="dark" container={portalContainer} className={SELECT_CONTENT_CLASS}>
<SelectGroup>
<SelectItem variant="dark" value="above">{i18n.t("options.videoSubtitles.style.translationPosition.above")}</SelectItem>
<SelectItem variant="dark" value="below">{i18n.t("options.videoSubtitles.style.translationPosition.below")}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</SettingRow>
</Activity>
<SliderRow
label={i18n.t("options.videoSubtitles.style.backgroundOpacity")}
value={container.backgroundOpacity}
display={`${container.backgroundOpacity}%`}
min={MIN_BACKGROUND_OPACITY}
max={MAX_BACKGROUND_OPACITY}
step={5}
onChange={v => updateStyle({ container: { backgroundOpacity: v } })}
/>
</SettingsGroup>
<TextStyleGroup
icon={<IconSubtitles className="size-3.5" />}
title={i18n.t("options.videoSubtitles.style.mainSubtitle")}
textStyle={config.style.main}
onChange={patch => updateStyle({ main: patch })}
onReset={() => updateStyle({ main: DEFAULT_TEXT_STYLE })}
portalContainer={portalContainer}
/>
<TextStyleGroup
icon={<IconLanguage className="size-3.5" />}
title={i18n.t("options.videoSubtitles.style.translationSubtitle")}
textStyle={config.style.translation}
onChange={patch => updateStyle({ translation: patch })}
onReset={() => updateStyle({ translation: DEFAULT_TEXT_STYLE })}
portalContainer={portalContainer}
/>
</div>
)
}

View file

@ -4,31 +4,25 @@ import { TRANSLATE_BUTTON_CLASS } from "@/utils/constants/subtitles"
import { cn } from "@/utils/styles/utils"
import {
subtitlesSettingsPanelOpenAtom,
subtitlesSettingsPanelViewAtom,
subtitlesStore,
subtitlesVisibleAtom,
} from "../atoms"
import { ROOT_VIEW } from "./subtitles-settings-panel/views"
export function SubtitlesTranslateButton() {
const isVisible = useAtomValue(subtitlesVisibleAtom, { store: subtitlesStore })
const panelOpen = useAtomValue(subtitlesSettingsPanelOpenAtom, { store: subtitlesStore })
const setPanelOpen = useSetAtom(subtitlesSettingsPanelOpenAtom, { store: subtitlesStore })
const setPanelView = useSetAtom(subtitlesSettingsPanelViewAtom, { store: subtitlesStore })
return (
<button
type="button"
aria-label="Subtitle Translation Panel"
aria-pressed={panelOpen}
onClick={() => {
setPanelView(ROOT_VIEW)
setPanelOpen(prev => !prev)
}}
onClick={() => setPanelOpen(prev => !prev)}
className={cn(
`${TRANSLATE_BUTTON_CLASS} w-12 h-full flex items-center justify-center relative border-none p-0 m-0 cursor-pointer rounded-[14px] transition-all duration-200`,
panelOpen
? "bg-accent shadow-inner"
? "bg-white/10 shadow-[inset_0_1px_0_rgba(255,255,255,0.12)]"
: "bg-transparent",
)}
>
@ -45,8 +39,8 @@ export function SubtitlesTranslateButton() {
className={cn(
"absolute bottom-1 right-0 min-w-7 px-1 py-0.5 rounded-md text-[8px] font-semibold leading-none tracking-[0.08em] text-center transition-colors duration-200",
isVisible
? "bg-primary text-primary-foreground shadow-sm"
: "bg-secondary text-secondary-foreground",
? "bg-[#d8a94b] text-[#24190a] shadow-[0_2px_8px_rgba(216,169,75,0.35)]"
: "bg-white/18 text-white/92",
)}
>
{isVisible ? "ON" : "OFF"}

View file

@ -1,14 +1,10 @@
import type { ControlsConfig } from "@/entrypoints/subtitles.content/platforms"
import type { UniversalVideoAdapter } from "@/entrypoints/subtitles.content/universal-adapter"
import { Provider as JotaiProvider } from "jotai"
import { createContext, use } from "react"
import { subtitlesStore } from "../atoms"
interface SubtitlesUIContextValue {
toggleSubtitles: (enabled: boolean) => void
downloadSourceSubtitles: () => Promise<void>
controlsConfig?: ControlsConfig
embedded?: boolean
}
export const SubtitlesUIContext = createContext<SubtitlesUIContextValue | null>(null)
@ -20,31 +16,3 @@ export function useSubtitlesUI() {
}
return ui
}
export type SubtitlesProvidersAdapter = Pick<
UniversalVideoAdapter,
"downloadSourceSubtitles" | "embedded" | "getControlsConfig" | "toggleSubtitlesManually"
>
export function SubtitlesProviders({
adapter,
children,
}: {
adapter: SubtitlesProvidersAdapter
children: React.ReactNode
}) {
return (
<JotaiProvider store={subtitlesStore}>
<SubtitlesUIContext
value={{
toggleSubtitles: adapter.toggleSubtitlesManually,
downloadSourceSubtitles: adapter.downloadSourceSubtitles,
controlsConfig: adapter.getControlsConfig(),
embedded: adapter.embedded,
}}
>
{children}
</SubtitlesUIContext>
</JotaiProvider>
)
}

View file

@ -1,17 +1,17 @@
import { IconGripHorizontal } from "@tabler/icons-react"
import { useAtomValue, useSetAtom } from "jotai"
import { Activity, useRef } from "react"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { SUBTITLES_VIEW_CLASS } from "@/utils/constants/subtitles"
import { cn } from "@/utils/styles/utils"
import { MainSubtitle, TranslationSubtitle } from "./subtitle-lines"
import { useSubtitlesUI } from "./subtitles-ui-context"
import { useControlsInfo } from "./use-controls-visible"
import { useVerticalDrag } from "./use-vertical-drag"
interface SubtitlesViewProps {
showContent: boolean
}
import { IconGripHorizontal } from "@tabler/icons-react"
import { useAtomValue, useSetAtom } from "jotai"
import { Activity, useRef } from "react"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { SUBTITLES_VIEW_CLASS } from "@/utils/constants/subtitles"
import { cn } from "@/utils/styles/utils"
import { MainSubtitle, TranslationSubtitle } from "./subtitle-lines"
import { useSubtitlesUI } from "./subtitles-ui-context"
import { useControlsInfo } from "./use-controls-visible"
import { useVerticalDrag } from "./use-vertical-drag"
interface SubtitlesViewProps {
showContent: boolean
}
function SubtitlesContent() {
const { style } = useAtomValue(configFieldsAtomMap.videoSubtitles)
@ -43,11 +43,11 @@ function SubtitlesContent() {
)
}
export function SubtitlesView({ showContent }: SubtitlesViewProps) {
const windowRef = useRef<HTMLDivElement>(null)
const { controlsConfig } = useSubtitlesUI()
const { controlsVisible, controlsHeight } = useControlsInfo(windowRef, controlsConfig)
const setVideoSubtitles = useSetAtom(configFieldsAtomMap.videoSubtitles)
export function SubtitlesView({ showContent }: SubtitlesViewProps) {
const windowRef = useRef<HTMLDivElement>(null)
const { controlsConfig } = useSubtitlesUI()
const { controlsVisible, controlsHeight } = useControlsInfo(windowRef, controlsConfig)
const setVideoSubtitles = useSetAtom(configFieldsAtomMap.videoSubtitles)
const { refs, windowStyle, positionStyle, isDragging } = useVerticalDrag({
controlsVisible,

View file

@ -28,9 +28,12 @@ export function useControlsInfo(
return
const element = elementRef.current
const shadowRoot = element ? getContainingShadowRoot(element) : null
if (!element)
return
const shadowRoot = getContainingShadowRoot(element)
const shadowHost = shadowRoot?.host as HTMLElement | undefined
const videoContainer = shadowHost?.parentElement ?? controlsConfig.findVideoContainer?.()
const videoContainer = shadowHost?.parentElement
if (!videoContainer)
return

View file

@ -15,12 +15,11 @@ import { OverlaySubtitlesError, ToastSubtitlesError } from "@/utils/subtitles/er
import { optimizeSubtitles } from "@/utils/subtitles/processor/optimizer"
import { buildSubtitlesSummaryContextHash, fetchSubtitlesSummary } from "@/utils/subtitles/processor/translator"
import { downloadSubtitlesAsSrt } from "@/utils/subtitles/srt"
import { subtitlesPositionAtom, subtitlesSettingsPanelOpenAtom, subtitlesSettingsPanelViewAtom, subtitlesStore } from "./atoms"
import { subtitlesPositionAtom, subtitlesSettingsPanelOpenAtom, subtitlesStore } from "./atoms"
import { renderSubtitlesTranslateButton } from "./renderer/render-translate-button"
import { SegmentationPipeline } from "./segmentation-pipeline"
import { SubtitlesScheduler } from "./subtitles-scheduler"
import { TranslationCoordinator } from "./translation-coordinator"
import { ROOT_VIEW } from "./ui/subtitles-settings-panel/views"
type SubtitlesToggleSource = "manual" | "auto"
@ -44,10 +43,6 @@ export class UniversalVideoAdapter {
private translationCoordinator: TranslationCoordinator | null = null
private subtitlesSummaryContextHash: string | null = null
get embedded() {
return this.config.embedded
}
get videoIdChanged() {
const currentVideoId = this.config.getVideoId?.()
return !!(this.sessionVideoId && currentVideoId && currentVideoId !== this.sessionVideoId)
@ -107,7 +102,6 @@ export class UniversalVideoAdapter {
this.clearSourceCache()
this.subtitlesFetcher.cleanup()
subtitlesStore.set(subtitlesSettingsPanelOpenAtom, false)
subtitlesStore.set(subtitlesSettingsPanelViewAtom, ROOT_VIEW)
this.showNativeSubtitles()
void this.restorePosition()
}
@ -190,7 +184,6 @@ export class UniversalVideoAdapter {
this.translationCoordinator?.stop()
this.segmentationPipeline?.stop()
subtitlesStore.set(subtitlesSettingsPanelOpenAtom, false)
subtitlesStore.set(subtitlesSettingsPanelViewAtom, ROOT_VIEW)
this.showNativeSubtitles()
}
@ -249,49 +242,25 @@ export class UniversalVideoAdapter {
}
private async renderTranslateButton() {
const container = await waitForElement(this.config.selectors.controlsBar)
if (!container) {
if (!this.config.embedded)
toast.error(i18n.t("subtitles.errors.controlsBarNotFound"))
const controlsBar = await waitForElement(this.config.selectors.controlsBar)
if (!controlsBar) {
toast.error(i18n.t("subtitles.errors.controlsBarNotFound"))
return
}
const existingButton = container.querySelector(`#${TRANSLATE_BUTTON_CONTAINER_ID}`)
const existingButton = controlsBar.querySelector(`#${TRANSLATE_BUTTON_CONTAINER_ID}`)
existingButton?.remove()
const toggleButton = renderSubtitlesTranslateButton(this)
const toggleButton = renderSubtitlesTranslateButton()
if (this.config.embedded) {
container.appendChild(toggleButton)
}
else {
container.insertBefore(toggleButton, container.firstChild)
}
controlsBar.insertBefore(toggleButton, controlsBar.firstChild)
}
private async tryAutoStartSubtitles() {
const config = await getLocalConfig()
const autoStart = config?.videoSubtitles?.autoStart ?? false
if (!autoStart)
return
if (this.config.embedded) {
const video = this.subtitlesScheduler?.getVideoElement()
if (!video)
return
const start = () => {
video.removeEventListener("playing", start)
this.toggleSubtitlesWithSource(true, "auto")
}
if (!video.paused) {
this.toggleSubtitlesWithSource(true, "auto")
}
else {
video.addEventListener("playing", start)
}
if (!autoStart) {
return
}

View file

@ -96,7 +96,7 @@ describe("extension env parsing", () => {
})).toThrowError("must be an origin without a trailing slash or path")
expect(() => parseResolvedExtensionEnv({
WXT_OFFICIAL_SITE_ORIGINS: "https://readfrog.app/docs",
WXT_OFFICIAL_SITE_ORIGINS: "https://readfrog.app/tutorial",
})).toThrowError("must be an origin without a trailing slash or path")
})

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