mirror of
https://github.com/mengxi-ream/read-frog.git
synced 2026-04-30 01:56:46 +00:00
Compare commits
30 commits
codex/fix-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbacbf52a7 |
||
|
|
281908a487 |
||
|
|
053aa9090d |
||
|
|
0bd869fd93 |
||
|
|
6dfeb40b67 |
||
|
|
c3debfbc0c |
||
|
|
466c1cefdb |
||
|
|
619c83defd |
||
|
|
37c0ced786 |
||
|
|
afa7dee1b0 |
||
|
|
596bcf7248 |
||
|
|
69221554ff |
||
|
|
c25b299ca4 |
||
|
|
f05a0bda21 |
||
|
|
18967cbab8 |
||
|
|
746a3c5c3b | ||
|
|
b282ef87ed |
||
|
|
068bdecc8a |
||
|
|
810623ba02 |
||
|
|
396dd0d36b |
||
|
|
adfc89add6 |
||
|
|
5b56df819a |
||
|
|
4667e3eb40 |
||
|
|
455584ff00 |
||
|
|
998f288cad |
||
|
|
14d4f2ed70 |
||
|
|
d2c75ace5a |
||
|
|
090463d588 |
||
|
|
56e3081d7d |
||
|
|
26b06af870 |
139 changed files with 4957 additions and 2196 deletions
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
fix(selection-toolbar): add more cursor clearance after text selection
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
fix(selection-toolbar): derive custom action webpage context by popover session
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
fix(subtitles): support stylized YouTube karaoke parsing and source export
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
fix: keep floating button close menu aligned after reopening
|
||||
5
.changeset/focused-provider-options.md
Normal file
5
.changeset/focused-provider-options.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
fix(options): preserve focused provider options drafts
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
fix(models): skip unsupported thinking options for instruct variants
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
fix(subtitles): stabilize YouTube subtitle navigation and popup mounting
|
||||
5
.changeset/rare-accordions-rewalk.md
Normal file
5
.changeset/rare-accordions-rewalk.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
fix(page-translation): re-walk revealed accordion content
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
refactor(env): simplify extension env wiring
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
chore(deps): upgrade WXT to 0.20.22 and preserve extension-safe bundle output
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
fix: floating button style
|
||||
48
.codex/environments/environment.toml
Normal file
48
.codex/environments/environment.toml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "read-frog"
|
||||
|
||||
[setup]
|
||||
script = '''
|
||||
set -eu
|
||||
|
||||
copy_env() {
|
||||
rel="$1"
|
||||
example_rel="$2"
|
||||
|
||||
src="$CODEX_SOURCE_TREE_PATH/$rel"
|
||||
dst="$CODEX_WORKTREE_PATH/$rel"
|
||||
example="$CODEX_SOURCE_TREE_PATH/$example_rel"
|
||||
|
||||
mkdir -p "$(dirname "$dst")"
|
||||
|
||||
if [ -f "$src" ]; then
|
||||
cp "$src" "$dst"
|
||||
echo "copied $rel from source tree"
|
||||
elif [ -f "$example" ]; then
|
||||
cp "$example" "$dst"
|
||||
echo "created $rel from example"
|
||||
else
|
||||
echo "skipped $rel (no source or example found)"
|
||||
fi
|
||||
}
|
||||
|
||||
copy_source_file() {
|
||||
rel="$1"
|
||||
|
||||
src="$CODEX_SOURCE_TREE_PATH/$rel"
|
||||
dst="$CODEX_WORKTREE_PATH/$rel"
|
||||
|
||||
mkdir -p "$(dirname "$dst")"
|
||||
|
||||
if [ -f "$src" ]; then
|
||||
cp "$src" "$dst"
|
||||
echo "copied $rel from source tree"
|
||||
else
|
||||
echo "skipped $rel (no source found)"
|
||||
fi
|
||||
}
|
||||
|
||||
copy_env ".env.development" ".env.example"
|
||||
copy_source_file "web-ext.config.ts"
|
||||
'''
|
||||
9
.github/workflows/stale-issue-pr.yml
vendored
9
.github/workflows/stale-issue-pr.yml
vendored
|
|
@ -18,9 +18,7 @@ jobs:
|
|||
# ---- Issue settings ----
|
||||
days-before-issue-stale: 30
|
||||
days-before-issue-close: 60
|
||||
exempt-issue-labels: |
|
||||
pinned
|
||||
on-hold
|
||||
exempt-issue-labels: pinned,on-hold
|
||||
stale-issue-message: |
|
||||
This issue has been inactive for 30 days and is now marked as stale.
|
||||
It will be automatically closed in 60 days if no further activity occurs.
|
||||
|
|
@ -37,7 +35,4 @@ jobs:
|
|||
close-pr-message: |
|
||||
Since it has been a long time without updates, the PR has been automatically closed.
|
||||
If you need to continue, please reopen or create a new PR.
|
||||
exempt-pr-labels: |
|
||||
pinned
|
||||
WIP
|
||||
on-hold
|
||||
exempt-pr-labels: pinned,WIP,on-hold
|
||||
|
|
|
|||
72
CHANGELOG.md
72
CHANGELOG.md
|
|
@ -1,5 +1,77 @@
|
|||
# @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
|
||||
|
|
|
|||
23
README.md
23
README.md
|
|
@ -6,7 +6,7 @@ An open-source AI-powered language learning extension for browsers.<br/>
|
|||
Supports immersive translation, article analysis, multiple AI models, and more.<br/>
|
||||
Master languages effortlessly and deeply with AI, right in your browser.
|
||||
|
||||
**English** · [简体中文](./README.zh-CN.md) · [Official Website](https://readfrog.app) · [Tutorial](https://www.readfrog.app/tutorial) · [Changelog](https://www.readfrog.app/changelog) · [Blog](https://www.readfrog.app/blog)
|
||||
**English** · [简体中文](./README.zh-CN.md) · [Official Website](https://readfrog.app) · [Tutorial](https://www.readfrog.app/docs) · [Changelog](https://www.readfrog.app/changelog) · [Blog](https://www.readfrog.app/blog)
|
||||
|
||||
<!-- SHIELD GROUP -->
|
||||
|
||||
|
|
@ -46,7 +46,6 @@ Master languages effortlessly and deeply with AI, right in your browser.
|
|||
- [🤖 20+ AI Providers](#-20-ai-providers)
|
||||
- [🎬 Subtitle Translation](#-subtitle-translation)
|
||||
- [🔊 Text-to-Speech (TTS)](#-text-to-speech-tts)
|
||||
- [📖 Read Article](#-read-article)
|
||||
- [🤝 Contribute](#-contribute)
|
||||
- [Contribute Code](#contribute-code)
|
||||
- [📜 Commercial License Grant](#-commercial-license-grant)
|
||||
|
|
@ -134,7 +133,7 @@ The extension automatically re-translates all visible content when you switch mo
|
|||
|
||||
### 🧠 [Context-Aware Translation][docs-tutorial]
|
||||
|
||||
Enable AI to understand the full context of what you're reading. When activated, Read Frog uses Mozilla's Readability library to extract the article's title and content, providing this context to the AI for more accurate, contextually-appropriate translations.
|
||||
Enable AI to understand the full context of what you're reading. When activated, Read Frog extracts the page title and a concise Markdown version of the page content, providing this context to the AI for more accurate, contextually-appropriate translations.
|
||||
|
||||
This means technical terms get translated correctly within their domain, literary expressions maintain their nuance, and ambiguous phrases are interpreted based on the surrounding content rather than in isolation.
|
||||
|
||||
|
|
@ -226,20 +225,6 @@ Automatic language detection (basic or LLM-powered) with per-language voice mapp
|
|||
|
||||
</div>
|
||||
|
||||
<!-- ![][image-feat-read] -->
|
||||
|
||||
### 📖 [Read Article][docs-tutorial]
|
||||
|
||||
One-click deep article analysis. Read Frog extracts the main content using Mozilla's Readability, detects the source language, and generates a summary and introduction in your target language.
|
||||
|
||||
Then it provides sentence-by-sentence translations with vocabulary explanations tailored to your language level (beginner, intermediate, or advanced). Each sentence includes key word definitions, grammatical analysis, and contextual explanations. It's like having a personal language tutor analyze every article you read.
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![Back to top][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## 🤝 Contribute
|
||||
|
||||
Contributions of all types are more than welcome.
|
||||
|
|
@ -254,7 +239,7 @@ Project Structure: [DeepWiki](https://deepwiki.com/mengxi-ream/read-frog)
|
|||
|
||||
Ask AI to understand the project: [Dosu](https://app.dosu.dev/29569286-71ba-47dd-b038-c7ab1b9d0df7/documents)
|
||||
|
||||
Check out the [Contribution Guide](https://readfrog.app/en/tutorial/code-contribution/contribution-guide) for more details.
|
||||
Check out the [Contribution Guide](https://readfrog.app/en/docs/code-contribution/contribution-guide) for more details.
|
||||
|
||||
ReadFrog is dual-licensed under GPLv3 and a commercial license.
|
||||
|
||||
|
|
@ -351,4 +336,4 @@ Every donation helps us build a better language learning experience. Thank you f
|
|||
|
||||
<!-- Feature docs link -->
|
||||
|
||||
[docs-tutorial]: https://readfrog.app/tutorial
|
||||
[docs-tutorial]: https://readfrog.app/docs
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
支持沉浸式翻译、文章分析、多种 AI 模型等功能。<br/>
|
||||
在浏览器中利用 AI 轻松深入地掌握语言。
|
||||
|
||||
[English](./README.md) · **简体中文** · [官方网站](https://readfrog.app) · [教程](https://www.readfrog.app/zh/tutorial) · [更新日志](https://www.readfrog.app/zh/changelog) · [博客](https://www.readfrog.app/zh/blog)
|
||||
[English](./README.md) · **简体中文** · [官方网站](https://readfrog.app) · [教程](https://www.readfrog.app/zh/docs) · [更新日志](https://www.readfrog.app/zh/changelog) · [博客](https://www.readfrog.app/zh/blog)
|
||||
|
||||
<!-- SHIELD GROUP -->
|
||||
|
||||
|
|
@ -46,7 +46,6 @@
|
|||
- [🤖 20+ AI 服务商](#-20-ai-服务商)
|
||||
- [🎬 字幕翻译](#-字幕翻译)
|
||||
- [🔊 文字转语音 (TTS)](#-文字转语音-tts)
|
||||
- [📖 阅读文章](#-阅读文章)
|
||||
- [🤝 贡献](#-贡献)
|
||||
- [贡献代码](#贡献代码)
|
||||
- [📜 商业授权](#-商业授权)
|
||||
|
|
@ -134,7 +133,7 @@ Read Frog 的愿景是为各个级别的语言学习者提供易于使用、智
|
|||
|
||||
### 🧠 [上下文感知翻译][docs-tutorial]
|
||||
|
||||
让 AI 理解您正在阅读内容的完整上下文。启用后,Read Frog 使用 Mozilla 的 Readability 库提取文章的标题和内容,将此上下文提供给 AI,以获得更准确、更符合语境的翻译。
|
||||
让 AI 理解您正在阅读内容的完整上下文。启用后,Read Frog 会提取页面标题和简洁的 Markdown 页面内容,将此上下文提供给 AI,以获得更准确、更符合语境的翻译。
|
||||
|
||||
这意味着技术术语会在其领域内被正确翻译,文学表达会保持其韵味,歧义短语会根据周围内容而非孤立地进行解释。
|
||||
|
||||
|
|
@ -226,20 +225,6 @@ Read Frog 的愿景是为各个级别的语言学习者提供易于使用、智
|
|||
|
||||
</div>
|
||||
|
||||
<!-- ![][image-feat-read] -->
|
||||
|
||||
### 📖 [阅读文章][docs-tutorial]
|
||||
|
||||
一键深度文章分析。Read Frog 使用 Mozilla 的 Readability 提取主要内容,检测源语言,并用您的目标语言生成摘要和导读。
|
||||
|
||||
然后提供逐句翻译,配合根据您的语言水平(初级、中级或高级)定制的词汇解释。每个句子都包含关键词定义、语法分析和上下文解释。就像有一位私人语言导师分析您阅读的每篇文章。
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![Back to top][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
我们欢迎各种类型的贡献。
|
||||
|
|
@ -252,7 +237,7 @@ Read Frog 的愿景是为各个级别的语言学习者提供易于使用、智
|
|||
|
||||
通过 AI 了解项目:[DeepWiki](https://deepwiki.com/mengxi-ream/read-frog)
|
||||
|
||||
查看[贡献指南](https://readfrog.app/zh/tutorial/code-contribution/contribution-guide)了解更多详情。
|
||||
查看[贡献指南](https://readfrog.app/zh/docs/code-contribution/contribution-guide)了解更多详情。
|
||||
|
||||
ReadFrog 采用 GPLv3 和商业许可双重授权。
|
||||
|
||||
|
|
@ -349,4 +334,4 @@ ReadFrog 采用 GPLv3 和商业许可双重授权。
|
|||
|
||||
<!-- Feature docs link -->
|
||||
|
||||
[docs-tutorial]: https://readfrog.app/zh/tutorial
|
||||
[docs-tutorial]: https://readfrog.app/zh/docs
|
||||
|
|
|
|||
19
package.json
19
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@read-frog/extension",
|
||||
"type": "module",
|
||||
"version": "1.32.2",
|
||||
"version": "1.33.1",
|
||||
"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 prepare",
|
||||
"postinstall": "WXT_SKIP_ENV_VALIDATION=true wxt prepare",
|
||||
"test": "vitest run",
|
||||
"test:cov": "vitest run --coverage",
|
||||
"test:watch": "vitest",
|
||||
|
|
@ -51,6 +51,7 @@
|
|||
"@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",
|
||||
|
|
@ -65,10 +66,9 @@
|
|||
"@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.13.14",
|
||||
"@orpc/tanstack-query": "^1.13.14",
|
||||
"@orpc/client": "^1.14.0",
|
||||
"@orpc/tanstack-query": "^1.14.0",
|
||||
"@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.99.2",
|
||||
"@tanstack/react-query": "^5.100.1",
|
||||
"@uiw/codemirror-extensions-color": "^4.25.9",
|
||||
"@uiw/react-codemirror": "^4.25.9",
|
||||
"@webext-core/messaging": "^2.3.0",
|
||||
"@wxt-dev/i18n": "^0.2.5",
|
||||
"ai": "^6.0.168",
|
||||
"better-auth": "^1.6.6",
|
||||
"better-auth": "^1.6.8",
|
||||
"case-anything": "^3.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -92,6 +92,7 @@
|
|||
"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",
|
||||
|
|
@ -100,7 +101,7 @@
|
|||
"jotai-family": "^1.0.1",
|
||||
"js-sha256": "^0.11.1",
|
||||
"ollama-ai-provider-v2": "^3.5.0",
|
||||
"posthog-js": "^1.369.5",
|
||||
"posthog-js": "^1.371.2",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
|
|
@ -153,7 +154,7 @@
|
|||
"tailwindcss": "^4.2.4",
|
||||
"type-fest": "^5.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.9",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5",
|
||||
"wxt": "0.20.25"
|
||||
},
|
||||
|
|
|
|||
544
pnpm-lock.yaml
generated
544
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m 8 1 c -1.65625 0 -3 1.34375 -3 3 s 1.34375 3 3 3 s 3 -1.34375 3 -3 s -1.34375 -3 -3 -3 z m -1.5 7 c -2.492188 0 -4.5 2.007812 -4.5 4.5 v 0.5 c 0 1.109375 0.890625 2 2 2 h 8 c 1.109375 0 2 -0.890625 2 -2 v -0.5 c 0 -2.492188 -2.007812 -4.5 -4.5 -4.5 z m 0 0" fill="#6a7282"/>
|
||||
</svg>
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path transform="translate(8 8) scale(0.75) translate(-8 -8)" d="m 8 1 c -1.65625 0 -3 1.34375 -3 3 s 1.34375 3 3 3 s 3 -1.34375 3 -3 s -1.34375 -3 -3 -3 z m -1.5 7 c -2.492188 0 -4.5 2.007812 -4.5 4.5 v 0.5 c 0 1.109375 0.890625 2 2 2 h 8 c 1.109375 0 2 -0.890625 2 -2 v -0.5 c 0 -2.492188 -2.007812 -4.5 -4.5 -4.5 z m 0 0" fill="#6a7282"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 510 B After Width: | Height: | Size: 565 B |
|
|
@ -1 +1 @@
|
|||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M22.75 17.5C22.75 17.91 22.41 18.25 22 18.25H15V18.5C15 20 14.1 20.5 13 20.5H7C5.9 20.5 5 20 5 18.5V18.25H2C1.59 18.25 1.25 17.91 1.25 17.5C1.25 17.09 1.59 16.75 2 16.75H5V16.5C5 15 5.9 14.5 7 14.5H13C14.1 14.5 15 15 15 16.5V16.75H22C22.41 16.75 22.75 17.09 22.75 17.5Z" fill="oklch(69.6% 0.17 162.48)"></path> <path opacity="0.4" d="M22.75 6.5C22.75 6.91 22.41 7.25 22 7.25H19V7.5C19 9 18.1 9.5 17 9.5H11C9.9 9.5 9 9 9 7.5V7.25H2C1.59 7.25 1.25 6.91 1.25 6.5C1.25 6.09 1.59 5.75 2 5.75H9V5.5C9 4 9.9 3.5 11 3.5H17C18.1 3.5 19 4 19 5.5V5.75H22C22.41 5.75 22.75 6.09 22.75 6.5Z" fill="oklch(69.6% 0.17 162.48)"></path> </g></svg>
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M22.75 17.5C22.75 17.91 22.41 18.25 22 18.25H15V18.5C15 20 14.1 20.5 13 20.5H7C5.9 20.5 5 20 5 18.5V18.25H2C1.59 18.25 1.25 17.91 1.25 17.5C1.25 17.09 1.59 16.75 2 16.75H5V16.5C5 15 5.9 14.5 7 14.5H13C14.1 14.5 15 15 15 16.5V16.75H22C22.41 16.75 22.75 17.09 22.75 17.5Z" fill="oklch(76.034% 0.12361 82.191)"></path> <path opacity="0.4" d="M22.75 6.5C22.75 6.91 22.41 7.25 22 7.25H19V7.5C19 9 18.1 9.5 17 9.5H11C9.9 9.5 9 9 9 7.5V7.25H2C1.59 7.25 1.25 6.91 1.25 6.5C1.25 6.09 1.59 5.75 2 5.75H9V5.5C9 4 9.9 3.5 11 3.5H17C18.1 3.5 19 4 19 5.5V5.75H22C22.41 5.75 22.75 6.09 22.75 6.5Z" fill="oklch(76.034% 0.12361 82.191)"></path> </g></svg>
|
||||
|
Before Width: | Height: | Size: 866 B After Width: | Height: | Size: 876 B |
|
|
@ -1,23 +0,0 @@
|
|||
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);")
|
||||
})
|
||||
})
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { readFileSync } from "node:fs"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
const translationNodePresetCss = readFileSync(new URL("../translation-node-preset.css", import.meta.url), "utf8")
|
||||
|
||||
describe("translation-node-preset.css", () => {
|
||||
it("keeps a float-wrap override for block translations", () => {
|
||||
expect(translationNodePresetCss).toContain(".read-frog-translated-block-content[data-read-frog-float-wrap=\"true\"]")
|
||||
expect(translationNodePresetCss).toContain("display: block !important;")
|
||||
})
|
||||
})
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
}
|
||||
|
||||
.read-frog-translated-block-content[data-read-frog-custom-translation-style="blockquote"] {
|
||||
border-left: 4px solid var(--read-frog-primary);
|
||||
border-left: 4px solid var(--read-frog-brand);
|
||||
padding: 4px 0 4px 8px;
|
||||
}
|
||||
|
||||
|
|
@ -24,22 +24,22 @@
|
|||
}
|
||||
|
||||
[data-read-frog-custom-translation-style="dashedLine"] {
|
||||
text-decoration: underline dashed var(--read-frog-primary) !important;
|
||||
text-decoration: underline dashed var(--read-frog-brand) !important;
|
||||
text-underline-offset: 5px;
|
||||
}
|
||||
|
||||
[data-read-frog-custom-translation-style="border"] {
|
||||
border: 1px solid var(--read-frog-primary);
|
||||
border: 1px solid var(--read-frog-brand);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
[data-read-frog-custom-translation-style="textColor"] {
|
||||
color: var(--read-frog-primary) !important;
|
||||
color: var(--read-frog-brand) !important;
|
||||
}
|
||||
|
||||
[data-read-frog-custom-translation-style="background"] {
|
||||
background-color: color-mix(in srgb, var(--read-frog-primary) 15%, transparent);
|
||||
background-color: color-mix(in srgb, var(--read-frog-brand) 15%, transparent);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
:root {
|
||||
--read-frog-primary: oklch(76.5% 0.177 163.223);
|
||||
--read-frog-primary: oklch(0.205 0 0);
|
||||
--read-frog-brand: oklch(76.034% 0.12361 82.191);
|
||||
--read-frog-foreground: oklch(0.985 0 0);
|
||||
--read-frog-muted: oklch(0.97 0 0);
|
||||
--read-frog-muted-foreground: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--read-frog-primary: oklch(59.6% 0.145 163.225);
|
||||
--read-frog-primary: oklch(0.922 0 0);
|
||||
--read-frog-brand: oklch(76.034% 0.12361 82.191);
|
||||
--read-frog-foreground: oklch(0.205 0 0);
|
||||
--read-frog-muted: oklch(0.269 0 0);
|
||||
--read-frog-muted-foreground: oklch(0.708 0 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@
|
|||
--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);
|
||||
|
|
@ -67,8 +69,10 @@
|
|||
--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.693 0.17 162.48);
|
||||
--rf-primary-foreground: oklch(0.98 0.02 166);
|
||||
--rf-primary: oklch(0.205 0 0);
|
||||
--rf-primary-foreground: oklch(0.985 0 0);
|
||||
--rf-brand: oklch(76.034% 0.12361 82.191);
|
||||
--rf-brand-foreground: oklch(0.985 0 0);
|
||||
--rf-secondary: oklch(0.967 0.001 286.375);
|
||||
--rf-secondary-foreground: oklch(0.47 0.006 285.885);
|
||||
--rf-muted: oklch(0.967 0.001 286.375);
|
||||
|
|
@ -79,16 +83,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(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-chart-1: oklch(89.215% 0.05562 82.191);
|
||||
--rf-chart-2: oklch(82.026% 0.09271 82.191);
|
||||
--rf-chart-3: oklch(76.034% 0.12361 82.191);
|
||||
--rf-chart-4: oklch(57.026% 0.09271 82.191);
|
||||
--rf-chart-5: oklch(38.017% 0.06181 82.191);
|
||||
--rf-radius: 0.625rem;
|
||||
--rf-sidebar: oklch(0.985 0 0);
|
||||
--rf-sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--rf-sidebar-primary: oklch(0.6 0.13 163);
|
||||
--rf-sidebar-primary-foreground: oklch(0.98 0.02 166);
|
||||
--rf-sidebar-primary: oklch(0.205 0 0);
|
||||
--rf-sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--rf-sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--rf-sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--rf-sidebar-border: oklch(0.92 0.004 286.32);
|
||||
|
|
@ -106,8 +110,10 @@
|
|||
--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.693 0.17 162.48);
|
||||
--rf-primary-foreground: oklch(0.26 0.05 173);
|
||||
--rf-primary: oklch(0.922 0 0);
|
||||
--rf-primary-foreground: oklch(0.205 0 0);
|
||||
--rf-brand: oklch(76.034% 0.12361 82.191);
|
||||
--rf-brand-foreground: oklch(0.205 0 0);
|
||||
--rf-secondary: oklch(0.274 0.006 286.033);
|
||||
--rf-secondary-foreground: oklch(0.775 0 0);
|
||||
--rf-muted: oklch(0.274 0.006 286.033);
|
||||
|
|
@ -118,15 +124,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(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-chart-1: oklch(38.017% 0.06181 82.191);
|
||||
--rf-chart-2: oklch(57.026% 0.09271 82.191);
|
||||
--rf-chart-3: oklch(76.034% 0.12361 82.191);
|
||||
--rf-chart-4: oklch(82.026% 0.09271 82.191);
|
||||
--rf-chart-5: oklch(89.215% 0.05562 82.191);
|
||||
--rf-sidebar: oklch(0.21 0.006 285.885);
|
||||
--rf-sidebar-foreground: oklch(0.985 0 0);
|
||||
--rf-sidebar-primary: oklch(0.77 0.15 163);
|
||||
--rf-sidebar-primary-foreground: oklch(0.26 0.05 173);
|
||||
--rf-sidebar-primary: oklch(0.922 0 0);
|
||||
--rf-sidebar-primary-foreground: oklch(0.205 0 0);
|
||||
--rf-sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--rf-sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--rf-sidebar-border: oklch(1 0 0 / 10%);
|
||||
|
|
|
|||
115
src/components/__tests__/user-account.test.tsx
Normal file
115
src/components/__tests__/user-account.test.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// @vitest-environment jsdom
|
||||
import type { ComponentProps } from "react"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import guest from "@/assets/icons/avatars/guest.svg"
|
||||
|
||||
const { sessionState, useSessionMock } = vi.hoisted(() => ({
|
||||
sessionState: {
|
||||
data: null as unknown,
|
||||
isPending: false,
|
||||
},
|
||||
useSessionMock: vi.fn(() => sessionState),
|
||||
}))
|
||||
|
||||
vi.mock("@/env", () => ({
|
||||
env: {
|
||||
WXT_WEBSITE_URL: "https://readfrog.app",
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/auth/auth-client", () => ({
|
||||
authClient: {
|
||||
useSession: useSessionMock,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/components/ui/base-ui/avatar", () => ({
|
||||
Avatar: ({
|
||||
children,
|
||||
className,
|
||||
size = "default",
|
||||
}: ComponentProps<"span"> & { size?: "default" | "sm" | "lg" }) => (
|
||||
<span data-slot="avatar" data-size={size} className={className}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
AvatarImage: (props: ComponentProps<"img">) => props.src ? <img data-slot="avatar-image" {...props} /> : null,
|
||||
AvatarFallback: ({ children }: ComponentProps<"span">) => (
|
||||
<span data-slot="avatar-fallback">{children}</span>
|
||||
),
|
||||
}))
|
||||
|
||||
describe("user account", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
sessionState.data = null
|
||||
sessionState.isPending = false
|
||||
})
|
||||
|
||||
it("shows the guest image and login action when signed out", async () => {
|
||||
const { UserAccount } = await import("../user-account")
|
||||
|
||||
render(<UserAccount />)
|
||||
|
||||
const image = screen.getByRole("img", { name: "Guest" })
|
||||
expect(image).toHaveAttribute("src", guest)
|
||||
expect(screen.getByText("Guest")).toBeInTheDocument()
|
||||
expect(screen.getByRole("button", { name: "Log in" })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("treats a session payload without user as signed out", async () => {
|
||||
sessionState.data = { session: { id: "session-1" } }
|
||||
const { UserAccount } = await import("../user-account")
|
||||
|
||||
expect(() => render(<UserAccount />)).not.toThrow()
|
||||
|
||||
const image = screen.getByRole("img", { name: "Guest" })
|
||||
expect(image).toHaveAttribute("src", guest)
|
||||
expect(screen.getByText("Guest")).toBeInTheDocument()
|
||||
expect(screen.getByRole("button", { name: "Log in" })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows the user's avatar and name when signed in with an image", async () => {
|
||||
sessionState.data = {
|
||||
user: {
|
||||
name: "John Doe",
|
||||
image: "https://cdn.example.com/john.png",
|
||||
},
|
||||
}
|
||||
const { UserAccount } = await import("../user-account")
|
||||
|
||||
render(<UserAccount />)
|
||||
|
||||
expect(screen.getByRole("img", { name: "John Doe" })).toHaveAttribute("src", "https://cdn.example.com/john.png")
|
||||
expect(screen.getByText("John Doe")).toBeInTheDocument()
|
||||
expect(screen.queryByRole("button", { name: "Log in" })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("uses initials fallback for signed-in users without an image", async () => {
|
||||
sessionState.data = {
|
||||
user: {
|
||||
name: "John Doe",
|
||||
image: null,
|
||||
},
|
||||
}
|
||||
const { UserAccount } = await import("../user-account")
|
||||
|
||||
render(<UserAccount />)
|
||||
|
||||
expect(screen.queryByRole("img", { name: "John Doe" })).not.toBeInTheDocument()
|
||||
expect(screen.getByText("JD")).toBeInTheDocument()
|
||||
expect(screen.getByText("John Doe")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("keeps the loading state without showing login", async () => {
|
||||
sessionState.isPending = true
|
||||
const { UserAccount } = await import("../user-account")
|
||||
|
||||
const view = render(<UserAccount />)
|
||||
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument()
|
||||
expect(screen.queryByRole("button", { name: "Log in" })).not.toBeInTheDocument()
|
||||
expect(view.container.querySelector("[data-slot='avatar']")).toHaveClass("animate-pulse")
|
||||
})
|
||||
})
|
||||
|
|
@ -13,7 +13,7 @@ export function APIConfigWarning({ className }: { className?: string }) {
|
|||
{i18n.t("noAPIKeyConfig.warningWithLink.youMust")}
|
||||
{" "}
|
||||
<a
|
||||
href="https://readfrog.app/tutorial/api-key"
|
||||
href="https://readfrog.app/docs/api-key"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
|
|
|
|||
|
|
@ -15,9 +15,11 @@ export const ThemeContext = createContext<ThemeContextI | undefined>(undefined)
|
|||
export function ThemeProvider({
|
||||
children,
|
||||
container,
|
||||
forcedTheme,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
container?: HTMLElement
|
||||
forcedTheme?: Theme
|
||||
}) {
|
||||
const [themeMode, setThemeMode] = useAtom(themeModeAtom)
|
||||
|
||||
|
|
@ -34,10 +36,12 @@ export function ThemeProvider({
|
|||
() => !!window?.matchMedia?.("(prefers-color-scheme: dark)")?.matches,
|
||||
)
|
||||
|
||||
const theme: Theme = themeMode === "system"
|
||||
const resolvedTheme: Theme = themeMode === "system"
|
||||
? (prefersDark ? "dark" : "light")
|
||||
: themeMode
|
||||
|
||||
const theme: Theme = forcedTheme ?? resolvedTheme
|
||||
|
||||
// Apply theme to document or shadow root container
|
||||
useLayoutEffect(() => {
|
||||
const target = container ?? document.documentElement
|
||||
|
|
|
|||
107
src/components/ui/base-ui/avatar.tsx
Normal file
107
src/components/ui/base-ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/utils/styles/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarBadge,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarImage,
|
||||
}
|
||||
|
|
@ -5,27 +5,35 @@ import { cva } from "class-variance-authority"
|
|||
import { cn } from "@/utils/styles/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
"default": "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
"outline": "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs",
|
||||
"secondary": "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
"ghost": "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
"ghost-secondary": "hover:bg-secondary text-secondary-foreground dark:hover:bg-secondary/50 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
"destructive": "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||
"outline":
|
||||
"border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
"secondary":
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
"ghost":
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
"ghost-secondary":
|
||||
"text-secondary-foreground hover:bg-secondary aria-expanded:bg-secondary aria-expanded:text-secondary-foreground dark:hover:bg-secondary/50",
|
||||
"destructive":
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
"link": "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
"default": "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
"xs": "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
"sm": "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
"lg": "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
"icon": "size-8",
|
||||
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
"default":
|
||||
"h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
"xs": "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
"sm": "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
|
||||
"lg": "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
"icon": "size-9",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,64 @@
|
|||
import type { VariantProps } from "class-variance-authority"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react"
|
||||
|
||||
import { cva } from "class-variance-authority"
|
||||
import * as React from "react"
|
||||
import { SHARED_POPUP_CLOSED_STATE_CLASS } from "@/components/ui/base-ui/popup-animation-classes"
|
||||
import { cn } from "@/utils/styles/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const selectTriggerVariants = cva(
|
||||
"gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "shadow-xs border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:ring-3 aria-invalid:ring-3 [&_[data-slot=select-icon]]:text-muted-foreground",
|
||||
dark: "border-white/10 bg-white/6 text-white/92 hover:bg-white/10 focus-visible:border-white/18 focus-visible:ring-white/18 focus-visible:ring-1 [&_[data-slot=select-icon]]:text-white/62",
|
||||
},
|
||||
size: {
|
||||
default: "h-8",
|
||||
sm: "h-7 rounded-[min(var(--radius-md),10px)]",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const selectContentVariants = cva(
|
||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 min-w-36 rounded-lg shadow-md duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-popover text-popover-foreground ring-foreground/10 ring-1",
|
||||
dark: "bg-[rgba(24,26,30,0.96)] text-white/90 ring-white/10 ring-1 backdrop-blur-xl",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const selectItemVariants = cva(
|
||||
"gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground",
|
||||
dark: "focus:bg-white/10 focus:text-white",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
|
|
@ -29,26 +81,22 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
|||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
}: SelectPrimitive.Trigger.Props & VariantProps<typeof selectTriggerVariants>) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"shadow-xs border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
className={cn(selectTriggerVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
data-slot="select-icon"
|
||||
render={
|
||||
<IconChevronDown className="text-muted-foreground size-4 pointer-events-none" />
|
||||
<IconChevronDown className="size-4 pointer-events-none" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
|
|
@ -58,6 +106,7 @@ function SelectTrigger({
|
|||
function SelectContent({
|
||||
container,
|
||||
className,
|
||||
variant = "default",
|
||||
children,
|
||||
positionerClassName,
|
||||
side = "bottom",
|
||||
|
|
@ -68,6 +117,7 @@ function SelectContent({
|
|||
...props
|
||||
}: SelectPrimitive.Popup.Props
|
||||
& Pick<SelectPrimitive.Portal.Props, "container">
|
||||
& VariantProps<typeof selectContentVariants>
|
||||
& {
|
||||
positionerClassName?: string
|
||||
}
|
||||
|
|
@ -83,12 +133,12 @@ function SelectContent({
|
|||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className={cn("isolate z-50", positionerClassName)}
|
||||
className={cn("pointer-events-auto isolate z-50", positionerClassName)}
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none", SHARED_POPUP_CLOSED_STATE_CLASS, className)}
|
||||
className={cn(selectContentVariants({ variant }), SHARED_POPUP_CLOSED_STATE_CLASS, className)}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
|
|
@ -115,16 +165,14 @@ function SelectLabel({
|
|||
|
||||
function SelectItem({
|
||||
className,
|
||||
variant = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
}: SelectPrimitive.Item.Props & VariantProps<typeof selectItemVariants>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
className={cn(selectItemVariants({ variant, className }))}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 gap-2 shrink-0 whitespace-nowrap">
|
||||
|
|
|
|||
|
|
@ -430,6 +430,13 @@ describe("selectionPopover", () => {
|
|||
})
|
||||
})
|
||||
|
||||
it("applies configured opacity on the popover surface instead of the viewport host", () => {
|
||||
const { element } = renderPopover()
|
||||
|
||||
expect(element.parentElement?.style.opacity).toBe("")
|
||||
expect(element.style.opacity).toBe("var(--rf-selection-opacity, 1)")
|
||||
})
|
||||
|
||||
it("keeps the body shrinkable so overflow can scroll after viewport changes", () => {
|
||||
const { element } = renderPopover()
|
||||
|
||||
|
|
|
|||
|
|
@ -290,6 +290,7 @@ function SelectionPopoverShell({
|
|||
style={{
|
||||
display: "flex",
|
||||
...style,
|
||||
opacity: "var(--rf-selection-opacity, 1)",
|
||||
maxWidth: "100vw",
|
||||
maxHeight: "100vh",
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,38 @@
|
|||
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">
|
||||
<img
|
||||
src={data?.user.image ?? guest}
|
||||
alt="User"
|
||||
className={cn("rounded-full border size-6", !data?.user.image && "p-1", isPending && "animate-pulse")}
|
||||
/>
|
||||
{isPending ? "Loading..." : data?.user.name ?? "Guest"}
|
||||
{!isPending && !data && (
|
||||
<Avatar size="sm" className={cn(isPending && "animate-pulse")}>
|
||||
<AvatarImage src={avatarSrc || ""} alt={displayName} />
|
||||
<AvatarFallback>{fallbackText}</AvatarFallback>
|
||||
</Avatar>
|
||||
{isPending ? "Loading..." : displayName}
|
||||
{!isPending && !user && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
|
|||
const streamTextMock = vi.fn()
|
||||
const outputObjectMock = vi.fn((params: Record<string, unknown>) => params)
|
||||
const getModelByIdMock = vi.fn()
|
||||
const loggerErrorMock = vi.fn()
|
||||
const parsePartialJsonMock = vi.fn(async (text: string | undefined) => {
|
||||
if (!text) {
|
||||
return { state: "undefined-input", value: undefined }
|
||||
}
|
||||
|
||||
try {
|
||||
return { state: "successful-parse", value: JSON.parse(text) }
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
return { state: "repaired-parse", value: JSON.parse(`${text}}`) }
|
||||
}
|
||||
catch {
|
||||
return { state: "failed-parse", value: undefined }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
class MockNoOutputGeneratedError extends Error {
|
||||
static isInstance(error: unknown): error is MockNoOutputGeneratedError {
|
||||
|
|
@ -13,6 +31,7 @@ class MockNoOutputGeneratedError extends Error {
|
|||
|
||||
vi.mock("ai", () => ({
|
||||
streamText: streamTextMock,
|
||||
parsePartialJson: parsePartialJsonMock,
|
||||
NoOutputGeneratedError: MockNoOutputGeneratedError,
|
||||
Output: {
|
||||
object: outputObjectMock,
|
||||
|
|
@ -25,7 +44,7 @@ vi.mock("@/utils/providers/model", () => ({
|
|||
|
||||
vi.mock("@/utils/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
error: loggerErrorMock,
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
@ -87,15 +106,16 @@ describe("background-stream", () => {
|
|||
it("streams structured object output from background", async () => {
|
||||
getModelByIdMock.mockResolvedValue("mock-model")
|
||||
streamTextMock.mockReturnValue({
|
||||
partialOutputStream: (async function* () {
|
||||
yield { score: 97 }
|
||||
yield { score: 97, summary: "Strong argument structure" }
|
||||
fullStream: (async function* () {
|
||||
yield { type: "text-delta", text: "{\"score\":97" }
|
||||
yield { type: "text-delta", text: ",\"summary\":\"Strong argument structure\"}" }
|
||||
})(),
|
||||
output: Promise.resolve({
|
||||
score: 97,
|
||||
summary: "Strong argument structure",
|
||||
}),
|
||||
fullStream: (async function* () {})(),
|
||||
get output() {
|
||||
throw new Error("structured stream should not consume output separately")
|
||||
},
|
||||
get partialOutputStream() {
|
||||
throw new Error("structured stream should not consume partialOutputStream separately")
|
||||
},
|
||||
})
|
||||
|
||||
const chunkSnapshots: BackgroundStructuredObjectStreamSnapshot[] = []
|
||||
|
|
@ -237,7 +257,9 @@ describe("background-stream", () => {
|
|||
options.onError?.({ error: rootCause })
|
||||
return {
|
||||
fullStream: (async function* () {})(),
|
||||
output: Promise.reject(new MockNoOutputGeneratedError("No output generated. Check the stream for errors.")),
|
||||
get output() {
|
||||
throw new Error("text stream should not consume output separately")
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -294,6 +316,50 @@ describe("background-stream", () => {
|
|||
expect(mockPort.disconnect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("treats stream port disconnect aborts as expected cancellation", async () => {
|
||||
getModelByIdMock.mockResolvedValue("mock-model")
|
||||
let streamSignal: AbortSignal | undefined
|
||||
|
||||
streamTextMock.mockImplementation((options: { abortSignal?: AbortSignal }) => {
|
||||
streamSignal = options.abortSignal
|
||||
return {
|
||||
fullStream: (async function* () {
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
options.abortSignal?.addEventListener("abort", () => {
|
||||
reject(options.abortSignal?.reason ?? new DOMException("aborted", "AbortError"))
|
||||
})
|
||||
})
|
||||
})(),
|
||||
output: new Promise<string>(() => {}),
|
||||
}
|
||||
})
|
||||
|
||||
const { handleStreamTextPort } = await import("../background-stream")
|
||||
const mockPort = createMockPort("stream-text")
|
||||
|
||||
handleStreamTextPort(mockPort.port as never)
|
||||
const startPromise = mockPort.emitMessage({
|
||||
type: "start",
|
||||
requestId: "req-text-abort",
|
||||
payload: {
|
||||
providerId: "openai-default",
|
||||
prompt: "Say hello",
|
||||
},
|
||||
})
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(streamTextMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
mockPort.emitDisconnect()
|
||||
await startPromise
|
||||
|
||||
expect(streamSignal?.aborted).toBe(true)
|
||||
expect(loggerErrorMock).not.toHaveBeenCalled()
|
||||
expect(mockPort.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: "error",
|
||||
}))
|
||||
})
|
||||
|
||||
it("returns error for invalid text start payload and disconnects", async () => {
|
||||
const { handleStreamTextPort } = await import("../background-stream")
|
||||
const mockPort = createMockPort("stream-text")
|
||||
|
|
|
|||
279
src/entrypoints/background/__tests__/side-panel.test.ts
Normal file
279
src/entrypoints/background/__tests__/side-panel.test.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { createSidePanelWindowState, createToggleSidePanelHandler, getSidePanelApi, setupSidePanelMessageHandler } from "../side-panel"
|
||||
|
||||
function createLogger() {
|
||||
return {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
const senderWindowMessage = {
|
||||
sender: {
|
||||
tab: {
|
||||
id: 123,
|
||||
windowId: 456,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function chromiumSidePanel<TApi>(api: TApi) {
|
||||
return {
|
||||
kind: "chromium-side-panel" as const,
|
||||
api,
|
||||
}
|
||||
}
|
||||
|
||||
function firefoxSidebarAction<TApi>(api: TApi) {
|
||||
return {
|
||||
kind: "firefox-sidebar-action" as const,
|
||||
api,
|
||||
}
|
||||
}
|
||||
|
||||
describe("background side panel", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it("opens the global side panel synchronously so Chrome keeps the user gesture", async () => {
|
||||
const logger = createLogger()
|
||||
const calls: string[] = []
|
||||
const sidePanel = {
|
||||
setOptions: vi.fn(() => {
|
||||
calls.push("setOptions")
|
||||
}),
|
||||
open: vi.fn((_options: { windowId: number }) => {
|
||||
calls.push("open")
|
||||
return Promise.resolve()
|
||||
}),
|
||||
}
|
||||
|
||||
const handler = createToggleSidePanelHandler({
|
||||
getApi: () => chromiumSidePanel(sidePanel),
|
||||
logger,
|
||||
})
|
||||
|
||||
const result = handler(senderWindowMessage)
|
||||
|
||||
expect(sidePanel.open).toHaveBeenCalledWith({ windowId: 456 })
|
||||
expect(sidePanel.open.mock.calls[0]?.[0]).not.toHaveProperty("tabId")
|
||||
expect(sidePanel.setOptions).not.toHaveBeenCalled()
|
||||
expect(calls).toEqual(["open"])
|
||||
await expect(result).resolves.toEqual({ ok: true, action: "opened" })
|
||||
})
|
||||
|
||||
it("closes the global side panel when the sender window is already open", async () => {
|
||||
const logger = createLogger()
|
||||
const windowState = createSidePanelWindowState()
|
||||
const calls: string[] = []
|
||||
const sidePanel = {
|
||||
close: vi.fn((_options: { windowId: number }) => {
|
||||
calls.push("close")
|
||||
return Promise.resolve()
|
||||
}),
|
||||
open: vi.fn((_options: { windowId: number }) => {
|
||||
calls.push("open")
|
||||
return Promise.resolve()
|
||||
}),
|
||||
}
|
||||
windowState.markOpened({ windowId: 456 })
|
||||
|
||||
const handler = createToggleSidePanelHandler({
|
||||
getApi: () => chromiumSidePanel(sidePanel),
|
||||
logger,
|
||||
windowState,
|
||||
})
|
||||
|
||||
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: true, action: "closed" })
|
||||
expect(sidePanel.close).toHaveBeenCalledWith({ windowId: 456 })
|
||||
expect(sidePanel.close.mock.calls[0]?.[0]).not.toHaveProperty("tabId")
|
||||
expect(sidePanel.open).not.toHaveBeenCalled()
|
||||
expect(calls).toEqual(["close"])
|
||||
expect(windowState.isOpen(456)).toBe(false)
|
||||
})
|
||||
|
||||
it("tracks browser side panel open and close events for toggle state", async () => {
|
||||
const logger = createLogger()
|
||||
const onOpenedListeners: Array<(info: { windowId?: number }) => void> = []
|
||||
const onClosedListeners: Array<(info: { windowId?: number }) => void> = []
|
||||
const registeredMessageHandlers = new Map<string, (message: typeof senderWindowMessage) => Promise<{ ok: true } | { ok: false, reason: string }>>()
|
||||
const sidePanel = {
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
open: vi.fn().mockResolvedValue(undefined),
|
||||
onClosed: {
|
||||
addListener: vi.fn((listener) => {
|
||||
onClosedListeners.push(listener)
|
||||
}),
|
||||
},
|
||||
onOpened: {
|
||||
addListener: vi.fn((listener) => {
|
||||
onOpenedListeners.push(listener)
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
setupSidePanelMessageHandler({
|
||||
extensionBrowser: { sidePanel } as any,
|
||||
logger,
|
||||
registerMessageHandler: ((type: string, handler: (message: typeof senderWindowMessage) => Promise<{ ok: true } | { ok: false, reason: string }>) => {
|
||||
registeredMessageHandlers.set(type, handler)
|
||||
}) as any,
|
||||
})
|
||||
|
||||
onOpenedListeners[0]?.({ windowId: 456 })
|
||||
|
||||
const handler = registeredMessageHandlers.get("toggleSidePanel")
|
||||
if (!handler) {
|
||||
throw new Error("toggleSidePanel handler was not registered")
|
||||
}
|
||||
|
||||
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: true, action: "closed" })
|
||||
expect(sidePanel.close).toHaveBeenCalledWith({ windowId: 456 })
|
||||
|
||||
onClosedListeners[0]?.({ windowId: 456 })
|
||||
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: true, action: "opened" })
|
||||
expect(sidePanel.open).toHaveBeenCalledWith({ windowId: 456 })
|
||||
})
|
||||
|
||||
it("returns an unsupported result when closing is unavailable", async () => {
|
||||
const logger = createLogger()
|
||||
const windowState = createSidePanelWindowState()
|
||||
const sidePanel = {
|
||||
open: vi.fn(),
|
||||
}
|
||||
windowState.markOpened({ windowId: 456 })
|
||||
|
||||
const handler = createToggleSidePanelHandler({
|
||||
getApi: () => chromiumSidePanel(sidePanel),
|
||||
logger,
|
||||
windowState,
|
||||
})
|
||||
|
||||
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: false, reason: "unsupported" })
|
||||
expect(logger.warn).toHaveBeenCalledWith("Side panel close API is unavailable in this browser")
|
||||
expect(sidePanel.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("clears stale open state when Chrome rejects close", async () => {
|
||||
const logger = createLogger()
|
||||
const windowState = createSidePanelWindowState()
|
||||
const error = new Error("No active global side panel")
|
||||
const sidePanel = {
|
||||
close: vi.fn().mockRejectedValue(error),
|
||||
open: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
windowState.markOpened({ windowId: 456 })
|
||||
|
||||
const handler = createToggleSidePanelHandler({
|
||||
getApi: () => chromiumSidePanel(sidePanel),
|
||||
logger,
|
||||
windowState,
|
||||
})
|
||||
|
||||
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: false, reason: "toggle-failed" })
|
||||
expect(logger.error).toHaveBeenCalledWith("Failed to close side panel", error)
|
||||
expect(windowState.isOpen(456)).toBe(false)
|
||||
|
||||
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: true, action: "opened" })
|
||||
expect(sidePanel.open).toHaveBeenCalledWith({ windowId: 456 })
|
||||
})
|
||||
|
||||
it("returns an unsupported result when the side panel API is unavailable", async () => {
|
||||
const logger = createLogger()
|
||||
const handler = createToggleSidePanelHandler({
|
||||
getApi: () => null,
|
||||
logger,
|
||||
})
|
||||
|
||||
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: false, reason: "unsupported" })
|
||||
expect(logger.warn).toHaveBeenCalledWith("Side panel API is unavailable in this browser")
|
||||
})
|
||||
|
||||
it("does not open the Firefox sidebar from a content-script message", async () => {
|
||||
const logger = createLogger()
|
||||
const sidebarAction = {
|
||||
open: vi.fn(),
|
||||
}
|
||||
const handler = createToggleSidePanelHandler({
|
||||
getApi: () => firefoxSidebarAction(sidebarAction),
|
||||
logger,
|
||||
})
|
||||
|
||||
await expect(handler(senderWindowMessage)).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "requires-extension-user-action",
|
||||
})
|
||||
expect(sidebarAction.open).not.toHaveBeenCalled()
|
||||
expect(logger.warn).toHaveBeenCalledWith("Firefox sidebar requires an extension user action")
|
||||
})
|
||||
|
||||
it("opens the Firefox sidebar when called from an extension user action", async () => {
|
||||
const logger = createLogger()
|
||||
const sidebarAction = {
|
||||
open: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
const handler = createToggleSidePanelHandler({
|
||||
getApi: () => firefoxSidebarAction(sidebarAction),
|
||||
logger,
|
||||
})
|
||||
|
||||
const result = handler({ data: { source: "extension-user-action" } })
|
||||
|
||||
expect(sidebarAction.open).toHaveBeenCalled()
|
||||
await expect(result).resolves.toEqual({ ok: true, action: "opened" })
|
||||
})
|
||||
|
||||
it("returns a missing-window result when the sender window id is unavailable", async () => {
|
||||
const logger = createLogger()
|
||||
const handler = createToggleSidePanelHandler({
|
||||
getApi: () => chromiumSidePanel({ open: vi.fn() }),
|
||||
logger,
|
||||
})
|
||||
|
||||
await expect(handler({ sender: { tab: { id: 123 } } })).resolves.toEqual({ ok: false, reason: "missing-window" })
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
"Cannot toggle side panel without a sender window",
|
||||
{ sender: { tab: { id: 123 } } },
|
||||
)
|
||||
})
|
||||
|
||||
it("returns a toggle-failed result when Chrome rejects the open request", async () => {
|
||||
const logger = createLogger()
|
||||
const error = new Error("sidePanel.open() may only be called in response to a user gesture")
|
||||
const handler = createToggleSidePanelHandler({
|
||||
getApi: () => chromiumSidePanel({
|
||||
open: vi.fn().mockRejectedValue(error),
|
||||
}),
|
||||
logger,
|
||||
})
|
||||
|
||||
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: false, reason: "toggle-failed" })
|
||||
expect(logger.error).toHaveBeenCalledWith("Failed to open side panel", error)
|
||||
})
|
||||
|
||||
it("finds the Chrome sidePanel API when the WXT browser wrapper does not expose it", () => {
|
||||
const sidePanel = {
|
||||
open: vi.fn(),
|
||||
}
|
||||
vi.stubGlobal("chrome", {
|
||||
sidePanel,
|
||||
})
|
||||
|
||||
expect(getSidePanelApi({} as any)).toEqual({
|
||||
kind: "chromium-side-panel",
|
||||
api: sidePanel,
|
||||
})
|
||||
})
|
||||
|
||||
it("finds the Firefox sidebarAction API from the WXT browser wrapper", () => {
|
||||
const sidebarAction = {
|
||||
open: vi.fn(),
|
||||
}
|
||||
|
||||
expect(getSidePanelApi({ sidebarAction } as any)).toEqual({
|
||||
kind: "firefox-sidebar-action",
|
||||
api: sidebarAction,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -14,7 +14,7 @@ import type {
|
|||
StreamRuntimeOptions,
|
||||
ThinkingSnapshot,
|
||||
} from "@/types/background-stream"
|
||||
import { Output, streamText } from "ai"
|
||||
import { Output, parsePartialJson, streamText } from "ai"
|
||||
import { z } from "zod"
|
||||
import { BACKGROUND_STREAM_PORTS } from "@/types/background-stream"
|
||||
import { extractAISDKErrorMessage } from "@/utils/error/extract-message"
|
||||
|
|
@ -23,6 +23,15 @@ import { getModelById } from "@/utils/providers/model"
|
|||
|
||||
const invalidStreamStartPayloadMessage = "Invalid stream start payload"
|
||||
|
||||
function createStreamAbortError(message: string) {
|
||||
return new DOMException(message, "AbortError")
|
||||
}
|
||||
|
||||
function isAbortLikeError(error: unknown) {
|
||||
return (error instanceof DOMException && error.name === "AbortError")
|
||||
|| (error instanceof Error && error.name === "AbortError")
|
||||
}
|
||||
|
||||
const streamPortStartEnvelopeSchema = z.object({
|
||||
type: z.literal("start"),
|
||||
requestId: z.string().trim().min(1),
|
||||
|
|
@ -128,7 +137,7 @@ function createStreamPortHandler<TSerializablePayload, TResponse>(
|
|||
}
|
||||
|
||||
disconnectListener = () => {
|
||||
abortController.abort()
|
||||
abortController.abort(createStreamAbortError("stream port disconnected"))
|
||||
cleanup()
|
||||
}
|
||||
|
||||
|
|
@ -191,10 +200,12 @@ function createStreamPortHandler<TSerializablePayload, TResponse>(
|
|||
}
|
||||
catch (error) {
|
||||
const finalError = streamError ?? error
|
||||
logger.error("[Background] Stream Function failed", finalError)
|
||||
if (!abortController.signal.aborted) {
|
||||
safePost({ type: "error", error: { message: extractAISDKErrorMessage(finalError) } })
|
||||
if (abortController.signal.aborted || isAbortLikeError(finalError)) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.error("[Background] Stream Function failed", finalError)
|
||||
safePost({ type: "error", error: { message: extractAISDKErrorMessage(finalError) } })
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
|
|
@ -225,6 +236,10 @@ function createStreamSnapshot<TOutput>(
|
|||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export async function runStreamTextInBackground(
|
||||
serializablePayload: BackgroundStreamTextSerializablePayload,
|
||||
options: StreamRuntimeOptions<BackgroundTextStreamSnapshot> = {},
|
||||
|
|
@ -279,16 +294,18 @@ export async function runStreamTextInBackground(
|
|||
onChunk?.(createStreamSnapshot(cumulativeText, thinking))
|
||||
break
|
||||
}
|
||||
case "error": {
|
||||
throw part.error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalText = await result.output
|
||||
thinking = {
|
||||
...thinking,
|
||||
status: "complete",
|
||||
}
|
||||
|
||||
return createStreamSnapshot(finalText, thinking)
|
||||
return createStreamSnapshot(cumulativeText, thinking)
|
||||
}
|
||||
|
||||
export async function runStructuredObjectStreamInBackground(
|
||||
|
|
@ -318,64 +335,60 @@ export async function runStructuredObjectStreamInBackground(
|
|||
for (const field of outputSchema) {
|
||||
schemaShape[field.name] = fieldTypeToZodSchema[field.type] ?? z.string().nullable()
|
||||
}
|
||||
const objectSchema = z.object(schemaShape).strict()
|
||||
|
||||
const result = streamText({
|
||||
...(streamParams as Parameters<typeof streamText>[0]),
|
||||
model,
|
||||
output: Output.object({
|
||||
schema: z.object(schemaShape).strict(),
|
||||
schema: objectSchema,
|
||||
}),
|
||||
abortSignal: signal,
|
||||
onError: ({ error }) => {
|
||||
onError?.(error)
|
||||
},
|
||||
})
|
||||
let cumulativeText = ""
|
||||
|
||||
const consumePartialOutput = async () => {
|
||||
for await (const partial of result.partialOutputStream) {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException("stream aborted", "AbortError")
|
||||
for await (const part of result.fullStream) {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException("stream aborted", "AbortError")
|
||||
}
|
||||
|
||||
switch (part.type) {
|
||||
case "text-delta": {
|
||||
cumulativeText += part.text
|
||||
const partial = await parsePartialJson(cumulativeText)
|
||||
if (isRecord(partial.value)) {
|
||||
cumulativeValue = { ...cumulativeValue, ...partial.value }
|
||||
onChunk?.(createStreamSnapshot(cumulativeValue, thinking))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (partial && typeof partial === "object" && !Array.isArray(partial)) {
|
||||
cumulativeValue = { ...cumulativeValue, ...partial }
|
||||
case "reasoning-delta": {
|
||||
thinking = {
|
||||
status: "thinking",
|
||||
text: thinking.text + part.text,
|
||||
}
|
||||
onChunk?.(createStreamSnapshot(cumulativeValue, thinking))
|
||||
break
|
||||
}
|
||||
case "reasoning-end": {
|
||||
thinking = {
|
||||
...thinking,
|
||||
status: "complete",
|
||||
}
|
||||
onChunk?.(createStreamSnapshot(cumulativeValue, thinking))
|
||||
break
|
||||
}
|
||||
case "error": {
|
||||
throw part.error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const consumeFullStream = async () => {
|
||||
for await (const part of result.fullStream) {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException("stream aborted", "AbortError")
|
||||
}
|
||||
|
||||
switch (part.type) {
|
||||
case "reasoning-delta": {
|
||||
thinking = {
|
||||
status: "thinking",
|
||||
text: thinking.text + part.text,
|
||||
}
|
||||
onChunk?.(createStreamSnapshot(cumulativeValue, thinking))
|
||||
break
|
||||
}
|
||||
case "reasoning-end": {
|
||||
thinking = {
|
||||
...thinking,
|
||||
status: "complete",
|
||||
}
|
||||
onChunk?.(createStreamSnapshot(cumulativeValue, thinking))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [finalValue] = await Promise.all([
|
||||
result.output,
|
||||
consumePartialOutput(),
|
||||
consumeFullStream(),
|
||||
])
|
||||
const finalJson = await parsePartialJson(cumulativeText)
|
||||
const finalValue = objectSchema.parse(finalJson.value)
|
||||
|
||||
thinking = {
|
||||
...thinking,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { browser, defineBackground } from "#imports"
|
|||
import { env } from "@/env"
|
||||
import { logger } from "@/utils/logger"
|
||||
import { onMessage } from "@/utils/message"
|
||||
import { openOptionsPage } from "@/utils/navigation"
|
||||
import { SessionCacheGroupRegistry } from "@/utils/session-cache/session-cache-group-registry"
|
||||
import { runAiSegmentSubtitles } from "./ai-segmentation"
|
||||
import { setupAnalyticsMessageHandlers } from "./analytics"
|
||||
|
|
@ -17,6 +18,7 @@ import { setupLLMGenerateTextMessageHandlers } from "./llm-generate-text"
|
|||
import { initMockData } from "./mock-data"
|
||||
import { newUserGuide } from "./new-user-guide"
|
||||
import { proxyFetch } from "./proxy-fetch"
|
||||
import { setupSidePanelMessageHandler } from "./side-panel"
|
||||
import { setUpSubtitlesTranslationQueue, setUpWebPageTranslationQueue } from "./translation-queues"
|
||||
import { translationMessage } from "./translation-signal"
|
||||
import { setupTTSPlaybackMessageHandlers } from "./tts-playback"
|
||||
|
|
@ -50,9 +52,15 @@ export default defineBackground({
|
|||
await browser.tabs.create({ url, active: active ?? true })
|
||||
})
|
||||
|
||||
onMessage("openOptionsPage", () => {
|
||||
onMessage("openOptionsPage", async () => {
|
||||
logger.info("openOptionsPage")
|
||||
void browser.runtime.openOptionsPage()
|
||||
await openOptionsPage()
|
||||
})
|
||||
|
||||
setupSidePanelMessageHandler({
|
||||
extensionBrowser: browser,
|
||||
logger,
|
||||
registerMessageHandler: onMessage,
|
||||
})
|
||||
|
||||
onMessage("aiSegmentSubtitles", async (message) => {
|
||||
|
|
|
|||
276
src/entrypoints/background/side-panel.ts
Normal file
276
src/entrypoints/background/side-panel.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import type { browser } from "#imports"
|
||||
import type { onMessage } from "@/utils/message"
|
||||
|
||||
interface ChromiumSidePanelApi {
|
||||
close?: (options: { windowId: number }) => Promise<void> | void
|
||||
open: (options: { windowId: number }) => Promise<void> | void
|
||||
onClosed?: SidePanelEvent<SidePanelStateInfo>
|
||||
onOpened?: SidePanelEvent<SidePanelStateInfo>
|
||||
}
|
||||
|
||||
interface FirefoxSidebarActionApi {
|
||||
close?: () => Promise<void> | void
|
||||
open?: () => Promise<void> | void
|
||||
toggle?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
type BrowserSidePanelApi
|
||||
= | { kind: "chromium-side-panel", api: ChromiumSidePanelApi }
|
||||
| { kind: "firefox-sidebar-action", api: FirefoxSidebarActionApi }
|
||||
|
||||
interface SidePanelEvent<TInfo> {
|
||||
addListener: (callback: (info: TInfo) => void) => void
|
||||
}
|
||||
|
||||
interface SidePanelStateInfo {
|
||||
windowId?: number
|
||||
}
|
||||
|
||||
interface ToggleSidePanelMessage {
|
||||
data?: {
|
||||
source?: "content-script" | "extension-user-action"
|
||||
}
|
||||
sender?: {
|
||||
tab?: {
|
||||
id?: number
|
||||
windowId?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ToggleSidePanelResult
|
||||
= | { ok: true, action: "opened" | "closed" }
|
||||
| { ok: false, reason: "missing-window" | "unsupported" | "toggle-failed" | "requires-extension-user-action" }
|
||||
|
||||
interface SidePanelLogger {
|
||||
error: (...args: any[]) => void
|
||||
warn: (...args: any[]) => void
|
||||
}
|
||||
|
||||
export function createSidePanelWindowState() {
|
||||
const activeWindowIds = new Set<number>()
|
||||
|
||||
return {
|
||||
isOpen(windowId: number) {
|
||||
return activeWindowIds.has(windowId)
|
||||
},
|
||||
markClosed(info: SidePanelStateInfo) {
|
||||
if (typeof info.windowId === "number") {
|
||||
activeWindowIds.delete(info.windowId)
|
||||
}
|
||||
},
|
||||
markOpened(info: SidePanelStateInfo) {
|
||||
if (typeof info.windowId === "number") {
|
||||
activeWindowIds.add(info.windowId)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getToggleSource(message: ToggleSidePanelMessage) {
|
||||
return message.data?.source ?? "content-script"
|
||||
}
|
||||
|
||||
function toChromiumSidePanelApi(api: Partial<ChromiumSidePanelApi> | undefined): BrowserSidePanelApi | null {
|
||||
if (typeof api?.open !== "function") {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "chromium-side-panel",
|
||||
api: api as ChromiumSidePanelApi,
|
||||
}
|
||||
}
|
||||
|
||||
function toFirefoxSidebarActionApi(api: Partial<FirefoxSidebarActionApi> | undefined): BrowserSidePanelApi | null {
|
||||
if (typeof api?.open !== "function" && typeof api?.toggle !== "function") {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "firefox-sidebar-action",
|
||||
api: api as FirefoxSidebarActionApi,
|
||||
}
|
||||
}
|
||||
|
||||
export function getSidePanelApi(extensionBrowser: typeof browser): BrowserSidePanelApi | null {
|
||||
const browserWithSidePanel = extensionBrowser as typeof extensionBrowser & { sidePanel?: Partial<ChromiumSidePanelApi> }
|
||||
if (typeof browserWithSidePanel.sidePanel?.open === "function") {
|
||||
return toChromiumSidePanelApi(browserWithSidePanel.sidePanel)
|
||||
}
|
||||
|
||||
const globalWithChrome = globalThis as typeof globalThis & {
|
||||
chrome?: { sidePanel?: Partial<ChromiumSidePanelApi> }
|
||||
}
|
||||
if (typeof globalWithChrome.chrome?.sidePanel?.open === "function") {
|
||||
return toChromiumSidePanelApi(globalWithChrome.chrome.sidePanel)
|
||||
}
|
||||
|
||||
const browserWithSidebarAction = extensionBrowser as typeof extensionBrowser & { sidebarAction?: Partial<FirefoxSidebarActionApi> }
|
||||
const sidebarAction = toFirefoxSidebarActionApi(browserWithSidebarAction.sidebarAction)
|
||||
if (sidebarAction) {
|
||||
return sidebarAction
|
||||
}
|
||||
|
||||
const globalWithBrowser = globalThis as typeof globalThis & {
|
||||
browser?: { sidebarAction?: Partial<FirefoxSidebarActionApi> }
|
||||
}
|
||||
const globalSidebarAction = toFirefoxSidebarActionApi(globalWithBrowser.browser?.sidebarAction)
|
||||
if (globalSidebarAction) {
|
||||
return globalSidebarAction
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function toggleFirefoxSidebarAction({
|
||||
api,
|
||||
logger,
|
||||
source,
|
||||
}: {
|
||||
api: FirefoxSidebarActionApi
|
||||
logger: SidePanelLogger
|
||||
source: ReturnType<typeof getToggleSource>
|
||||
}): Promise<ToggleSidePanelResult> {
|
||||
if (source !== "extension-user-action") {
|
||||
logger.warn("Firefox sidebar requires an extension user action")
|
||||
return Promise.resolve({ ok: false, reason: "requires-extension-user-action" } as const)
|
||||
}
|
||||
|
||||
const openSidebar = typeof api.open === "function"
|
||||
? () => api.open?.()
|
||||
: typeof api.toggle === "function"
|
||||
? () => api.toggle?.()
|
||||
: null
|
||||
if (!openSidebar) {
|
||||
logger.warn("Firefox sidebar open API is unavailable in this browser")
|
||||
return Promise.resolve({ ok: false, reason: "unsupported" } as const)
|
||||
}
|
||||
|
||||
try {
|
||||
const openResult = openSidebar()
|
||||
return Promise.resolve(openResult)
|
||||
.then(() => ({ ok: true, action: "opened" } as const))
|
||||
.catch((error) => {
|
||||
logger.error("Failed to open Firefox sidebar", error)
|
||||
return { ok: false, reason: "toggle-failed" } as const
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
logger.error("Failed to open Firefox sidebar", error)
|
||||
return Promise.resolve({ ok: false, reason: "toggle-failed" } as const)
|
||||
}
|
||||
}
|
||||
|
||||
export function createToggleSidePanelHandler({
|
||||
getApi,
|
||||
logger,
|
||||
windowState = createSidePanelWindowState(),
|
||||
}: {
|
||||
getApi: () => BrowserSidePanelApi | null
|
||||
logger: SidePanelLogger
|
||||
windowState?: ReturnType<typeof createSidePanelWindowState>
|
||||
}) {
|
||||
return (message: ToggleSidePanelMessage): Promise<ToggleSidePanelResult> => {
|
||||
const browserSidePanel = getApi()
|
||||
if (!browserSidePanel) {
|
||||
logger.warn("Side panel API is unavailable in this browser")
|
||||
return Promise.resolve({ ok: false, reason: "unsupported" } as const)
|
||||
}
|
||||
|
||||
if (browserSidePanel.kind === "firefox-sidebar-action") {
|
||||
return toggleFirefoxSidebarAction({
|
||||
api: browserSidePanel.api,
|
||||
logger,
|
||||
source: getToggleSource(message),
|
||||
})
|
||||
}
|
||||
|
||||
const windowId = message.sender?.tab?.windowId
|
||||
if (typeof windowId !== "number") {
|
||||
logger.warn("Cannot toggle side panel without a sender window", message)
|
||||
return Promise.resolve({ ok: false, reason: "missing-window" } as const)
|
||||
}
|
||||
|
||||
const sidePanel = browserSidePanel.api
|
||||
|
||||
if (windowState.isOpen(windowId)) {
|
||||
if (typeof sidePanel.close !== "function") {
|
||||
logger.warn("Side panel close API is unavailable in this browser")
|
||||
return Promise.resolve({ ok: false, reason: "unsupported" } as const)
|
||||
}
|
||||
|
||||
try {
|
||||
const closeResult = sidePanel.close({ windowId })
|
||||
return Promise.resolve(closeResult)
|
||||
.then(() => {
|
||||
windowState.markClosed({ windowId })
|
||||
return { ok: true, action: "closed" } as const
|
||||
})
|
||||
.catch((error) => {
|
||||
windowState.markClosed({ windowId })
|
||||
logger.error("Failed to close side panel", error)
|
||||
return { ok: false, reason: "toggle-failed" } as const
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
windowState.markClosed({ windowId })
|
||||
logger.error("Failed to close side panel", error)
|
||||
return Promise.resolve({ ok: false, reason: "toggle-failed" } as const)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Chrome requires sidePanel.open() to run directly in the user-gesture
|
||||
// task. Do not await other async APIs before this call.
|
||||
const openResult = sidePanel.open({ windowId })
|
||||
return Promise.resolve(openResult)
|
||||
.then(() => {
|
||||
windowState.markOpened({ windowId })
|
||||
return { ok: true, action: "opened" } as const
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error("Failed to open side panel", error)
|
||||
return { ok: false, reason: "toggle-failed" } as const
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
logger.error("Failed to open side panel", error)
|
||||
return Promise.resolve({ ok: false, reason: "toggle-failed" } as const)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setupSidePanelMessageHandler({
|
||||
extensionBrowser,
|
||||
logger,
|
||||
registerMessageHandler,
|
||||
}: {
|
||||
extensionBrowser: typeof browser
|
||||
logger: SidePanelLogger
|
||||
registerMessageHandler: typeof onMessage
|
||||
}) {
|
||||
const windowState = createSidePanelWindowState()
|
||||
const sidePanel = getSidePanelApi(extensionBrowser)
|
||||
if (sidePanel?.kind !== "chromium-side-panel") {
|
||||
registerMessageHandler("toggleSidePanel", createToggleSidePanelHandler({
|
||||
getApi: () => getSidePanelApi(extensionBrowser),
|
||||
logger,
|
||||
windowState,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
sidePanel.api.onOpened?.addListener((info) => {
|
||||
windowState.markOpened(info)
|
||||
})
|
||||
sidePanel.api.onClosed?.addListener((info) => {
|
||||
windowState.markClosed(info)
|
||||
})
|
||||
|
||||
registerMessageHandler("toggleSidePanel", createToggleSidePanelHandler({
|
||||
getApi: () => getSidePanelApi(extensionBrowser),
|
||||
logger,
|
||||
windowState,
|
||||
}))
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import type { LangCodeISO6393 } from "@read-frog/definitions"
|
|||
import type { Config } from "@/types/config/config"
|
||||
import { storage } from "#imports"
|
||||
import { DEFAULT_CONFIG, DETECTED_CODE_STORAGE_KEY } from "@/utils/constants/config"
|
||||
import { getDocumentInfo } from "@/utils/content/analyze"
|
||||
import { detectPageLanguageLightweight } from "@/utils/content/page-language"
|
||||
import { ensurePresetStyles } from "@/utils/host/translate/ui/style-injector"
|
||||
import { logger } from "@/utils/logger"
|
||||
import { onMessage, sendMessage } from "@/utils/message"
|
||||
|
|
@ -55,7 +55,7 @@ export async function bootstrapHostContent(ctx: ContentScriptContext, initialCon
|
|||
}
|
||||
// Only the top frame should detect and set language to avoid race conditions from iframes
|
||||
if (window === window.top) {
|
||||
const { detectedCodeOrUnd } = await getDocumentInfo()
|
||||
const { detectedCodeOrUnd } = await detectPageLanguageLightweight()
|
||||
const detectedCode: LangCodeISO6393 = detectedCodeOrUnd === "und" ? "eng" : detectedCodeOrUnd
|
||||
await storage.setItem<LangCodeISO6393>(`local:${DETECTED_CODE_STORAGE_KEY}`, detectedCode)
|
||||
// Notify background script that URL has changed, let it decide whether to automatically enable translation
|
||||
|
|
@ -92,7 +92,7 @@ export async function bootstrapHostContent(ctx: ContentScriptContext, initialCon
|
|||
|
||||
// Only the top frame should detect and set language to avoid race conditions from iframes
|
||||
if (window === window.top) {
|
||||
const { detectedCodeOrUnd } = await getDocumentInfo()
|
||||
const { detectedCodeOrUnd } = await detectPageLanguageLightweight()
|
||||
const initialDetectedCode: LangCodeISO6393 = detectedCodeOrUnd === "und" ? "eng" : detectedCodeOrUnd
|
||||
await storage.setItem<LangCodeISO6393>(`local:${DETECTED_CODE_STORAGE_KEY}`, initialDetectedCode)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,307 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { DEFAULT_CONFIG } from "@/utils/constants/config"
|
||||
import { PageTranslationManager } from "../page-translation"
|
||||
|
||||
const {
|
||||
mockDeepQueryTopLevelSelector,
|
||||
mockGetDetectedCodeFromStorage,
|
||||
mockGetLocalConfig,
|
||||
mockGetOrCreateWebPageContext,
|
||||
mockHasNoWalkAncestor,
|
||||
mockIsDontWalkIntoAndDontTranslateAsChildElement,
|
||||
mockIsDontWalkIntoButTranslateAsChildElement,
|
||||
mockRemoveAllTranslatedWrapperNodes,
|
||||
mockSendMessage,
|
||||
mockTranslateTextForPageTitle,
|
||||
mockTranslateWalkedElement,
|
||||
mockValidateTranslationConfigAndToast,
|
||||
mockWalkAndLabelElement,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetDetectedCodeFromStorage: vi.fn(),
|
||||
mockGetLocalConfig: vi.fn(),
|
||||
mockGetOrCreateWebPageContext: vi.fn(),
|
||||
mockDeepQueryTopLevelSelector: vi.fn(),
|
||||
mockHasNoWalkAncestor: vi.fn(),
|
||||
mockIsDontWalkIntoAndDontTranslateAsChildElement: vi.fn(),
|
||||
mockIsDontWalkIntoButTranslateAsChildElement: vi.fn(),
|
||||
mockWalkAndLabelElement: vi.fn(),
|
||||
mockRemoveAllTranslatedWrapperNodes: vi.fn(),
|
||||
mockTranslateWalkedElement: vi.fn(),
|
||||
mockTranslateTextForPageTitle: vi.fn(),
|
||||
mockValidateTranslationConfigAndToast: vi.fn(),
|
||||
mockSendMessage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/config/languages", () => ({
|
||||
getDetectedCodeFromStorage: mockGetDetectedCodeFromStorage,
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/config/storage", () => ({
|
||||
getLocalConfig: mockGetLocalConfig,
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/crypto-polyfill", () => ({
|
||||
getRandomUUID: () => "walk-id",
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/host/dom/filter", () => ({
|
||||
hasNoWalkAncestor: mockHasNoWalkAncestor,
|
||||
isDontWalkIntoAndDontTranslateAsChildElement: mockIsDontWalkIntoAndDontTranslateAsChildElement,
|
||||
isDontWalkIntoButTranslateAsChildElement: mockIsDontWalkIntoButTranslateAsChildElement,
|
||||
isHTMLElement: (node: unknown) => node instanceof HTMLElement,
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/host/dom/find", () => ({
|
||||
deepQueryTopLevelSelector: mockDeepQueryTopLevelSelector,
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/host/dom/traversal", () => ({
|
||||
walkAndLabelElement: mockWalkAndLabelElement,
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/host/translate/node-manipulation", () => ({
|
||||
removeAllTranslatedWrapperNodes: mockRemoveAllTranslatedWrapperNodes,
|
||||
translateWalkedElement: mockTranslateWalkedElement,
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/host/translate/translate-text", () => ({
|
||||
validateTranslationConfigAndToast: mockValidateTranslationConfigAndToast,
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/host/translate/translate-variants", () => ({
|
||||
translateTextForPageTitle: mockTranslateTextForPageTitle,
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/host/translate/webpage-context", () => ({
|
||||
getOrCreateWebPageContext: mockGetOrCreateWebPageContext,
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/message", () => ({
|
||||
sendMessage: mockSendMessage,
|
||||
}))
|
||||
|
||||
const intersectionObservers: MockIntersectionObserver[] = []
|
||||
|
||||
class MockIntersectionObserver {
|
||||
observe = vi.fn((target: Element) => {
|
||||
this.targets.add(target)
|
||||
})
|
||||
|
||||
unobserve = vi.fn((target: Element) => {
|
||||
this.targets.delete(target)
|
||||
})
|
||||
|
||||
disconnect = vi.fn(() => {
|
||||
this.targets.clear()
|
||||
})
|
||||
|
||||
private readonly targets = new Set<Element>()
|
||||
|
||||
constructor(
|
||||
private readonly callback: IntersectionObserverCallback,
|
||||
_options?: IntersectionObserverInit,
|
||||
) {
|
||||
intersectionObservers.push(this)
|
||||
}
|
||||
|
||||
async triggerIntersect(target: Element): Promise<void> {
|
||||
await this.callback([{
|
||||
isIntersecting: true,
|
||||
target,
|
||||
} as IntersectionObserverEntry], this as unknown as IntersectionObserver)
|
||||
}
|
||||
}
|
||||
|
||||
async function flushDomUpdates(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function deepQueryTopLevelSelectorImpl(
|
||||
root: Document | ShadowRoot | HTMLElement,
|
||||
selectorFn: (element: HTMLElement) => boolean,
|
||||
): HTMLElement[] {
|
||||
if (root instanceof Document) {
|
||||
return root.body ? deepQueryTopLevelSelectorImpl(root.body, selectorFn) : []
|
||||
}
|
||||
|
||||
if (root instanceof HTMLElement && selectorFn(root)) {
|
||||
return [root]
|
||||
}
|
||||
|
||||
const result: HTMLElement[] = []
|
||||
|
||||
if (root instanceof HTMLElement && root.shadowRoot) {
|
||||
result.push(...deepQueryTopLevelSelectorImpl(root.shadowRoot, selectorFn))
|
||||
}
|
||||
|
||||
for (const child of root.children) {
|
||||
if (child instanceof HTMLElement) {
|
||||
result.push(...deepQueryTopLevelSelectorImpl(child, selectorFn))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function isBlockedForTraversal(element: HTMLElement): boolean {
|
||||
return Boolean(element.hidden)
|
||||
|| element.getAttribute("aria-hidden") === "true"
|
||||
|| element.classList.contains("closed")
|
||||
}
|
||||
|
||||
function walkAndLabelVisibleParagraphs(element: HTMLElement, walkId: string) {
|
||||
if (isBlockedForTraversal(element)) {
|
||||
return {
|
||||
forceBlock: false,
|
||||
isInlineNode: false,
|
||||
}
|
||||
}
|
||||
|
||||
element.setAttribute("data-read-frog-walked", walkId)
|
||||
|
||||
for (const child of element.children) {
|
||||
if (child instanceof HTMLElement) {
|
||||
walkAndLabelVisibleParagraphs(child, walkId)
|
||||
}
|
||||
}
|
||||
|
||||
if (element.tagName === "P" && element.textContent?.trim()) {
|
||||
element.setAttribute("data-read-frog-paragraph", "")
|
||||
}
|
||||
|
||||
return {
|
||||
forceBlock: false,
|
||||
isInlineNode: false,
|
||||
}
|
||||
}
|
||||
|
||||
describe("pageTranslationManager mutation re-walk", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
intersectionObservers.length = 0
|
||||
|
||||
document.head.innerHTML = ""
|
||||
document.body.innerHTML = ""
|
||||
document.title = ""
|
||||
|
||||
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver)
|
||||
|
||||
mockGetDetectedCodeFromStorage.mockResolvedValue("eng")
|
||||
mockGetLocalConfig.mockResolvedValue(DEFAULT_CONFIG)
|
||||
mockGetOrCreateWebPageContext.mockResolvedValue({
|
||||
url: window.location.href,
|
||||
webTitle: "",
|
||||
webContent: "",
|
||||
})
|
||||
mockHasNoWalkAncestor.mockReturnValue(false)
|
||||
mockIsDontWalkIntoButTranslateAsChildElement.mockReturnValue(false)
|
||||
mockIsDontWalkIntoAndDontTranslateAsChildElement.mockImplementation((element: HTMLElement) => isBlockedForTraversal(element))
|
||||
mockDeepQueryTopLevelSelector.mockImplementation(deepQueryTopLevelSelectorImpl)
|
||||
mockWalkAndLabelElement.mockImplementation((element: HTMLElement, walkId: string) => walkAndLabelVisibleParagraphs(element, walkId))
|
||||
mockTranslateTextForPageTitle.mockResolvedValue("")
|
||||
mockValidateTranslationConfigAndToast.mockReturnValue(true)
|
||||
mockSendMessage.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it("observes and translates hidden accordion content after it becomes visible", async () => {
|
||||
document.body.innerHTML = `
|
||||
<section id="accordion" hidden>
|
||||
<p id="panel">Accordion body</p>
|
||||
</section>
|
||||
`
|
||||
|
||||
const manager = new PageTranslationManager()
|
||||
await manager.start()
|
||||
await flushDomUpdates()
|
||||
|
||||
const observer = intersectionObservers[0]
|
||||
const accordion = document.getElementById("accordion") as HTMLElement
|
||||
const panel = document.getElementById("panel") as HTMLElement
|
||||
|
||||
expect(observer.observe).not.toHaveBeenCalled()
|
||||
|
||||
accordion.removeAttribute("hidden")
|
||||
await flushDomUpdates()
|
||||
|
||||
expect(observer.observe).toHaveBeenCalledWith(panel)
|
||||
|
||||
await observer.triggerIntersect(panel)
|
||||
await flushDomUpdates()
|
||||
|
||||
expect(mockTranslateWalkedElement).toHaveBeenCalledWith(panel, "walk-id", DEFAULT_CONFIG)
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("observes and translates aria-hidden accordion content after it becomes visible", async () => {
|
||||
document.body.innerHTML = `
|
||||
<section id="accordion" aria-hidden="true">
|
||||
<p id="panel">Accordion body</p>
|
||||
</section>
|
||||
`
|
||||
|
||||
const manager = new PageTranslationManager()
|
||||
await manager.start()
|
||||
await flushDomUpdates()
|
||||
|
||||
const observer = intersectionObservers[0]
|
||||
const accordion = document.getElementById("accordion") as HTMLElement
|
||||
const panel = document.getElementById("panel") as HTMLElement
|
||||
|
||||
expect(observer.observe).not.toHaveBeenCalled()
|
||||
|
||||
accordion.setAttribute("aria-hidden", "false")
|
||||
await flushDomUpdates()
|
||||
|
||||
expect(observer.observe).toHaveBeenCalledWith(panel)
|
||||
|
||||
await observer.triggerIntersect(panel)
|
||||
await flushDomUpdates()
|
||||
|
||||
expect(mockTranslateWalkedElement).toHaveBeenCalledWith(panel, "walk-id", DEFAULT_CONFIG)
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("keeps style/class based re-walk behavior for existing hidden panels", async () => {
|
||||
document.body.innerHTML = `
|
||||
<section id="accordion" class="closed">
|
||||
<p id="panel">Accordion body</p>
|
||||
</section>
|
||||
`
|
||||
|
||||
const manager = new PageTranslationManager()
|
||||
await manager.start()
|
||||
await flushDomUpdates()
|
||||
|
||||
const observer = intersectionObservers[0]
|
||||
const accordion = document.getElementById("accordion") as HTMLElement
|
||||
const panel = document.getElementById("panel") as HTMLElement
|
||||
|
||||
expect(observer.observe).not.toHaveBeenCalled()
|
||||
|
||||
accordion.classList.remove("closed")
|
||||
await flushDomUpdates()
|
||||
|
||||
expect(observer.observe).toHaveBeenCalledWith(panel)
|
||||
|
||||
await observer.triggerIntersect(panel)
|
||||
await flushDomUpdates()
|
||||
|
||||
expect(mockTranslateWalkedElement).toHaveBeenCalledWith(panel, "walk-id", DEFAULT_CONFIG)
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
})
|
||||
|
|
@ -38,6 +38,7 @@ vi.mock("@/utils/config/storage", () => ({
|
|||
|
||||
vi.mock("@/utils/host/dom/filter", () => ({
|
||||
hasNoWalkAncestor: vi.fn().mockReturnValue(false),
|
||||
isDontWalkIntoAndDontTranslateAsChildElement: vi.fn().mockReturnValue(false),
|
||||
isDontWalkIntoButTranslateAsChildElement: vi.fn().mockReturnValue(false),
|
||||
isHTMLElement: (node: unknown) => node instanceof HTMLElement,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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"
|
||||
|
|
@ -7,7 +8,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, isDontWalkIntoButTranslateAsChildElement, isHTMLElement } from "@/utils/host/dom/filter"
|
||||
import { hasNoWalkAncestor, isDontWalkIntoAndDontTranslateAsChildElement, isDontWalkIntoButTranslateAsChildElement, isHTMLElement } from "@/utils/host/dom/filter"
|
||||
import { deepQueryTopLevelSelector } from "@/utils/host/dom/find"
|
||||
import { walkAndLabelElement } from "@/utils/host/dom/traversal"
|
||||
import { removeAllTranslatedWrapperNodes, translateWalkedElement } from "@/utils/host/translate/node-manipulation"
|
||||
|
|
@ -59,7 +60,7 @@ export class PageTranslationManager implements IPageTranslationManager {
|
|||
private mutationObservers: MutationObserver[] = []
|
||||
private walkId: string | null = null
|
||||
private intersectionOptions: IntersectionObserverInit
|
||||
private dontWalkIntoElementsCache = new WeakSet<HTMLElement>()
|
||||
private walkBlockedElementsCache = new WeakSet<HTMLElement>()
|
||||
private titleObserver: MutationObserver | null = null
|
||||
private lastSourceTitle: string | null = null
|
||||
private lastAppliedTranslatedTitle: string | null = null
|
||||
|
|
@ -153,8 +154,8 @@ export class PageTranslationManager implements IPageTranslationManager {
|
|||
}, this.intersectionOptions)
|
||||
|
||||
// Initialize walkability state for existing elements
|
||||
this.addDontWalkIntoElements(document.body)
|
||||
await this.observerTopLevelParagraphs(document.body)
|
||||
this.addWalkBlockedElements(document.body, config)
|
||||
await this.observerTopLevelParagraphs(document.body, config)
|
||||
|
||||
// Start observing mutations from document.body and all shadow roots
|
||||
this.observeMutations(document.body)
|
||||
|
|
@ -189,7 +190,7 @@ export class PageTranslationManager implements IPageTranslationManager {
|
|||
|
||||
this.isPageTranslating = false
|
||||
this.walkId = null
|
||||
this.dontWalkIntoElementsCache = new WeakSet()
|
||||
this.walkBlockedElementsCache = new WeakSet()
|
||||
this.stopDocumentTitleTracking()
|
||||
|
||||
if (this.intersectionObserver) {
|
||||
|
|
@ -386,12 +387,12 @@ export class PageTranslationManager implements IPageTranslationManager {
|
|||
}
|
||||
}
|
||||
|
||||
private async observerTopLevelParagraphs(container: HTMLElement): Promise<void> {
|
||||
private async observerTopLevelParagraphs(container: HTMLElement, existingConfig?: Config): Promise<void> {
|
||||
const observer = this.intersectionObserver
|
||||
if (!this.walkId || !observer)
|
||||
return
|
||||
|
||||
const config = await getLocalConfig()
|
||||
const config = existingConfig ?? await getLocalConfig()
|
||||
if (!config) {
|
||||
logger.error("Global config is not initialized")
|
||||
return
|
||||
|
|
@ -454,33 +455,39 @@ export class PageTranslationManager implements IPageTranslationManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Handle style/class attribute changes and only trigger observation
|
||||
* when element transitions from "don't walk into" to "walkable"
|
||||
* Track the same blocked states that the traversal skips, so hidden accordion
|
||||
* panels can be re-walked when the site reveals an existing subtree.
|
||||
*/
|
||||
private didChangeToWalkable(element: HTMLElement): boolean {
|
||||
const wasDontWalkInto = this.dontWalkIntoElementsCache.has(element)
|
||||
const isDontWalkIntoNow = isDontWalkIntoButTranslateAsChildElement(element)
|
||||
private isWalkBlockedElement(element: HTMLElement, config: Config): boolean {
|
||||
return isDontWalkIntoButTranslateAsChildElement(element)
|
||||
|| isDontWalkIntoAndDontTranslateAsChildElement(element, config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle attribute changes and only trigger observation
|
||||
* when element transitions from blocked to walkable.
|
||||
*/
|
||||
private didChangeToWalkable(element: HTMLElement, config: Config): boolean {
|
||||
const wasWalkBlocked = this.walkBlockedElementsCache.has(element)
|
||||
const isWalkBlockedNow = this.isWalkBlockedElement(element, config)
|
||||
|
||||
// Update cache with current state
|
||||
if (isDontWalkIntoNow) {
|
||||
this.dontWalkIntoElementsCache.add(element)
|
||||
if (isWalkBlockedNow) {
|
||||
this.walkBlockedElementsCache.add(element)
|
||||
}
|
||||
else {
|
||||
this.dontWalkIntoElementsCache.delete(element)
|
||||
this.walkBlockedElementsCache.delete(element)
|
||||
}
|
||||
|
||||
// Only trigger observation if element transitioned from "don't walk into" to "walkable"
|
||||
// wasDontWalkInto === true means it was previously not walkable
|
||||
// isDontWalkIntoNow === false means it's now walkable
|
||||
return wasDontWalkInto === true && isDontWalkIntoNow === false
|
||||
return wasWalkBlocked === true && isWalkBlockedNow === false
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize walkability state for an element and its descendants
|
||||
*/
|
||||
private addDontWalkIntoElements(element: HTMLElement): void {
|
||||
const dontWalkIntoElements = deepQueryTopLevelSelector(element, isDontWalkIntoButTranslateAsChildElement)
|
||||
dontWalkIntoElements.forEach(el => this.dontWalkIntoElementsCache.add(el))
|
||||
private addWalkBlockedElements(element: HTMLElement, config: Config): void {
|
||||
const walkBlockedElements = deepQueryTopLevelSelector(element, el => this.isWalkBlockedElement(el, config))
|
||||
walkBlockedElements.forEach(el => this.walkBlockedElementsCache.add(el))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -488,39 +495,54 @@ export class PageTranslationManager implements IPageTranslationManager {
|
|||
*/
|
||||
private observeMutations(container: HTMLElement): void {
|
||||
const mutationObserver = new MutationObserver((records) => {
|
||||
for (const rec of records) {
|
||||
if (rec.type === "childList") {
|
||||
rec.addedNodes.forEach((node) => {
|
||||
if (isHTMLElement(node)) {
|
||||
this.addDontWalkIntoElements(node)
|
||||
void this.observerTopLevelParagraphs(node)
|
||||
this.observeIsolatedDescendantsMutations(node)
|
||||
}
|
||||
})
|
||||
}
|
||||
else if (
|
||||
rec.type === "attributes"
|
||||
&& (rec.attributeName === "style" || rec.attributeName === "class")
|
||||
) {
|
||||
const el = rec.target
|
||||
if (isHTMLElement(el) && this.didChangeToWalkable(el)) {
|
||||
void this.observerTopLevelParagraphs(el)
|
||||
}
|
||||
}
|
||||
}
|
||||
void this.handleMutationRecords(records)
|
||||
})
|
||||
|
||||
mutationObserver.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["style", "class"],
|
||||
attributeFilter: ["style", "class", "hidden", "aria-hidden"],
|
||||
})
|
||||
|
||||
this.mutationObservers.push(mutationObserver)
|
||||
this.observeIsolatedDescendantsMutations(container)
|
||||
}
|
||||
|
||||
private async handleMutationRecords(records: MutationRecord[]): Promise<void> {
|
||||
const config = await getLocalConfig()
|
||||
if (!config) {
|
||||
logger.error("Global config is not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
for (const rec of records) {
|
||||
if (rec.type === "childList") {
|
||||
rec.addedNodes.forEach((node) => {
|
||||
if (isHTMLElement(node)) {
|
||||
this.addWalkBlockedElements(node, config)
|
||||
void this.observerTopLevelParagraphs(node, config)
|
||||
this.observeIsolatedDescendantsMutations(node)
|
||||
}
|
||||
})
|
||||
}
|
||||
else if (this.isWalkabilityAttributeMutation(rec)) {
|
||||
const el = rec.target
|
||||
if (isHTMLElement(el) && this.didChangeToWalkable(el, config)) {
|
||||
void this.observerTopLevelParagraphs(el, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isWalkabilityAttributeMutation(record: MutationRecord): boolean {
|
||||
return record.type === "attributes"
|
||||
&& (record.attributeName === "style"
|
||||
|| record.attributeName === "class"
|
||||
|| record.attributeName === "hidden"
|
||||
|| record.attributeName === "aria-hidden")
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find and observe shadow roots and iframes in an element and its descendants
|
||||
* These can't be find as top level paragraph elements because isolated shadow roots and iframes are not
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { defineContentScript } from "#imports"
|
|||
import { injectPlayerApi } from "./inject-player-api"
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["*://*.youtube.com/*"],
|
||||
matches: ["*://*.youtube.com/*", "*://*.youtube-nocookie.com/*"],
|
||||
allFrames: true,
|
||||
world: "MAIN",
|
||||
runAt: "document_start",
|
||||
main() {
|
||||
|
|
|
|||
|
|
@ -37,10 +37,15 @@ declare global {
|
|||
function findYoutubePlayer(): YouTubePlayer | null {
|
||||
return document.querySelector(
|
||||
".html5-video-player.playing-mode, .html5-video-player.paused-mode",
|
||||
)
|
||||
) ?? document.querySelector(".html5-video-player")
|
||||
}
|
||||
|
||||
export function injectPlayerApi(): void {
|
||||
if ((window as any).__READ_FROG_INTERCEPTOR_INJECTED__) {
|
||||
return
|
||||
}
|
||||
;(window as any).__READ_FROG_INTERCEPTOR_INJECTED__ = true
|
||||
|
||||
setupTimedtextObserver()
|
||||
window.addEventListener("message", handleMessage)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,17 +22,23 @@ vi.mock("@/components/ui/json-code-editor", () => ({
|
|||
JSONCodeEditor: ({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
placeholder,
|
||||
}: {
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
onBlur?: () => void
|
||||
onFocus?: () => void
|
||||
placeholder?: string
|
||||
}) => (
|
||||
<textarea
|
||||
aria-label="provider-options-editor"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onBlur={onBlur}
|
||||
onChange={event => onChange?.(event.target.value)}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
|
@ -53,16 +59,22 @@ const baseProviderConfig: APIProviderConfig = {
|
|||
function ProviderOptionsFieldHarness({
|
||||
initialConfig,
|
||||
externalProviderOptions,
|
||||
submitDelayMs = 0,
|
||||
}: {
|
||||
initialConfig: APIProviderConfig
|
||||
externalProviderOptions?: Record<string, unknown>
|
||||
submitDelayMs?: number
|
||||
}) {
|
||||
const [providerConfig, setProviderConfig] = useState(initialConfig)
|
||||
const form = useAppForm({
|
||||
...formOpts,
|
||||
defaultValues: providerConfig,
|
||||
onSubmit: async ({ value }) => {
|
||||
setProviderConfig(value)
|
||||
if (submitDelayMs > 0) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, submitDelayMs))
|
||||
}
|
||||
|
||||
setProviderConfig(submitDelayMs > 0 ? structuredClone(value) : value)
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -105,6 +117,7 @@ describe("providerOptionsField", () => {
|
|||
render(<ProviderOptionsFieldHarness initialConfig={baseProviderConfig} />)
|
||||
|
||||
const editor = screen.getByLabelText("provider-options-editor")
|
||||
fireEvent.focus(editor)
|
||||
fireEvent.change(editor, { target: { value: "{\"reasoningEffort\":\"minimal\"}" } })
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -115,6 +128,29 @@ describe("providerOptionsField", () => {
|
|||
expect(screen.getByLabelText("provider-options-editor")).toHaveValue("{\"reasoningEffort\":\"minimal\"}")
|
||||
})
|
||||
|
||||
it("keeps focused draft edits when a delayed autosave echo arrives", async () => {
|
||||
render(<ProviderOptionsFieldHarness initialConfig={baseProviderConfig} submitDelayMs={100} />)
|
||||
|
||||
const editor = screen.getByLabelText("provider-options-editor")
|
||||
fireEvent.focus(editor)
|
||||
fireEvent.change(editor, { target: { value: "{\"reasoningEffort\":\"minimal\"}" } })
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
fireEvent.change(editor, { target: { value: "{\"reasoningEffort\":\"low\"}" } })
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText("provider-options-editor")).toHaveValue("{\"reasoningEffort\":\"low\"}")
|
||||
})
|
||||
|
||||
it("shows the matched recommended provider options as the placeholder when the value is empty", () => {
|
||||
render(<ProviderOptionsFieldHarness initialConfig={baseProviderConfig} />)
|
||||
|
||||
|
|
@ -180,7 +216,9 @@ describe("providerOptionsField", () => {
|
|||
)
|
||||
|
||||
const editor = screen.getByLabelText("provider-options-editor")
|
||||
fireEvent.focus(editor)
|
||||
fireEvent.change(editor, { target: { value: "{" } })
|
||||
fireEvent.blur(editor)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "apply-external" }))
|
||||
await Promise.resolve()
|
||||
|
|
|
|||
|
|
@ -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}/tutorial/providers/${providerType}`
|
||||
return `${env.WXT_WEBSITE_URL}/docs/providers/${providerType}`
|
||||
}
|
||||
const groupSlug = getProviderGroupSlug(providerType)
|
||||
if (!groupSlug)
|
||||
return undefined
|
||||
|
||||
return `${env.WXT_WEBSITE_URL}/tutorial/providers/${groupSlug}`
|
||||
return `${env.WXT_WEBSITE_URL}/docs/providers/${groupSlug}`
|
||||
}
|
||||
|
||||
function getProviderGroupSlug(providerType: APIProviderTypes): string | undefined {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ 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
|
||||
|
|
@ -55,6 +56,18 @@ export const ProviderOptionsField = withForm({
|
|||
return jsonInput
|
||||
})
|
||||
|
||||
const handleJsonInputChange = useCallback((nextJson: string) => {
|
||||
setJsonInput(nextJson)
|
||||
}, [])
|
||||
|
||||
const handleEditorFocus = useCallback(() => {
|
||||
editorFocusedRef.current = true
|
||||
}, [])
|
||||
|
||||
const handleEditorBlur = useCallback(() => {
|
||||
editorFocusedRef.current = false
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
resetSyncStateForProvider()
|
||||
}, [providerConfig.id])
|
||||
|
|
@ -66,9 +79,15 @@ export const ProviderOptionsField = withForm({
|
|||
}
|
||||
|
||||
pendingEditorCommitRef.current = false
|
||||
|
||||
const currentJsonInput = readJsonInput()
|
||||
if (editorFocusedRef.current && currentJsonInput !== lastCommittedJsonRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastCommittedJsonRef.current = externalJson
|
||||
|
||||
if (readJsonInput() !== externalJson) {
|
||||
if (currentJsonInput !== externalJson) {
|
||||
syncJsonInput(externalJson)
|
||||
}
|
||||
}, [providerConfig.providerOptions, externalJson])
|
||||
|
|
@ -127,7 +146,9 @@ export const ProviderOptionsField = withForm({
|
|||
</FieldLabel>
|
||||
<JSONCodeEditor
|
||||
value={jsonInput}
|
||||
onChange={setJsonInput}
|
||||
onChange={handleJsonInputChange}
|
||||
onFocus={handleEditorFocus}
|
||||
onBlur={handleEditorBlur}
|
||||
placeholder={placeholderText}
|
||||
hasError={!!jsonError}
|
||||
height="150px"
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ function DialogContent({ onResolved, onCancelled }: DialogContentProps) {
|
|||
const canConfirm = status.isValid && !isConfirming
|
||||
|
||||
return (
|
||||
<AlertDialogContent className="w-[min(80rem,calc(100vw-2rem))] max-w-none max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<AlertDialogContent className="data-[size=default]:max-w-[calc(100vw-2rem)] data-[size=default]:md:max-w-2xl data-[size=default]:lg:max-w-4xl data-[size=default]:xl:max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<Icon icon="mdi:alert" className="size-5 text-yellow-500" />
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { FloatingButtonClickAction as FloatingButtonClickActionValue } from "@/types/config/floating-button"
|
||||
import { i18n } from "#imports"
|
||||
import { useAtom } from "jotai"
|
||||
import {
|
||||
|
|
@ -8,13 +9,14 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/base-ui/select"
|
||||
import { floatingButtonClickActionSchema } from "@/types/config/floating-button"
|
||||
import { configFieldsAtomMap } from "@/utils/atoms/config"
|
||||
import { ConfigCard } from "../../components/config-card"
|
||||
|
||||
const items = [
|
||||
{ value: "panel", label: i18n.t("options.floatingButtonAndToolbar.floatingButton.clickAction.panel") },
|
||||
{ value: "translate", label: i18n.t("options.floatingButtonAndToolbar.floatingButton.clickAction.translate") },
|
||||
]
|
||||
] satisfies Array<{ value: FloatingButtonClickActionValue, label: string }>
|
||||
|
||||
export function FloatingButtonClickAction() {
|
||||
const [floatingButton, setFloatingButton] = useAtom(
|
||||
|
|
@ -32,9 +34,10 @@ export function FloatingButtonClickAction() {
|
|||
items={items}
|
||||
value={floatingButton.clickAction}
|
||||
onValueChange={(value) => {
|
||||
if (!value)
|
||||
const parsedValue = floatingButtonClickActionSchema.safeParse(value)
|
||||
if (!parsedValue.success)
|
||||
return
|
||||
void setFloatingButton({ ...floatingButton, clickAction: value })
|
||||
void setFloatingButton({ ...floatingButton, clickAction: parsedValue.data })
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { i18n } from "#imports"
|
||||
import { useAtom } from "jotai"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Slider } from "@/components/ui/base-ui/slider"
|
||||
import { configFieldsAtomMap } from "@/utils/atoms/config"
|
||||
import { MAX_SELECTION_OVERLAY_OPACITY, MIN_SELECTION_OVERLAY_OPACITY } from "@/utils/constants/selection"
|
||||
|
|
@ -7,6 +8,12 @@ import { ConfigCard } from "../../components/config-card"
|
|||
|
||||
export function SelectionToolbarOpacity() {
|
||||
const [selectionToolbar, setSelectionToolbar] = useAtom(configFieldsAtomMap.selectionToolbar)
|
||||
const [draftOpacity, setDraftOpacity] = useState(selectionToolbar.opacity)
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react/set-state-in-effect
|
||||
setDraftOpacity(selectionToolbar.opacity)
|
||||
}, [selectionToolbar.opacity])
|
||||
|
||||
return (
|
||||
<ConfigCard
|
||||
|
|
@ -19,14 +26,17 @@ export function SelectionToolbarOpacity() {
|
|||
min={MIN_SELECTION_OVERLAY_OPACITY}
|
||||
max={MAX_SELECTION_OVERLAY_OPACITY}
|
||||
step={1}
|
||||
value={selectionToolbar.opacity}
|
||||
value={draftOpacity}
|
||||
onValueChange={(value) => {
|
||||
setDraftOpacity(value as number)
|
||||
}}
|
||||
onValueCommitted={(value) => {
|
||||
void setSelectionToolbar({ opacity: value as number })
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-10 text-sm text-right">
|
||||
{selectionToolbar.opacity}
|
||||
{draftOpacity}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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}/tutorial/custom-css`} className="text-xs text-link hover:opacity-90" target="_blank" rel="noreferrer">
|
||||
<a href={`${env.WXT_WEBSITE_URL}/docs/custom-css`} className="text-xs text-link hover:opacity-90" target="_blank" rel="noreferrer">
|
||||
{i18n.t("options.apiProviders.howToConfigure")}
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { i18n } from "#imports"
|
|||
import { Icon } from "@iconify/react"
|
||||
import { deepmerge } from "deepmerge-ts"
|
||||
import { useAtom } from "jotai"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/base-ui/button"
|
||||
import { Card } from "@/components/ui/base-ui/card"
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/base-ui/field"
|
||||
|
|
@ -20,6 +21,12 @@ const SLIDER_LABEL_CLASS_NAME = "text-sm whitespace-nowrap @xs/field-group:min-w
|
|||
export function GeneralSettings() {
|
||||
const [videoSubtitlesConfig, setVideoSubtitlesConfig] = useAtom(configFieldsAtomMap.videoSubtitles)
|
||||
const { displayMode, translationPosition, container } = videoSubtitlesConfig.style
|
||||
const [draftBackgroundOpacity, setDraftBackgroundOpacity] = useState(container.backgroundOpacity)
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react/set-state-in-effect
|
||||
setDraftBackgroundOpacity(container.backgroundOpacity)
|
||||
}, [container.backgroundOpacity])
|
||||
|
||||
const handleDisplayModeChange = (value: SubtitlesDisplayMode | null) => {
|
||||
if (!value)
|
||||
|
|
@ -123,12 +130,13 @@ export function GeneralSettings() {
|
|||
min={MIN_BACKGROUND_OPACITY}
|
||||
max={MAX_BACKGROUND_OPACITY}
|
||||
step={5}
|
||||
value={container.backgroundOpacity}
|
||||
onValueChange={value => handleContainerChange({ backgroundOpacity: value as number })}
|
||||
value={draftBackgroundOpacity}
|
||||
onValueChange={value => setDraftBackgroundOpacity(value as number)}
|
||||
onValueCommitted={value => handleContainerChange({ backgroundOpacity: value as number })}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-10 text-sm text-right">
|
||||
{container.backgroundOpacity}
|
||||
{draftBackgroundOpacity}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { SubtitlesFontFamily, SubtitleTextStyle } from "@/types/config/subt
|
|||
import { i18n } from "#imports"
|
||||
import { deepmerge } from "deepmerge-ts"
|
||||
import { useAtom } from "jotai"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/base-ui/field"
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/base-ui/select"
|
||||
import { Slider } from "@/components/ui/base-ui/slider"
|
||||
|
|
@ -26,6 +27,18 @@ interface SubtitlesTextStyleFormProps {
|
|||
export function SubtitlesTextStyleForm({ type }: SubtitlesTextStyleFormProps) {
|
||||
const [videoSubtitlesConfig, setVideoSubtitlesConfig] = useAtom(configFieldsAtomMap.videoSubtitles)
|
||||
const textStyle = videoSubtitlesConfig.style[type]
|
||||
const [draftFontScale, setDraftFontScale] = useState(textStyle.fontScale)
|
||||
const [draftFontWeight, setDraftFontWeight] = useState(textStyle.fontWeight)
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react/set-state-in-effect
|
||||
setDraftFontScale(textStyle.fontScale)
|
||||
}, [textStyle.fontScale])
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react/set-state-in-effect
|
||||
setDraftFontWeight(textStyle.fontWeight)
|
||||
}, [textStyle.fontWeight])
|
||||
|
||||
const handleChange = (style: Partial<SubtitleTextStyle>) => {
|
||||
void setVideoSubtitlesConfig(deepmerge(videoSubtitlesConfig, { style: { [type]: style } }))
|
||||
|
|
@ -71,12 +84,13 @@ export function SubtitlesTextStyleForm({ type }: SubtitlesTextStyleFormProps) {
|
|||
min={MIN_FONT_SCALE}
|
||||
max={MAX_FONT_SCALE}
|
||||
step={10}
|
||||
value={textStyle.fontScale}
|
||||
onValueChange={value => handleChange({ fontScale: value as number })}
|
||||
value={draftFontScale}
|
||||
onValueChange={value => setDraftFontScale(value as number)}
|
||||
onValueCommitted={value => handleChange({ fontScale: value as number })}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-10 text-sm text-right">
|
||||
{textStyle.fontScale}
|
||||
{draftFontScale}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -91,11 +105,12 @@ export function SubtitlesTextStyleForm({ type }: SubtitlesTextStyleFormProps) {
|
|||
min={MIN_FONT_WEIGHT}
|
||||
max={MAX_FONT_WEIGHT}
|
||||
step={100}
|
||||
value={textStyle.fontWeight}
|
||||
onValueChange={value => handleChange({ fontWeight: value as number })}
|
||||
value={draftFontWeight}
|
||||
onValueChange={value => setDraftFontWeight(value as number)}
|
||||
onValueCommitted={value => handleChange({ fontWeight: value as number })}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-10 text-sm text-right">{textStyle.fontWeight}</span>
|
||||
<span className="w-10 text-sm text-right">{draftFontWeight}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { browser, i18n } from "#imports"
|
||||
import { i18n } from "#imports"
|
||||
import { Icon } from "@iconify/react"
|
||||
import { UserAccount } from "@/components/user-account"
|
||||
import { openOptionsPage } from "@/utils/navigation"
|
||||
import { version } from "../../../package.json"
|
||||
import { AISmartContext } from "./components/ai-smart-context"
|
||||
import { AlwaysTranslate } from "./components/always-translate"
|
||||
|
|
@ -44,7 +45,9 @@ function App() {
|
|||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 hover:bg-neutral-300 dark:hover:bg-neutral-700"
|
||||
onClick={() => browser.runtime.openOptionsPage()}
|
||||
onClick={() => {
|
||||
void openOptionsPage()
|
||||
}}
|
||||
>
|
||||
<Icon icon="tabler:settings" className="size-4" strokeWidth={1.6} />
|
||||
<span className="text-[13px] font-medium">
|
||||
|
|
|
|||
|
|
@ -1,20 +1,26 @@
|
|||
import type { LangCodeISO6393 } from "@read-frog/definitions"
|
||||
import type { LanguageItem } from "@/components/language-combobox-options"
|
||||
import { i18n } from "#imports"
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
||||
import { Icon } from "@iconify/react"
|
||||
import {
|
||||
LANG_CODE_TO_EN_NAME,
|
||||
LANG_CODE_TO_LOCALE_NAME,
|
||||
langCodeISO6393Schema,
|
||||
} from "@read-frog/definitions"
|
||||
import { IconChevronDown } from "@tabler/icons-react"
|
||||
import { useAtom, useAtomValue } from "jotai"
|
||||
import { useMemo } from "react"
|
||||
import { filterLanguage } from "@/components/language-combobox-options"
|
||||
import { Button } from "@/components/ui/base-ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/base-ui/select"
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/base-ui/combobox"
|
||||
import { configFieldsAtomMap } from "@/utils/atoms/config"
|
||||
import { detectedCodeAtom } from "@/utils/atoms/detected-code"
|
||||
|
||||
|
|
@ -22,82 +28,154 @@ function langCodeLabel(langCode: LangCodeISO6393) {
|
|||
return `${LANG_CODE_TO_EN_NAME[langCode]} (${LANG_CODE_TO_LOCALE_NAME[langCode]})`
|
||||
}
|
||||
|
||||
const langSelectorTriggerClasses = "!h-14 w-30 rounded-lg shadow-xs pr-2 gap-1"
|
||||
function createLanguageItem(code: LangCodeISO6393): LanguageItem<LangCodeISO6393> {
|
||||
return {
|
||||
value: code,
|
||||
label: langCodeLabel(code),
|
||||
name: LANG_CODE_TO_EN_NAME[code],
|
||||
}
|
||||
}
|
||||
|
||||
const langSelectorTriggerClasses = "!h-14 w-30 rounded-lg shadow-xs pr-2 gap-1 justify-between bg-transparent"
|
||||
|
||||
const langSelectorContentClasses = "flex flex-col items-start text-base font-medium min-w-0 flex-1"
|
||||
|
||||
function LanguageComboboxTrigger({
|
||||
label,
|
||||
subtitle,
|
||||
ariaLabel,
|
||||
}: {
|
||||
label: string
|
||||
subtitle: string
|
||||
ariaLabel: string
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
render={(
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={langSelectorTriggerClasses}
|
||||
aria-label={ariaLabel}
|
||||
title={label}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className={langSelectorContentClasses}>
|
||||
<span className="truncate w-full text-left">{label}</span>
|
||||
<span className="text-sm text-neutral-500">{subtitle}</span>
|
||||
</div>
|
||||
<IconChevronDown className="size-4 text-muted-foreground" />
|
||||
</ComboboxPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LanguageOptionsSelector() {
|
||||
const [language, setLanguage] = useAtom(configFieldsAtomMap.language)
|
||||
const detectedCode = useAtomValue(detectedCodeAtom)
|
||||
const targetLanguageItems = useMemo(
|
||||
() => langCodeISO6393Schema.options.map(createLanguageItem),
|
||||
[],
|
||||
)
|
||||
const sourceLanguageItems = useMemo<LanguageItem[]>(
|
||||
() => [
|
||||
{
|
||||
value: "auto",
|
||||
label: langCodeLabel(detectedCode),
|
||||
name: LANG_CODE_TO_EN_NAME[detectedCode],
|
||||
},
|
||||
...targetLanguageItems,
|
||||
],
|
||||
[detectedCode, targetLanguageItems],
|
||||
)
|
||||
const currentSourceItem = useMemo(
|
||||
() => sourceLanguageItems.find(item => item.value === language.sourceCode) ?? sourceLanguageItems[0] ?? null,
|
||||
[language.sourceCode, sourceLanguageItems],
|
||||
)
|
||||
const currentTargetItem = useMemo(
|
||||
() => targetLanguageItems.find(item => item.value === language.targetCode) ?? null,
|
||||
[language.targetCode, targetLanguageItems],
|
||||
)
|
||||
|
||||
const handleSourceLangChange = (newLangCode: LangCodeISO6393 | "auto" | null) => {
|
||||
if (!newLangCode)
|
||||
const handleSourceLangChange = (item: LanguageItem | null) => {
|
||||
if (!item || item.value === language.sourceCode)
|
||||
return
|
||||
void setLanguage({ sourceCode: newLangCode })
|
||||
void setLanguage({ sourceCode: item.value })
|
||||
}
|
||||
|
||||
const handleTargetLangChange = (newLangCode: LangCodeISO6393 | null) => {
|
||||
if (!newLangCode)
|
||||
const handleTargetLangChange = (item: LanguageItem | null) => {
|
||||
if (!item || item.value === "auto" || item.value === language.targetCode)
|
||||
return
|
||||
void setLanguage({ targetCode: newLangCode })
|
||||
void setLanguage({ targetCode: item.value })
|
||||
}
|
||||
|
||||
const sourceLangLabel
|
||||
= language.sourceCode === "auto"
|
||||
? `${langCodeLabel(detectedCode)} (auto)`
|
||||
: langCodeLabel(language.sourceCode)
|
||||
? `${currentSourceItem?.label ?? langCodeLabel(detectedCode)} (auto)`
|
||||
: currentSourceItem?.label ?? langCodeLabel(language.sourceCode)
|
||||
|
||||
const targetLangLabel = langCodeLabel(language.targetCode)
|
||||
const targetLangLabel = currentTargetItem?.label ?? langCodeLabel(language.targetCode)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Select value={language.sourceCode} onValueChange={handleSourceLangChange}>
|
||||
<SelectTrigger className={langSelectorTriggerClasses}>
|
||||
<div className={langSelectorContentClasses}>
|
||||
<SelectValue render={<span className="truncate w-full" />}>
|
||||
{sourceLangLabel}
|
||||
</SelectValue>
|
||||
<span className="text-sm text-neutral-500">
|
||||
{language.sourceCode === "auto"
|
||||
? i18n.t("popup.autoLang")
|
||||
: i18n.t("popup.sourceLang")}
|
||||
</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-lg shadow-md w-72">
|
||||
<SelectGroup>
|
||||
<SelectItem value="auto">
|
||||
{langCodeLabel(detectedCode)}
|
||||
<AutoLangCell />
|
||||
</SelectItem>
|
||||
{langCodeISO6393Schema.options.map(key => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{langCodeLabel(key)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Combobox
|
||||
value={currentSourceItem}
|
||||
onValueChange={handleSourceLangChange}
|
||||
items={sourceLanguageItems}
|
||||
filter={filterLanguage}
|
||||
autoHighlight
|
||||
>
|
||||
<LanguageComboboxTrigger
|
||||
label={sourceLangLabel}
|
||||
subtitle={language.sourceCode === "auto"
|
||||
? i18n.t("popup.autoLang")
|
||||
: i18n.t("popup.sourceLang")}
|
||||
ariaLabel={i18n.t("popup.sourceLang")}
|
||||
/>
|
||||
<ComboboxContent className="rounded-lg shadow-md w-72">
|
||||
<ComboboxInput
|
||||
showTrigger={false}
|
||||
placeholder={i18n.t("translationHub.searchLanguages")}
|
||||
/>
|
||||
<ComboboxList>
|
||||
{(item: LanguageItem) => (
|
||||
<ComboboxItem key={item.value} value={item}>
|
||||
{item.label}
|
||||
{item.value === "auto" && <AutoLangCell />}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
<ComboboxEmpty>{i18n.t("translationHub.noLanguagesFound")}</ComboboxEmpty>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<Icon icon="tabler:arrow-right" className="h-4 w-4 text-neutral-500" />
|
||||
<Select value={language.targetCode} onValueChange={handleTargetLangChange}>
|
||||
<SelectTrigger className={langSelectorTriggerClasses}>
|
||||
<div className={langSelectorContentClasses}>
|
||||
<SelectValue render={<span className="truncate w-full" />}>
|
||||
{targetLangLabel}
|
||||
</SelectValue>
|
||||
<span className="text-sm text-neutral-500">{i18n.t("popup.targetLang")}</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-lg shadow-md w-72">
|
||||
<SelectGroup>
|
||||
{langCodeISO6393Schema.options.map(key => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{langCodeLabel(key)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Combobox
|
||||
value={currentTargetItem}
|
||||
onValueChange={handleTargetLangChange}
|
||||
items={targetLanguageItems}
|
||||
filter={filterLanguage}
|
||||
autoHighlight
|
||||
>
|
||||
<LanguageComboboxTrigger
|
||||
label={targetLangLabel}
|
||||
subtitle={i18n.t("popup.targetLang")}
|
||||
ariaLabel={i18n.t("popup.targetLang")}
|
||||
/>
|
||||
<ComboboxContent className="rounded-lg shadow-md w-72">
|
||||
<ComboboxInput
|
||||
showTrigger={false}
|
||||
placeholder={i18n.t("translationHub.searchLanguages")}
|
||||
/>
|
||||
<ComboboxList>
|
||||
{(item: LanguageItem<LangCodeISO6393>) => (
|
||||
<ComboboxItem key={item.value} value={item}>
|
||||
{item.label}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
<ComboboxEmpty>{i18n.t("translationHub.noLanguagesFound")}</ComboboxEmpty>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export function MoreMenu() {
|
|||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => window.open("https://readfrog.app/tutorial/", "_blank", "noopener,noreferrer")}
|
||||
onClick={() => window.open("https://readfrog.app/docs/", "_blank", "noopener,noreferrer")}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Icon icon="tabler:help-circle" className="size-4" strokeWidth={1.6} />
|
||||
|
|
|
|||
|
|
@ -33,12 +33,6 @@ function HydrateAtoms({
|
|||
return children
|
||||
}
|
||||
|
||||
const selectionContentCss = `
|
||||
body {
|
||||
opacity: var(--rf-selection-opacity, 1);
|
||||
}
|
||||
`
|
||||
|
||||
// eslint-disable-next-line import/no-mutable-exports
|
||||
export let shadowWrapper: HTMLElement | null = null
|
||||
|
||||
|
|
@ -57,7 +51,6 @@ async function mountSelectionUI(ctx: ContentScriptContext) {
|
|||
name: `${kebabCase(APP_NAME)}-selection`,
|
||||
position: "overlay",
|
||||
anchor: "body",
|
||||
css: selectionContentCss,
|
||||
onMount: (container, shadow, shadowHost) => {
|
||||
const wrapper = insertShadowRootUIWrapperInto(container)
|
||||
shadowWrapper = wrapper
|
||||
|
|
|
|||
|
|
@ -44,14 +44,15 @@ function showSpinner(element: HTMLElement): () => void {
|
|||
spinner.id = SPINNER_ID
|
||||
|
||||
// Use the same border spinner style as page translation
|
||||
// Colors: primary green (#4ade80 / oklch(76.5% 0.177 163.223)) and muted gray
|
||||
// Colors: brand yellow (oklch(76.034% 0.12361 82.191)) and muted gray
|
||||
spinner.style.cssText = `
|
||||
--rf-brand: oklch(76.034% 0.12361 82.191);
|
||||
position: absolute !important;
|
||||
display: inline-block !important;
|
||||
width: 10px !important;
|
||||
height: 10px !important;
|
||||
border: 3px solid #e5e5e5 !important;
|
||||
border-top: 3px solid #4ade80 !important;
|
||||
border-top: 3px solid var(--rf-brand) !important;
|
||||
border-radius: 50% !important;
|
||||
box-sizing: content-box !important;
|
||||
z-index: 999999 !important;
|
||||
|
|
@ -76,9 +77,9 @@ function showSpinner(element: HTMLElement): () => void {
|
|||
)
|
||||
}
|
||||
else {
|
||||
// For reduced motion, keep the spinner static but preserve the primary
|
||||
// For reduced motion, keep the spinner static but preserve the brand
|
||||
// segment so the loading state remains visible without animation.
|
||||
spinner.style.borderTopColor = "#4ade80"
|
||||
spinner.style.borderTopColor = "var(--rf-brand)"
|
||||
}
|
||||
|
||||
// Calculate position - vertically centered relative to the element
|
||||
|
|
|
|||
|
|
@ -1131,6 +1131,56 @@ describe("selection toolbar requests", () => {
|
|||
})
|
||||
})
|
||||
|
||||
it("keeps a pending custom action request alive across a passive config refresh", async () => {
|
||||
const pendingRun = createDeferredPromise<BackgroundStructuredObjectStreamSnapshot>()
|
||||
const signals: AbortSignal[] = []
|
||||
|
||||
streamBackgroundStructuredObjectMock.mockImplementationOnce((_payload, options: { signal?: AbortSignal }) => {
|
||||
signals.push(options.signal as AbortSignal)
|
||||
return pendingRun.promise
|
||||
})
|
||||
|
||||
const paragraph = document.createElement("p")
|
||||
paragraph.textContent = "Selected text inside a paragraph."
|
||||
document.body.appendChild(paragraph)
|
||||
|
||||
const store = createStore()
|
||||
store.set(configAtom, cloneConfig(DEFAULT_CONFIG))
|
||||
setSelectionState(store, { text: "Selected text", range: createRangeFor(paragraph) })
|
||||
renderWithProviders(<SelectionToolbarCustomActionButtons />, store)
|
||||
|
||||
const actionName = DEFAULT_CONFIG.selectionToolbar.customActions[0]?.name
|
||||
if (!actionName) {
|
||||
throw new Error("Default custom action is missing")
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: actionName }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(streamBackgroundStructuredObjectMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
store.set(configAtom, cloneConfig(store.get(configAtom)))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(streamBackgroundStructuredObjectMock).toHaveBeenCalledTimes(1)
|
||||
expect(signals[0]?.aborted).toBe(false)
|
||||
|
||||
await act(async () => {
|
||||
pendingRun.resolve(createStructuredObjectSnapshot({ summary: "still alive" }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("{\"summary\":\"still alive\"}")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it("reruns custom action requests from the footer and aborts the previous run", async () => {
|
||||
const firstRun = createDeferredPromise<BackgroundStructuredObjectStreamSnapshot>()
|
||||
const secondRun = createDeferredPromise<BackgroundStructuredObjectStreamSnapshot>()
|
||||
|
|
@ -1187,6 +1237,79 @@ describe("selection toolbar requests", () => {
|
|||
})
|
||||
})
|
||||
|
||||
it("switches a custom action provider from the footer without aborting the replacement request", async () => {
|
||||
const firstRun = createDeferredPromise<BackgroundStructuredObjectStreamSnapshot>()
|
||||
const secondRun = createDeferredPromise<BackgroundStructuredObjectStreamSnapshot>()
|
||||
const signals: AbortSignal[] = []
|
||||
|
||||
streamBackgroundStructuredObjectMock
|
||||
.mockImplementationOnce((_payload, options: { signal?: AbortSignal }) => {
|
||||
signals.push(options.signal as AbortSignal)
|
||||
options.signal?.addEventListener("abort", () => {
|
||||
firstRun.reject(new DOMException("aborted", "AbortError"))
|
||||
})
|
||||
return firstRun.promise
|
||||
})
|
||||
.mockImplementationOnce((_payload, options: { signal?: AbortSignal }) => {
|
||||
signals.push(options.signal as AbortSignal)
|
||||
return secondRun.promise
|
||||
})
|
||||
|
||||
const paragraph = document.createElement("p")
|
||||
paragraph.textContent = "Selected text inside a paragraph."
|
||||
document.body.appendChild(paragraph)
|
||||
|
||||
const store = createStore()
|
||||
store.set(configAtom, cloneConfig(DEFAULT_CONFIG))
|
||||
setSelectionState(store, { text: "Selected text", range: createRangeFor(paragraph) })
|
||||
renderWithProviders(<SelectionToolbarCustomActionButtons />, store)
|
||||
|
||||
const action = DEFAULT_CONFIG.selectionToolbar.customActions[0]
|
||||
if (!action) {
|
||||
throw new Error("Default custom action is missing")
|
||||
}
|
||||
const nextProviderId = findAlternateLLMProviderId(store.get(configAtom), action.providerId)
|
||||
if (!nextProviderId) {
|
||||
throw new Error("No alternate LLM provider available for custom action provider switch test")
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: action.name }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(streamBackgroundStructuredObjectMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Change provider" }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(streamBackgroundStructuredObjectMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
expect(streamBackgroundStructuredObjectMock.mock.calls[1]?.[0]).toMatchObject({
|
||||
providerId: nextProviderId,
|
||||
})
|
||||
expect(signals[0]?.aborted).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(signals[1]?.aborted).toBe(false)
|
||||
|
||||
await act(async () => {
|
||||
secondRun.resolve(createStructuredObjectSnapshot({ summary: "provider switched" }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("{\"summary\":\"provider switched\"}")).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByRole("alert")).toBeNull()
|
||||
expect(store.get(configAtom).selectionToolbar.customActions[0]?.providerId).toBe(nextProviderId)
|
||||
})
|
||||
|
||||
it("shows a precheck alert when a custom action has no selected text", async () => {
|
||||
const paragraph = document.createElement("p")
|
||||
paragraph.textContent = "Selected text inside a paragraph."
|
||||
|
|
|
|||
|
|
@ -131,6 +131,17 @@ describe("selectionToolbar - isInputOrTextarea logic", () => {
|
|||
expect(document.querySelector(".absolute.z-2147483647")).toHaveClass("opacity-0")
|
||||
}
|
||||
|
||||
const getToolbar = () => document.querySelector(".absolute.z-2147483647") as HTMLElement | null
|
||||
|
||||
const getToolbarSurface = () => document.querySelector("[data-slot='selection-toolbar-surface']") as HTMLElement | null
|
||||
|
||||
it("applies configured opacity on the toolbar surface instead of the overlay host", () => {
|
||||
render(<SelectionToolbar />)
|
||||
|
||||
expect(getToolbar()?.style.opacity).toBe("")
|
||||
expect(getToolbarSurface()?.style.opacity).toBe("var(--rf-selection-opacity, 1)")
|
||||
})
|
||||
|
||||
it("should show toolbar when selecting text in a normal div element", async () => {
|
||||
render(
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { JSONValue } from "ai"
|
||||
import type { RefObject } from "react"
|
||||
import type { SelectionToolbarCustomActionRequestSlice } from "../atoms"
|
||||
import type { SelectionToolbarInlineError } from "../inline-error"
|
||||
|
|
@ -45,6 +46,23 @@ 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) {
|
||||
|
|
@ -53,6 +71,27 @@ 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,
|
||||
|
|
@ -153,6 +192,64 @@ 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,
|
||||
|
|
@ -173,6 +270,19 @@ export function useCustomActionExecution({
|
|||
const [error, setError] = useState<SelectionToolbarInlineError | null>(null)
|
||||
const [thinking, setThinking] = useState<ThinkingSnapshot | null>(null)
|
||||
const lastRunKeyRef = useRef<string | null>(null)
|
||||
const bodyRefRef = useRef(bodyRef)
|
||||
bodyRefRef.current = bodyRef
|
||||
const executionRequest = executionContext
|
||||
? buildCustomActionExecutionRequest({
|
||||
analyticsSurface,
|
||||
executionContext,
|
||||
popoverSessionKey,
|
||||
rerunNonce,
|
||||
})
|
||||
: null
|
||||
const executionRequestRef = useRef<CustomActionExecutionRequest | null>(null)
|
||||
executionRequestRef.current = executionRequest
|
||||
const executionRequestKey = executionRequest?.key ?? null
|
||||
|
||||
const resetSessionState = useCallback(() => {
|
||||
setIsRunning(false)
|
||||
|
|
@ -182,53 +292,34 @@ export function useCustomActionExecution({
|
|||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !executionContext) {
|
||||
if (!open || !executionRequestKey) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextRunKey = JSON.stringify({
|
||||
actionId: executionContext.action.id,
|
||||
paragraphs: executionContext.promptTokens.paragraphs,
|
||||
popoverSessionKey,
|
||||
providerId: executionContext.providerConfig.id,
|
||||
rerunNonce,
|
||||
selection: executionContext.promptTokens.selection,
|
||||
targetLanguage: executionContext.promptTokens.targetLanguage,
|
||||
webContent: executionContext.promptTokens.webContent,
|
||||
webTitle: executionContext.promptTokens.webTitle,
|
||||
})
|
||||
if (lastRunKeyRef.current === nextRunKey) {
|
||||
const request = executionRequestRef.current
|
||||
if (!request || request.key !== executionRequestKey) {
|
||||
return
|
||||
}
|
||||
lastRunKeyRef.current = nextRunKey
|
||||
|
||||
if (lastRunKeyRef.current === executionRequestKey) {
|
||||
return
|
||||
}
|
||||
lastRunKeyRef.current = executionRequestKey
|
||||
|
||||
let isCancelled = false
|
||||
const abortController = new AbortController()
|
||||
const { action, providerConfig, promptTokens } = executionContext
|
||||
|
||||
const analyticsContext = createFeatureUsageContext(
|
||||
ANALYTICS_FEATURE.CUSTOM_AI_ACTION,
|
||||
analyticsSurface,
|
||||
request.analytics.surface,
|
||||
Date.now(),
|
||||
{
|
||||
action_id: action.id,
|
||||
action_name: action.name,
|
||||
action_id: request.analytics.actionId,
|
||||
action_name: request.analytics.actionName,
|
||||
},
|
||||
)
|
||||
|
||||
const run = async () => {
|
||||
const systemPrompt = buildSelectionToolbarCustomActionSystemPrompt(
|
||||
action.systemPrompt,
|
||||
promptTokens,
|
||||
action.outputSchema,
|
||||
)
|
||||
const prompt = replaceSelectionToolbarCustomActionPromptTokens(action.prompt, promptTokens)
|
||||
const modelName = resolveModelId(providerConfig.model) ?? ""
|
||||
const providerOptions = getProviderOptionsWithOverride(
|
||||
modelName,
|
||||
providerConfig.provider,
|
||||
providerConfig.providerOptions,
|
||||
)
|
||||
|
||||
setIsRunning(true)
|
||||
setResult(null)
|
||||
setError(null)
|
||||
|
|
@ -239,14 +330,7 @@ export function useCustomActionExecution({
|
|||
|
||||
try {
|
||||
const finalResult = await streamBackgroundStructuredObject(
|
||||
{
|
||||
providerId: providerConfig.id,
|
||||
system: systemPrompt,
|
||||
prompt,
|
||||
outputSchema: action.outputSchema.map(({ name, type }) => ({ name, type })),
|
||||
providerOptions,
|
||||
temperature: providerConfig.temperature,
|
||||
},
|
||||
request.payload,
|
||||
{
|
||||
signal: abortController.signal,
|
||||
onChunk: (partial: BackgroundStructuredObjectStreamSnapshot) => {
|
||||
|
|
@ -256,7 +340,7 @@ export function useCustomActionExecution({
|
|||
|
||||
setResult(partial.output)
|
||||
setThinking(partial.thinking)
|
||||
scrollSelectionPopoverBodyToBottom(bodyRef)
|
||||
scrollSelectionPopoverBodyToBottom(bodyRefRef.current)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
@ -301,7 +385,7 @@ export function useCustomActionExecution({
|
|||
isCancelled = true
|
||||
abortController.abort()
|
||||
}
|
||||
}, [analyticsSurface, bodyRef, executionContext, open, popoverSessionKey, rerunNonce])
|
||||
}, [executionRequestKey, open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
|
|
|
|||
|
|
@ -444,16 +444,22 @@ export function SelectionToolbar() {
|
|||
ref={tooltipRef}
|
||||
inert={!isSelectionToolbarVisible}
|
||||
className={cn(
|
||||
`group absolute ${SELECTION_CONTENT_OVERLAY_LAYERS.selectionOverlay} bg-popover rounded-sm shadow-floating border border-border/50 overflow-visible flex items-center transition-opacity`,
|
||||
`group absolute ${SELECTION_CONTENT_OVERLAY_LAYERS.selectionOverlay} overflow-visible transition-opacity`,
|
||||
isSelectionToolbarVisible ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center overflow-x-auto overflow-y-hidden rounded-sm max-w-105 no-scrollbar">
|
||||
{features.translate.enabled && <TranslateButton />}
|
||||
{!isFirefox && features.speak.enabled && <SpeakButton />}
|
||||
<SelectionToolbarCustomActionButtons />
|
||||
<div
|
||||
data-slot="selection-toolbar-surface"
|
||||
className="bg-popover rounded-sm shadow-floating border border-border/50 flex items-center"
|
||||
style={{ opacity: "var(--rf-selection-opacity, 1)" }}
|
||||
>
|
||||
<div className="flex items-center overflow-x-auto overflow-y-hidden rounded-sm max-w-105 no-scrollbar">
|
||||
{features.translate.enabled && <TranslateButton />}
|
||||
{!isFirefox && features.speak.enabled && <SpeakButton />}
|
||||
<SelectionToolbarCustomActionButtons />
|
||||
</div>
|
||||
<CloseButton />
|
||||
</div>
|
||||
<CloseButton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import FrogToast from "@/components/frog-toast"
|
||||
import FloatingButton from "./components/floating-button"
|
||||
import SideContent from "./components/side-content"
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<FloatingButton />
|
||||
<SideContent />
|
||||
<FrogToast />
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
// @vitest-environment jsdom
|
||||
import { fireEvent, render, screen } from "@testing-library/react"
|
||||
import { atom } from "jotai"
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest"
|
||||
import type { FloatingButtonConfig } from "@/types/config/floating-button"
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react"
|
||||
import { atom, createStore, Provider } from "jotai"
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
import { configFieldsAtomMap } from "@/utils/atoms/config"
|
||||
import { sendMessage } from "@/utils/message"
|
||||
import FloatingButton from ".."
|
||||
|
||||
const toastInfoMock = vi.fn()
|
||||
|
||||
vi.mock("#imports", () => ({
|
||||
browser: {
|
||||
runtime: {
|
||||
|
|
@ -15,17 +20,32 @@ vi.mock("#imports", () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/atoms/config", () => ({
|
||||
configFieldsAtomMap: {
|
||||
floatingButton: atom({
|
||||
enabled: true,
|
||||
position: 0.66,
|
||||
clickAction: "panel",
|
||||
disabledFloatingButtonPatterns: [],
|
||||
}),
|
||||
sideContent: atom({ width: 360 }),
|
||||
},
|
||||
}))
|
||||
vi.mock("@/utils/atoms/config", () => {
|
||||
const floatingButtonBaseAtom = atom<FloatingButtonConfig>({
|
||||
enabled: true,
|
||||
position: 0.66,
|
||||
side: "right",
|
||||
clickAction: "panel",
|
||||
disabledFloatingButtonPatterns: [],
|
||||
locked: false,
|
||||
})
|
||||
const floatingButtonAtom = atom(
|
||||
get => get(floatingButtonBaseAtom),
|
||||
(get, set, patch: Partial<FloatingButtonConfig>) => {
|
||||
set(floatingButtonBaseAtom, {
|
||||
...get(floatingButtonBaseAtom),
|
||||
...patch,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
configFieldsAtomMap: {
|
||||
floatingButton: floatingButtonAtom,
|
||||
sideContent: atom({ width: 360 }),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("../../../atoms", () => ({
|
||||
enablePageTranslationAtom: atom({ enabled: false }),
|
||||
|
|
@ -37,22 +57,16 @@ vi.mock("../../../index", () => ({
|
|||
shadowWrapper: document.body,
|
||||
}))
|
||||
|
||||
vi.mock("../translate-button", () => ({
|
||||
default: ({ className }: { className?: string }) => (
|
||||
<div data-testid="translate-button" className={className} />
|
||||
),
|
||||
}))
|
||||
|
||||
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(),
|
||||
}))
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
info: (...args: unknown[]) => toastInfoMock(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
beforeAll(() => {
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
|
|
@ -63,25 +77,315 @@ beforeAll(() => {
|
|||
vi.stubGlobal("ResizeObserver", ResizeObserverMock)
|
||||
})
|
||||
|
||||
describe("floatingButton close trigger", () => {
|
||||
it("keeps the close trigger in the layout with visibility classes instead of display:none", () => {
|
||||
render(<FloatingButton />)
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.mocked(sendMessage).mockReset()
|
||||
toastInfoMock.mockReset()
|
||||
setViewport(1024, 768)
|
||||
})
|
||||
|
||||
const closeTrigger = screen.getByTitle("Close floating button")
|
||||
function setViewport(width: number, height: number) {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
value: width,
|
||||
})
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: height,
|
||||
})
|
||||
}
|
||||
|
||||
function renderFloatingButton(
|
||||
floatingButtonOverrides: Partial<FloatingButtonConfig> = {},
|
||||
) {
|
||||
const store = createStore()
|
||||
void store.set(configFieldsAtomMap.floatingButton, floatingButtonOverrides)
|
||||
|
||||
return {
|
||||
store,
|
||||
...render(
|
||||
<Provider store={store}>
|
||||
<FloatingButton />
|
||||
</Provider>,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function getMainButton() {
|
||||
return screen.getByTestId("floating-main-button")
|
||||
}
|
||||
|
||||
function getFloatingButtonConfig(store: ReturnType<typeof createStore>) {
|
||||
return store.get(configFieldsAtomMap.floatingButton)
|
||||
}
|
||||
|
||||
function mockRect(element: Element, rect: Partial<DOMRect>) {
|
||||
const left = rect.left ?? 0
|
||||
const top = rect.top ?? 0
|
||||
const width = rect.width ?? 0
|
||||
const height = rect.height ?? 0
|
||||
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
|
||||
x: left,
|
||||
y: top,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: rect.right ?? left + width,
|
||||
bottom: rect.bottom ?? top + height,
|
||||
toJSON: () => {},
|
||||
} as DOMRect)
|
||||
}
|
||||
|
||||
describe("floatingButton controls", () => {
|
||||
it("shows the close trigger only after entering the main floating button", () => {
|
||||
renderFloatingButton()
|
||||
|
||||
const closeTrigger = screen.getByRole("button", { name: "Close floating button" })
|
||||
const mainButton = getMainButton()
|
||||
|
||||
expect(mainButton).toHaveClass("transition-transform")
|
||||
expect(mainButton).toHaveClass("duration-300")
|
||||
expect(closeTrigger).toHaveClass("-top-1")
|
||||
expect(closeTrigger).toHaveClass("left-0")
|
||||
expect(closeTrigger).toHaveClass("invisible")
|
||||
expect(closeTrigger).toHaveClass("group-hover:visible")
|
||||
expect(closeTrigger).not.toHaveClass("hidden")
|
||||
expect(closeTrigger).not.toHaveClass("group-hover:block")
|
||||
expect(closeTrigger).toHaveClass("pointer-events-none")
|
||||
expect(closeTrigger).toHaveClass("text-neutral-300")
|
||||
expect(closeTrigger).toHaveClass("hover:scale-110")
|
||||
expect(closeTrigger).toHaveClass("active:scale-90")
|
||||
expect(closeTrigger).toHaveClass("hover:text-neutral-500")
|
||||
expect(closeTrigger).toHaveClass("active:text-neutral-500")
|
||||
|
||||
fireEvent.mouseEnter(mainButton)
|
||||
|
||||
expect(closeTrigger).toHaveClass("visible")
|
||||
expect(closeTrigger).toHaveClass("pointer-events-auto")
|
||||
expect(closeTrigger).toHaveClass("-left-6")
|
||||
})
|
||||
|
||||
it("renders a lock trigger at the lower-left corner and keeps controls expanded after entering the main button", () => {
|
||||
renderFloatingButton()
|
||||
|
||||
const lockTrigger = screen.getByRole("button", { name: "Lock floating button" })
|
||||
const mainButton = getMainButton()
|
||||
const floatingButtonContainer = screen.getByTestId("floating-button-container")
|
||||
|
||||
expect(lockTrigger).toHaveClass("left-0")
|
||||
expect(lockTrigger).toHaveClass("-bottom-1")
|
||||
expect(lockTrigger).toHaveClass("invisible")
|
||||
expect(lockTrigger).toHaveClass("pointer-events-none")
|
||||
expect(lockTrigger).toHaveClass("text-neutral-300")
|
||||
expect(lockTrigger).toHaveClass("hover:scale-110")
|
||||
expect(lockTrigger).toHaveClass("active:scale-90")
|
||||
expect(lockTrigger).toHaveClass("hover:text-neutral-500")
|
||||
expect(lockTrigger).toHaveClass("active:text-neutral-500")
|
||||
expect(mainButton).toHaveClass("translate-x-6")
|
||||
|
||||
fireEvent.mouseEnter(mainButton)
|
||||
|
||||
expect(lockTrigger).toHaveClass("visible")
|
||||
expect(lockTrigger).toHaveClass("pointer-events-auto")
|
||||
expect(lockTrigger).toHaveClass("-left-6")
|
||||
expect(mainButton).toHaveClass("translate-x-0")
|
||||
|
||||
fireEvent.click(lockTrigger)
|
||||
|
||||
const unlockTrigger = screen.getByRole("button", { name: "Unlock floating button" })
|
||||
|
||||
expect(unlockTrigger).toHaveClass("text-neutral-300")
|
||||
expect(unlockTrigger).toHaveClass("-left-6")
|
||||
expect(mainButton).toHaveClass("translate-x-0")
|
||||
expect(mainButton).toHaveClass("opacity-100")
|
||||
expect(mainButton).not.toHaveClass("translate-x-6")
|
||||
|
||||
fireEvent.mouseLeave(floatingButtonContainer)
|
||||
|
||||
expect(mainButton).toHaveClass("translate-x-0")
|
||||
expect(mainButton).toHaveClass("opacity-60")
|
||||
|
||||
fireEvent.mouseEnter(mainButton)
|
||||
|
||||
expect(mainButton).toHaveClass("opacity-100")
|
||||
})
|
||||
|
||||
it("forces the close trigger visible while the dropdown is open", () => {
|
||||
render(<FloatingButton />)
|
||||
renderFloatingButton()
|
||||
|
||||
const closeTrigger = screen.getByTitle("Close floating button")
|
||||
const closeTrigger = screen.getByRole("button", { name: "Close floating button" })
|
||||
const mainButton = getMainButton()
|
||||
|
||||
fireEvent.mouseEnter(mainButton)
|
||||
fireEvent.click(closeTrigger)
|
||||
|
||||
expect(closeTrigger).toHaveClass("visible")
|
||||
expect(closeTrigger).toHaveClass("pointer-events-auto")
|
||||
expect(screen.getByText("options.floatingButtonAndToolbar.floatingButton.closeMenu.disableForSite")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("toggles the browser side panel on a normal panel click", () => {
|
||||
vi.useFakeTimers()
|
||||
renderFloatingButton({ clickAction: "panel" })
|
||||
|
||||
const mainButton = getMainButton()
|
||||
|
||||
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
|
||||
vi.advanceTimersByTime(349)
|
||||
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith("toggleSidePanel", undefined)
|
||||
})
|
||||
|
||||
it("shows a Firefox sidebar help link when the browser requires an extension user action", async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(sendMessage).mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "requires-extension-user-action",
|
||||
})
|
||||
renderFloatingButton({ clickAction: "panel" })
|
||||
|
||||
const mainButton = getMainButton()
|
||||
|
||||
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
|
||||
vi.advanceTimersByTime(349)
|
||||
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
const toastContent = toastInfoMock.mock.calls[0]?.[0]
|
||||
expect(toastContent).toBeDefined()
|
||||
render(<>{toastContent}</>)
|
||||
|
||||
expect(screen.getByText("sidePanel.firefoxUserActionHint")).toBeInTheDocument()
|
||||
const link = screen.getByRole("link", { name: "sidePanel.firefoxUserActionHelpText" })
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"sidePanel.firefoxUserActionHelpUrl",
|
||||
)
|
||||
expect(link).toHaveAttribute("target", "_blank")
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer")
|
||||
})
|
||||
|
||||
it("keeps translate as a normal click action", () => {
|
||||
vi.useFakeTimers()
|
||||
renderFloatingButton({ clickAction: "translate" })
|
||||
|
||||
const mainButton = getMainButton()
|
||||
|
||||
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
|
||||
vi.advanceTimersByTime(349)
|
||||
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"tryToSetEnablePageTranslationOnContentScript",
|
||||
expect.objectContaining({ enabled: true }),
|
||||
)
|
||||
})
|
||||
|
||||
it("turns the frog into the only visible control after a long press", () => {
|
||||
vi.useFakeTimers()
|
||||
renderFloatingButton()
|
||||
|
||||
const mainButton = getMainButton()
|
||||
expect(screen.getAllByRole("button")).toHaveLength(4)
|
||||
|
||||
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350)
|
||||
})
|
||||
|
||||
expect(mainButton).toHaveClass("rounded-full")
|
||||
expect(screen.queryAllByRole("button")).toHaveLength(0)
|
||||
|
||||
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
|
||||
expect(sendMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("starts dragging before the long-press delay after enough pointer movement", () => {
|
||||
vi.useFakeTimers()
|
||||
renderFloatingButton()
|
||||
|
||||
const mainButton = getMainButton()
|
||||
|
||||
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
|
||||
fireEvent.pointerMove(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 908, clientY: 500 })
|
||||
|
||||
expect(mainButton).toHaveClass("rounded-full")
|
||||
expect(screen.queryAllByRole("button")).toHaveLength(0)
|
||||
|
||||
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 908, clientY: 500 })
|
||||
expect(sendMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("persists the left side and vertical position after dragging to the left half", () => {
|
||||
vi.useFakeTimers()
|
||||
setViewport(1000, 1000)
|
||||
const { store } = renderFloatingButton({ position: 0.6, side: "right" })
|
||||
|
||||
const mainButton = getMainButton()
|
||||
const floatingButtonContainer = screen.getByTestId("floating-button-container")
|
||||
mockRect(floatingButtonContainer, { left: 956, top: 600, width: 44, height: 120 })
|
||||
mockRect(mainButton, { left: 956, top: 640, width: 44, height: 40 })
|
||||
|
||||
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 978, clientY: 660 })
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350)
|
||||
})
|
||||
fireEvent.pointerMove(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 120, clientY: 520 })
|
||||
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 120, clientY: 520 })
|
||||
|
||||
expect(getFloatingButtonConfig(store).side).toBe("left")
|
||||
expect(getFloatingButtonConfig(store).position).toBeCloseTo(0.46)
|
||||
})
|
||||
|
||||
it("persists the right side and vertical position after dragging to the right half", () => {
|
||||
vi.useFakeTimers()
|
||||
setViewport(1000, 1000)
|
||||
const { store } = renderFloatingButton({ position: 0.6, side: "left" })
|
||||
|
||||
const mainButton = getMainButton()
|
||||
const floatingButtonContainer = screen.getByTestId("floating-button-container")
|
||||
mockRect(floatingButtonContainer, { left: 0, top: 600, width: 44, height: 120 })
|
||||
mockRect(mainButton, { left: 0, top: 640, width: 44, height: 40 })
|
||||
|
||||
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 22, clientY: 660 })
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350)
|
||||
})
|
||||
fireEvent.pointerMove(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 520 })
|
||||
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 520 })
|
||||
|
||||
expect(getFloatingButtonConfig(store).side).toBe("right")
|
||||
expect(getFloatingButtonConfig(store).position).toBeCloseTo(0.46)
|
||||
})
|
||||
|
||||
it("mirrors the controls when attached to the left edge", () => {
|
||||
renderFloatingButton({ side: "left" })
|
||||
|
||||
const closeTrigger = screen.getByRole("button", { name: "Close floating button" })
|
||||
const lockTrigger = screen.getByRole("button", { name: "Lock floating button" })
|
||||
const mainButton = getMainButton()
|
||||
const hiddenButtons = screen.getAllByRole("button").filter(button => (
|
||||
button !== closeTrigger && button !== lockTrigger
|
||||
))
|
||||
|
||||
expect(mainButton).toHaveClass("rounded-r-full")
|
||||
expect(mainButton).toHaveClass("-translate-x-6")
|
||||
expect(closeTrigger).toHaveClass("right-0")
|
||||
expect(lockTrigger).toHaveClass("right-0")
|
||||
for (const hiddenButton of hiddenButtons) {
|
||||
expect(hiddenButton).toHaveClass("-translate-x-12")
|
||||
}
|
||||
|
||||
fireEvent.mouseEnter(mainButton)
|
||||
|
||||
expect(mainButton).toHaveClass("translate-x-0")
|
||||
expect(closeTrigger).toHaveClass("-right-6")
|
||||
expect(lockTrigger).toHaveClass("-right-6")
|
||||
for (const hiddenButton of hiddenButtons) {
|
||||
expect(hiddenButton).toHaveClass("translate-x-0")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { FloatingButtonSide } from "@/types/config/floating-button"
|
||||
import { cn } from "@/utils/styles/utils"
|
||||
|
||||
export default function HiddenButton({
|
||||
|
|
@ -5,17 +6,25 @@ export default function HiddenButton({
|
|||
onClick,
|
||||
children,
|
||||
className,
|
||||
side = "right",
|
||||
expanded = false,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
onClick: () => void
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
side?: FloatingButtonSide
|
||||
expanded?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"border-border mr-2 translate-x-12 cursor-pointer rounded-full border bg-white 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",
|
||||
"border-border cursor-pointer rounded-full border bg-white shadow-lg p-1.5 text-neutral-600 dark:text-neutral-400 transition-transform duration-300 hover:bg-neutral-100 dark:bg-neutral-900 dark:hover:bg-neutral-800",
|
||||
side === "right" ? "mr-2" : "ml-2",
|
||||
expanded
|
||||
? "translate-x-0"
|
||||
: side === "right" ? "translate-x-12" : "-translate-x-12",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import type { FloatingButtonSide } from "@/types/config/floating-button"
|
||||
import { browser, i18n } from "#imports"
|
||||
import { IconSettings, IconX } from "@tabler/icons-react"
|
||||
import { IconLock, IconLockOpen, IconSettings, IconX } from "@tabler/icons-react"
|
||||
import { useAtom, useAtomValue } from "jotai"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import readFrogLogo from "@/assets/icons/read-frog.png?url&no-inline"
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -16,194 +18,513 @@ import { APP_NAME } from "@/utils/constants/app"
|
|||
import { sendMessage } from "@/utils/message"
|
||||
import { cn } from "@/utils/styles/utils"
|
||||
import { matchDomainPattern } from "@/utils/url"
|
||||
import { enablePageTranslationAtom, isDraggingButtonAtom, isSideOpenAtom } from "../../atoms"
|
||||
import { enablePageTranslationAtom, isDraggingButtonAtom } from "../../atoms"
|
||||
import { shadowWrapper } from "../../index"
|
||||
import HiddenButton from "./components/hidden-button"
|
||||
import TranslateButton from "./translate-button"
|
||||
|
||||
const readFrogLogoUrl = new URL(readFrogLogo, browser.runtime.getURL("/")).href
|
||||
const LONG_PRESS_DELAY_MS = 350
|
||||
const DRAG_START_DISTANCE_PX = 6
|
||||
const MIN_FLOATING_CONTAINER_TOP_PX = 30
|
||||
const FLOATING_CONTAINER_BOTTOM_CLEARANCE_PX = 200
|
||||
|
||||
interface DragPoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface PendingDragState {
|
||||
pointerId: number
|
||||
startClientX: number
|
||||
startClientY: number
|
||||
currentClientX: number
|
||||
currentClientY: number
|
||||
pointerOffsetX: number
|
||||
pointerOffsetY: number
|
||||
mainOffsetY: number
|
||||
buttonWidth: number
|
||||
buttonHeight: number
|
||||
hasActiveDrag: boolean
|
||||
longPressTimerId: number
|
||||
}
|
||||
|
||||
const floatingButtonControlClassName = cn(
|
||||
"absolute invisible cursor-pointer pointer-events-none flex size-6 items-center justify-center",
|
||||
"text-neutral-300 transition-[color,left,right,transform] duration-300 hover:scale-110 hover:text-neutral-500 active:scale-90 active:text-neutral-500",
|
||||
"dark:text-neutral-700 dark:hover:text-neutral-500 dark:active:text-neutral-500",
|
||||
)
|
||||
const floatingButtonControlOffsetClassNames = {
|
||||
right: {
|
||||
collapsed: "left-0",
|
||||
expanded: "-left-6",
|
||||
},
|
||||
left: {
|
||||
collapsed: "right-0",
|
||||
expanded: "-right-6",
|
||||
},
|
||||
} satisfies Record<FloatingButtonSide, { collapsed: string, expanded: string }>
|
||||
|
||||
function FirefoxSidebarHelpToast() {
|
||||
return (
|
||||
<span>
|
||||
{i18n.t("sidePanel.firefoxUserActionHint")}
|
||||
{" "}
|
||||
<a
|
||||
href={i18n.t("sidePanel.firefoxUserActionHelpUrl")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
{i18n.t("sidePanel.firefoxUserActionHelpText")}
|
||||
</a>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function getFloatingButtonSide(side: string | undefined): FloatingButtonSide {
|
||||
return side === "left" ? "left" : "right"
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value))
|
||||
}
|
||||
|
||||
function getPointerDistance(startX: number, startY: number, currentX: number, currentY: number) {
|
||||
return Math.hypot(currentX - startX, currentY - startY)
|
||||
}
|
||||
|
||||
function getDragPreviewPosition(pendingDrag: PendingDragState): DragPoint {
|
||||
return {
|
||||
x: clamp(
|
||||
pendingDrag.currentClientX - pendingDrag.pointerOffsetX,
|
||||
0,
|
||||
Math.max(0, window.innerWidth - pendingDrag.buttonWidth),
|
||||
),
|
||||
y: clamp(
|
||||
pendingDrag.currentClientY - pendingDrag.pointerOffsetY,
|
||||
0,
|
||||
Math.max(0, window.innerHeight - pendingDrag.buttonHeight),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function getNormalizedFloatingContainerTop(mainButtonTop: number, mainOffsetY: number) {
|
||||
const viewportHeight = Math.max(1, window.innerHeight)
|
||||
const maxTop = Math.max(
|
||||
MIN_FLOATING_CONTAINER_TOP_PX,
|
||||
viewportHeight - FLOATING_CONTAINER_BOTTOM_CLEARANCE_PX,
|
||||
)
|
||||
const containerTop = clamp(
|
||||
mainButtonTop - mainOffsetY,
|
||||
MIN_FLOATING_CONTAINER_TOP_PX,
|
||||
maxTop,
|
||||
)
|
||||
return containerTop / viewportHeight
|
||||
}
|
||||
|
||||
export default function FloatingButton() {
|
||||
const [floatingButton, setFloatingButton] = useAtom(
|
||||
configFieldsAtomMap.floatingButton,
|
||||
)
|
||||
const sideContent = useAtomValue(configFieldsAtomMap.sideContent)
|
||||
const translationState = useAtomValue(enablePageTranslationAtom)
|
||||
const [isSideOpen, setIsSideOpen] = useAtom(isSideOpenAtom)
|
||||
const [isDraggingButton, setIsDraggingButton] = useAtom(isDraggingButtonAtom)
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
||||
const [dragPosition, setDragPosition] = useState<number | null>(null)
|
||||
const initialClientYRef = useRef<number | null>(null)
|
||||
const [isHitAreaExpanded, setIsHitAreaExpanded] = useState(false)
|
||||
const [dragPreviewPosition, setDragPreviewPosition] = useState<DragPoint | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const mainButtonRef = useRef<HTMLDivElement | null>(null)
|
||||
const pendingDragRef = useRef<PendingDragState | null>(null)
|
||||
const lastDragPreviewRef = useRef<DragPoint | null>(null)
|
||||
const isFloatingButtonLocked = floatingButton.locked
|
||||
const floatingButtonSide = getFloatingButtonSide(floatingButton.side)
|
||||
const isFloatingButtonExpanded = isHitAreaExpanded || isDropdownOpen
|
||||
const isMainButtonAttached = isFloatingButtonLocked || isFloatingButtonExpanded
|
||||
|
||||
// 按钮拖动处理
|
||||
useEffect(() => {
|
||||
const initialClientY = initialClientYRef.current
|
||||
if (!isDraggingButton || !initialClientY || !floatingButton)
|
||||
if (!isDraggingButton)
|
||||
return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const initialY = floatingButton.position * window.innerHeight
|
||||
const newY = Math.max(
|
||||
30,
|
||||
Math.min(
|
||||
window.innerHeight - 200,
|
||||
initialY + e.clientY - initialClientY,
|
||||
),
|
||||
)
|
||||
const newPosition = newY / window.innerHeight
|
||||
setDragPosition(newPosition)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDraggingButton(false)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
|
||||
const previousUserSelect = document.body.style.userSelect
|
||||
const previousCursor = document.body.style.cursor
|
||||
document.body.style.userSelect = "none"
|
||||
document.body.style.cursor = "grabbing"
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
document.body.style.userSelect = ""
|
||||
document.body.style.userSelect = previousUserSelect
|
||||
document.body.style.cursor = previousCursor
|
||||
}
|
||||
// eslint-disable-next-line react/exhaustive-deps
|
||||
}, [isDraggingButton])
|
||||
|
||||
// 拖拽结束时写入 storage
|
||||
useEffect(() => {
|
||||
if (!isDraggingButton && dragPosition !== null) {
|
||||
void setFloatingButton({ position: dragPosition })
|
||||
// eslint-disable-next-line react/set-state-in-effect
|
||||
setDragPosition(null)
|
||||
}
|
||||
}, [isDraggingButton, dragPosition, setFloatingButton])
|
||||
|
||||
const handleButtonDragStart = (e: React.MouseEvent) => {
|
||||
// 记录初始位置,用于后续判断是点击还是拖动
|
||||
initialClientYRef.current = e.clientY
|
||||
let hasMoved = false // 标记是否发生了移动
|
||||
|
||||
e.preventDefault()
|
||||
setIsDraggingButton(true)
|
||||
|
||||
// 创建一个监听器检测移动
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const moveDistance = Math.abs(moveEvent.clientY - e.clientY)
|
||||
// 如果移动距离大于阈值,标记为已移动
|
||||
if (moveDistance > 5) {
|
||||
hasMoved = true
|
||||
return () => {
|
||||
const pendingDrag = pendingDragRef.current
|
||||
if (pendingDrag) {
|
||||
window.clearTimeout(pendingDrag.longPressTimerId)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 在鼠标释放时,只有未移动才触发点击事件
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
|
||||
// 只有未移动过才触发点击
|
||||
if (!hasMoved) {
|
||||
if (floatingButton.clickAction === "translate") {
|
||||
const nextEnabled = !translationState.enabled
|
||||
void sendMessage("tryToSetEnablePageTranslationOnContentScript", {
|
||||
enabled: nextEnabled,
|
||||
analyticsContext: nextEnabled
|
||||
? createFeatureUsageContext(ANALYTICS_FEATURE.PAGE_TRANSLATION, ANALYTICS_SURFACE.FLOATING_BUTTON)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
else {
|
||||
setIsSideOpen(o => !o)
|
||||
}
|
||||
}
|
||||
const handleFloatingButtonClick = () => {
|
||||
if (floatingButton.clickAction === "translate") {
|
||||
const nextEnabled = !translationState.enabled
|
||||
void sendMessage("tryToSetEnablePageTranslationOnContentScript", {
|
||||
enabled: nextEnabled,
|
||||
analyticsContext: nextEnabled
|
||||
? createFeatureUsageContext(ANALYTICS_FEATURE.PAGE_TRANSLATION, ANALYTICS_SURFACE.FLOATING_BUTTON)
|
||||
: undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
void Promise.resolve(sendMessage("toggleSidePanel", undefined)).then((result) => {
|
||||
if (result?.ok === false && result.reason === "requires-extension-user-action") {
|
||||
toast.info(<FirefoxSidebarHelpToast />)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const attachSideClassName = isDraggingButton || isSideOpen || isDropdownOpen ? "translate-x-0" : ""
|
||||
const startActiveDrag = () => {
|
||||
const pendingDrag = pendingDragRef.current
|
||||
if (!pendingDrag || pendingDrag.hasActiveDrag)
|
||||
return
|
||||
|
||||
pendingDrag.hasActiveDrag = true
|
||||
const nextPreviewPosition = getDragPreviewPosition(pendingDrag)
|
||||
lastDragPreviewRef.current = nextPreviewPosition
|
||||
setDragPreviewPosition(nextPreviewPosition)
|
||||
setIsHitAreaExpanded(false)
|
||||
setIsDropdownOpen(false)
|
||||
setIsDraggingButton(true)
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.pointerType === "mouse" && e.button !== 0)
|
||||
return
|
||||
|
||||
const mainButton = mainButtonRef.current ?? e.currentTarget
|
||||
const mainButtonRect = mainButton.getBoundingClientRect()
|
||||
const containerRect = containerRef.current?.getBoundingClientRect()
|
||||
const mainOffsetY = containerRect
|
||||
? mainButtonRect.top - containerRect.top
|
||||
: 0
|
||||
|
||||
e.preventDefault()
|
||||
if (typeof e.currentTarget.setPointerCapture === "function") {
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
pendingDragRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
startClientX: e.clientX,
|
||||
startClientY: e.clientY,
|
||||
currentClientX: e.clientX,
|
||||
currentClientY: e.clientY,
|
||||
pointerOffsetX: e.clientX - mainButtonRect.left,
|
||||
pointerOffsetY: e.clientY - mainButtonRect.top,
|
||||
mainOffsetY,
|
||||
buttonWidth: mainButtonRect.width || 40,
|
||||
buttonHeight: mainButtonRect.height || 40,
|
||||
hasActiveDrag: false,
|
||||
longPressTimerId: window.setTimeout(startActiveDrag, LONG_PRESS_DELAY_MS),
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const pendingDrag = pendingDragRef.current
|
||||
if (!pendingDrag || pendingDrag.pointerId !== e.pointerId)
|
||||
return
|
||||
|
||||
pendingDrag.currentClientX = e.clientX
|
||||
pendingDrag.currentClientY = e.clientY
|
||||
|
||||
if (!pendingDrag.hasActiveDrag) {
|
||||
const pointerDistance = getPointerDistance(
|
||||
pendingDrag.startClientX,
|
||||
pendingDrag.startClientY,
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
)
|
||||
if (pointerDistance > DRAG_START_DISTANCE_PX) {
|
||||
startActiveDrag()
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingDrag.hasActiveDrag) {
|
||||
const nextPreviewPosition = getDragPreviewPosition(pendingDrag)
|
||||
lastDragPreviewRef.current = nextPreviewPosition
|
||||
setDragPreviewPosition(nextPreviewPosition)
|
||||
}
|
||||
}
|
||||
|
||||
const finishPointerInteraction = (
|
||||
e: React.PointerEvent<HTMLDivElement>,
|
||||
shouldTriggerClick: boolean,
|
||||
) => {
|
||||
const pendingDrag = pendingDragRef.current
|
||||
if (!pendingDrag || pendingDrag.pointerId !== e.pointerId)
|
||||
return
|
||||
|
||||
window.clearTimeout(pendingDrag.longPressTimerId)
|
||||
pendingDragRef.current = null
|
||||
|
||||
if (typeof e.currentTarget.releasePointerCapture === "function") {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
if (pendingDrag.hasActiveDrag) {
|
||||
const finalPreviewPosition = lastDragPreviewRef.current
|
||||
?? getDragPreviewPosition(pendingDrag)
|
||||
const finalCenterX = finalPreviewPosition.x + pendingDrag.buttonWidth / 2
|
||||
const nextSide: FloatingButtonSide = finalCenterX < window.innerWidth / 2
|
||||
? "left"
|
||||
: "right"
|
||||
const nextPosition = getNormalizedFloatingContainerTop(
|
||||
finalPreviewPosition.y,
|
||||
pendingDrag.mainOffsetY,
|
||||
)
|
||||
|
||||
lastDragPreviewRef.current = null
|
||||
setDragPreviewPosition(null)
|
||||
void setFloatingButton({ position: nextPosition, side: nextSide })
|
||||
setIsDraggingButton(false)
|
||||
return
|
||||
}
|
||||
|
||||
lastDragPreviewRef.current = null
|
||||
setDragPreviewPosition(null)
|
||||
setIsDraggingButton(false)
|
||||
|
||||
if (shouldTriggerClick) {
|
||||
handleFloatingButtonClick()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
finishPointerInteraction(e, true)
|
||||
}
|
||||
|
||||
const handlePointerCancel = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
finishPointerInteraction(e, false)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isDraggingButton) {
|
||||
setIsHitAreaExpanded(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!isDropdownOpen && !isDraggingButton) {
|
||||
setIsHitAreaExpanded(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!floatingButton.enabled || floatingButton.disabledFloatingButtonPatterns.some(pattern => matchDomainPattern(window.location.href, pattern))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const containerStyle: React.CSSProperties = isDraggingButton && dragPreviewPosition
|
||||
? {
|
||||
left: `${dragPreviewPosition.x}px`,
|
||||
right: "auto",
|
||||
top: `${dragPreviewPosition.y}px`,
|
||||
}
|
||||
: {
|
||||
left: floatingButtonSide === "left" ? "0px" : undefined,
|
||||
right: floatingButtonSide === "right"
|
||||
? "var(--removed-body-scroll-bar-size, 0px)"
|
||||
: undefined,
|
||||
top: `${floatingButton.position * 100}vh`,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group fixed z-2147483647 flex flex-col items-end gap-2 print:hidden"
|
||||
style={{
|
||||
right: isSideOpen
|
||||
? `calc(${sideContent.width}px + var(--removed-body-scroll-bar-size, 0px))`
|
||||
: "var(--removed-body-scroll-bar-size, 0px)",
|
||||
top: `${(dragPosition ?? floatingButton.position) * 100}vh`,
|
||||
}}
|
||||
ref={containerRef}
|
||||
data-testid="floating-button-container"
|
||||
className={cn(
|
||||
"fixed z-2147483647 flex flex-col gap-2 print:hidden",
|
||||
isDraggingButton
|
||||
? "items-center"
|
||||
: floatingButtonSide === "right" ? "items-end" : "items-start",
|
||||
!isDraggingButton && isFloatingButtonExpanded && (
|
||||
floatingButtonSide === "right" ? "pl-6" : "pr-6"
|
||||
),
|
||||
)}
|
||||
style={containerStyle}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<TranslateButton className={attachSideClassName} />
|
||||
<div
|
||||
className={cn(
|
||||
"border-border flex h-10 w-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,
|
||||
)}
|
||||
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"
|
||||
{!isDraggingButton && (
|
||||
<TranslateButton
|
||||
side={floatingButtonSide}
|
||||
expanded={isFloatingButtonExpanded}
|
||||
/>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={mainButtonRef}
|
||||
data-testid="floating-main-button"
|
||||
className={cn(
|
||||
"border-border relative flex h-10 items-center bg-white shadow-lg transition-transform duration-300 dark:bg-neutral-900",
|
||||
isDraggingButton
|
||||
? "w-10 touch-none justify-center rounded-full border cursor-grabbing opacity-100"
|
||||
: floatingButtonSide === "right"
|
||||
? "w-11 justify-start rounded-l-full border border-r-0"
|
||||
: "w-11 justify-end rounded-r-full border border-l-0",
|
||||
!isDraggingButton && (isMainButtonAttached
|
||||
? "translate-x-0"
|
||||
: floatingButtonSide === "right" ? "translate-x-6" : "-translate-x-6"),
|
||||
!isDraggingButton && (isFloatingButtonExpanded ? "opacity-100" : "opacity-60"),
|
||||
!isDraggingButton && "cursor-pointer",
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
>
|
||||
<img
|
||||
src={readFrogLogoUrl}
|
||||
alt={APP_NAME}
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full",
|
||||
!isDraggingButton && (floatingButtonSide === "right" ? "ml-1" : "mr-1"),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isDraggingButton && (
|
||||
<>
|
||||
<FloatingButtonCloseMenu
|
||||
expanded={isFloatingButtonExpanded}
|
||||
side={floatingButtonSide}
|
||||
onDropdownOpenChange={setIsDropdownOpen}
|
||||
/>
|
||||
<FloatingButtonLockControl
|
||||
expanded={isFloatingButtonExpanded}
|
||||
side={floatingButtonSide}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<HiddenButton
|
||||
className={attachSideClassName}
|
||||
icon={<IconSettings className="h-5 w-5" />}
|
||||
onClick={() => {
|
||||
void sendMessage("openOptionsPage", undefined)
|
||||
}}
|
||||
/>
|
||||
{!isDraggingButton && (
|
||||
<HiddenButton
|
||||
side={floatingButtonSide}
|
||||
expanded={isFloatingButtonExpanded}
|
||||
icon={<IconSettings className="h-5 w-5" />}
|
||||
onClick={() => {
|
||||
void sendMessage("openOptionsPage", undefined)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FloatingButtonCloseMenuProps {
|
||||
expanded: boolean
|
||||
side: FloatingButtonSide
|
||||
onDropdownOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function FloatingButtonCloseMenu({
|
||||
expanded,
|
||||
side,
|
||||
onDropdownOpenChange,
|
||||
}: FloatingButtonCloseMenuProps) {
|
||||
const [floatingButton, setFloatingButton] = useAtom(configFieldsAtomMap.floatingButton)
|
||||
const [open, setOpen] = useState(false)
|
||||
const controlOffsetClassName = !floatingButton.locked && !expanded
|
||||
? floatingButtonControlOffsetClassNames[side].collapsed
|
||||
: floatingButtonControlOffsetClassNames[side].expanded
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
onDropdownOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
const handleDisableForSite = () => {
|
||||
const currentDomain = window.location.hostname
|
||||
const currentPatterns = floatingButton.disabledFloatingButtonPatterns || []
|
||||
|
||||
void setFloatingButton({
|
||||
...floatingButton,
|
||||
disabledFloatingButtonPatterns: [...currentPatterns, currentDomain],
|
||||
})
|
||||
}
|
||||
|
||||
const handleDisableGlobally = () => {
|
||||
void setFloatingButton({ ...floatingButton, enabled: false })
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close floating button"
|
||||
className={cn(
|
||||
floatingButtonControlClassName,
|
||||
"-top-1",
|
||||
controlOffsetClassName,
|
||||
expanded && "visible pointer-events-auto",
|
||||
open && "visible pointer-events-auto",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<IconX className="h-3 w-3" strokeWidth={3} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent container={shadowWrapper} align="start" side={side === "right" ? "left" : "right"} className="z-2147483647 w-fit! whitespace-nowrap">
|
||||
<DropdownMenuItem
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onClick={handleDisableForSite}
|
||||
>
|
||||
{i18n.t("options.floatingButtonAndToolbar.floatingButton.closeMenu.disableForSite")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onClick={handleDisableGlobally}
|
||||
>
|
||||
{i18n.t("options.floatingButtonAndToolbar.floatingButton.closeMenu.disableGlobally")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
interface FloatingButtonLockControlProps {
|
||||
expanded: boolean
|
||||
side: FloatingButtonSide
|
||||
}
|
||||
|
||||
function FloatingButtonLockControl({ expanded, side }: FloatingButtonLockControlProps) {
|
||||
const [floatingButton, setFloatingButton] = useAtom(configFieldsAtomMap.floatingButton)
|
||||
const locked = floatingButton.locked
|
||||
const controlOffsetClassName = !locked && !expanded
|
||||
? floatingButtonControlOffsetClassNames[side].collapsed
|
||||
: floatingButtonControlOffsetClassNames[side].expanded
|
||||
|
||||
const handleToggleLocked = () => {
|
||||
void setFloatingButton({ ...floatingButton, locked: !locked })
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={locked ? "Unlock floating button" : "Lock floating button"}
|
||||
className={cn(
|
||||
floatingButtonControlClassName,
|
||||
"-bottom-1",
|
||||
controlOffsetClassName,
|
||||
expanded && "visible pointer-events-auto",
|
||||
)}
|
||||
onClick={handleToggleLocked}
|
||||
>
|
||||
{locked
|
||||
? <IconLock className="h-3 w-3" strokeWidth={3} />
|
||||
: <IconLockOpen className="h-3 w-3" strokeWidth={3} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { FloatingButtonSide } from "@/types/config/floating-button"
|
||||
import { RiTranslate } from "@remixicon/react"
|
||||
import { IconCheck } from "@tabler/icons-react"
|
||||
import { useAtomValue } from "jotai"
|
||||
|
|
@ -6,7 +7,15 @@ import { cn } from "@/utils/styles/utils"
|
|||
import { enablePageTranslationAtom } from "../../atoms"
|
||||
import HiddenButton from "./components/hidden-button"
|
||||
|
||||
export default function TranslateButton({ className }: { className: string }) {
|
||||
export default function TranslateButton({
|
||||
className,
|
||||
side = "right",
|
||||
expanded = false,
|
||||
}: {
|
||||
className?: string
|
||||
side?: FloatingButtonSide
|
||||
expanded?: boolean
|
||||
}) {
|
||||
const translationState = useAtomValue(enablePageTranslationAtom)
|
||||
const isEnabled = translationState.enabled
|
||||
|
||||
|
|
@ -14,6 +23,8 @@ export default function TranslateButton({ className }: { className: string }) {
|
|||
<HiddenButton
|
||||
icon={<RiTranslate className="h-5 w-5" />}
|
||||
className={className}
|
||||
side={side}
|
||||
expanded={expanded}
|
||||
onClick={() => {
|
||||
void sendMessage("tryToSetEnablePageTranslationOnContentScript", { enabled: !isEnabled })
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
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" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
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()
|
||||
13
src/entrypoints/sidepanel/index.html
Normal file
13
src/entrypoints/sidepanel/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!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>
|
||||
66
src/entrypoints/sidepanel/main.tsx
Normal file
66
src/entrypoints/sidepanel/main.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
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()
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
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()
|
||||
|
||||
|
|
@ -16,6 +18,8 @@ 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"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ declare global {
|
|||
}
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["*://*.youtube.com/*"],
|
||||
matches: ["*://*.youtube.com/*", "*://*.youtube-nocookie.com/*"],
|
||||
allFrames: true,
|
||||
cssInjectionMode: "manifest",
|
||||
async main(ctx) {
|
||||
if (window.__READ_FROG_SUBTITLES_INJECTED__)
|
||||
|
|
|
|||
|
|
@ -1,22 +1,33 @@
|
|||
import { YOUTUBE_NAVIGATE_FINISH_EVENT, YOUTUBE_WATCH_URL_PATTERN } from "@/utils/constants/subtitles"
|
||||
import { YOUTUBE_EMBED_PATH_PATTERN, YOUTUBE_NAVIGATE_FINISH_EVENT, YOUTUBE_WATCH_URL_PATTERN } from "@/utils/constants/subtitles"
|
||||
import { setupYoutubeSubtitles } from "./platforms/youtube"
|
||||
import { youtubeConfig } from "./platforms/youtube/config"
|
||||
import { getYoutubeConfig } 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 (!window.location.href.includes(YOUTUBE_WATCH_URL_PATTERN)) {
|
||||
if (!isYoutubeWatch() && !embedded) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!mountedAdapter) {
|
||||
mountedAdapter = setupYoutubeSubtitles()
|
||||
mountedAdapter = setupYoutubeSubtitles(config)
|
||||
}
|
||||
|
||||
await mountSubtitlesUI({ adapter: mountedAdapter, config: youtubeConfig })
|
||||
await mountSubtitlesUI({ adapter: mountedAdapter, config })
|
||||
|
||||
if (initialized) {
|
||||
return
|
||||
|
|
@ -28,5 +39,7 @@ export function initYoutubeSubtitles() {
|
|||
|
||||
void tryInit()
|
||||
|
||||
window.addEventListener(YOUTUBE_NAVIGATE_FINISH_EVENT, () => void tryInit())
|
||||
if (!embedded) {
|
||||
window.addEventListener(YOUTUBE_NAVIGATE_FINISH_EVENT, () => void tryInit())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
export interface ControlsConfig {
|
||||
findVideoContainer?: () => HTMLElement | null
|
||||
measureHeight: (container: HTMLElement) => number
|
||||
checkVisibility: (container: HTMLElement) => boolean
|
||||
}
|
||||
|
||||
export interface PlatformConfig {
|
||||
embedded?: boolean
|
||||
|
||||
selectors: {
|
||||
video: string
|
||||
playerContainer: string
|
||||
|
|
@ -11,10 +14,10 @@ export interface PlatformConfig {
|
|||
nativeSubtitles: string
|
||||
}
|
||||
|
||||
events: {
|
||||
navigateStart?: string
|
||||
navigateFinish?: string
|
||||
}
|
||||
events: {
|
||||
navigateStart?: string
|
||||
navigateFinish?: string
|
||||
}
|
||||
|
||||
controls?: ControlsConfig
|
||||
|
||||
|
|
|
|||
|
|
@ -6,32 +6,57 @@ 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,
|
||||
},
|
||||
|
||||
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,
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
export function setupYoutubeSubtitles(config: PlatformConfig) {
|
||||
const subtitlesFetcher = new YoutubeSubtitlesFetcher()
|
||||
|
||||
return new UniversalVideoAdapter({
|
||||
config: youtubeConfig,
|
||||
config,
|
||||
subtitlesFetcher,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { UniversalVideoAdapter } from "../universal-adapter"
|
||||
import type { SubtitlesProvidersAdapter } from "../ui/subtitles-ui-context"
|
||||
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"
|
||||
|
|
@ -9,19 +8,13 @@ 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 { SubtitlesUIContext } from "../ui/subtitles-ui-context"
|
||||
import { SubtitlesProviders } 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: MountSubtitlesUIAdapter
|
||||
adapter: SubtitlesProvidersAdapter
|
||||
config: Pick<PlatformConfig, "selectors">
|
||||
}
|
||||
|
||||
|
|
@ -90,22 +83,14 @@ export async function mountSubtitlesUI(
|
|||
parentEl.appendChild(shadowHost)
|
||||
|
||||
const app = (
|
||||
<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>
|
||||
<ShadowWrapperContext value={reactContainer}>
|
||||
<ThemeProvider container={reactContainer}>
|
||||
<SubtitlesProviders adapter={adapter}>
|
||||
<SubtitlesContainer />
|
||||
<Toaster richColors className="z-2147483647 notranslate" />
|
||||
</SubtitlesProviders>
|
||||
</ThemeProvider>
|
||||
</ShadowWrapperContext>
|
||||
)
|
||||
|
||||
reactRoot.render(app)
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,13 +1,16 @@
|
|||
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">
|
||||
|
|
@ -20,9 +23,11 @@ export function SubtitlesContainer() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 z-40 overflow-visible">
|
||||
<SubtitlesSettingsPanel />
|
||||
</div>
|
||||
{!ui?.embedded && (
|
||||
<div className="absolute inset-0 z-40 overflow-visible">
|
||||
<SubtitlesSettingsPanel />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,11 +43,10 @@ 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 text-white/80" />
|
||||
: <IconDownload className="size-3.5 text-white/72" />}
|
||||
? <IconLoader2 className="size-3.5 animate-spin" />
|
||||
: <IconDownload className="size-3.5" />}
|
||||
</Button>
|
||||
</SubtitlesSettingsItem>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -14,12 +14,12 @@ export function SubtitlesSettingsItem({
|
|||
children,
|
||||
}: SubtitlesSettingsItemProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-[14px] px-2 py-2 transition-colors hover:bg-white/4.5">
|
||||
<div className="hover:bg-muted/50 flex items-center gap-3 rounded-[14px] px-2 py-2 transition-colors">
|
||||
<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 text-white/96 transition-colors hover:text-white"
|
||||
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"
|
||||
>
|
||||
<div className="shrink-0 text-white/82">
|
||||
<div className="text-muted-foreground shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ 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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
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)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
|
@ -1,12 +1,49 @@
|
|||
import { DownloadSourceSubtitles } from "./components/download-source-subtitles"
|
||||
import { SettingsPanelShell } from "./components/settings-panel-shell"
|
||||
import { SubtitlesToggle } from "./components/subtitles-toggle"
|
||||
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"
|
||||
|
||||
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 (
|
||||
<SettingsPanelShell>
|
||||
<SubtitlesToggle />
|
||||
<DownloadSourceSubtitles />
|
||||
</SettingsPanelShell>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
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"
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,25 +4,31 @@ 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={() => setPanelOpen(prev => !prev)}
|
||||
onClick={() => {
|
||||
setPanelView(ROOT_VIEW)
|
||||
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-white/10 shadow-[inset_0_1px_0_rgba(255,255,255,0.12)]"
|
||||
? "bg-accent shadow-inner"
|
||||
: "bg-transparent",
|
||||
)}
|
||||
>
|
||||
|
|
@ -39,8 +45,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-[#d8a94b] text-[#24190a] shadow-[0_2px_8px_rgba(216,169,75,0.35)]"
|
||||
: "bg-white/18 text-white/92",
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
>
|
||||
{isVisible ? "ON" : "OFF"}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
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)
|
||||
|
|
@ -16,3 +20,31 @@ 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -28,12 +28,9 @@ export function useControlsInfo(
|
|||
return
|
||||
|
||||
const element = elementRef.current
|
||||
if (!element)
|
||||
return
|
||||
|
||||
const shadowRoot = getContainingShadowRoot(element)
|
||||
const shadowRoot = element ? getContainingShadowRoot(element) : null
|
||||
const shadowHost = shadowRoot?.host as HTMLElement | undefined
|
||||
const videoContainer = shadowHost?.parentElement
|
||||
const videoContainer = shadowHost?.parentElement ?? controlsConfig.findVideoContainer?.()
|
||||
if (!videoContainer)
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -15,11 +15,12 @@ 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, subtitlesStore } from "./atoms"
|
||||
import { subtitlesPositionAtom, subtitlesSettingsPanelOpenAtom, subtitlesSettingsPanelViewAtom, 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"
|
||||
|
||||
|
|
@ -43,6 +44,10 @@ 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)
|
||||
|
|
@ -102,6 +107,7 @@ export class UniversalVideoAdapter {
|
|||
this.clearSourceCache()
|
||||
this.subtitlesFetcher.cleanup()
|
||||
subtitlesStore.set(subtitlesSettingsPanelOpenAtom, false)
|
||||
subtitlesStore.set(subtitlesSettingsPanelViewAtom, ROOT_VIEW)
|
||||
this.showNativeSubtitles()
|
||||
void this.restorePosition()
|
||||
}
|
||||
|
|
@ -184,6 +190,7 @@ export class UniversalVideoAdapter {
|
|||
this.translationCoordinator?.stop()
|
||||
this.segmentationPipeline?.stop()
|
||||
subtitlesStore.set(subtitlesSettingsPanelOpenAtom, false)
|
||||
subtitlesStore.set(subtitlesSettingsPanelViewAtom, ROOT_VIEW)
|
||||
this.showNativeSubtitles()
|
||||
}
|
||||
|
||||
|
|
@ -242,25 +249,49 @@ export class UniversalVideoAdapter {
|
|||
}
|
||||
|
||||
private async renderTranslateButton() {
|
||||
const controlsBar = await waitForElement(this.config.selectors.controlsBar)
|
||||
if (!controlsBar) {
|
||||
toast.error(i18n.t("subtitles.errors.controlsBarNotFound"))
|
||||
const container = await waitForElement(this.config.selectors.controlsBar)
|
||||
if (!container) {
|
||||
if (!this.config.embedded)
|
||||
toast.error(i18n.t("subtitles.errors.controlsBarNotFound"))
|
||||
return
|
||||
}
|
||||
|
||||
const existingButton = controlsBar.querySelector(`#${TRANSLATE_BUTTON_CONTAINER_ID}`)
|
||||
const existingButton = container.querySelector(`#${TRANSLATE_BUTTON_CONTAINER_ID}`)
|
||||
existingButton?.remove()
|
||||
|
||||
const toggleButton = renderSubtitlesTranslateButton()
|
||||
const toggleButton = renderSubtitlesTranslateButton(this)
|
||||
|
||||
controlsBar.insertBefore(toggleButton, controlsBar.firstChild)
|
||||
if (this.config.embedded) {
|
||||
container.appendChild(toggleButton)
|
||||
}
|
||||
else {
|
||||
container.insertBefore(toggleButton, container.firstChild)
|
||||
}
|
||||
}
|
||||
|
||||
private async tryAutoStartSubtitles() {
|
||||
const config = await getLocalConfig()
|
||||
const autoStart = config?.videoSubtitles?.autoStart ?? false
|
||||
|
||||
if (!autoStart) {
|
||||
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)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
2
src/env/__tests__/shared.test.ts
vendored
2
src/env/__tests__/shared.test.ts
vendored
|
|
@ -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/tutorial",
|
||||
WXT_OFFICIAL_SITE_ORIGINS: "https://readfrog.app/docs",
|
||||
})).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
Loading…
Add table
Add a link
Reference in a new issue