├── .husky ├── commit-msg ├── pre-commit └── pre-push ├── .lintstagedrc.json ├── src ├── utils │ ├── constants │ │ ├── selection.ts │ │ ├── proxy-fetch.ts │ │ ├── backup.ts │ │ ├── app.ts │ │ ├── storage-keys.ts │ │ ├── hotkeys.ts │ │ ├── translation-node-style.ts │ │ ├── url.ts │ │ ├── tts.ts │ │ ├── translate.ts │ │ ├── dom-labels.ts │ │ └── side.ts │ ├── db │ │ └── dexie │ │ │ ├── db.ts │ │ │ ├── tables │ │ │ ├── translation-cache.ts │ │ │ ├── article-summary-cache.ts │ │ │ └── batch-request-record.ts │ │ │ └── mock-data.ts │ ├── google-drive │ │ └── constants.ts │ ├── host │ │ ├── dom │ │ │ ├── node.ts │ │ │ ├── style.ts │ │ │ └── __tests__ │ │ │ │ └── filter.test.ts │ │ ├── translate │ │ │ ├── api │ │ │ │ ├── index.ts │ │ │ │ ├── ai.ts │ │ │ │ ├── __tests__ │ │ │ │ │ └── free-api.test.ts │ │ │ │ └── google.ts │ │ │ ├── core │ │ │ │ └── translation-state.ts │ │ │ ├── translation-attributes.ts │ │ │ ├── dom │ │ │ │ └── translation-wrapper.ts │ │ │ ├── auto-translation.ts │ │ │ ├── ui │ │ │ │ └── translation-utils.ts │ │ │ └── node-manipulation.ts │ │ └── __tests__ │ │ │ ├── test-website.md │ │ │ └── utils.ts │ ├── config │ │ ├── errors.ts │ │ ├── migration-scripts │ │ │ ├── v014-to-v015.ts │ │ │ ├── v023-to-v024.ts │ │ │ ├── v015-to-v016.ts │ │ │ ├── v001-to-v002.ts │ │ │ ├── v017-to-v018.ts │ │ │ ├── v019-to-v020.ts │ │ │ ├── v025-to-v026.ts │ │ │ ├── v027-to-v028.ts │ │ │ ├── v011-to-v012.ts │ │ │ ├── v006-to-v007.ts │ │ │ ├── v020-to-v021.ts │ │ │ ├── v024-to-v025.ts │ │ │ ├── v008-to-v009.ts │ │ │ ├── v013-to-v014.ts │ │ │ ├── v028-to-v029.ts │ │ │ ├── types.ts │ │ │ ├── v012-to-v013.ts │ │ │ ├── v002-to-v003.ts │ │ │ ├── v034-to-v035.ts │ │ │ ├── v036-to-v037.ts │ │ │ ├── v037-to-v038.ts │ │ │ ├── v010-to-v011.ts │ │ │ ├── v007-to-v008.ts │ │ │ ├── v004-to-v005.ts │ │ │ ├── v005-to-v006.ts │ │ │ ├── v029-to-v030.ts │ │ │ ├── v032-to-v033.ts │ │ │ ├── v035-to-v036.ts │ │ │ ├── v031-to-v032.ts │ │ │ ├── v033-to-v034.ts │ │ │ ├── v022-to-v023.ts │ │ │ ├── v018-to-v019.ts │ │ │ ├── v030-to-v031.ts │ │ │ ├── v009-to-v010.ts │ │ │ └── v016-to-v017.ts │ │ ├── chart-theme.ts │ │ ├── languages.ts │ │ └── __tests__ │ │ │ ├── migration.test.ts │ │ │ └── example │ │ │ ├── types.ts │ │ │ ├── v001.ts │ │ │ ├── v002.ts │ │ │ └── v003.ts │ ├── styles │ │ └── tailwind.ts │ ├── logo.ts │ ├── theme.ts │ ├── shadow-root.ts │ ├── url.ts │ ├── http.ts │ ├── hash.ts │ ├── api-error.ts │ ├── tanstack-query.ts │ ├── debug.ts │ ├── __tests__ │ │ └── hash.test.ts │ ├── atoms │ │ ├── last-sync-time.ts │ │ ├── storage-adapter.ts │ │ └── translation-state.ts │ ├── content │ │ └── utils.ts │ ├── survey.ts │ ├── react-shadow-host │ │ └── css-registry.ts │ ├── ai-request.ts │ ├── utils.ts │ ├── logger.ts │ ├── auth │ │ └── auth-client.ts │ └── orpc │ │ └── client.ts ├── assets │ ├── icons │ │ ├── read-frog.png │ │ ├── original │ │ │ └── read-frog.png │ │ └── avatars │ │ │ └── guest.svg │ ├── demo │ │ ├── context-menu.png │ │ ├── floating-button.png │ │ └── selection-toolbar.png │ ├── styles │ │ ├── text-small.css │ │ ├── host-theme.css │ │ └── custom-translation-node.css │ └── providers │ │ ├── custom-provider.svg │ │ ├── deeplx-dark.svg │ │ └── deeplx-light.svg ├── entrypoints │ ├── options │ │ ├── pages │ │ │ ├── statistics │ │ │ │ ├── charts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── batch-request-record │ │ │ │ │ │ ├── atom.ts │ │ │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ │ ├── api-providers │ │ │ │ ├── provider-config-form │ │ │ │ │ ├── form-context.ts │ │ │ │ │ ├── form.ts │ │ │ │ │ ├── components │ │ │ │ │ │ ├── select-field.tsx │ │ │ │ │ │ └── input-field.tsx │ │ │ │ │ └── base-url-field.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── atoms.ts │ │ │ │ ├── utils.ts │ │ │ │ └── promotion.tsx │ │ │ ├── translation │ │ │ │ ├── personalized-prompt │ │ │ │ │ ├── atoms.ts │ │ │ │ │ ├── export-prompt.tsx │ │ │ │ │ └── utils │ │ │ │ │ │ └── prompt-file.ts │ │ │ │ ├── custom-translation-style │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── custom-translation-style-switch.tsx │ │ │ │ ├── page-translation-shortcut.tsx │ │ │ │ └── index.tsx │ │ │ ├── text-to-speech │ │ │ │ └── index.tsx │ │ │ ├── general │ │ │ │ └── index.tsx │ │ │ ├── config │ │ │ │ ├── index.tsx │ │ │ │ ├── google-drive-sync │ │ │ │ │ └── components │ │ │ │ │ │ └── utils.ts │ │ │ │ ├── beta-experience.tsx │ │ │ │ └── components │ │ │ │ │ └── view-config.tsx │ │ │ ├── context-menu │ │ │ │ ├── index.tsx │ │ │ │ └── context-menu-translate-toggle.tsx │ │ │ ├── floating-button │ │ │ │ ├── floating-button-global-toggle.tsx │ │ │ │ └── index.tsx │ │ │ └── selection-toolbar │ │ │ │ ├── selection-toolbar-global-toggle.tsx │ │ │ │ └── index.tsx │ │ ├── style.css │ │ ├── app.tsx │ │ ├── components │ │ │ ├── set-api-key-warning.tsx │ │ │ ├── config-card.tsx │ │ │ └── page-layout.tsx │ │ ├── app-sidebar │ │ │ ├── animated-indicator.tsx │ │ │ ├── nav-items.ts │ │ │ └── index.tsx │ │ └── index.html │ ├── background │ │ ├── uninstall-survey.ts │ │ ├── config.ts │ │ ├── mock-data.ts │ │ ├── new-user-guide.ts │ │ └── iframe-injection.ts │ ├── host.content │ │ ├── app.tsx │ │ └── translation-control │ │ │ └── bind-translation-shortcut.ts │ ├── selection.content │ │ ├── app.tsx │ │ └── selection-toolbar │ │ │ └── atom.ts │ ├── popup │ │ ├── index.html │ │ ├── components │ │ │ ├── floating-button.tsx │ │ │ ├── discord-button.tsx │ │ │ ├── always-translate.tsx │ │ │ ├── text-selection-tooltip.tsx │ │ │ ├── read-provider-field.tsx │ │ │ ├── translate-provider-field.tsx │ │ │ ├── language-level-selector.tsx │ │ │ ├── read-button.tsx │ │ │ ├── ai-smart-context.tsx │ │ │ └── translate-button.tsx │ │ └── atoms │ │ │ └── ignore.ts │ └── side.content │ │ ├── components │ │ └── floating-button │ │ │ ├── floating-read-button.tsx │ │ │ ├── components │ │ │ └── hidden-button.tsx │ │ │ └── translate-button.tsx │ │ ├── app.tsx │ │ └── atoms.ts ├── types │ ├── dom.ts │ ├── config │ │ ├── read.ts │ │ └── meta.ts │ ├── translation-state.ts │ ├── backup.ts │ ├── reset.d.ts │ ├── proxy-fetch.ts │ └── utils.ts ├── lib │ └── utils.ts ├── components │ ├── shadcn │ │ ├── collapsible.tsx │ │ ├── spinner.tsx │ │ ├── skeleton.tsx │ │ ├── label.tsx │ │ ├── hint.tsx │ │ ├── separator.tsx │ │ ├── progress.tsx │ │ ├── kbd.tsx │ │ ├── textarea.tsx │ │ ├── input.tsx │ │ ├── checkbox.tsx │ │ └── radio-group.tsx │ ├── translation │ │ └── error │ │ │ ├── index.tsx │ │ │ └── retry-button.tsx │ ├── badges │ │ ├── new-badge.tsx │ │ └── beta-badge.tsx │ ├── container.tsx │ ├── llm-status-indicator.tsx │ ├── loading-dots.tsx │ ├── frog-toast │ │ └── index.tsx │ ├── user-account.tsx │ ├── api-config-warning.tsx │ └── gradient-background.tsx └── hooks │ ├── use-debounced-value.ts │ ├── use-mobile.ts │ ├── read │ └── extract.tsx │ ├── use-export-config.ts │ ├── use-unresolved-field.ts │ └── use-google-drive-auth.ts ├── assets ├── star.png ├── read-demo.gif ├── translate.png ├── popup-page.png ├── store │ ├── intro1.png │ ├── intro2.png │ ├── intro3.png │ ├── large-promo-tile.png │ └── small-promo-tile.png ├── wechat-account.jpg ├── pdf-example │ └── test.pdf ├── node-translation-demo.gif └── page-translation-demo.gif ├── public └── icon │ ├── 16.png │ ├── 32.png │ ├── 48.png │ ├── 96.png │ └── 128.png ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── config.yml ├── release.yml ├── workflows │ ├── stale-issue-pr.yml │ └── pr-test.yml └── PULL_REQUEST_TEMPLATE.md ├── tsconfig.json ├── postcss.config.cjs ├── .changeset ├── config.json └── README.md ├── commitlint.config.cjs ├── vitest.config.ts ├── .gitignore ├── .claude └── commands │ ├── fix-github-issue.md │ └── create-pr.md ├── components.json ├── .cursor └── commands │ ├── fix-github-issue.md │ └── create-pr.md ├── eslint.config.mjs └── .vscode └── settings.json /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | pnpm exec commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*": ["pnpm lint:fix"] 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/constants/selection.ts: -------------------------------------------------------------------------------- 1 | export const MARGIN = 25 2 | -------------------------------------------------------------------------------- /assets/star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/assets/star.png -------------------------------------------------------------------------------- /public/icon/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/public/icon/16.png -------------------------------------------------------------------------------- /public/icon/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/public/icon/32.png -------------------------------------------------------------------------------- /public/icon/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/public/icon/48.png -------------------------------------------------------------------------------- /public/icon/96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/public/icon/96.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: mengxi-ream 4 | -------------------------------------------------------------------------------- /assets/read-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/assets/read-demo.gif -------------------------------------------------------------------------------- /assets/translate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/assets/translate.png -------------------------------------------------------------------------------- /public/icon/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/public/icon/128.png -------------------------------------------------------------------------------- /src/utils/db/dexie/db.ts: -------------------------------------------------------------------------------- 1 | import AppDB from './app-db' 2 | 3 | export const db = new AppDB() 4 | -------------------------------------------------------------------------------- /assets/popup-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/assets/popup-page.png -------------------------------------------------------------------------------- /assets/store/intro1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/assets/store/intro1.png -------------------------------------------------------------------------------- /assets/store/intro2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/assets/store/intro2.png -------------------------------------------------------------------------------- /assets/store/intro3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/assets/store/intro3.png -------------------------------------------------------------------------------- /src/utils/constants/proxy-fetch.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_PROXY_CACHE_TTL_MS = 24 * 60 * 60 * 1000 2 | -------------------------------------------------------------------------------- /assets/wechat-account.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/assets/wechat-account.jpg -------------------------------------------------------------------------------- /src/utils/google-drive/constants.ts: -------------------------------------------------------------------------------- 1 | export const GOOGLE_DRIVE_CONFIG_FILENAME = 'read-frog-config.json' 2 | -------------------------------------------------------------------------------- /assets/pdf-example/test.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/assets/pdf-example/test.pdf -------------------------------------------------------------------------------- /src/assets/icons/read-frog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/src/assets/icons/read-frog.png -------------------------------------------------------------------------------- /assets/node-translation-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/assets/node-translation-demo.gif -------------------------------------------------------------------------------- /assets/page-translation-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/assets/page-translation-demo.gif -------------------------------------------------------------------------------- /assets/store/large-promo-tile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/assets/store/large-promo-tile.png -------------------------------------------------------------------------------- /assets/store/small-promo-tile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/assets/store/small-promo-tile.png -------------------------------------------------------------------------------- /src/assets/demo/context-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/src/assets/demo/context-menu.png -------------------------------------------------------------------------------- /src/assets/demo/floating-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/src/assets/demo/floating-button.png -------------------------------------------------------------------------------- /src/assets/demo/selection-toolbar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/src/assets/demo/selection-toolbar.png -------------------------------------------------------------------------------- /src/entrypoints/options/pages/statistics/charts/index.ts: -------------------------------------------------------------------------------- 1 | export { default as BatchRequestRecord } from './batch-request-record' 2 | -------------------------------------------------------------------------------- /src/assets/icons/original/read-frog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mengxi-ream/read-frog/HEAD/src/assets/icons/original/read-frog.png -------------------------------------------------------------------------------- /src/types/dom.ts: -------------------------------------------------------------------------------- 1 | export interface Point { 2 | x: number 3 | y: number 4 | } 5 | 6 | export type TransNode = HTMLElement | Text 7 | -------------------------------------------------------------------------------- /src/utils/host/dom/node.ts: -------------------------------------------------------------------------------- 1 | export function getOwnerDocument(node: Node): Document { 2 | return node.ownerDocument || document 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | if [ "$SKIP_HUSKY" = "true" ]; then 2 | echo "⏭️ Skipping pre-push checks (SKIP_PRE_COMMIT=true)" 3 | exit 0 4 | fi 5 | 6 | pnpm exec lint-staged -------------------------------------------------------------------------------- /src/assets/styles/text-small.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --text-xs: 10px; 3 | --text-sm: 12px; 4 | --text-base: 14px; 5 | --text-lg: 16px; 6 | --text-xl: 18px; 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.wxt/tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "allowImportingTsExtensions": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/constants/backup.ts: -------------------------------------------------------------------------------- 1 | export const MAX_BACKUPS_COUNT = 8 2 | export const BACKUP_ID_PREFIX = 'configBackup' 3 | export const BACKUP_INTERVAL_MINUTES = 60 // 1 hour 4 | -------------------------------------------------------------------------------- /src/utils/config/errors.ts: -------------------------------------------------------------------------------- 1 | export class ConfigVersionTooNewError extends Error { 2 | constructor(message: string) { 3 | super(message) 4 | this.name = 'ConfigVersionTooNewError' 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v014-to-v015.ts: -------------------------------------------------------------------------------- 1 | export function migrate(oldConfig: any): any { 2 | return { 3 | ...oldConfig, 4 | selectionToolbar: { 5 | enabled: true, 6 | }, 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v023-to-v024.ts: -------------------------------------------------------------------------------- 1 | export function migrate(oldConfig: any): any { 2 | return { 3 | ...oldConfig, 4 | betaExperience: { 5 | enabled: false, 6 | }, 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/constants/app.ts: -------------------------------------------------------------------------------- 1 | import { browser } from '#imports' 2 | 3 | export const APP_NAME = 'Read Frog' 4 | const manifest = browser.runtime.getManifest() 5 | export const EXTENSION_VERSION = manifest.version 6 | -------------------------------------------------------------------------------- /src/entrypoints/options/style.css: -------------------------------------------------------------------------------- 1 | @import '@/assets/styles/custom-translation-node.css'; 2 | 3 | body { 4 | /* Chrome set the font to 75% of the default size */ 5 | font-size: initial; /* reset to 16px */ 6 | } 7 | -------------------------------------------------------------------------------- /src/types/config/read.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const readConfigSchema = z.object({ 4 | providerId: z.string().nonempty(), 5 | }) 6 | 7 | export type ReadConfig = z.infer 8 | -------------------------------------------------------------------------------- /src/utils/db/dexie/tables/translation-cache.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from 'dexie' 2 | 3 | export default class TranslationCache extends Entity { 4 | key!: string 5 | translation!: string 6 | createdAt!: Date 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/host/translate/api/index.ts: -------------------------------------------------------------------------------- 1 | export { aiTranslate } from './ai' 2 | export { deeplxTranslate } from './deeplx' 3 | export { googleTranslate } from './google' 4 | export { microsoftTranslate } from './microsoft' 5 | -------------------------------------------------------------------------------- /src/entrypoints/options/pages/statistics/charts/batch-request-record/atom.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | 3 | const DEFAULT_RECENT_DAY = '5' 4 | 5 | export const recentDayAtom = atom(DEFAULT_RECENT_DAY) 6 | -------------------------------------------------------------------------------- /src/types/translation-state.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const translationStateSchema = z.object({ 4 | enabled: z.boolean(), 5 | }) 6 | 7 | export type TranslationState = z.infer 8 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import type { ClassValue } from 'clsx' 2 | import { clsx } from 'clsx' 3 | import { twMerge } from 'tailwind-merge' 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)) 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/styles/tailwind.ts: -------------------------------------------------------------------------------- 1 | import type { ClassValue } from 'clsx' 2 | import { clsx } from 'clsx' 3 | import { twMerge } from 'tailwind-merge' 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)) 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v015-to-v016.ts: -------------------------------------------------------------------------------- 1 | export function migrate(oldConfig: any): any { 2 | return { 3 | ...oldConfig, 4 | translate: { 5 | ...oldConfig.translate, 6 | mode: 'bilingual', 7 | }, 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/entrypoints/background/uninstall-survey.ts: -------------------------------------------------------------------------------- 1 | import { browser, i18n } from '#imports' 2 | 3 | export async function setupUninstallSurvey() { 4 | const surveyUrl = i18n.t('uninstallSurveyUrl') 5 | void browser.runtime.setUninstallURL(surveyUrl) 6 | } 7 | -------------------------------------------------------------------------------- /src/entrypoints/options/pages/api-providers/provider-config-form/form-context.ts: -------------------------------------------------------------------------------- 1 | import { createFormHookContexts } from '@tanstack/react-form' 2 | 3 | export const { fieldContext, formContext, useFieldContext, useFormContext } 4 | = createFormHookContexts() 5 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v001-to-v002.ts: -------------------------------------------------------------------------------- 1 | import { deepmerge } from 'deepmerge-ts' 2 | 3 | export function migrate(oldConfig: any): any { 4 | return deepmerge(oldConfig, { 5 | pageTranslate: { 6 | range: 'mainContent', 7 | }, 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v017-to-v018.ts: -------------------------------------------------------------------------------- 1 | export function migrate(oldConfig: any): any { 2 | return { 3 | ...oldConfig, 4 | translate: { 5 | ...oldConfig.translate, 6 | customAutoTranslateShortcutKey: ['alt', 'q'], 7 | }, 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v019-to-v020.ts: -------------------------------------------------------------------------------- 1 | export function migrate(oldConfig: any): any { 2 | return { 3 | ...oldConfig, 4 | floatingButton: { 5 | ...oldConfig.floatingButton, 6 | disabledFloatingButtonPatterns: [], 7 | }, 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/db/dexie/tables/article-summary-cache.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from 'dexie' 2 | 3 | export default class ArticleSummaryCache extends Entity { 4 | key!: string // Sha256Hex(textContentHash, JSON.stringify(providerConfig)) 5 | summary!: string 6 | createdAt!: Date 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v025-to-v026.ts: -------------------------------------------------------------------------------- 1 | export function migrate(oldConfig: any): any { 2 | return { 3 | ...oldConfig, 4 | tts: { 5 | providerId: null, 6 | model: 'tts-1', 7 | voice: 'alloy', 8 | speed: 1, 9 | }, 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v027-to-v028.ts: -------------------------------------------------------------------------------- 1 | export function migrate(oldConfig: any): any { 2 | return { 3 | ...oldConfig, 4 | selectionToolbar: { 5 | ...oldConfig.selectionToolbar, 6 | disabledSelectionToolbarPatterns: [], 7 | }, 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/constants/storage-keys.ts: -------------------------------------------------------------------------------- 1 | export const TRANSLATION_STATE_KEY_PREFIX = 'session:translationState' as const 2 | 3 | export function getTranslationStateKey(tabId: number): `session:translationState.${number}` { 4 | return `${TRANSLATION_STATE_KEY_PREFIX}.${tabId}` as const 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/db/dexie/tables/batch-request-record.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from 'dexie' 2 | 3 | export default class BatchRequestRecord extends Entity { 4 | key!: string 5 | createdAt!: Date 6 | originalRequestCount!: number 7 | provider!: string 8 | model!: string 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/config/chart-theme.ts: -------------------------------------------------------------------------------- 1 | import type { ITheme } from '@visactor/vchart' 2 | 3 | export const customDarkTheme: Partial = { 4 | type: 'dark', 5 | background: 'oklch(0.145 0 0)', 6 | } 7 | export const customLightTheme: Partial = { 8 | type: 'light', 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v011-to-v012.ts: -------------------------------------------------------------------------------- 1 | export function migrate(oldConfig: any): any { 2 | // Integrate Translation Node Style 3 | return { 4 | ...oldConfig, 5 | translate: { 6 | ...oldConfig.translate, 7 | translationNodeStyle: 'default', 8 | }, 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/entrypoints/host.content/app.tsx: -------------------------------------------------------------------------------- 1 | import FrogToast from '@/components/frog-toast' 2 | import { NOTRANSLATE_CLASS } from '@/utils/constants/dom-labels' 3 | 4 | export default function App() { 5 | return ( 6 |
7 | 8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/logo.ts: -------------------------------------------------------------------------------- 1 | import type { Theme } from '@/components/providers/theme-provider' 2 | 3 | export function getLobeIconsCDNUrlFn(iconSlug: string) { 4 | return (theme: Theme = 'light') => { 5 | return `https://registry.npmmirror.com/@lobehub/icons-static-webp/1.65.0/files/${theme}/${iconSlug}.webp` 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v006-to-v007.ts: -------------------------------------------------------------------------------- 1 | import { deepmerge } from 'deepmerge-ts' 2 | 3 | export function migrate(oldConfig: any): any { 4 | return deepmerge(oldConfig, { 5 | translate: { 6 | page: { 7 | autoTranslatePatterns: ['news.ycombinator.com'], 8 | }, 9 | }, 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v020-to-v021.ts: -------------------------------------------------------------------------------- 1 | export function migrate(oldConfig: any): any { 2 | return { 3 | ...oldConfig, 4 | translate: { 5 | ...oldConfig.translate, 6 | page: { 7 | ...oldConfig.translate.page, 8 | autoTranslateLanguages: [], 9 | }, 10 | }, 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v024-to-v025.ts: -------------------------------------------------------------------------------- 1 | export function migrate(oldConfig: any): any { 2 | return { 3 | ...oldConfig, 4 | translate: { 5 | ...oldConfig.translate, 6 | batchQueueConfig: { 7 | maxCharactersPerBatch: 1000, 8 | maxItemsPerBatch: 4, 9 | }, 10 | }, 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Read Frog Discord 4 | url: https://discord.gg/ej45e3PezJ 5 | about: Quick questions or support are often answered faster in Discord. 6 | - name: Official Website 7 | url: https://readfrog.app 8 | about: Learn more about Read Frog and access docs and resources. 9 | -------------------------------------------------------------------------------- /src/components/shadcn/collapsible.tsx: -------------------------------------------------------------------------------- 1 | import * as CollapsiblePrimitive from '@radix-ui/react-collapsible' 2 | 3 | const Collapsible = CollapsiblePrimitive.Root 4 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger 5 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent 6 | 7 | export { Collapsible, CollapsibleContent, CollapsibleTrigger } 8 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v008-to-v009.ts: -------------------------------------------------------------------------------- 1 | export function migrate(oldConfig: any): any { 2 | // Expose request queue rate parameters 3 | return { 4 | ...oldConfig, 5 | translate: { 6 | ...oldConfig.translate, 7 | requestQueueConfig: { 8 | capacity: 300, 9 | rate: 5, 10 | }, 11 | }, 12 | 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/theme.ts: -------------------------------------------------------------------------------- 1 | export function isDarkMode() { 2 | return ( 3 | // TODO: change this to storage API for browser extension 4 | localStorage.theme === 'dark' 5 | || (!('theme' in localStorage) 6 | && typeof window !== 'undefined' 7 | && window.matchMedia 8 | && window.matchMedia('(prefers-color-scheme: dark)').matches) 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/constants/hotkeys.ts: -------------------------------------------------------------------------------- 1 | export const HOTKEYS = ['Control', 'Alt', 'Shift', '`'] as const 2 | export const HOTKEY_ITEMS: Record = { 3 | 'Control': { label: 'Ctrl', icon: '⌃' }, 4 | 'Alt': { label: 'Option', icon: '⌥' }, 5 | 'Shift': { label: 'Shift', icon: '⇧' }, 6 | '`': { label: 'Backtick', icon: '`' }, 7 | } 8 | -------------------------------------------------------------------------------- /src/entrypoints/options/app.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router' 2 | import { ROUTE_CONFIG } from './app-sidebar/nav-items' 3 | 4 | export default function App() { 5 | return ( 6 | 7 | {ROUTE_CONFIG.map(({ path, component: Component }) => ( 8 | } /> 9 | ))} 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | './src/utils/styles/postcss-rename-custom-props.cjs': { 5 | fromPrefix: '--tw-', 6 | toPrefix: '--rf-tw-', 7 | }, 8 | 'autoprefixer': {}, 9 | 'postcss-rem-to-responsive-pixel': { 10 | rootValue: 16, 11 | propList: ['*'], 12 | transformUnit: 'px', 13 | }, 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /src/entrypoints/options/pages/translation/personalized-prompt/atoms.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | 3 | /** 4 | * Atom to manage export mode state 5 | * When true, the prompt list is in export/selection mode 6 | */ 7 | export const isExportPromptModeAtom = atom(false) 8 | 9 | /** 10 | * Atom to manage selected prompts for export 11 | */ 12 | export const selectedPromptsToExportAtom = atom([]) 13 | -------------------------------------------------------------------------------- /src/entrypoints/options/pages/text-to-speech/index.tsx: -------------------------------------------------------------------------------- 1 | import { i18n } from '#imports' 2 | import { PageLayout } from '../../components/page-layout' 3 | import { TtsConfig } from './tts-config' 4 | 5 | export function TextToSpeechPage() { 6 | return ( 7 | 8 | 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v013-to-v014.ts: -------------------------------------------------------------------------------- 1 | export function migrate(oldConfig: any): any { 2 | return { 3 | ...oldConfig, 4 | read: { 5 | ...oldConfig.read, 6 | models: { 7 | ...oldConfig.read.models, 8 | gemini: { 9 | model: 'gemini-2.5-pro', 10 | isCustomModel: false, 11 | customModel: '', 12 | }, 13 | }, 14 | }, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/constants/translation-node-style.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_TRANSLATION_NODE_STYLE = 'default' 2 | export const TRANSLATION_NODE_STYLE_ON_INSTALLED = 'textColor' 3 | 4 | export const TRANSLATION_NODE_STYLE = [DEFAULT_TRANSLATION_NODE_STYLE, 'blur', 'blockquote', 'weakened', 'dashedLine', 'border', 'textColor', 'background'] as const 5 | 6 | export const CUSTOM_TRANSLATION_NODE_ATTRIBUTE = 'read-frog-custom-translation-style' 7 | -------------------------------------------------------------------------------- /src/assets/styles/host-theme.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --read-frog-primary: oklch(76.5% 0.177 163.223); 3 | --read-frog-muted: oklch(0.97 0 0); 4 | --read-frog-muted-foreground: oklch(0.556 0 0); 5 | } 6 | 7 | @media (prefers-color-scheme: dark) { 8 | :root { 9 | --read-frog-primary: oklch(59.6% 0.145 163.225); 10 | --read-frog-muted: oklch(0.269 0 0); 11 | --read-frog-muted-foreground: oklch(0.708 0 0); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v028-to-v029.ts: -------------------------------------------------------------------------------- 1 | export function migrate(oldConfig: any): any { 2 | const { customAutoTranslateShortcutKey, ...restTranslate } = oldConfig.translate 3 | 4 | return { 5 | ...oldConfig, 6 | translate: { 7 | ...restTranslate, 8 | page: { 9 | ...oldConfig.translate.page, 10 | shortcut: customAutoTranslateShortcutKey, 11 | }, 12 | }, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/constants/url.ts: -------------------------------------------------------------------------------- 1 | import { LOCALHOST_DOMAIN, READFROG_DOMAIN, WEBSITE_DEV_URL, WEBSITE_PROD_URL } from '@read-frog/definitions' 2 | 3 | export const OFFICIAL_SITE_URL_PATTERNS = [ 4 | `https://*.${READFROG_DOMAIN}/*`, 5 | `http://${LOCALHOST_DOMAIN}/*`, 6 | ] 7 | 8 | export const WEBSITE_URL = (import.meta.env.DEV && import.meta.env.WXT_USE_LOCAL_PACKAGES === 'true') 9 | ? WEBSITE_DEV_URL 10 | : WEBSITE_PROD_URL 11 | -------------------------------------------------------------------------------- /src/types/backup.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from './config/config' 2 | 3 | export interface ConfigBackup { 4 | schemaVersion: number 5 | config: Config 6 | } 7 | 8 | export interface ConfigBackupMetadata extends Record { 9 | createdAt: number 10 | extensionVersion: string 11 | } 12 | 13 | export interface ConfigBackupWithMetadata extends ConfigBackup { 14 | metadata: ConfigBackupMetadata 15 | id: string 16 | } 17 | -------------------------------------------------------------------------------- /src/types/reset.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * TypeScript type resets from @total-typescript/ts-reset 3 | * Improves TypeScript's built-in types for better ergonomics 4 | * 5 | * @see https://github.com/total-typescript/ts-reset 6 | */ 7 | 8 | // Fix array.includes() to accept wider types (e.g., union types) 9 | // This eliminates the need for type casts when checking if a value exists in an array 10 | import '@total-typescript/ts-reset/array-includes' 11 | -------------------------------------------------------------------------------- /src/entrypoints/options/pages/statistics/index.tsx: -------------------------------------------------------------------------------- 1 | import { i18n } from '#imports' 2 | import { PageLayout } from '../../components/page-layout' 3 | import { BatchRequestRecord } from './charts' 4 | 5 | export function StatisticsPage() { 6 | return ( 7 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/entrypoints/options/pages/statistics/charts/batch-request-record/index.tsx: -------------------------------------------------------------------------------- 1 | import Aside from './aside' 2 | import Chart from './chart' 3 | import Metrics from './metrics' 4 | 5 | export default function BatchRequestRecord() { 6 | return ( 7 |
8 | 9 |
10 |
13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/components/shadcn/spinner.tsx: -------------------------------------------------------------------------------- 1 | import type { IconProps } from '@tabler/icons-react' 2 | import { IconLoader2 } from '@tabler/icons-react' 3 | import { cn } from '@/utils/styles/tailwind' 4 | 5 | function Spinner({ className, ...props }: IconProps) { 6 | return ( 7 | 13 | ) 14 | } 15 | 16 | export { Spinner } 17 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Definition of migration function 3 | */ 4 | export type MigrationFunction = (oldConfig: any) => any 5 | 6 | /** 7 | * Interface of migration script module 8 | */ 9 | export interface MigrationModule { 10 | migrate: MigrationFunction 11 | } 12 | 13 | /** 14 | * Metadata of migration script 15 | */ 16 | export interface MigrationInfo { 17 | fromVersion: number 18 | toVersion: number 19 | description?: string 20 | } 21 | -------------------------------------------------------------------------------- /src/entrypoints/options/pages/api-providers/index.tsx: -------------------------------------------------------------------------------- 1 | import { i18n } from '#imports' 2 | import { PageLayout } from '../../components/page-layout' 3 | import { ProvidersConfig } from './providers-config' 4 | 5 | export function ApiProvidersPage() { 6 | return ( 7 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/translation/error/index.tsx: -------------------------------------------------------------------------------- 1 | import type { APICallError } from 'ai' 2 | import { ErrorButton } from './error-button' 3 | import { RetryButton } from './retry-button' 4 | 5 | export function TranslationError({ nodes, error }: { nodes: ChildNode[], error: APICallError }) { 6 | return ( 7 |
8 | 9 | 10 |
11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/shadow-root.ts: -------------------------------------------------------------------------------- 1 | import { cn } from '@/utils/styles/tailwind' 2 | import { isDarkMode } from './theme' 3 | 4 | export function insertShadowRootUIWrapperInto(container: HTMLElement) { 5 | const wrapper = document.createElement('div') 6 | wrapper.className = cn( 7 | 'text-base antialiased font-sans z-[2147483647]', 8 | isDarkMode() && 'dark', 9 | ) 10 | wrapper.style.colorScheme = isDarkMode() ? 'dark' : 'light' 11 | container.append(wrapper) 12 | return wrapper 13 | } 14 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { 6 | "repo": "mengxi-ream/read-frog" 7 | } 8 | ], 9 | "commit": false, 10 | "fixed": [], 11 | "linked": [], 12 | "access": "restricted", 13 | "baseBranch": "main", 14 | "updateInternalDependencies": "patch", 15 | "ignore": [], 16 | "privatePackages": { 17 | "version": true, 18 | "tag": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/host/translate/core/translation-state.ts: -------------------------------------------------------------------------------- 1 | import { MARK_ATTRIBUTES } from '../../../constants/dom-labels' 2 | 3 | // State management for translation operations 4 | export const translatingNodes = new WeakSet() 5 | export const originalContentMap = new Map() 6 | 7 | // Pre-compiled regex for better performance - removes all mark attributes 8 | export const MARK_ATTRIBUTES_REGEX = new RegExp(`\\s*(?:${Array.from(MARK_ATTRIBUTES).join('|')})(?:=['""][^'"]*['""]|=[^\\s>]*)?`, 'g') 9 | -------------------------------------------------------------------------------- /src/entrypoints/options/pages/general/index.tsx: -------------------------------------------------------------------------------- 1 | import { i18n } from '#imports' 2 | import { PageLayout } from '../../components/page-layout' 3 | import { ReadConfig } from './read-config' 4 | import TranslationConfig from './translation-config' 5 | 6 | export function GeneralPage() { 7 | return ( 8 | 9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/entrypoints/selection.content/app.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster } from 'sonner' 2 | import { NOTRANSLATE_CLASS } from '@/utils/constants/dom-labels' 3 | import { cn } from '@/utils/styles/tailwind' 4 | import { SelectionToolbar } from './selection-toolbar' 5 | 6 | export default function App() { 7 | return ( 8 |
9 | 10 | 11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v012-to-v013.ts: -------------------------------------------------------------------------------- 1 | export function migrate(oldConfig: any): any { 2 | return { 3 | ...oldConfig, 4 | providersConfig: { 5 | ...oldConfig.providersConfig, 6 | deeplx: { 7 | apiKey: undefined, 8 | baseURL: 'https://deeplx.vercel.app', 9 | }, 10 | }, 11 | translate: { 12 | ...oldConfig.translate, 13 | models: { 14 | ...oldConfig.translate?.models, 15 | deeplx: null, 16 | }, 17 | }, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/badges/new-badge.tsx: -------------------------------------------------------------------------------- 1 | import type { VariantProps } from 'class-variance-authority' 2 | import type { badgeVariants } from '@/components/shadcn/badge' 3 | import { Badge } from '@/components/shadcn/badge' 4 | 5 | type NewBadgeProps = Pick, 'size'> & { className?: string } 6 | 7 | export function NewBadge({ size, className }: NewBadgeProps) { 8 | return ( 9 | 10 | New 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/components/container.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/utils/styles/tailwind' 2 | 3 | function Container({ ref, className, children, ...props }: React.ComponentPropsWithoutRef<'div'> & { ref?: React.RefObject }) { 4 | return ( 5 |
13 | {children} 14 |
15 | ) 16 | } 17 | 18 | export default Container 19 | -------------------------------------------------------------------------------- /src/components/badges/beta-badge.tsx: -------------------------------------------------------------------------------- 1 | import type { VariantProps } from 'class-variance-authority' 2 | import type { badgeVariants } from '@/components/shadcn/badge' 3 | import { Badge } from '@/components/shadcn/badge' 4 | 5 | type BetaBadgeProps = Pick, 'size'> 6 | & { className?: string } 7 | 8 | export function BetaBadge({ size, className }: BetaBadgeProps) { 9 | return ( 10 | 11 | Beta 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/types/proxy-fetch.ts: -------------------------------------------------------------------------------- 1 | export interface ProxyResponse { 2 | status: number 3 | statusText: string 4 | headers: [string, string][] 5 | body: string 6 | } 7 | 8 | export interface CacheConfig { 9 | enabled: boolean 10 | groupKey: string 11 | ttl?: number 12 | maxSize?: number 13 | } 14 | 15 | export interface ProxyRequest { 16 | url: string 17 | method?: string 18 | headers?: [string, string][] 19 | body?: string 20 | credentials?: 'omit' | 'same-origin' | 'include' 21 | cacheConfig?: CacheConfig 22 | } 23 | -------------------------------------------------------------------------------- /src/entrypoints/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Popup | Read Frog 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /src/assets/icons/avatars/guest.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/utils/url.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export function matchDomainPattern(url: string, pattern: string): boolean { 4 | if (!z.url().safeParse(url).success) { 5 | return false 6 | } 7 | 8 | const urlObj = new URL(url) 9 | const hostname = urlObj.hostname.toLowerCase() 10 | const patternLower = pattern.toLowerCase().trim() 11 | 12 | if (hostname === patternLower) { 13 | return true 14 | } 15 | 16 | if (hostname.endsWith(`.${patternLower}`)) { 17 | return true 18 | } 19 | 20 | return false 21 | } 22 | -------------------------------------------------------------------------------- /src/entrypoints/side.content/components/floating-button/floating-read-button.tsx: -------------------------------------------------------------------------------- 1 | import { useSetAtom } from 'jotai' 2 | 3 | import { isSideOpenAtom } from '../../atoms' 4 | import HiddenButton from './components/hidden-button' 5 | 6 | export default function FloatingReadButton({ className }: { className: string }) { 7 | const setIsSideOpen = useSetAtom(isSideOpenAtom) 8 | 9 | const openPanel = () => { 10 | setIsSideOpen(true) 11 | } 12 | 13 | return 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v002-to-v003.ts: -------------------------------------------------------------------------------- 1 | import { deepmerge } from 'deepmerge-ts' 2 | 3 | export function migrate(oldConfig: any): any { 4 | const { 5 | manualTranslate, 6 | pageTranslate, 7 | ...restConfig 8 | } = oldConfig 9 | 10 | if (pageTranslate.range === 'mainContent') { 11 | pageTranslate.range = 'main' 12 | } 13 | 14 | return deepmerge(restConfig, { 15 | translate: { 16 | provider: 'microsoft', 17 | node: manualTranslate, 18 | page: pageTranslate, 19 | }, 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v034-to-v035.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Migration script from v034 to v035 3 | * Adds 'contextMenu' configuration for right-click menu translation feature 4 | * 5 | * Before (v034): 6 | * { language: {...}, translate: {...}, ... } 7 | * 8 | * After (v035): 9 | * { language: {...}, translate: {...}, contextMenu: { enabled: true }, ... } 10 | */ 11 | 12 | export function migrate(oldConfig: any): any { 13 | return { 14 | ...oldConfig, 15 | contextMenu: { 16 | enabled: true, 17 | }, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/entrypoints/side.content/app.tsx: -------------------------------------------------------------------------------- 1 | import FrogToast from '@/components/frog-toast' 2 | import { NOTRANSLATE_CLASS } from '@/utils/constants/dom-labels' 3 | import { cn } from '@/utils/styles/tailwind' 4 | import FloatingButton from './components/floating-button' 5 | import SideContent from './components/side-content' 6 | 7 | export default function App() { 8 | return ( 9 |
10 | 11 | 12 | 13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/host/translate/translation-attributes.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@/types/config/config' 2 | import { ISO6393_TO_6391, RTL_LANG_CODES } from '@read-frog/definitions' 3 | 4 | export function setTranslationDirAndLang(element: HTMLElement, config: Config): void { 5 | const dir = RTL_LANG_CODES.includes(config.language.targetCode as typeof RTL_LANG_CODES[number]) ? 'rtl' : 'ltr' 6 | element.setAttribute('dir', dir) 7 | const langAttr = ISO6393_TO_6391[config.language.targetCode] 8 | if (langAttr) 9 | element.setAttribute('lang', langAttr) 10 | } 11 | -------------------------------------------------------------------------------- /src/entrypoints/selection.content/selection-toolbar/atom.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | 3 | export const selectionContentAtom = atom(null) 4 | export const selectionRangeAtom = atom(null) 5 | export const isSelectionToolbarVisibleAtom = atom(false) 6 | 7 | // 新增:管理 translate popover 的显示状态 8 | export const isTranslatePopoverVisibleAtom = atom(false) 9 | export const isAiPopoverVisibleAtom = atom(false) 10 | 11 | // 新增:存储鼠标点击位置 12 | export const mouseClickPositionAtom = atom<{ x: number, y: number } | null>(null) 13 | -------------------------------------------------------------------------------- /src/components/shadcn/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { cn } from '@/utils/styles/tailwind' 3 | 4 | // TODO: Remove 'popover' Omit when Radix UI supports React 19.2 5 | // React 19.2 added "hint" value to popover attribute, but Radix UI doesn't support it yet 6 | function Skeleton({ className, ...props }: Omit, 'popover'>) { 7 | return ( 8 |
13 | ) 14 | } 15 | 16 | export { Skeleton } 17 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'type-enum': [ 5 | 2, 6 | 'always', 7 | [ 8 | // Default types from conventional commits 9 | 'build', 10 | 'chore', 11 | 'ci', 12 | 'docs', 13 | 'feat', 14 | 'fix', 15 | 'perf', 16 | 'refactor', 17 | 'revert', 18 | 'style', 19 | 'test', 20 | // Custom types from PR template 21 | 'i18n', 22 | 'ai', 23 | ], 24 | ], 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /src/entrypoints/options/components/set-api-key-warning.tsx: -------------------------------------------------------------------------------- 1 | import { i18n } from '#imports' 2 | import { Link } from 'react-router' 3 | 4 | export function SetApiKeyWarning() { 5 | return ( 6 |
7 | {i18n.t('options.setAPIKeyWarning.please')} 8 | {' '} 9 | {i18n.t('options.apiProviders.title')} 10 | {' '} 11 | {i18n.t('options.setAPIKeyWarning.page')} 12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/entrypoints/options/app-sidebar/animated-indicator.tsx: -------------------------------------------------------------------------------- 1 | interface AnimatedIndicatorProps { 2 | show: boolean 3 | } 4 | 5 | export function AnimatedIndicator({ show }: AnimatedIndicatorProps) { 6 | if (!show) 7 | return null 8 | 9 | return ( 10 | 11 | 12 | 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/http.ts: -------------------------------------------------------------------------------- 1 | export function normalizeHeaders(headersInit?: HeadersInit): [string, string][] { 2 | if (!headersInit) 3 | return [] 4 | if (headersInit instanceof Headers) 5 | return Array.from(headersInit.entries()) 6 | if (Array.isArray(headersInit)) 7 | return headersInit.map(([k, v]) => [k, String(v)]) 8 | // plain object shape 9 | const entries: [string, string][] = [] 10 | for (const key of Object.keys(headersInit)) { 11 | const value = (headersInit as Record)[key] 12 | entries.push([key, String(value)]) 13 | } 14 | return entries 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v036-to-v037.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Migration script from v036 to v037 3 | * Removes 'detectedCode' from language config as it's now stored separately 4 | * 5 | * Before (v036): 6 | * { language: { detectedCode, sourceCode, targetCode, level }, ... } 7 | * 8 | * After (v037): 9 | * { language: { sourceCode, targetCode, level }, ... } 10 | */ 11 | 12 | export function migrate(oldConfig: any): any { 13 | const { detectedCode: _, ...restLanguage } = oldConfig.language ?? {} 14 | 15 | return { 16 | ...oldConfig, 17 | language: restLanguage, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import { defineConfig } from 'vitest/config' 3 | 4 | import { WxtVitest } from 'wxt/testing' 5 | 6 | export default defineConfig({ 7 | // TODO: remove any 8 | plugins: [WxtVitest() as any, react()], 9 | test: { 10 | environment: 'node', 11 | globals: true, 12 | setupFiles: 'vitest.setup.ts', 13 | watch: false, 14 | coverage: { 15 | provider: 'istanbul', 16 | reporter: ['text', 'html', 'lcov'], 17 | // include: ['src/**/*.{ts,tsx}'], 18 | // exclude: ['src/**/*.spec.ts'] 19 | }, 20 | }, 21 | }) 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .output 12 | stats.html 13 | stats-*.html 14 | stats-*.json 15 | .wxt 16 | web-ext.config.ts 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | !.vscode/settings.json 22 | .idea 23 | .DS_Store 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | 30 | .env 31 | .env.* 32 | !.env.local.example 33 | 34 | coverage/ 35 | .turbo/ 36 | .nx/ 37 | 38 | tsconfig.local.json 39 | 40 | immersive-translate-config-example.txt -------------------------------------------------------------------------------- /src/utils/config/languages.ts: -------------------------------------------------------------------------------- 1 | import type { LangCodeISO6393 } from '@read-frog/definitions' 2 | import { storage } from '#imports' 3 | import { DEFAULT_DETECTED_CODE, DETECTED_CODE_STORAGE_KEY } from '../constants/config' 4 | 5 | export function getFinalSourceCode(sourceCode: LangCodeISO6393 | 'auto', detectedCode: LangCodeISO6393): LangCodeISO6393 { 6 | return sourceCode === 'auto' ? detectedCode : sourceCode 7 | } 8 | 9 | export async function getDetectedCodeFromStorage(): Promise { 10 | return await storage.getItem(`local:${DETECTED_CODE_STORAGE_KEY}`) ?? DEFAULT_DETECTED_CODE 11 | } 12 | -------------------------------------------------------------------------------- /src/types/utils.ts: -------------------------------------------------------------------------------- 1 | export function pick< 2 | T extends object, 3 | K extends readonly (keyof T)[], 4 | >(obj: T, keys: K): { [I in K[number]]: T[I] } { 5 | const res = {} as { [I in K[number]]: T[I] } 6 | for (const key of keys) { 7 | if (key in obj) { 8 | res[key] = obj[key] 9 | } 10 | } 11 | return res 12 | } 13 | 14 | export function omit< 15 | T extends object, 16 | K extends readonly (keyof T)[], 17 | >(obj: T, keys: K): Omit { 18 | const res = { ...obj } as T 19 | for (const key of keys) { 20 | delete (res as any)[key] 21 | } 22 | return res as Omit 23 | } 24 | -------------------------------------------------------------------------------- /.claude/commands/fix-github-issue.md: -------------------------------------------------------------------------------- 1 | Please analyze and fix the GitHub issue: $ARGUMENTS. 2 | 3 | Follow these steps: 4 | 5 | 1. Use `gh issue view` to get the issue details 6 | 2. Understand the problem described in the issue 7 | 3. Search the codebase for relevant files 8 | 4. Implement the necessary changes to fix the issue 9 | 5. Write and run tests to verify the fix 10 | 6. Ensure code passes linting and type checking 11 | 7. Create a descriptive commit message 12 | 8. Push and create a PR following the steps in the `.claude/commands/create-pr.md` file 13 | 14 | Remember to use the GitHub CLI (`gh`) for all GitHub-related tasks. 15 | -------------------------------------------------------------------------------- /src/entrypoints/background/config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@/types/config/config' 2 | import { storage } from '#imports' 3 | import { initializeConfig } from '@/utils/config/init' 4 | import { CONFIG_STORAGE_KEY } from '@/utils/constants/config' 5 | 6 | let configPromise: Promise | null = null 7 | 8 | // To avoid background script initialize config simultaneously and avoid race condition 9 | export async function ensureInitializedConfig() { 10 | if (!configPromise) { 11 | configPromise = initializeConfig() 12 | } 13 | await configPromise 14 | return storage.getItem(`local:${CONFIG_STORAGE_KEY}`) 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v037-to-v038.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Migration script from v037 to v038 3 | * Adds 'clickAction' field to floatingButton config 4 | * 5 | * Before (v037): 6 | * { floatingButton: { enabled, position, disabledFloatingButtonPatterns }, ... } 7 | * 8 | * After (v038): 9 | * { floatingButton: { enabled, position, disabledFloatingButtonPatterns, clickAction: 'panel' }, ... } 10 | */ 11 | 12 | export function migrate(oldConfig: any): any { 13 | return { 14 | ...oldConfig, 15 | floatingButton: { 16 | ...oldConfig.floatingButton, 17 | clickAction: 'panel', 18 | }, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/config/__tests__/migration.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { CONFIG_SCHEMA_VERSION } from '@/utils/constants/config' 3 | import { ConfigVersionTooNewError } from '../errors' 4 | import { migrateConfig } from '../migration' 5 | 6 | describe('migrateConfig', () => { 7 | it('should throw ConfigVersionTooNewError when schema version is newer than current', async () => { 8 | const futureVersion = CONFIG_SCHEMA_VERSION + 1 9 | const config = {} 10 | 11 | await expect(migrateConfig(config, futureVersion)) 12 | .rejects 13 | .toThrow(ConfigVersionTooNewError) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/assets/styles/theme.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "iconLibrary": "lucide", 14 | "aliases": { 15 | "components": "@/components", 16 | "utils": "@/utils/styles/tailwind", 17 | "ui": "src/components/shadcn", 18 | "lib": "@/utils/styles", 19 | "hooks": "@/hooks" 20 | }, 21 | "registries": { 22 | "@reui": "https://reui.io/r/{name}.json" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.cursor/commands/fix-github-issue.md: -------------------------------------------------------------------------------- 1 | # Fix GitHub Issue 2 | 3 | Please analyze and fix the GitHub issue: $ARGUMENTS. 4 | 5 | Follow these steps: 6 | 7 | 1. Use `gh issue view` to get the issue details 8 | 2. Understand the problem described in the issue 9 | 3. Search the codebase for relevant files 10 | 4. Implement the necessary changes to fix the issue 11 | 5. Write and run tests to verify the fix 12 | 6. Ensure code passes linting and type checking 13 | 7. Create a descriptive commit message 14 | 8. Push and create a PR following the steps in the `.cursor/commands/create-pr.md` file 15 | 16 | Remember to use the GitHub CLI (`gh`) for all GitHub-related tasks. 17 | -------------------------------------------------------------------------------- /src/hooks/use-debounced-value.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom hook for debouncing values 3 | * 4 | * @param value - The value to debounce 5 | * @param delay - Debounce delay in milliseconds 6 | * @returns Debounced value 7 | */ 8 | 9 | import { useEffect, useState } from 'react' 10 | 11 | export function useDebouncedValue(value: T, delay: number): T { 12 | const [debouncedValue, setDebouncedValue] = useState(value) 13 | 14 | useEffect(() => { 15 | const timeoutId = setTimeout(() => { 16 | setDebouncedValue(value) 17 | }, delay) 18 | 19 | return () => clearTimeout(timeoutId) 20 | }, [value, delay]) 21 | 22 | return debouncedValue 23 | } 24 | -------------------------------------------------------------------------------- /src/components/llm-status-indicator.tsx: -------------------------------------------------------------------------------- 1 | import { i18n } from '#imports' 2 | 3 | interface LLMStatusIndicatorProps { 4 | hasLLMProvider: boolean 5 | } 6 | 7 | export function LLMStatusIndicator({ hasLLMProvider }: LLMStatusIndicatorProps) { 8 | return ( 9 |
10 |
11 | 12 | {hasLLMProvider 13 | ? i18n.t('options.translation.llmProviderConfigured') 14 | : i18n.t('options.translation.llmProviderNotConfigured')} 15 | 16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/host/__tests__/test-website.md: -------------------------------------------------------------------------------- 1 | | Website | Github Issue | 2 | | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------- | 3 | | https://rutracker.org/forum/viewtopic.php?t=6460937 | https://github.com/mengxi-ream/read-frog/issues/395 | 4 | | https://www.economist.com/business/2025/08/21/china-is-quietly-upstaging-america-with-its-open-models | https://github.com/mengxi-ream/read-frog/issues/384 | 5 | -------------------------------------------------------------------------------- /src/utils/hash.ts: -------------------------------------------------------------------------------- 1 | import { sha256 } from 'js-sha256' 2 | 3 | /** 4 | * Generate a SHA256 hash of multiple text parameters 5 | * @param texts Variable number of text parameters 6 | * @returns SHA256 hexadecimal string 7 | * 8 | * @example 9 | * Sha256Hex('hello') // single parameter 10 | * Sha256Hex('hello', 'world') // multiple parameters joined with separator 11 | */ 12 | export function Sha256Hex(...texts: string[]): string { 13 | if (texts.length === 0) { 14 | throw new Error('At least one text parameter is required') 15 | } 16 | 17 | // prevent parameter boundary ambiguity, e.g. 'a|bc' and 'ab|c' are different 18 | const combined = texts.join('|') 19 | return sha256(combined) 20 | } 21 | -------------------------------------------------------------------------------- /src/components/shadcn/label.tsx: -------------------------------------------------------------------------------- 1 | import * as LabelPrimitive from '@radix-ui/react-label' 2 | import * as React from 'react' 3 | 4 | import { cn } from '@/utils/styles/tailwind' 5 | 6 | function Label({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 19 | ) 20 | } 21 | 22 | export { Label } 23 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v010-to-v011.ts: -------------------------------------------------------------------------------- 1 | export function migrate(oldConfig: any): any { 2 | // Integrate Gemini Api 3 | const newConfig = { 4 | ...oldConfig, 5 | providersConfig: { 6 | ...oldConfig.providersConfig, 7 | gemini: { 8 | apiKey: undefined, 9 | baseURL: 'https://generativelanguage.googleapis.com/v1beta', 10 | }, 11 | }, 12 | translate: { 13 | ...oldConfig.translate, 14 | models: { 15 | ...oldConfig.translate?.models, 16 | gemini: { 17 | model: 'gemini-2.5-pro', 18 | isCustomModel: false, 19 | customModel: '', 20 | }, 21 | }, 22 | }, 23 | } 24 | 25 | return newConfig 26 | } 27 | -------------------------------------------------------------------------------- /src/entrypoints/options/pages/api-providers/provider-config-form/form.ts: -------------------------------------------------------------------------------- 1 | import { createFormHook, formOptions } from '@tanstack/react-form' 2 | import { apiProviderConfigItemSchema } from '@/types/config/provider' 3 | import { InputField } from './components/input-field' 4 | import { SelectField } from './components/select-field' 5 | import { fieldContext, formContext } from './form-context' 6 | 7 | export const { useAppForm, withForm } = createFormHook({ 8 | fieldComponents: { 9 | InputField, 10 | SelectField, 11 | }, 12 | formComponents: {}, 13 | fieldContext, 14 | formContext, 15 | }) 16 | 17 | export const formOpts = formOptions({ 18 | validators: { 19 | onChange: apiProviderConfigItemSchema, 20 | }, 21 | }) 22 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v007-to-v008.ts: -------------------------------------------------------------------------------- 1 | export function migrate(oldConfig: any): any { 2 | const promptsConfig = { 3 | prompt: 'Read Frog: TRANSLATE_DEFAULT_PROMPT', 4 | patterns: [{ 5 | id: 'Read Frog: TRANSLATE_DEFAULT_PROMPT', 6 | name: 'Read Frog: TRANSLATE_DEFAULT_PROMPT', 7 | prompt: `Treat input as plain text input and translate it into {{targetLang}}, output translation ONLY. If translation is unnecessary (e.g. proper nouns, codes, etc.), return the original text. NO explanations. NO notes. 8 | Input: 9 | {{input}} 10 | `, 11 | }], 12 | } 13 | 14 | return { 15 | ...oldConfig, 16 | translate: { 17 | ...oldConfig.translate, 18 | promptsConfig, 19 | }, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/components/loading-dots.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/utils/styles/tailwind' 2 | 3 | interface LoadingDotsProps { 4 | className?: string 5 | } 6 | 7 | export default function LoadingDots({ className }: LoadingDotsProps) { 8 | return ( 9 |
12 | {[...Array.from({ length: 3 })].map((_, i) => ( 13 |
21 | ))} 22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState(undefined) 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 12 | } 13 | mql.addEventListener('change', onChange) 14 | // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect 15 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 16 | return () => mql.removeEventListener('change', onChange) 17 | }, []) 18 | 19 | return !!isMobile 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/api-error.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Extract error message from API response 3 | * Handles various error formats: JSON string, { error: { message } }, { message }, plain text 4 | */ 5 | export async function extractErrorMessage(response: Response): Promise { 6 | const fallback = `${response.status} ${response.statusText}` 7 | const text = await response.text() 8 | 9 | if (!text) 10 | return fallback 11 | 12 | try { 13 | const json = JSON.parse(text) 14 | if (typeof json === 'string') 15 | return json 16 | if (json.error?.message) 17 | return json.error.message 18 | if (json.message) 19 | return json.message 20 | return fallback 21 | } 22 | catch { 23 | return text.slice(0, 100) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v004-to-v005.ts: -------------------------------------------------------------------------------- 1 | export function migrate(oldConfig: any): any { 2 | const oldProvidersConfig = oldConfig.providersConfig 3 | const newProvidersConfig = Object.fromEntries( 4 | (Object.entries(oldProvidersConfig) as [string, { apiKey?: string }][]).map(([key, value]) => { 5 | const baseURLs = { 6 | openai: 'https://api.openai.com/v1', 7 | deepseek: 'https://api.deepseek.com/v1', 8 | openrouter: 'https://openrouter.ai/api/v1', 9 | } 10 | return [key, { 11 | ...value, 12 | baseURL: baseURLs[key as keyof typeof baseURLs], 13 | }] 14 | }), 15 | ) 16 | 17 | return { 18 | ...oldConfig, 19 | providersConfig: newProvidersConfig, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/host/translate/dom/translation-wrapper.ts: -------------------------------------------------------------------------------- 1 | import { CONTENT_WRAPPER_CLASS, WALKED_ATTRIBUTE } from '../../../constants/dom-labels' 2 | import { isHTMLElement } from '../../dom/filter' 3 | 4 | export function findPreviousTranslatedWrapperInside(node: Element | Text, walkId: string): HTMLElement | null { 5 | if (isHTMLElement(node)) { 6 | // Check if the node itself is a translated wrapper 7 | if (node.classList.contains(CONTENT_WRAPPER_CLASS) && node.getAttribute(WALKED_ATTRIBUTE) !== walkId) { 8 | return node 9 | } 10 | // Otherwise, look for a wrapper as a child that doesn't match the current walkId 11 | return node.querySelector(`.${CONTENT_WRAPPER_CLASS}:not([${WALKED_ATTRIBUTE}="${walkId}"])`) 12 | } 13 | return null 14 | } 15 | -------------------------------------------------------------------------------- /src/entrypoints/options/pages/config/index.tsx: -------------------------------------------------------------------------------- 1 | import { i18n } from '#imports' 2 | import { PageLayout } from '../../components/page-layout' 3 | import { BetaExperienceConfig } from './beta-experience' 4 | import { ConfigBackup } from './config-backup' 5 | import { GoogleDriveSyncCard } from './google-drive-sync' 6 | import { ManualConfigSync } from './manual-config-sync' 7 | import { ResetConfig } from './reset-config' 8 | 9 | export function ConfigPage() { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/components/shadcn/hint.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentProps } from 'react' 2 | import { IconHelpCircle } from '@tabler/icons-react' 3 | import { cn } from '@/utils/styles/tailwind' 4 | import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip' 5 | 6 | type HintProps = { 7 | content: string 8 | } & Omit, 'children'> 9 | 10 | export function Hint({ content, className, ...props }: HintProps) { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | {content} 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/constants/tts.ts: -------------------------------------------------------------------------------- 1 | // This is a list of voices available for the OpenAI API 2 | 3 | import type { TTSConfig, TTSVoice } from '@/types/config/tts' 4 | 5 | // https://www.openai.fm/ 6 | export const TTS_VOICES_ITEMS: Record = { 7 | alloy: { name: 'Alloy' }, 8 | ash: { name: 'Ash' }, 9 | ballad: { name: 'Ballad' }, 10 | coral: { name: 'Coral' }, 11 | echo: { name: 'Echo' }, 12 | fable: { name: 'Fable' }, 13 | nova: { name: 'Nova' }, 14 | onyx: { name: 'Onyx' }, 15 | sage: { name: 'Sage' }, 16 | shimmer: { name: 'Shimmer' }, 17 | verse: { name: 'Verse' }, 18 | } 19 | 20 | export const DEFAULT_TTS_CONFIG: TTSConfig = { 21 | providerId: 'openai-default', 22 | model: 'gpt-4o-mini-tts', 23 | voice: 'ash', 24 | speed: 1, 25 | } 26 | -------------------------------------------------------------------------------- /src/entrypoints/options/components/config-card.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/utils/styles/tailwind' 2 | 3 | export function ConfigCard( 4 | { title, description, children, className, titleClassName }: 5 | { title: React.ReactNode, description: React.ReactNode, children: React.ReactNode, className?: string, titleClassName?: string }, 6 | ) { 7 | return ( 8 |
9 |
10 |

{title}

11 |
{description}
12 |
13 |
14 | {children} 15 |
16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/entrypoints/popup/components/floating-button.tsx: -------------------------------------------------------------------------------- 1 | import { i18n } from '#imports' 2 | import { useAtom } from 'jotai' 3 | import { Switch } from '@/components/shadcn/switch' 4 | import { configFieldsAtomMap } from '@/utils/atoms/config' 5 | 6 | export default function FloatingButton() { 7 | const [floatingButton, setFloatingButton] = useAtom( 8 | configFieldsAtomMap.floatingButton, 9 | ) 10 | 11 | return ( 12 |
13 | 14 | {i18n.t('popup.enabledFloatingButton')} 15 | 16 | { 19 | void setFloatingButton({ enabled: checked }) 20 | }} 21 | /> 22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/entrypoints/side.content/atoms.ts: -------------------------------------------------------------------------------- 1 | import { atom, createStore } from 'jotai' 2 | import { createTranslationStateAtomForContentScript } from '@/utils/atoms/translation-state' 3 | 4 | export const store = createStore() 5 | 6 | export const isSideOpenAtom = atom(false) 7 | 8 | export const isDraggingButtonAtom = atom(false) 9 | 10 | export const progressAtom = atom({ 11 | completed: 0, 12 | total: 0, 13 | }) 14 | 15 | export const enablePageTranslationAtom = createTranslationStateAtomForContentScript( 16 | { enabled: false }, 17 | ) 18 | 19 | // export const explainAtom = atomWithMutation(() => ({ 20 | // mutationKey: ["explainArticle"], 21 | // mutationFn: mutationFn, 22 | // })); 23 | 24 | export const readStateAtom = atom< 25 | 'extracting' | 'analyzing' | 'continue?' | 'explaining' | undefined 26 | >(undefined) 27 | -------------------------------------------------------------------------------- /src/components/shadcn/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as SeparatorPrimitive from '@radix-ui/react-separator' 2 | import * as React from 'react' 3 | 4 | import { cn } from '@/utils/styles/tailwind' 5 | 6 | function Separator({ 7 | className, 8 | orientation = 'horizontal', 9 | decorative = true, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 23 | ) 24 | } 25 | 26 | export { Separator } 27 | -------------------------------------------------------------------------------- /src/entrypoints/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Options | Read Frog 7 | 8 | 9 | 10 | 11 | 12 | 13 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/entrypoints/popup/components/discord-button.tsx: -------------------------------------------------------------------------------- 1 | import { i18n } from '#imports' 2 | import { Icon } from '@iconify/react' 3 | import { Button } from '@/components/shadcn/button' 4 | import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/shadcn/tooltip' 5 | 6 | export function DiscordButton() { 7 | return ( 8 | 9 | 10 | 17 | 18 | 19 | {i18n.t('popup.discord.tooltip')} 20 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/tanstack-query.ts: -------------------------------------------------------------------------------- 1 | import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query' 2 | import { toast } from 'sonner' 3 | 4 | export const queryClient = new QueryClient({ 5 | queryCache: new QueryCache({ 6 | onError: (error, query) => { 7 | const errorDescription 8 | = query.meta?.errorDescription || 'Something went wrong' 9 | toast.error(`${errorDescription}`, { 10 | description: error.message || undefined, 11 | }) 12 | }, 13 | }), 14 | mutationCache: new MutationCache({ 15 | onError: (error, _variables, _context, mutation) => { 16 | const errorDescription 17 | = mutation.meta?.errorDescription || 'Something went wrong' 18 | toast.error(`${errorDescription}`, { 19 | description: error.message || undefined, 20 | }) 21 | }, 22 | }), 23 | }) 24 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v005-to-v006.ts: -------------------------------------------------------------------------------- 1 | export function migrate(oldConfig: any): any { 2 | // 添加 Ollama 提供商配置 3 | const oldProvidersConfig = oldConfig.providersConfig 4 | const newProvidersConfig = { 5 | ...oldProvidersConfig, 6 | ollama: { 7 | apiKey: undefined, 8 | baseURL: 'http://127.0.0.1:11434/v1', 9 | }, 10 | } 11 | 12 | // 添加 Ollama 翻译模型配置 13 | const oldTranslateModels = oldConfig.translate.models 14 | const newTranslateModels = { 15 | ...oldTranslateModels, 16 | ollama: { 17 | model: 'gemma3:1b', 18 | isCustomModel: false, 19 | customModel: '', 20 | }, 21 | } 22 | 23 | return { 24 | ...oldConfig, 25 | providersConfig: newProvidersConfig, 26 | translate: { 27 | ...oldConfig.translate, 28 | models: newTranslateModels, 29 | }, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/assets/providers/custom-provider.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v029-to-v030.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Migration script from v029 to v030 3 | * Restructures translationNodeStyle from simple string to object with custom CSS support 4 | * 5 | * Before (v029): 6 | * translationNodeStyle: 'default' | 'blur' | 'custom' | ... 7 | * 8 | * After (v030): 9 | * translationNodeStyle: { 10 | * preset: 'default' | 'blur' | ... (excludes 'custom') 11 | * isCustom: boolean 12 | * customCSS: string 13 | * } 14 | */ 15 | 16 | export function migrate(oldConfig: any): any { 17 | const oldStyle = oldConfig.translate?.translationNodeStyle ?? 'default' 18 | 19 | return { 20 | ...oldConfig, 21 | translate: { 22 | ...oldConfig.translate, 23 | translationNodeStyle: { 24 | preset: oldStyle, 25 | isCustom: false, 26 | customCSS: null, 27 | }, 28 | }, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/components/shadcn/progress.tsx: -------------------------------------------------------------------------------- 1 | import * as ProgressPrimitive from '@radix-ui/react-progress' 2 | 3 | import * as React from 'react' 4 | 5 | import { cn } from '@/utils/styles/tailwind' 6 | 7 | function Progress({ 8 | className, 9 | value, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | 26 | 27 | ) 28 | } 29 | 30 | export { Progress } 31 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v032-to-v033.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Migration script from v032 to v033 3 | * Adds 'enableAIContentAware' field to translate config 4 | * 5 | * Before (v032): 6 | * translate: { 7 | * providerId: 'microsoft-default', 8 | * mode: 'bilingual', 9 | * node: {...}, 10 | * page: {...}, 11 | * customPromptsConfig: {...}, 12 | * ... 13 | * } 14 | * 15 | * After (v033): 16 | * translate: { 17 | * providerId: 'microsoft-default', 18 | * mode: 'bilingual', 19 | * node: {...}, 20 | * page: {...}, 21 | * enableAIContentAware: false, 22 | * customPromptsConfig: {...}, 23 | * ... 24 | * } 25 | */ 26 | 27 | export function migrate(oldConfig: any): any { 28 | return { 29 | ...oldConfig, 30 | translate: { 31 | ...oldConfig.translate, 32 | enableAIContentAware: false, 33 | }, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/entrypoints/options/pages/config/google-drive-sync/components/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if a string is a meaningful field name (not an array index). 3 | * Used to decide whether to display the field key label in conflict UI. 4 | * 5 | * @example 6 | * isMeaningfulFieldKey("theme") // true - object property name 7 | * isMeaningfulFieldKey("0") // false - array index 8 | */ 9 | export function isMeaningfulFieldKey(key: string): boolean { 10 | return Number.isNaN(Number(key)) 11 | } 12 | 13 | export function formatValue(val: unknown): string { 14 | if (val === null) 15 | return 'null' 16 | if (val === undefined) 17 | return 'undefined' 18 | if (typeof val === 'string') 19 | return `"${val}"` 20 | if (typeof val === 'boolean') 21 | return val ? 'true' : 'false' 22 | if (typeof val === 'object') 23 | return JSON.stringify(val, null, 2) 24 | return String(val) 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/constants/translate.ts: -------------------------------------------------------------------------------- 1 | export const MIN_TRANSLATE_RATE = 1 2 | export const MIN_TRANSLATE_CAPACITY = 1 3 | export const MIN_BATCH_CHARACTERS = 1 4 | export const MIN_BATCH_ITEMS = 1 5 | 6 | export const DEFAULT_REQUEST_RATE = 8 7 | export const DEFAULT_REQUEST_CAPACITY = 60 8 | 9 | export const DEFAULT_MAX_CHARACTER_PER_BATCH = 1000 10 | export const DEFAULT_MAX_ITEMS_PER_BATCH = 4 11 | 12 | export const DEFAULT_BATCH_CONFIG = { 13 | maxCharactersPerBatch: DEFAULT_MAX_CHARACTER_PER_BATCH, 14 | maxItemsPerBatch: DEFAULT_MAX_ITEMS_PER_BATCH, 15 | } 16 | 17 | export const DEFAULT_AUTO_TRANSLATE_SHORTCUT_KEY = ['alt', 'e'] 18 | 19 | export const MIN_PRELOAD_MARGIN = 0 20 | export const MAX_PRELOAD_MARGIN = 5000 21 | export const DEFAULT_PRELOAD_MARGIN = 1000 22 | 23 | export const MIN_PRELOAD_THRESHOLD = 0 24 | export const MAX_PRELOAD_THRESHOLD = 1 25 | export const DEFAULT_PRELOAD_THRESHOLD = 0 26 | -------------------------------------------------------------------------------- /src/types/config/meta.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from './config' 2 | 3 | /** 4 | * Metadata stored with config via WXT storage.setMeta 5 | */ 6 | export interface ConfigMetaFields { 7 | schemaVersion: number 8 | lastModifiedAt: number 9 | } 10 | 11 | export interface ConfigMeta extends ConfigMetaFields, Record {} 12 | 13 | export interface ConfigValueAndMeta { 14 | value: Config 15 | meta: ConfigMeta 16 | } 17 | 18 | /** 19 | * Metadata stored with lastSyncedConfig via WXT storage.setMeta 20 | */ 21 | export interface LastSyncedConfigMetaFields { 22 | schemaVersion: number 23 | lastModifiedAt: number 24 | lastSyncedAt: number 25 | email: string 26 | } 27 | 28 | export interface LastSyncedConfigMeta extends LastSyncedConfigMetaFields, Record {} 29 | 30 | export interface LastSyncedConfigValueAndMeta { 31 | value: Config 32 | meta: LastSyncedConfigMeta 33 | } 34 | -------------------------------------------------------------------------------- /src/entrypoints/options/pages/translation/custom-translation-style/index.tsx: -------------------------------------------------------------------------------- 1 | import { i18n } from '#imports' 2 | import { FieldGroup } from '@/components/shadcn/field' 3 | import { ConfigCard } from '@/entrypoints/options/components/config-card' 4 | import { CSSEditor } from './css-editor' 5 | import { CustomTranslationStyleSwitch } from './custom-translation-style-switch' 6 | import { PresetStyleSelector } from './preset-style-selector' 7 | import { StylePreview } from './style-preview' 8 | 9 | export function CustomTranslationStyle() { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/entrypoints/side.content/components/floating-button/components/hidden-button.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@iconify/react' 2 | import { cn } from '@/utils/styles/tailwind' 3 | 4 | export default function HiddenButton({ 5 | icon, 6 | onClick, 7 | children, 8 | className, 9 | }: { 10 | icon: string 11 | onClick: () => void 12 | children?: React.ReactNode 13 | className?: string 14 | }) { 15 | return ( 16 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v035-to-v036.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Migration script from v035 to v036 3 | * Adds 'preload' config to translate.page for controlling 4 | * pre-translation of content below the viewport 5 | * 6 | * Before (v035): 7 | * { translate: { page: { range, autoTranslatePatterns, ... } }, ... } 8 | * 9 | * After (v036): 10 | * { translate: { page: { ..., preload: { margin: 1000, threshold: 0 } } }, ... } 11 | */ 12 | 13 | import { DEFAULT_PRELOAD_MARGIN, DEFAULT_PRELOAD_THRESHOLD } from '@/utils/constants/translate' 14 | 15 | export function migrate(oldConfig: any): any { 16 | return { 17 | ...oldConfig, 18 | translate: { 19 | ...oldConfig.translate, 20 | page: { 21 | ...oldConfig.translate?.page, 22 | preload: { 23 | margin: DEFAULT_PRELOAD_MARGIN, 24 | threshold: DEFAULT_PRELOAD_THRESHOLD, 25 | }, 26 | }, 27 | }, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/entrypoints/options/pages/api-providers/atoms.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { configFieldsAtomMap } from '@/utils/atoms/config' 3 | import { getAPIProvidersConfig } from '@/utils/config/helpers' 4 | 5 | const internalSelectedProviderIdAtom = atom(undefined) 6 | 7 | export const selectedProviderIdAtom = atom( 8 | (get) => { 9 | const selected = get(internalSelectedProviderIdAtom) 10 | if (selected !== undefined) { 11 | return selected 12 | } 13 | 14 | const providersConfig = get(configFieldsAtomMap.providersConfig) 15 | const apiProvidersConfig = getAPIProvidersConfig(providersConfig) 16 | const firstProviderId = apiProvidersConfig.length > 0 17 | ? apiProvidersConfig[0].id 18 | : undefined 19 | return firstProviderId 20 | }, 21 | (_get, set, newValue: string | undefined) => { 22 | set(internalSelectedProviderIdAtom, newValue) 23 | }, 24 | ) 25 | -------------------------------------------------------------------------------- /src/entrypoints/options/pages/context-menu/index.tsx: -------------------------------------------------------------------------------- 1 | import { i18n } from '#imports' 2 | import contextMenuDemoImage from '@/assets/demo/context-menu.png' 3 | import { GradientBackground } from '@/components/gradient-background' 4 | import { PageLayout } from '../../components/page-layout' 5 | import { ContextMenuTranslateToggle } from './context-menu-translate-toggle' 6 | 7 | export function ContextMenuPage() { 8 | return ( 9 | 10 | 11 | {i18n.t('options.floatingButtonAndToolbar.selectionToolbarDemoImageAlt')} 16 | 17 |
18 | 19 |
20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/entrypoints/host.content/translation-control/bind-translation-shortcut.ts: -------------------------------------------------------------------------------- 1 | import type { PageTranslationManager } from './page-translation' 2 | import hotkeys from 'hotkeys-js' 3 | import { getLocalConfig } from '@/utils/config/storage' 4 | 5 | export async function bindTranslationShortcutKey(pageTranslationManager: PageTranslationManager) { 6 | // Clear all existing hotkeys first 7 | hotkeys.unbind() 8 | 9 | const config = await getLocalConfig() 10 | if (!config) 11 | return 12 | 13 | const shortcut = config.translate.page.shortcut.join('+') 14 | 15 | hotkeys(shortcut, () => { 16 | void (async () => { 17 | const currentConfig = await getLocalConfig() 18 | if (!currentConfig) 19 | return 20 | 21 | if (pageTranslationManager.isActive) { 22 | pageTranslationManager.stop() 23 | } 24 | else { 25 | void pageTranslationManager.start() 26 | } 27 | })() 28 | return false 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /src/entrypoints/popup/components/always-translate.tsx: -------------------------------------------------------------------------------- 1 | import { i18n } from '#imports' 2 | import { useAtom, useAtomValue } from 'jotai' 3 | import { Switch } from '@/components/shadcn/switch' 4 | import { isCurrentSiteInPatternsAtom, toggleCurrentSiteAtom } from '../atoms/auto-translate' 5 | import { isIgnoreTabAtom } from '../atoms/ignore' 6 | 7 | export function AlwaysTranslate() { 8 | const isCurrentSiteInPatterns = useAtomValue(isCurrentSiteInPatternsAtom) 9 | const [, toggleCurrentSite] = useAtom(toggleCurrentSiteAtom) 10 | const isIgnoreTab = useAtomValue(isIgnoreTabAtom) 11 | 12 | return ( 13 |
14 | 15 | {i18n.t('popup.alwaysTranslate')} 16 | 17 | 22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/config/migration-scripts/v031-to-v032.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Migration script from v031 to v032 3 | * Adds 'enableLLMDetection' field to translate.page config 4 | * 5 | * Before (v031): 6 | * translate: { 7 | * page: { 8 | * range: 'main', 9 | * autoTranslatePatterns: [...], 10 | * autoTranslateLanguages: [...], 11 | * shortcut: [...] 12 | * } 13 | * } 14 | * 15 | * After (v032): 16 | * translate: { 17 | * page: { 18 | * range: 'main', 19 | * autoTranslatePatterns: [...], 20 | * autoTranslateLanguages: [...], 21 | * shortcut: [...], 22 | * enableLLMDetection: false 23 | * } 24 | * } 25 | */ 26 | 27 | export function migrate(oldConfig: any): any { 28 | return { 29 | ...oldConfig, 30 | translate: { 31 | ...oldConfig.translate, 32 | page: { 33 | ...oldConfig.translate?.page, 34 | enableLLMDetection: false, 35 | }, 36 | }, 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/entrypoints/popup/components/text-selection-tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { i18n } from '#imports' 2 | import { useAtom } from 'jotai' 3 | import { useId } from 'react' 4 | import { Switch } from '@/components/shadcn/switch' 5 | import { configFieldsAtomMap } from '@/utils/atoms/config' 6 | 7 | export default function SelectionToolbar() { 8 | const labelId = useId() 9 | const [selectionToolbar, setSelectionToolbar] = useAtom( 10 | configFieldsAtomMap.selectionToolbar, 11 | ) 12 | 13 | return ( 14 |
15 | 16 | {i18n.t('popup.enabledSelectionToolbar')} 17 | 18 | { 22 | void setSelectionToolbar({ ...selectionToolbar, enabled: checked }) 23 | }} 24 | /> 25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/entrypoints/options/pages/config/beta-experience.tsx: -------------------------------------------------------------------------------- 1 | import { i18n } from '#imports' 2 | import { useAtom } from 'jotai' 3 | import { Switch } from '@/components/shadcn/switch' 4 | import { configFieldsAtomMap } from '@/utils/atoms/config' 5 | import { ConfigCard } from '../../components/config-card' 6 | 7 | export function BetaExperienceConfig() { 8 | const [betaExperienceConfig, setBetaExperienceConfig] = useAtom(configFieldsAtomMap.betaExperience) 9 | 10 | return ( 11 | 15 |
16 | { 19 | void setBetaExperienceConfig({ 20 | enabled: checked, 21 | }) 22 | }} 23 | /> 24 |
25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/debug.ts: -------------------------------------------------------------------------------- 1 | export function printNodeStructure(node: Node, indent = 0): string { 2 | const spacing = ' '.repeat(indent * 2) 3 | let result = '' 4 | 5 | if (node.nodeType === 3) { 6 | // 文本节点 7 | const text = node.textContent?.trim() || '' 8 | if (text) { 9 | result += `${spacing}"${text}"\n` 10 | } 11 | } 12 | else if (node.nodeType === 1) { 13 | // 元素节点 14 | const elem = node as HTMLElement 15 | const tagName = elem.tagName.toLowerCase() 16 | const attrs = Array.from(elem.attributes) 17 | .map(attr => `${attr.name}="${attr.value}"`) 18 | .join(' ') 19 | 20 | result += `${spacing}<${tagName}${attrs ? ` ${attrs}` : ''}>\n` 21 | 22 | // 递归处理子节点 23 | if (elem.childNodes.length > 0) { 24 | Array.from(elem.childNodes).forEach((child) => { 25 | result += printNodeStructure(child, indent + 1) 26 | }) 27 | } 28 | 29 | result += `${spacing}\n` 30 | } 31 | 32 | return result 33 | } 34 | -------------------------------------------------------------------------------- /src/components/shadcn/kbd.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/utils/styles/tailwind' 2 | 3 | function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) { 4 | return ( 5 | 15 | ) 16 | } 17 | 18 | function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) { 19 | return ( 20 | 25 | ) 26 | } 27 | 28 | export { Kbd, KbdGroup } 29 | -------------------------------------------------------------------------------- /src/entrypoints/options/components/page-layout.tsx: -------------------------------------------------------------------------------- 1 | import Container from '@/components/container' 2 | import { Separator } from '@/components/shadcn/separator' 3 | import { SidebarTrigger } from '@/components/shadcn/sidebar' 4 | import { cn } from '@/utils/styles/tailwind' 5 | 6 | export function PageLayout({ title, children, className, innerClassName }: { title: React.ReactNode, children: React.ReactNode, className?: string, innerClassName?: string }) { 7 | return ( 8 |
9 |
10 | 11 |
12 | 13 | 14 |

{title}

15 |
16 |
17 |
18 | 19 | {children} 20 | 21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/components/shadcn/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { cn } from '@/utils/styles/tailwind' 3 | 4 | // TODO: Remove 'popover' Omit when Radix UI supports React 19.2 5 | // React 19.2 added "hint" value to popover attribute, but Radix UI doesn't support it yet 6 | function Textarea({ className, ...props }: Omit, 'popover'>) { 7 | return ( 8 |