Repository: fishjar/kiss-translator Branch: dev Commit: b7202b8adbf7 Files: 185 Total size: 903.6 KB Directory structure: gitextract_hbvdxx0r/ ├── .babelrc ├── .github/ │ └── workflows/ │ └── release.yml ├── .gitignore ├── .pnpm-version ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.en.md ├── README.ja.md ├── README.ko.md ├── README.md ├── VERSION_MANAGEMENT.md ├── config-overrides.js ├── custom-api.md ├── custom-api_v2.md ├── kiss-translator.webm ├── package.json ├── public/ │ ├── .nojekyll │ ├── _locales/ │ │ ├── de/ │ │ │ └── messages.json │ │ ├── en/ │ │ │ └── messages.json │ │ ├── es/ │ │ │ └── messages.json │ │ ├── fr/ │ │ │ └── messages.json │ │ ├── ja/ │ │ │ └── messages.json │ │ ├── ko/ │ │ │ └── messages.json │ │ ├── zh_CN/ │ │ │ └── messages.json │ │ └── zh_TW/ │ │ └── messages.json │ ├── content.html │ ├── index.html │ ├── manifest.firefox.json │ ├── manifest.json │ └── manifest.thunderbird.json └── src/ ├── apis/ │ ├── baidu.js │ ├── deepl.js │ ├── history.js │ ├── index.js │ └── trans.js ├── background.js ├── common.js ├── components/ │ └── Logo/ │ ├── icon.base64.js │ └── index.js ├── config/ │ ├── api.js │ ├── app.js │ ├── client.js │ ├── i18n.js │ ├── index.js │ ├── msg.js │ ├── quotes.js │ ├── rules.js │ ├── setting.js │ ├── storage.js │ ├── styles.js │ └── url.js ├── content.js ├── hooks/ │ ├── Alert.js │ ├── Api.js │ ├── Audio.js │ ├── ColorMode.js │ ├── Confirm.js │ ├── CustomStyles.js │ ├── DebouncedCallback.js │ ├── Fab.js │ ├── FavWords.js │ ├── Fetch.js │ ├── I18n.js │ ├── InputRule.js │ ├── Loading.js │ ├── MouseHover.js │ ├── Rules.js │ ├── Setting.js │ ├── Shortcut.js │ ├── Storage.js │ ├── SubRules.js │ ├── Subtitle.js │ ├── Sync.js │ ├── Theme.js │ ├── Tranbox.js │ ├── ValidationInput.js │ ├── WindowSize.js │ ├── useAutoHideTranBtn.js │ ├── useSelectionController.js │ ├── useTranBoxState.js │ └── useTranboxShortcuts.js ├── index.js ├── injector-shadowroot.js ├── injector-subtitle.js ├── injectors/ │ ├── index.js │ ├── shadowroot.js │ └── xmlhttp.js ├── libs/ │ ├── auth.js │ ├── batchQueue.js │ ├── blacklist.js │ ├── browser.js │ ├── builtinAI.js │ ├── cache.js │ ├── client.js │ ├── detect.js │ ├── docInfo.js │ ├── domManager.js │ ├── fabManager.js │ ├── fetch.js │ ├── gm.js │ ├── iframe.js │ ├── injector.js │ ├── inputTranslate.js │ ├── interpreter.js │ ├── log.js │ ├── mobile.js │ ├── msg.js │ ├── pool.js │ ├── popupManager.js │ ├── rules.js │ ├── shadowDomManager.js │ ├── shortcut.js │ ├── storage.js │ ├── stream.js │ ├── style.js │ ├── subRules.js │ ├── svg.js │ ├── sync.js │ ├── touch.js │ ├── tranbox.js │ ├── translator.js │ ├── translatorManager.js │ ├── trustedTypes.js │ ├── url.js │ └── utils.js ├── options.js ├── popup.js ├── rules.js ├── scripts/ │ ├── archive.mjs │ ├── build-ios.mjs │ ├── build-safari.js │ ├── build-safari.mjs │ ├── build-task.mjs │ ├── sync-version.mjs │ └── update-version.mjs ├── subtitle/ │ ├── BilingualSubtitleManager.js │ ├── Menus.js │ ├── YouTubeCaptionProvider.js │ ├── YouTubeSubtitleList.js │ ├── subtitle.js │ └── vtt.js ├── userscript.js └── views/ ├── Action/ │ ├── ContentFab.js │ ├── Draggable.js │ └── index.js ├── Options/ │ ├── About.js │ ├── Apis.js │ ├── DarkModeButton.js │ ├── DownloadButton.js │ ├── FavWords.js │ ├── Header.js │ ├── HelpButton.js │ ├── InputSetting.js │ ├── Layout.js │ ├── MouseHover.js │ ├── Navigator.js │ ├── Playground.js │ ├── ReusableAutocomplete.js │ ├── Rules.js │ ├── Setting.js │ ├── ShortcutInput.js │ ├── ShowMoreButton.js │ ├── StylesSetting.js │ ├── Subtitle.js │ ├── SyncSetting.js │ ├── Tranbox.js │ ├── UploadButton.js │ └── index.js ├── Popup/ │ ├── Header.js │ ├── PopupCont.js │ └── index.js └── Selection/ ├── AudioBtn.js ├── CopyBtn.js ├── DictCont.js ├── DictHandler.js ├── DraggableResizable.js ├── FavBtn.js ├── SugCont.js ├── TranBox.js ├── TranBtn.js ├── TranCont.js ├── TranForm.js └── index.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "presets": [ "@babel/preset-env" ] } ================================================ FILE: .github/workflows/release.yml ================================================ name: publish release version on: push: tags: - "v*" jobs: build: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 9.14.4 - uses: actions/setup-node@v4 with: node-version: 24 cache: "pnpm" - run: pnpm install - run: pnpm build+zip - uses: actions/upload-artifact@v4 with: name: build-artifacts path: build deploy-web: needs: build runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: name: build-artifacts path: build - name: Deploy to GitHub Pages uses: JamesIves/github-pages-deploy-action@v4 with: folder: build/web create-release: needs: build runs-on: ubuntu-24.04 outputs: upload_url: ${{ steps.create-release.outputs.upload_url }} steps: - uses: actions/checkout@v4 - name: Extract Release Notes id: extract_notes shell: bash run: | LOG_FILE="CHANGELOG.md" if [ -f "$LOG_FILE" ]; then # 使用 awk 提取:从第一个 "## " 后面开始,直到遇到下一个 "## " 停止 # 这里的逻辑假设最新日志在文件最上方 NOTES=$(awk '/^## / {if (p) exit; p=1; next} p' "$LOG_FILE") else NOTES="Release ${{ github.ref }}" fi # 处理多行文本并写入 GITHUB_OUTPUT { echo "body<> "$GITHUB_OUTPUT" - uses: actions/create-release@v1 id: create-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} release_name: Release ${{ github.ref }} body: ${{ steps.extract_notes.outputs.body }} draft: false prerelease: false upload-release: needs: [build, create-release] strategy: matrix: client: ["chrome", "edge", "firefox", "userscript", "thunderbird"] runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: name: build-artifacts path: build - uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: ./build/${{ matrix.client }}.zip asset_name: kiss-translator_${{ github.ref_name }}_${{ matrix.client }}.zip asset_content_type: application/zip ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp /.obsidian .pnp.js .yarn # testing /coverage # production /build # misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* package-lock.json *.crx *.pem *.zip ================================================ FILE: .pnpm-version ================================================ 9.14.4 ================================================ FILE: .prettierignore ================================================ node_modules build public package.json ================================================ FILE: .prettierrc ================================================ { "arrowParens": "always", "bracketSpacing": true, "endOfLine": "lf", "htmlWhitespaceSensitivity": "css", "insertPragma": false, "singleAttributePerLine": false, "bracketSameLine": false, "jsxSingleQuote": false, "printWidth": 80, "proseWrap": "preserve", "quoteProps": "as-needed", "requirePragma": false, "semi": true, "singleQuote": false, "tabWidth": 2, "trailingComma": "es5", "useTabs": false, "embeddedLanguageFormatting": "auto", "vueIndentScriptAndStyle": false, "experimentalTernaries": false, "parser": "babel" } ================================================ FILE: CHANGELOG.md ================================================ ## v2.0.20 - 优化本地语言识别不准确时的处理逻辑 - 修复 MacOS 编译 Safari 插件脚本错误 - 优化调整划词翻译逻辑 - 修复移动端不显示header的bug - 仅对翻译框的大小和位置持久化,其他设置不持久化,仅在当前页面生效 - 修复翻译框有时会突然变成0,0位置,和0,0大小的bug - 修复移动端输入框翻译圆点按钮无效的bug - youtube视频右边的字幕滚动列表可以在设置中选择关闭 - 接口设置中增加`复制接口`功能 - 修复接口权重排序在popup无效的bug - 规则设置增加`扫描全部节点`的功能,并替换popup中`shadowroot`的位置 - `扫描全部节点`相当于忽略所有`忽略元素`,翻译全部内容,并同时扫描`shadowroot` - 更新内置部分规则,新安装时会尝试同步订阅规则 - 其他一下小优化 ## v2.0.19 - 修复油猴脚本切换翻译时的脚本错误 - 添加自动更新版本号脚本 ## v2.0.18 - 支持翻译closed模式的 shadowroot 中的内容 - 保存规则时,可选子域名 - AI聚合翻译,支持流式传输,优化翻译体验 - 优化划词翻译窗口显示效果 - 划词翻译按钮增加自动隐藏逻辑(5秒 / 移动100px / 右键) - 增加翻译状态的图标显示,已翻译会有一个绿色小勾 - 优化输入框翻译,解决一些兼容性问题 - 可设置在输入框上方显示一个圆形翻译按钮,使得移动端也可以使用输入框翻译功能 - 优化字幕翻译,增加一些新功能 - 字幕翻译设置样式时,支持可视化编辑 - 优化字幕翻译逻辑,优先使用用户选择的字幕轨 - 字幕翻译右侧的字幕滚动列表可以手动点击按钮关闭 - 字幕翻译的菜单去掉shadowroot - 增加下拉选项,可以随时切换AI断句的模型 - 重构字幕增强功能设置,新增“移动端禁用”选项(默认) - 优化移动端检测逻辑,修复触屏笔记本被误判为移动端的问题 - 在所有场景下均给AI接口添加上下文信息,包括网页标题、描述、摘要 - 翻译接口增加排序权重 - 新增占位标签格式,优化google2的翻译效果 - bing词典增加显示单词时态信息 ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.en.md ================================================ # KISS Translator [English](README.en.md) | [中文](README.md) | [日本語](README.ja.md) | [한국어](README.ko.md) A simple, open source [bilingual translation extension & Greasemonkey script](https://github.com/fishjar/kiss-translator). [kiss-translator.webm](https://github.com/fishjar/kiss-translator/assets/1157624/f7ba8a5c-e4a8-4d5a-823a-5c5c67a0a47f) ## Features - [x] Keep it simple, smart - [x] Open source - [x] Adapt to common browsers - [x] Chrome/Edge - [x] Firefox - [x] Kiwi (Android) - [x] Orion (iOS) - [x] Safari - [x] Thunderbird - [x] Supports multiple translation services - [x] Google/Microsoft - [x] Tencent/Volcengine - [x] OpenAI/Gemini/Claude/Ollama/DeepSeek/OpenRouter - [x] DeepL/DeepLX/NiuTrans - [x] AzureAI / CloudflareAI - [x] Chrome built-in AI translation (BuiltinAI) - [x] Covers common translation scenarios - [x] Webpage bilingual translation - [x] Input-box translation - Instantly translate text in input fields into other languages via shortcut keys - [x] Text selection translation - [x] Open translation popup on any page, support multiple translation services for comparison - [x] English dictionary lookup - [x] Save vocabulary - [x] Hover translation - [x] YouTube subtitle translation - Support translating video subtitles with any translation service and display bilingually - Built-in basic subtitle merging and sentence-splitting algorithm to improve translation quality - Supports AI-powered sentence segmentation for even better translation - Custom subtitle style - [x] Supports diverse translation modes - [x] Supports both automatic text recognition and manual rule modes - Automatic text recognition mode allows most sites to be translated fully without writing rules - Manual rule mode enables extreme optimization for specific sites - [x] Custom translation styling - [x] Supports rich-text translation and rendering, preserving links and other text styles where possible - [x] Option to show only translation (hide original text) - [x] Advanced translation API features - [x] With custom API support, theoretically works with any translation service - [x] Batch aggregation of translation requests - [x] Supports streaming for real-time translation results - [x] Supports AI conversation context memory to improve translation quality - [x] Custom AI terminology dictionary - [x] All APIs support hooks and custom parameters for advanced usage - [x] Cross-client data synchronization - [x] KISS-Worker(cloudflare/docker) - [x] WebDAV - [x] Custom translation rules - [x] Rule subscription/rule sharing - [x] Customized terminology - [x] Custom shortcut keys - `Alt+Q` Toggle Translation - `Alt+C` Toggle Styles - `Alt+K` Open Setting Popup - `Alt+S` Open Translate Popup / Translate Selected Text - `Alt+O` Open Options Page - `Alt+I` Input Box Translation ## Install > Note: For the following reasons, it is recommended to use browser extensions first > > - Browser extensions have more complete functions (local language recognition, context menu, etc.) > - Grease Monkey script will encounter more usage problems (cross domain issues, script conflicts, etc.) - [x] Browser extension - [x] Chrome [Installation address](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=en) - [x] Kiwi (Android) - [x] Orion (iOS) - [x] Edge [Installation address](https://microsoftedge.microsoft.com/addons/detail/%E7%AE%80%E7%BA%A6%E7%BF%BB%E8%AF%91/jemckldkclkinpjighnoilpbldbdmmlh?hl=en) - [x] Firefox [Installation address](https://addons.mozilla.org/en-US/firefox/addon/kiss-translator/) - [ ] Safari - [ ] Safari (Mac) - [ ] Safari (iOS) - [x] Thunderbird [Download address](https://github.com/fishjar/kiss-translator/releases) - [x] GreaseMonkey Script - [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [Installation link](https://fishjar.github.io/kiss-translator/kiss-translator.user.js) - [Greasy Fork](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator) - [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [Installation link](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js) ## Associated Projects - Data synchronization service: [https://github.com/fishjar/kiss-worker](https://github.com/fishjar/kiss-worker) - Data synchronization service available for this project. - Can also be used to share personal private rule lists. - Deploy by yourself, manage by yourself, data is private. - Community subscription rules: [https://github.com/fishjar/kiss-rules](https://github.com/fishjar/kiss-rules) - Provides the latest and most complete list of subscription rules maintained by the community. - Help with rules-related issues. ## Frequently Asked Questions ### How to Set Keyboard Shortcuts Set this in the extension management page, for example: - chrome [chrome://extensions/shortcuts](chrome://extensions/shortcuts) - firefox [about:addons](about:addons) ### What is the priority order of rule settings? Personal Rules > Subscription Rules > Global Rules Among these, Global Rules have the lowest priority but are very important as they serve as the default rules. ### API (Ollama, etc.) Test Failure Common reasons for API test failures include: - Incorrect address: - For example, `Ollama` has a native API address and an `Openai`-compatible address. This plugin currently supports the `Openai`-compatible address and does not support the `Ollama` native API address. - Some AI models do not support batch translation: - In this case, you can choose to disable batch translation or use a custom API. - Alternatively, you can use a custom API. For details, please refer to: [Custom API Example Documentation](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md) - Some AI models have inconsistent parameters: - For example, the parameters of the `Gemini` native API are highly inconsistent. Some model versions do not support certain parameters, leading to errors. - In this case, you can modify the request body using a `Hook`, or replace it with `Gemini2` (an OpenAI-compatible address). - The server restricts cross-origin access, returning a 403 error: - For example, `Ollama` requires adding the environment variable `OLLAMA_ORIGINS=*` when starting. See: https://github.com/fishjar/kiss-translator/issues/174 ### Custom API doesn't work in Tampermonkey scripts Tampermonkey scripts require adding domains to the whitelist; otherwise, requests cannot be sent. ### How to set up a hook function for a custom API Custom APIs are very powerful and flexible, and can theoretically connect to any translation API. Example reference: [custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md) ### How to directly access the Tampermonkey script settings page Settings page address: https://fishjar.github.io/kiss-translator/options.html ## Future Plans This is a side project with no strict timeline. Community contributions are welcome. The following are preliminary feature directions: - [x] **Batch Text Requests**: Optimize request strategy to reduce translation API calls and improve performance. - [x] **Enhanced Rich Text Translation**: Support accurate translation of complex page structures and rich text content. - [x] **Advanced Custom/AI Interfaces**: Add support for streaming, context memory, multi-turn conversations, and other advanced AI features. - [x] **Fallback English Dictionary**: When translation services fail, fall back to a local dictionary lookup. - [x] **Improved YouTube Subtitle Support**: Enhance merging and translation experience for streaming subtitles, reducing sentence fragmentation. - [ ] **Upgraded Rule Collaboration System**: Introduce more flexible rule sharing, version management, and community review processes. If you're interested in any of these directions, feel free to discuss in [Issues](https://github.com/fishjar/kiss-translator/issues) or submit a PR! ## Development Guidelines ```sh git clone https://github.com/fishjar/kiss-translator.git cd kiss-translator git checkout dev # Submit a PR suggestion to push to the dev branch pnpm install pnpm build ``` ### External Trigger Example ```js // `toggle_translate` Toggle translation // `toggle_styles` Toggle styles // `toggle_popup` Open/close control panel // `toggle_transbox` Open/close translation popup // `toggle_hover_node` Translate hovered paragraph // `input_translate` Translate input box window.dispatchEvent(new CustomEvent("kiss_translator", {detail: { action: "toggle_translate" }})); ``` ## Discussion - Join [Telegram Group](https://t.me/+RRCu_4oNwrM2NmFl) ## Appreciate ![appreciate](https://github.com/fishjar/kiss-translator/assets/1157624/ebaecabe-2934-4172-8085-af236f5ee399) ================================================ FILE: README.ja.md ================================================ # KISS Translator シンプル翻訳 [English](README.en.md) | [中文](README.md) | [日本語](README.ja.md) | [한국어](README.ko.md) シンプルでオープンソースの [バイリンガル対照翻訳拡張機能&ユーザースクリプト](https://github.com/fishjar/kiss-translator)です。 [kiss-translator.webm](https://github.com/fishjar/kiss-translator/assets/1157624/f7ba8a5c-e4a8-4d5a-823a-5c5c67a0a47f) ## 特徴 - [x] シンプルさを維持 - [x] オープンソース - [x] 主要なブラウザに対応 - [x] Chrome/Edge - [x] Firefox - [x] Kiwi (Android) - [x] Orion (iOS) - [x] Safari - [x] Thunderbird - [x] 複数の翻訳サービスをサポート - [x] Google/Microsoft - [x] Tencent/Volcengine - [x] OpenAI/Gemini/Claude/Ollama/DeepSeek/OpenRouter - [x] DeepL/DeepLX/NiuTrans - [x] AzureAI/CloudflareAI - [x] Chromeブラウザ内蔵AI翻訳(BuiltinAI) - [x] 一般的な翻訳シナリオをカバー - [x] Webページのバイリンガル対照翻訳 - [x] 入力ボックス翻訳 - ショートカットキーで入力ボックス内のテキストを即座に他言語に翻訳 - [x] テキスト選択翻訳 - [x] 任意のページで翻訳ボックスを開き、複数の翻訳サービスで比較翻訳が可能 - [x] 英語辞書翻訳 - [x] 単語のブックマーク - [x] マウスオーバー翻訳 - [x] YouTube 字幕翻訳 - 任意の翻訳サービスを使用してビデオ字幕を翻訳し、バイリンガル表示をサポート - 基本的な字幕結合・改行アルゴリズムを内蔵し、翻訳品質を向上 - AIによる改行機能をサポートし、翻訳品質をさらに向上 - 字幕スタイルのカスタマイズ - [x] 多様な翻訳効果をサポート - [x] テキスト自動認識と手動ルールの2つのモードをサポート - テキスト自動認識モードにより、ほとんどのWebサイトでルールを記述しなくても完全な翻訳が可能 - 手動ルールモードで、特定のWebサイトに合わせた最適な最適化が可能 - [x] 翻訳テキストスタイルのカスタマイズ - [x] リッチテキストの翻訳と表示をサポートし、原文のリンクやその他のテキストスタイルを可能な限り保持 - [x] 翻訳文のみの表示(原文を非表示)をサポート - [x] 翻訳APIの高度な機能 - [x] カスタムAPIにより、理論上あらゆる翻訳インターフェースをサポート - [x] 翻訳テキストの統合バッチ送信 - [x] ストリーミング伝送をサポートし、翻訳結果をリアルタイムで表示 - [x] AIコンテキスト(会話メモリ)機能をサポートし、翻訳品質を向上 - [x] カスタムAI用語集 - [x] すべてのインターフェースがフックやカスタムパラメータなどの高度な機能をサポート - [x] クライアント間のデータ同期 - [x] KISS-Worker(cloudflare/docker) - [x] WebDAV - [x] カスタム翻訳ルール - [x] ルールの購読/ルール共有 - [x] カスタム専門用語 - [x] カスタムショートカットキー - `Alt+Q` 翻訳をオン - `Alt+C` スタイル切り替え - `Alt+K` 設定ポップアップを開く - `Alt+S` 翻訳ポップアップを開く/選択テキストを翻訳 - `Alt+O` 設定ページを開く - `Alt+I` 入力ボックス翻訳 ## インストール > 注:以下の理由により、ブラウザ拡張機能の使用を優先することをお勧めします > > - ブラウザ拡張機能の方が機能が完全です(ローカル言語認識、右クリックメニューなど) > - ユーザースクリプトはより多くの問題(クロスドメイン問題、スクリプトの競合など)に遭遇する可能性があります - [x] ブラウザ拡張機能 - [x] Chrome [インストール](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=ja) - [x] Kiwi (Android) - [x] Orion (iOS) - [x] Edge [インストール](https://microsoftedge.microsoft.com/addons/detail/%E7%AE%80%E7%BA%A6%E7%BF%BB%E8%AF%91/jemckldkclkinpjighnoilpbldbdmmlh?hl=ja) - [x] Firefox [インストール](https://addons.mozilla.org/ja/firefox/addon/kiss-translator/) - [ ] Safari - [ ] Safari (Mac) - [ ] Safari (iOS) - [x] Thunderbird [ダウンロード](https://github.com/fishjar/kiss-translator/releases) - [x] ユーザースクリプト - [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [インストールリンク](https://fishjar.github.io/kiss-translator/kiss-translator.user.js) - [Greasy Fork](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator) - [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [インストールリンク](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js) ## 関連プロジェクト - データ同期サービス: [https://github.com/fishjar/kiss-worker](https://github.com/fishjar/kiss-worker) - 本プロジェクトのデータ同期サービスとして使用できます。 - 個人のプライベートなルールリストの共有にも使用できます。 - セルフホスト、セルフマネジメント、データはプライベート。 - コミュニティ購読ルール: [https://github.com/fishjar/kiss-rules](https://github.com/fishjar/kiss-rules) - コミュニティによってメンテナンスされた、最新かつ最も完全な購読ルールリストを提供します。 - ルール関連の問題についての助けを求める。 ## よくある質問(FAQ) ### ショートカットキーの設定方法 拡張機能の管理ページで設定します。例: - chrome [chrome://extensions/shortcuts](chrome://extensions/shortcuts) - firefox [about:addons](about:addons) ### ルール設定の優先順位は? 個人ルール > 購読ルール > グローバルルール グローバルルールの優先順位は最も低いですが、フォールバックルールとして非常に重要です。 ### API(Ollamaなど)のテストに失敗する APIテストの失敗には、一般的に以下の原因が考えられます: - アドレスが間違っている: - 例えば `Ollama` にはネイティブAPIアドレスと `Openai` 互換のアドレスがありますが、本プラグインは現在、`Openai` 互換アドレスをサポートしており、`Ollama` ネイティブAPIアドレスはサポートしていません - 一部のAIモデルが統合翻訳をサポートしていない: - この場合、統合翻訳を無効にするか、カスタムAPIを使用して対応できます。 - または、カスタムAPIを使用して対応します。詳細は[カスタムAPIサンプルドキュメント](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)を参照してください - 一部のAIモデルでパラメータが一致しない: - 例えば `Gemini` のネイティブAPIはパラメータの不一致が大きく、一部のバージョンのモデルが特定のパラメータをサポートしていないためエラーが返されることがあります。 - この場合、`Hook` を使用してリクエスト `body` を変更するか、`Gemini2` (`Openai` 互換アドレス) に切り替えることができます - サーバーのクロスドメイン制限によりアクセスが拒否され、403エラーが返される: - 例えば `Ollama` を起動する際に、環境変数 `OLLAMA_ORIGINS=*` を追加する必要があります。参考:https://github.com/fishjar/kiss-translator/issues/174 ### 入力したAPIがユーザースクリプトで使用できない ユーザースクリプトは、リクエストを送信するためにドメインのホワイトリストを追加する必要があります。 ### カスタムAPIのhook関数の設定方法 カスタムAPI機能は非常に強力で柔軟性があり、理論的にはどんな翻訳APIにも接続できます。 サンプル参照: [custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md) ### ユーザースクリプトの設定ページに直接アクセスする方法 設定ページアドレス: https://fishjar.github.io/kiss-translator/options.html ## 今後の計画 本プロジェクトは余暇に開発しており、厳密なタイムスケジュールはありません。コミュニティの共同構築を歓迎します。以下は初期段階の機能の方向性です: - [x] **テキストの統合送信**:リクエスト戦略を最適化し、翻訳APIの呼び出し回数を減らし、パフォーマンスを向上させます。 - [x] **リッチテキスト翻訳の強化**:より複雑なページ構造やリッチテキストコンテンツの正確な翻訳をサポートします。 - [x] **カスタム/AI APIの強化**:ストリーミング伝送、コンテキストメモリ、複数ラウンドの対話など、高度なAI機能をサポートします。 - [x] **英語辞書のフォールバックメカニズム**:翻訳サービスが利用できない場合、他の辞書に切り替えるか、ローカル辞書での検索にフォールバックします。 - [x] **YouTube字幕サポートの最適化**:ストリーミング字幕の結合と翻訳体験を改善し、途切れを減らします。 - [ ] **ルール共同構築メカニズムのアップグレード**:より柔軟なルールの共有、バージョン管理、コミュニティレビュープロセスを導入します。 特定の方向に興味がある場合は、[Issues](https://github.com/fishjar/kiss-translator/issues) で議論したり、PRを送信したりすることを歓迎します! ## 開発ガイド ```sh git clone [https://github.com/fishjar/kiss-translator.git](https://github.com/fishjar/kiss-translator.git) cd kiss-translator git checkout dev # PRを送信する場合はdevブランチにプッシュすることをお勧めします pnpm install pnpm build ``` ### 外部トリガーの例 ```js // `toggle_translate` 翻訳を切り替え // `toggle_styles` スタイルを切り替え // `toggle_popup` コントロールパネルを開く/閉じる // `toggle_transbox` 翻訳ポップアップを開く/閉じる // `toggle_hover_node` マウスオーバー中の段落を翻訳 // `input_translate` 入力欄を翻訳 window.dispatchEvent(new CustomEvent("kiss_translator", {detail: { action: "toggle_translate" }})); ``` ## コミュニケーション - [Telegram グループ](https://t.me/+RRCu_4oNwrM2NmFl)に参加 ## 寄付 ![appreciate](https://github.com/fishjar/kiss-translator/assets/1157624/ebaecabe-2934-4172-8085-af236f5ee399) ================================================ FILE: README.ko.md ================================================ # KISS Translator 심플 번역 [English](README.en.md) | [中文](README.md) | [日本語](README.ja.md) | [한국어](README.ko.md) 심플하고 오픈 소스인 [이중 언어 대조 번역 확장 프로그램 & 유저 스크립트](https://github.com/fishjar/kiss-translator)입니다. [kiss-translator.webm](https://github.com/fishjar/kiss-translator/assets/1157624/f7ba8a5c-e4a8-4d5a-823a-5c5c67a0a47f) ## 특징 - [x] 심플함 유지 - [x] 오픈 소스 - [x] 주요 브라우저 지원 - [x] Chrome/Edge - [x] Firefox - [x] Kiwi (Android) - [x] Orion (iOS) - [x] Safari - [x] Thunderbird - [x] 다양한 번역 서비스 지원 - [x] Google/Microsoft - [x] Tencent/Volcengine - [x] OpenAI/Gemini/Claude/Ollama/DeepSeek/OpenRouter - [x] DeepL/DeepLX/NiuTrans - [x] AzureAI/CloudflareAI - [x] Chrome 브라우저 내장 AI 번역(BuiltinAI) - [x] 일반적인 번역 시나리오 지원 - [x] 웹페이지 이중 언어 대조 번역 - [x] 입력창 번역 - 단축키를 통해 입력창 내 텍스트를 즉시 다른 언어로 번역 - [x] 텍스트 선택 번역 - [x] 모든 페이지에서 번역창을 열어 여러 번역 서비스로 비교 번역 가능 - [x] 영어 사전 번역 - [x] 단어 즐겨찾기 - [x] 마우스오버 번역 - [x] YouTube 자막 번역 - 모든 번역 서비스를 사용하여 비디오 자막을 번역하고 이중 언어로 표시 지원 - 기본적인 자막 병합 및 줄 바꿈 알고리즘 내장으로 번역 품질 향상 - AI 줄 바꿈 기능 지원으로 번역 품질 추가 향상 - 사용자 정의 자막 스타일 - [x] 다양한 번역 효과 지원 - [x] 자동 텍스트 인식 및 수동 규칙 두 가지 모드 지원 - 자동 텍스트 인식 모드는 대부분의 웹사이트에서 규칙 작성 없이도 완벽한 번역 가능 - 수동 규칙 모드로 특정 웹사이트에 대한 최적의 최적화 가능 - [x] 번역문 스타일 사용자 정의 - [x] 리치 텍스트 번역 및 표시 지원, 원문의 링크 및 기타 텍스트 스타일 최대한 보존 - [x] 번역문만 표시 (원문 숨기기) 지원 - [x] 번역 인터페이스 고급 기능 - [x] 사용자 정의 인터페이스를 통해 이론상 모든 번역 인터페이스 지원 - [x] 번역 텍스트 일괄 통합 전송 - [x] 스트리밍 전송 지원, 번역 결과 실시간 표시 - [x] AI 컨텍스트 (대화 기억) 기능 지원으로 번역 품질 향상 - [x] 사용자 정의 AI 용어 사전 - [x] 모든 인터페이스는 후크 및 사용자 정의 파라미터 등 고급 기능 지원 - [x] 클라이언트 간 데이터 동기화 - [x] KISS-Worker (cloudflare/docker) - [x] WebDAV - [x] 사용자 정의 번역 규칙 - [x] 규칙 구독 / 규칙 공유 - [x] 사용자 정의 전문 용어 - [x] 사용자 정의 단축키 - `Alt+Q` 번역 켜기 - `Alt+C` 스타일 전환 - `Alt+K` 설정 팝업 열기 - `Alt+S` 번역 팝업 열기 / 선택한 텍스트 번역 - `Alt+O` 설정 페이지 열기 - `Alt+I` 입력창 번역 ## 설치 > 참고: 다음과 같은 이유로 브라우저 확장 프로그램 사용을 우선적으로 권장합니다. > > - 브라우저 확장 프로그램의 기능이 더 완전합니다 (로컬 언어 인식, 우클릭 메뉴 등). > - 유저 스크립트는 사용상 더 많은 문제 (크로스 도메인 문제, 스크립트 충돌 등)를 겪을 수 있습니다. - [x] 브라우저 확장 프로그램 - [x] Chrome [설치 주소](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=ko) - [x] Kiwi (Android) - [x] Orion (iOS) - [x] Edge [설치 주소](https://microsoftedge.microsoft.com/addons/detail/%E7%AE%80%E7%BA%A6%E7%BF%BB%E8%AF%91/jemckldkclkinpjighnoilpbldbdmmlh?hl=ko) - [x] Firefox [설치 주소](https://addons.mozilla.org/ko/firefox/addon/kiss-translator/) - [ ] Safari - [ ] Safari (Mac) - [ ] Safari (iOS) - [x] Thunderbird [다운로드 주소](https://github.com/fishjar/kiss-translator/releases) - [x] 유저 스크립트 - [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [설치 링크](https://fishjar.github.io/kiss-translator/kiss-translator.user.js) - [Greasy Fork](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator) - [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [설치 링크](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js) ## 관련 프로젝트 - 데이터 동기화 서비스: [https://github.com/fishjar/kiss-worker](https://github.com/fishjar/kiss-worker) - 본 프로젝트의 데이터 동기화 서비스로 사용할 수 있습니다. - 개인의 비공개 규칙 목록을 공유하는 데에도 사용할 수 있습니다. - 직접 배포, 직접 관리, 데이터 비공개. - 커뮤니티 구독 규칙: [https://github.com/fishjar/kiss-rules](https://github.com/fishjar/kiss-rules) - 커뮤니티에서 유지 관리하는 최신의 가장 완벽한 구독 규칙 목록을 제공합니다. - 규칙 관련 문제에 대한 도움 요청. ## 자주 묻는 질문 (FAQ) ### 단축키는 어떻게 설정하나요? 플러그인 관리 페이지에서 설정합니다. 예: - chrome [chrome://extensions/shortcuts](chrome://extensions/shortcuts) - firefox [about:addons](about:addons) ### 규칙 설정의 우선순위는 어떻게 되나요? 개인 규칙 > 구독 규칙 > 전역 규칙 그중 전역 규칙은 우선순위가 가장 낮지만, 예비 규칙으로서 매우 중요합니다. ### 인터페이스 (Ollama 등) 테스트 실패 일반적으로 인터페이스 테스트 실패는 다음과 같은 몇 가지 원인이 있습니다: - 주소를 잘못 입력한 경우: - 예를 들어 `Ollama`는 네이티브 인터페이스 주소와 `Openai` 호환 주소가 있습니다. 본 플러그인은 현재 `Openai` 호환 주소를 통일되게 지원하며, `Ollama` 네이티브 인터페이스 주소는 지원하지 않습니다. - 일부 AI 모델이 통합 번역을 지원하지 않는 경우: - 이 경우 통합 번역을 비활성화하거나 사용자 정의 인터페이스 방식을 통해 사용할 수 있습니다. - 또는 사용자 정의 인터페이스 방식을 통해 사용합니다. 자세한 내용은 [사용자 정의 인터페이스 예시 문서](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)를 참조하세요. - 일부 AI 모델의 파라미터가 일치하지 않는 경우: - 예를 들어 `Gemini` 네이티브 인터페이스 파라미터는 매우 불일치하며, 일부 버전의 모델은 특정 파라미터를 지원하지 않아 오류를 반환할 수 있습니다. - 이 경우 `Hook`을 사용하여 요청 `body`를 수정하거나, `Gemini2` (`Openai` 호환 주소)로 변경할 수 있습니다. - 서버의 크로스 도메인 접근 제한으로 403 오류가 반환되는 경우: - 예를 들어 `Ollama` 시작 시 환경 변수 `OLLAMA_ORIGINS=*`를 추가해야 합니다. 참고: https://github.com/fishjar/kiss-translator/issues/174 ### 입력한 인터페이스를 유저 스크립트에서 사용할 수 없습니다 유저 스크립트는 도메인 화이트리스트를 추가해야 요청을 보낼 수 있습니다. ### 사용자 정의 인터페이스의 hook 함수는 어떻게 설정하나요? 사용자 정의 인터페이스 기능은 매우 강력하고 유연하며, 이론적으로 어떤 번역 인터페이스든 연결할 수 있습니다. 예시 참고: [custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md) ### 유저 스크립트 설정 페이지로 바로 이동하는 방법 설정 페이지 주소: https://fishjar.github.io/kiss-translator/options.html ## 향후 계획 본 프로젝트는 여가 시간에 개발되며, 엄격한 시간표는 없습니다. 커뮤니티의 공동 구축을 환영합니다. 다음은 초기 구상 중인 기능 방향입니다: - [x] **텍스트 통합 전송**: 요청 전략을 최적화하여 번역 인터페이스 호출 횟수를 줄이고 성능을 향상시킵니다. - [x] **리치 텍스트 번역 강화**: 더 복잡한 페이지 구조와 리치 텍스트 콘텐츠의 정확한 번역을 지원합니다. - [x] **사용자 정의/AI 인터페이스 강화**: 스트리밍 전송, 컨텍스트 기억, 다중 턴 대화 등 고급 AI 기능을 지원합니다. - [x] **영어 사전 예비 메커니즘**: 번역 서비스가 실패할 경우 다른 사전으로 전환하거나 로컬 사전 조회로 대체합니다. - [x] **YouTube 자막 지원 최적화**: 스트리밍 자막의 병합 및 번역 경험을 개선하고, 끊김을 줄입니다. - [ ] **규칙 공동 구축 메커니즘 업그레이드**: 더 유연한 규칙 공유, 버전 관리 및 커뮤니티 검토 프로세스를 도입합니다. 특정 방향에 관심이 있다면, [Issues](https://github.com/fishjar/kiss-translator/issues)에서 토론하거나 PR을 제출해 주세요! ## 개발 가이드 ```sh git clone [https://github.com/fishjar/kiss-translator.git](https://github.com/fishjar/kiss-translator.git) cd kiss-translator git checkout dev # PR 제출 시 dev 브랜치로 푸시하는 것을 권장합니다 pnpm install pnpm build ``` ### 외부 트리거 예시 ```js // `toggle_translate` 번역 전환 // `toggle_styles` 스타일 전환 // `toggle_popup` 제어 패널 열기/닫기 // `toggle_transbox` 번역 팝업 열기/닫기 // `toggle_hover_node` 마우스를 올린 문단 번역 // `input_translate` 입력창 번역 window.dispatchEvent(new CustomEvent("kiss_translator", {detail: { action: "toggle_translate" }})); ``` ## 커뮤니티 - [Telegram 그룹](https://t.me/+RRCu_4oNwrM2NmFl) 가입 ## 후원 ![appreciate](https://github.com/fishjar/kiss-translator/assets/1157624/ebaecabe-2934-4172-8085-af236f5ee399) ================================================ FILE: README.md ================================================ # KISS Translator 简约翻译 [English](README.en.md) | [中文](README.md) | [日本語](README.ja.md) | [한국어](README.ko.md) 一个简约、开源的 [双语对照翻译扩展 & 油猴脚本](https://github.com/fishjar/kiss-translator)。 [kiss-translator.webm](https://github.com/fishjar/kiss-translator/assets/1157624/f7ba8a5c-e4a8-4d5a-823a-5c5c67a0a47f) ## 特性 - [x] 保持简约 - [x] 开放源代码 - [x] 适配常见浏览器 - [x] Chrome/Edge - [x] Firefox - [x] Kiwi (Android) - [x] Orion (iOS) - [x] Safari - [x] Thunderbird - [x] 支持多种翻译服务 - [x] Google/Microsoft - [x] Tencent/Volcengine - [x] OpenAI/Gemini/Claude/Ollama/DeepSeek/OpenRouter - [x] DeepL/DeepLX/NiuTrans - [x] AzureAI/CloudflareAI - [x] Chrome浏览器内置AI翻译(BuiltinAI) - [x] 覆盖常见翻译场景 - [x] 网页双语对照翻译 - [x] 输入框翻译 - 通过快捷键立即将输入框内文本翻译成其他语言 - [x] 划词翻译 - [x] 任意页面打开翻译框,可用多种翻译服务对比翻译 - [x] 英文词典翻译 - [x] 收藏词汇 - [x] 鼠标悬停翻译 - [x] YouTube 字幕翻译 - 支持任意翻译服务对视频字幕进行翻译并双语显示 - 内置基础的字幕合并与断句算法,提升翻译效果 - 支持AI断句功能,可进一步提升翻译质量 - 自定义字幕样式 - [x] 支持多样翻译效果 - [x] 支持自动识别文本与手动规则两种模式 - 自动识别文本模式使得绝大部分网站无需编写规则也能翻译完整 - 手动规则模式,可以针对特定网站极致优化 - [x] 自定义译文样式 - [x] 支持富文本翻译及显示,能够尽量保留原文中的链接及其他文本样式 - [x] 支持仅显示译文(隐藏原文) - [x] 翻译接口高级功能 - [x] 通过自定义接口,理论上支持任何翻译接口 - [x] 聚合批量发送翻译文本 - [x] 支持流式传输,实时显示翻译结果 - [x] 支持AI上下文会话记忆功能,提升翻译效果 - [x] 自定义AI术语词典 - [x] 所有接口均支持Hook和自定义参数等高级功能 - [x] 跨客户端数据同步 - [x] KISS-Worker(cloudflare/docker) - [x] WebDAV - [x] 自定义翻译规则 - [x] 规则订阅/规则分享 - [x] 自定义专业术语 - [x] 自定义快捷键 - `Alt+Q` 开启翻译 - `Alt+C` 切换样式 - `Alt+K` 打开设置弹窗 - `Alt+S` 打开翻译弹窗/翻译选中文字 - `Alt+O` 打开设置页面 - `Alt+I` 输入框翻译 ## 安装 > 注:基于以下原因,建议优先使用浏览器扩展 > > - 浏览器扩展的功能更完整(本地语言识别、右键菜单等) > - 油猴脚本会遇到更多使用上的问题(跨域问题、脚本冲突等) - [x] 浏览器扩展 - [x] Chrome [安装地址](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=zh-CN) - [x] Kiwi (Android) - [x] Orion (iOS) - [x] Edge [安装地址](https://microsoftedge.microsoft.com/addons/detail/%E7%AE%80%E7%BA%A6%E7%BF%BB%E8%AF%91/jemckldkclkinpjighnoilpbldbdmmlh?hl=zh-CN) - [x] Firefox [安装地址](https://addons.mozilla.org/zh-CN/firefox/addon/kiss-translator/) - [ ] Safari - [ ] Safari (Mac) - [ ] Safari (iOS) - [x] Thunderbird [下载地址](https://github.com/fishjar/kiss-translator/releases) - [x] 油猴脚本 - [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [安装链接](https://fishjar.github.io/kiss-translator/kiss-translator.user.js) - [Greasy Fork](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator) - [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [安装链接](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js) ## 关联项目 - 数据同步服务: [https://github.com/fishjar/kiss-worker](https://github.com/fishjar/kiss-worker) - 可用于本项目的数据同步服务。 - 亦可用于分享个人的私有规则列表。 - 自己部署,自己管理,数据私有。 - 社区订阅规则: [https://github.com/fishjar/kiss-rules](https://github.com/fishjar/kiss-rules) - 提供社区维护的,最新最全的订阅规则列表。 - 求助规则相关的问题。 ## 常见问题 ### 如何设置快捷键 在插件管理那里设置,例如: - chrome [chrome://extensions/shortcuts](chrome://extensions/shortcuts) - firefox [about:addons](about:addons) ### 规则设置的优先级是如何的 个人规则 > 订阅规则 > 全局规则 其中全局规则优先级最低,但非常重要,相当于兜底规则。 ### 接口(Ollama等)测试失败 一般接口测试失败常见有以下几种原因: - 地址填错了: - 比如 `Ollama` 有原生接口地址和 `Openai` 兼容的地址,本插件目前统一支持 `Openai` 兼容的地址,不支持 `Ollama` 原生接口地址 - 某些AI模型不支持聚合翻译: - 此种情况可以选择禁用聚合翻译或通过自定义接口的方式来使用。 - 或通过自定义接口的方式来使用,详情参考: [自定义接口示例文档](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md) - 某些AI模型的参数不一致: - 比如 `Gemini` 原生接口参数非常不一致,部分版本的模型不支持某些参数会导致返回错误。 - 此种情况可以通过 `Hook` 修改请求 `body` ,或者更换为 `Gemini2` (`Openai` 兼容的地址) - 服务器跨域限制访问,返回403错误: - 比如 `Ollama` 启动时须添加环境变量 `OLLAMA_ORIGINS=*`, 参考:https://github.com/fishjar/kiss-translator/issues/174 ### 填写的接口在油猴脚本不能使用 油猴脚本需要增加域名白名单,否则不能发出请求。 ### 如何设置自定义接口的hook函数 自定义接口功能非常强大、灵活,理论可以接入任何翻译接口。 示例参考: [custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md) ### 如何直接进入油猴脚本设置页面 设置页面地址: https://fishjar.github.io/kiss-translator/options.html ## 未来规划 本项目为业余开发,无严格时间表,欢迎社区共建。以下为初步设想的功能方向: - [x] **聚合发送文本**:优化请求策略,减少翻译接口调用次数,提升性能。 - [x] **增强富文本翻译**:支持更复杂的页面结构和富文本内容的准确翻译。 - [x] **强化自定义/AI 接口**:支持流式传输、上下文记忆、多轮对话等高级 AI 功能。 - [x] **英文词典备灾机制**:当翻译服务失效时,可切换其他词典或 fallback 到本地词典查询。 - [x] **优化 YouTube 字幕支持**:改进流式字幕的合并与翻译体验,减少断句。 - [ ] **规则共建机制升级**:引入更灵活的规则分享、版本管理与社区评审流程。 如果你对某个方向感兴趣,欢迎在 [Issues](https://github.com/fishjar/kiss-translator/issues) 中讨论或提交 PR! ## 开发指引 ```sh git clone https://github.com/fishjar/kiss-translator.git cd kiss-translator git checkout dev # 提交PR建议推送到dev分支 pnpm install pnpm build ``` ### 外部触发示例 ```js // `toggle_translate` 切换翻译 // `toggle_styles` 切换样式 // `toggle_popup` 打开/关闭控制面板 // `toggle_transbox` 打开/关闭翻译弹窗 // `toggle_hover_node` 翻译鼠标悬停段落 // `input_translate` 翻译输入框 window.dispatchEvent(new CustomEvent("kiss_translator", {detail: { action: "toggle_translate" }})); ``` ## 交流 - 加入 [Telegram 群](https://t.me/+RRCu_4oNwrM2NmFl) ## 赞赏 ![appreciate](https://github.com/fishjar/kiss-translator/assets/1157624/ebaecabe-2934-4172-8085-af236f5ee399) ================================================ FILE: VERSION_MANAGEMENT.md ================================================ # 版本号管理说明 ## 📌 背景 项目的版本号分散在多个文件中: - `package.json` - `.env` (REACT_APP_VERSION) - `public/manifest.json` - `public/manifest.firefox.json` - `public/manifest.thunderbird.json` 为了避免手动更新多个文件的麻烦,现已实现自动化版本号管理方案。 ## ✨ 解决方案 ### 单一版本源 **`package.json` 是唯一的版本号来源**,其他文件的版本号会自动从 `package.json` 同步。 ### 自动同步机制 构建时会自动同步版本号: ```bash pnpm build # 构建前自动同步版本号 pnpm build+zip # 打包前自动同步版本号 ``` 手动同步版本号: ```bash pnpm sync-version # 手动触发版本号同步 ``` ## 🚀 版本号更新方法 ### 方法一:使用快捷命令(推荐) ```bash # 补丁版本更新: 2.0.19 -> 2.0.20 pnpm version:patch # 次版本更新: 2.0.19 -> 2.1.0 pnpm version:minor # 主版本更新: 2.0.19 -> 3.0.0 pnpm version:major # 手动指定版本号: 设置为 2.1.0 pnpm version:set -- 2.1.0 ``` 这些命令会自动完成: 1. ✅ 更新 `package.json` 中的版本号 2. ✅ 自动同步到其他所有文件 ### 方法二:手动更新(不推荐) 如果你手动修改了 `package.json` 的版本号,记得运行: ```bash pnpm sync-version ``` ## 📝 完整的版本发布流程 ```bash # 0. 格式化 pnpm format # 1. 更新版本号(自动完成所有文件的同步) pnpm version:patch # 2. 更新 CHANGELOG.md(手动编辑) # 添加新版本的更新内容 # 3. 提交更改 git add . git commit -m "chore: bump version to 2.0.20" # 4. 构建和打包(构建前会再次确保版本号同步) pnpm build+zip # 5. 推送代码 git push origin dev # 6. 合并到 master git checkout master git merge dev # 7. 打 tag git tag -a v2.0.20 -m "Release version 2.0.20" git push origin master v2.0.20 # 8. 切换回 dev 分支 git checkout dev ``` ## 🛠️ 相关脚本文件 - `src/scripts/sync-version.mjs` - 版本号同步脚本 - `src/scripts/update-version.mjs` - 版本号更新脚本 ## ⚠️ 注意事项 1. **不要手动修改** `.env`、`manifest.json` 等文件中的版本号 2. **只需修改** `package.json` 中的版本号,或者使用 `pnpm version:*` 命令 3. 每次构建前会自动同步版本号,确保所有文件版本一致 4. 更新版本后记得同步更新 `CHANGELOG.md` ================================================ FILE: config-overrides.js ================================================ const paths = require("react-scripts/config/paths"); const HtmlWebpackPlugin = require("html-webpack-plugin"); const { WebpackManifestPlugin } = require("webpack-manifest-plugin"); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const TerserPlugin = require("terser-webpack-plugin"); // const webpack = require("webpack"); console.log("process.env.REACT_APP_CLIENT", process.env.REACT_APP_CLIENT); // 扩展 const extWebpack = (config, env) => { const isEnvProduction = env === "production"; const minify = isEnvProduction && { removeComments: true, collapseWhitespace: true, removeRedundantAttributes: true, useShortDoctype: true, removeEmptyAttributes: true, removeStyleLinkTypeAttributes: true, keepClosingSlash: true, minifyJS: true, minifyCSS: true, minifyURLs: true, }; const names = [ "HtmlWebpackPlugin", "WebpackManifestPlugin", "MiniCssExtractPlugin", ]; config.entry = { popup: paths.appSrc + "/popup.js", options: paths.appSrc + "/options.js", background: paths.appSrc + "/background.js", content: paths.appSrc + "/content.js", "injector-subtitle": paths.appSrc + "/injector-subtitle.js", "injector-shadowroot": paths.appSrc + "/injector-shadowroot.js", }; config.output.filename = "[name].js"; config.output.assetModuleFilename = "media/[name][ext]"; config.optimization.splitChunks = { cacheGroups: { default: false } }; config.optimization.runtimeChunk = false; config.plugins = config.plugins.filter( (plugin) => !names.includes(plugin.constructor.name) ); config.plugins.push( new HtmlWebpackPlugin({ inject: true, chunks: ["options"], template: paths.appHtml, filename: "options.html", minify, }), new HtmlWebpackPlugin({ inject: true, chunks: ["popup"], template: paths.appHtml, filename: "popup.html", minify, }), new WebpackManifestPlugin({ fileName: "asset-manifest.json", }), new MiniCssExtractPlugin({ filename: "css/[name].css", }) ); return config; }; // 油猴 const userscriptWebpack = (config, env) => { const banner = `// ==UserScript== // @name ${process.env.REACT_APP_NAME} // @namespace ${process.env.REACT_APP_HOMEPAGE} // @version ${process.env.REACT_APP_VERSION} // @description A simple bilingual translation extension & Greasemonkey script (一个简约的双语对照翻译扩展 & 油猴脚本) // @author Gabe // @homepageURL ${process.env.REACT_APP_HOMEPAGE} // @license GPL-3.0 // @match *://*/* // @icon ${process.env.REACT_APP_LOGOURL} // @downloadURL ${process.env.REACT_APP_USERSCRIPT_DOWNLOADURL} // @updateURL ${process.env.REACT_APP_USERSCRIPT_DOWNLOADURL} // @grant GM.xmlHttpRequest // @grant GM_xmlhttpRequest // @grant GM.registerMenuCommand // @grant GM_registerMenuCommand // @grant GM.unregisterMenuCommand // @grant GM_unregisterMenuCommand // @grant GM.setValue // @grant GM_setValue // @grant GM.getValue // @grant GM_getValue // @grant GM.deleteValue // @grant GM_deleteValue // @grant GM.info // @grant GM_info // @grant unsafeWindow // @connect translate.googleapis.com // @connect translate-pa.googleapis.com // @connect generativelanguage.googleapis.com // @connect api-edge.cognitive.microsofttranslator.com // @connect edge.microsoft.com // @connect bing.com // @connect api-free.deepl.com // @connect api.deepl.com // @connect www2.deepl.com // @connect api.openai.com // @connect generativelanguage.googleapis.com // @connect openai.azure.com // @connect workers.dev // @connect github.io // @connect github.com // @connect githubusercontent.com // @connect kiss-translator.rayjar.com // @connect ghproxy.com // @connect dav.jianguoyun.com // @connect fanyi.baidu.com // @connect transmart.qq.com // @connect niutrans.com // @connect translate.volcengine.com // @connect dict.youdao.com // @connect api.anthropic.com // @connect api.siliconflow.cn // @connect api.cloudflare.com // @connect openrouter.ai // @connect localhost // @connect 127.0.0.1 // @run-at document-end // ==/UserScript== `; const names = ["HtmlWebpackPlugin"]; config.entry = { main: paths.appIndexJs, options: paths.appSrc + "/options.js", "kiss-translator.user": paths.appSrc + "/userscript.js", }; config.output.filename = "[name].js"; config.output.publicPath = env === "production" ? "./" : "/"; config.optimization.splitChunks = { cacheGroups: { default: false } }; config.optimization.runtimeChunk = false; config.optimization.minimize = env === "production"; if (config.optimization.minimize) { config.optimization.minimizer = [ new TerserPlugin({ extractComments: false, terserOptions: { format: { comments: false, preamble: banner, }, }, }), ]; } if (env === "production") config.devtool = false; config.plugins = config.plugins.filter( (plugin) => !names.includes(plugin.constructor.name) ); config.plugins.push( new HtmlWebpackPlugin({ inject: true, chunks: ["main"], template: paths.appHtml, filename: "index.html", }), new HtmlWebpackPlugin({ inject: true, chunks: ["options"], template: paths.appHtml, filename: "options.html", }) // new webpack.BannerPlugin({ // banner, // raw: true, // entryOnly: true, // include: "kiss-translator.user", // }) ); return config; }; // 开发 const webWebpack = (config, env) => { const names = ["HtmlWebpackPlugin"]; config.entry = { main: paths.appIndexJs, options: paths.appSrc + "/options.js", content: paths.appSrc + "/userscript.js", }; config.output.filename = "[name].js"; config.output.publicPath = "/"; config.plugins = config.plugins.filter( (plugin) => !names.includes(plugin.constructor.name) ); config.plugins.push( new HtmlWebpackPlugin({ inject: true, chunks: ["main"], template: paths.appHtml, filename: "index.html", }), new HtmlWebpackPlugin({ inject: true, chunks: ["options"], template: paths.appHtml, filename: "options.html", }), new HtmlWebpackPlugin({ inject: true, chunks: ["content"], template: paths.appPublic + "/content.html", filename: "content.html", }) ); return config; }; let webpackConfig; switch (process.env.REACT_APP_CLIENT) { case "userscript": webpackConfig = userscriptWebpack; break; case "web": webpackConfig = webWebpack; break; default: webpackConfig = extWebpack; } module.exports = { webpack: webpackConfig, }; ================================================ FILE: custom-api.md ================================================ # 自定义接口示例(本文档已过期,新版不再适用) V2版的示例请查看这里:[custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md) 以下示例为网友提供,仅供学习参考。 ## 本地运行 Seed-X-PPO-7B 量化模型 > 由网友 emptyghost6 提供,来源:https://linux.do/t/topic/828257 URL ```sh http://localhost:8000/v1/completions ``` Request Hook ```js (text, from, to, url, key) => { // 模型支持的语言代码到完整名称的映射 const langFullNameMap = { ar: 'Arabic', fr: 'French', ms: 'Malay', ru: 'Russian', cs: 'Czech', hr: 'Croatian', nb: 'Norwegian Bokmal', sv: 'Swedish', da: 'Danish', hu: 'Hungarian', nl: 'Dutch', th: 'Thai', de: 'German', id: 'Indonesian', no: 'Norwegian', tr: 'Turkish', en: 'English', it: 'Italian', pl: 'Polish', uk: 'Ukrainian', es: 'Spanish', ja: 'Japanese', pt: 'Portuguese', vi: 'Vietnamese', fi: 'Finnish', ko: 'Korean', ro: 'Romanian', zh: 'Chinese' }; // 将 Hook 系统的语言代码转换为模型 API 支持的代码 const getModelLangCode = (lang) => { if (lang === 'zh-CN' || lang === 'zh-TW') return 'zh'; return lang; }; const sourceLangCode = getModelLangCode(from); const targetLangCode = getModelLangCode(to); const sourceLangName = langFullNameMap[sourceLangCode] || from; const targetLangName = langFullNameMap[targetLangCode] || to; const prompt = `Translate it to ${targetLangName}:\n${text} <${targetLangCode}>`; // 构建请求体对象 const bodyObject = { model: "./ByteDance-Seed/Seed-X-PPO-7B-AWQ-Int4", prompt: prompt, max_tokens: 2048, temperature: 0.0, }; // 返回最终的请求配置 return [url, { method: "POST", headers: { "Content-Type": "application/json", }, // 关键改动:将 JavaScript 对象转换为 JSON 字符串 body: JSON.stringify(bodyObject), }]; } ``` Response Hook ```js (res, text, from, to) => { // 检查返回是否有效 if (res && res.choices && res.choices.length > 0 && res.choices[0].text) { // 提取译文并去除可能存在的前后空格 const translatedText = res.choices[0].text.trim(); // 比较原文与译文,相同为 true,否则为 false。 const areTextsIdentical = text.trim() === translatedText; // 返回数组:[翻译后的文本, 是否与原文相同] return [translatedText, areTextsIdentical]; } // 如果响应格式不正确或没有结果,则抛出错误 throw new Error("Invalid API response format or no translation found."); } ``` ## 接入 openrouter > 由网友 Rick Sanchez 提供 URL ```sh https://openrouter.ai/api/v1/chat/completions ``` Request Hook ```js (text, from, to, url, key) => [url, { method: "POST", headers: { "Authorization": `Bearer ${key}`, "Content-type": "application/json", }, body: JSON.stringify({ "model": "deepseek/deepseek-chat-v3-0324:free", //可自定义你的模型 "messages": [ { "role": "user", "content": //可自定义你的提示词 `You are a professional ${to} native translator. Your task is to produce a fluent, natural, and culturally appropriate translation of the following text from ${from} to ${to}, fully conveying the meaning, tone, and nuance of the original. ## Translation Rules 1. Output only the final polished translation — no explanations, intermediate drafts, or notes. 2. Translate in a way that reads naturally to a native ${to} audience, adapting idioms, cultural references, and tone when necessary. 3. Preserve proper nouns, technical terms, brand names, and URLs exactly as in the original text unless a widely accepted ${to} equivalent exists. 4. Keep any formatting (Markdown, HTML tags, bullet points, numbering) intact and positioned naturally within the translation. 5. Adapt humor, metaphors, and figurative language to culturally relevant forms in ${to} while keeping the original intent. 6. Maintain the same level of formality or informality as the original. Source Text: ${text} Translated Text:` } ] }) }] ``` Response Hook ```js (res, text, from, to) => [ res.choices?.[0]?.message?.content ?? "", false ] ``` ## 接入 gemini-2.5-flash, 关闭思考模式, 去审查 > 由网友 Rick Sanchez 提供 URL ```sh https://generativelanguage.googleapis.com/v1beta/models ``` Request Hook ```js (text, from, to, url, key) => [`${url}/gemini-2.5-flash:generateContent?key=${key}`, { headers: { "Content-Type": "application/json", }, method: "POST", body: JSON.stringify({ "generationConfig": { "temperature": 0.8, "thinkingConfig": { "thinkingBudget": 0, //gemini-2.5-flash设为0关闭思考模式 }, }, "safetySettings": [ { "category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE", }, { "category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE", }, { "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE", }, { "category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE", } ], "contents": [{ "parts": [{ "text": `自定义提示词` }] }], }), }] ``` Response Hook ```js (res, text, from, to) => [ res.candidates?.[0]?.content?.parts?.[0]?.text ?? "", false ] ``` ## 接入 Qwen-MT > 由网友 atom 提供 URL ```sh https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions ``` Request Hook ```js (text, from, to, url, key) => { const mapLanguageCode = (lang) => ({ 'zh-CN': 'zh', 'zh-TW': 'zh_tw', })[lang] || lang; const targetLang = mapLanguageCode(to); return [ url, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` }, body: JSON.stringify({ "model": "qwen-mt-turbo", "messages": [ { "role": "user", "content": text } ], "translation_options": { "source_lang": "auto", "target_lang": targetLang } }) } ]; } ``` Response Hook ```js (res, text, from, to) => [res.choices?.[0]?.message?.content ?? "", false] ``` ## 接入 deepl 接口 > 来源: https://github.com/fishjar/kiss-translator/issues/101#issuecomment-2123786236 Request Hook ```js (text, from, to, url, key) => [ url, { headers: { "Content-type": "application/json", }, method: "POST", body: JSON.stringify({ text, target_lang: "ZH", source_lang: "auto", }), }, ] ``` Response Hook ```js (res, text, from, to) => [res.data, "ZH" === res.source_lang] ``` ## 接入智谱AI大模型 > 来源: https://github.com/fishjar/kiss-translator/issues/205#issuecomment-2642422679 Request Hook ```js (text, from, to, url, key) => [url, { "method": "POST", "headers": { "Content-type": "application/json", "Authorization": key }, "body": JSON.stringify({ "model": "glm-4-flash", "messages": [ { "role":"system", "content": "You are a professional, authentic machine translation engine. You only return the translated text, without any explanations." }, { "role": "user", "content": `Translate the following text into ${to}. If translation is unnecessary (e.g. proper nouns, codes, etc.), return the original text. NO explanations. NO notes:\n\n ${text} ` } ] }) }] ``` ## 接入谷歌新接口 > 由网友 Bush2021 提供,来源:https://github.com/fishjar/kiss-translator/issues/225#issuecomment-2810950717 URL ```sh https://translate-pa.googleapis.com/v1/translateHtml ``` KEY ```sh AIzaSyATBXajvzQLTDHEQbcpq0Ihe0vWDHmO520 ``` Request Hook ```js (text, from, to, url, key) => [url, { method: "POST", headers: { "Content-Type": "application/json+protobuf", "X-Goog-API-Key": key }, body: JSON.stringify([[[text], from || "auto", to], "wt_lib"]) }] ``` Response Hook ```js (res, text, from, to) => [res?.[0]?.join(" ") || "Translation unavailable", to === res?.[1]?.[0]] ``` ================================================ FILE: custom-api_v2.md ================================================ # 自定义接口说明及示例 ## 默认接口规范 如果接口的请求数据和返回数据符合以下规范, 则无需填写 `Request Hook` 或 `Response Hook`。 ### 非聚合翻译 Request body ```json { "text": "hello", // 需要翻译的文本列表 "from":"auto", // 原文语言 "to": "zh-CN" // 目标语言 } ``` Response ```json { "text": "你好", // 译文 "src": "en" // 原文语言 } // 或者 { "text": "你好", // 译文 "from": "en" // 原文语言 } ``` ### 聚合翻译 Request body ```json { "texts": ["hello"], // 需要翻译的文本列表 "from":"auto", // 原文语言 "to": "zh-CN" // 目标语言 } ``` Response ```json [ { "text": "你好", // 译文 "src": "en" // 原文语言 } ] ``` v2.0.4版后亦支持以下 Response 格式 ```json { "translations": [ // 译文列表 { "text": "你好", // 译文 "src": "en" // 原文语言 } ] } ``` ## Prompt 相关 `Prompt` 可替换占位符: ```js `{{from}}` // 原文语言名称 `{{to}}` // 目标语言名称 `{{fromLang}}` // 原文语言代码 `{{toLang}}` // 目标语言代码 `{{text}}` // 原文 `{{tone}}` // 风格 `{{title}}` // 页面标题 `{{description}}` // 页面描述 ``` Hook 中 `Prompt` 类型说明: ```js `systemPrompt` // 聚合翻译 System Prompt `nobatchPrompt` // 非聚合翻译 System Prompt `nobatchUserPrompt` // 非聚合翻译 User Prompt `subtitlePrompt` // 字幕翻译 System Prompt ``` ## 谷歌翻译接口 > 此接口不支持聚合 URL ``` https://translate.googleapis.com/translate_a/single?client=gtx&dj=1&dt=t&ie=UTF-8&q={{text}}&sl=en&tl=zh-CN ``` Request Hook ```js async (args) => { const url = args.url.replace("{{text}}", args.texts[0]); const method = "GET"; return { url, method }; }; ``` Response Hook ```js async ({ res }) => { return { translations: [[res?.sentences?.[0]?.trans || "", res?.src]] }; }; ``` ## Ollama > 此示例为开启聚合翻译的写法 * 注意 ollama 启动参数需要添加环境变量 `OLLAMA_ORIGINS=*` * 检查环境变量生效命令:`systemctl show ollama | grep OLLAMA_ORIGINS` URL ``` http://localhost:11434/v1/chat/completions ``` Request Hook ```js async (args) => { const url = args.url; const method = "POST"; const headers = { "Content-type": "application/json" }; const body = { model: "gemma3", // 或 args.model messages: [ { role: "system", content: args.systemPrompt, }, { role: "user", content: JSON.stringify({ targetLanguage: args.toLang, segments: args.texts.map((text, id) => ({ id, text })), title: "", // 可省略 description: "", // 可省略 glossary: {}, // 可省略 tone: "", // 可省略 }), }, ], temperature: 0, max_tokens: 20480, think: false, stream: false, }; return { url, body, headers, method }; }; ``` Response Hook ```js async ({ res, parseAIRes }) => { const translations = parseAIRes(res?.choices?.[0]?.message?.content); return { translations }; }; ``` ## 硅基流动 > 此示例为禁用聚合翻译的写法 URL ``` https://api.siliconflow.cn/v1/chat/completions ``` Request Hook ```js async (args) => { const url = args.url; const method = "POST"; const headers = { "Content-type": "application/json", Authorization: `Bearer ${args.key}`, }; const body = { model: "tencent/Hunyuan-MT-7B", // 或 args.model, messages: [ { role: "system", content: args.systemPrompt, }, { role: "user", content: args.userPrompt, }, ], temperature: 0, max_tokens: 20480, }; return { url, body, headers, method }; }; ``` Response Hook ```js async ({ res }) => { return { translations: [[res?.choices?.[0]?.message?.content || ""]] }; }; ``` ## 语言代码表及说明 Hook参数里面的语言含义说明: - `toLang`, `fromLang` 是本插件支持的标准语言代码 - `to`, `from` 是转换后的适用于特定接口的语言代码 如果你的自定义接口与下面的标准语言代码不匹配,需要自行映射转换。 ``` ["en", "English - English"], ["zh-CN", "Simplified Chinese - 简体中文"], ["zh-TW", "Traditional Chinese - 繁體中文"], ["ar", "Arabic - العربية"], ["bg", "Bulgarian - Български"], ["ca", "Catalan - Català"], ["hr", "Croatian - Hrvatski"], ["cs", "Czech - Čeština"], ["da", "Danish - Dansk"], ["nl", "Dutch - Nederlands"], ["fa", "Persian - فارسی"], ["fi", "Finnish - Suomi"], ["fr", "French - Français"], ["de", "German - Deutsch"], ["el", "Greek - Ελληνικά"], ["hi", "Hindi - हिन्दी"], ["hu", "Hungarian - Magyar"], ["id", "Indonesian - Indonesia"], ["it", "Italian - Italiano"], ["ja", "Japanese - 日本語"], ["ko", "Korean - 한국어"], ["ms", "Malay - Melayu"], ["mt", "Maltese - Malti"], ["nb", "Norwegian - Norsk Bokmål"], ["pl", "Polish - Polski"], ["pt", "Portuguese - Português"], ["ro", "Romanian - Română"], ["ru", "Russian - Русский"], ["sk", "Slovak - Slovenčina"], ["sl", "Slovenian - Slovenščina"], ["es", "Spanish - Español"], ["sv", "Swedish - Svenska"], ["ta", "Tamil - தமிழ்"], ["te", "Telugu - తెలుగు"], ["th", "Thai - ไทย"], ["tr", "Turkish - Türkçe"], ["uk", "Ukrainian - Українська"], ["vi", "Vietnamese - Tiếng Việt"], ``` ================================================ FILE: package.json ================================================ { "name": "kiss-translator", "description": "A minimalist bilingual translation Extension & Greasemonkey Script", "version": "2.0.20", "author": "Gabe", "private": true, "dependencies": { "@emotion/cache": "^11.11.0", "@emotion/css": "^11.13.5", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.15.15", "@mui/lab": "5.0.0-alpha.170", "@mui/material": "^5.15.15", "@streamparser/json": "^0.0.22", "query-string": "^8.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^8.0.7", "react-router-dom": "^6.16.0", "react-scripts": "5.0.1", "sval": "^0.5.2", "webdav": "^5.3.0", "webextension-polyfill": "^0.10.0" }, "scripts": { "start": "cross-env REACT_APP_CLIENT=web react-app-rewired start", "start:userscript": "cross-env REACT_APP_CLIENT=userscript react-app-rewired start", "sync-version": "zx src/scripts/sync-version.mjs", "version:patch": "zx src/scripts/update-version.mjs patch", "version:minor": "zx src/scripts/update-version.mjs minor", "version:major": "zx src/scripts/update-version.mjs major", "version:set": "zx src/scripts/update-version.mjs set", "build:chrome": "zx src/scripts/build-task.mjs --target=chrome", "build:edge": "zx src/scripts/build-task.mjs --target=edge", "build:safari-output": "zx src/scripts/build-task.mjs --target=safari", "build:thunderbird": "zx src/scripts/build-task.mjs --target=thunderbird", "build:firefox": "zx src/scripts/build-task.mjs --target=firefox", "build:web": "zx src/scripts/build-task.mjs --target=web", "build:safari": "node src/scripts/build-safari.mjs", "build:userscript-ios": "zx src/scripts/build-ios.mjs", "build:rules": "babel-node src/rules.js", "format": "prettier --write \"**/*.{js,json,html}\"", "build": "pnpm sync-version && pnpm format && pnpm build:chrome && pnpm build:edge && pnpm build:thunderbird && pnpm build:firefox && pnpm build:web && pnpm build:userscript-ios && pnpm build:rules", "zip": "zx src/scripts/archive.mjs", "build+zip": "cross-env CI=true pnpm build && pnpm zip", "test": "react-app-rewired test", "eject": "react-scripts eject" }, "eslintConfig": { "extends": [ "react-app", "react-app/jest" ], "globals": { "GM": true, "unsafeWindow": true, "globalThis": true, "messenger": true, "LanguageDetector": true, "Translator": true } }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] }, "devDependencies": { "@babel/core": "^7.22.20", "@babel/node": "^7.22.19", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/preset-env": "^7.22.20", "bestzip": "^2.2.1", "cross-env": "^7.0.3", "dotenv": "^17.2.1", "find-up": "^7.0.0", "prettier": "3.6.2", "react-app-rewired": "^2.2.1", "zx": "^8.8.1" } } ================================================ FILE: public/.nojekyll ================================================ ================================================ FILE: public/_locales/de/messages.json ================================================ { "app_name": { "message": "KISS Übersetzer" }, "app_description": { "message": "Eine minimalistische zweisprachige Übersetzungserweiterung, die Webseiten-, Textauswahl- und Videountertitel-Übersetzung unterstützt." }, "toggle_translate": { "message": "Übersetzung umschalten" }, "toggle_style": { "message": "Stile umschalten" }, "open_options": { "message": "Einstellungen öffnen" }, "open_tranbox": { "message": "Popup-Fenster öffnen" }, "open_separate_window": { "message": "In eigenem Fenster öffnen" } } ================================================ FILE: public/_locales/en/messages.json ================================================ { "app_name": { "message": "KISS Translator" }, "app_description": { "message": "A minimalist bilingual translation extension that supports webpage, text selection, and video subtitle translation." }, "toggle_translate": { "message": "Toggle Translation" }, "toggle_style": { "message": "Toggle Styles" }, "open_options": { "message": "Open Setting" }, "open_tranbox": { "message": "Open Popup Box" }, "open_separate_window": { "message": "Open Independent Window" } } ================================================ FILE: public/_locales/es/messages.json ================================================ { "app_name": { "message": "KISS Traductor" }, "app_description": { "message": "Una extensión de traducción bilingüe minimalista que admite la traducción de páginas web, selección de texto y subtítulos de video." }, "toggle_translate": { "message": "Alternar traducción" }, "toggle_style": { "message": "Cambiar estilo" }, "open_options": { "message": "Abrir configuración" }, "open_tranbox": { "message": "Abrir ventana emergente" }, "open_separate_window": { "message": "Abrir en ventana independiente" } } ================================================ FILE: public/_locales/fr/messages.json ================================================ { "app_name": { "message": "KISS Traducteur" }, "app_description": { "message": "Une extension de traduction bilingue minimaliste prenant en charge la traduction de pages web, de texte sélectionné et de sous-titres vidéo." }, "toggle_translate": { "message": "Activer/désactiver la traduction" }, "toggle_style": { "message": "Changer de style" }, "open_options": { "message": "Ouvrir les paramètres" }, "open_tranbox": { "message": "Ouvrir la fenêtre contextuelle" }, "open_separate_window": { "message": "Ouvrir dans une fenêtre indépendante" } } ================================================ FILE: public/_locales/ja/messages.json ================================================ { "app_name": { "message": "シンプル翻訳" }, "app_description": { "message": "Webサイト、テキスト選択、動画字幕などの翻訳に対応した、シンプルで直感的な二言語対応翻訳拡張機能。複数の翻訳サービスやAI翻訳APIをサポートし、豊富で柔軟なカスタマイズオプションを備えています。" }, "toggle_translate": { "message": "翻訳の切り替え" }, "toggle_style": { "message": "スタイル切り替え" }, "open_options": { "message": "設定を開く" }, "open_tranbox": { "message": "ポップアップを開く" }, "open_separate_window": { "message": "独立ウィンドウで開く" } } ================================================ FILE: public/_locales/ko/messages.json ================================================ { "app_name": { "message": "심플 번역" }, "app_description": { "message": "웹페이지, 단어 선택, 동영상 자막 번역 등을 지원하는 심플한 이중 언어 대조 번역 확장 프로그램. 다양한 번역 서비스 및 AI 번역 인터페이스를 지원하며, 풍부하고 유연한 사용자 설정 옵션을 제공합니다." }, "toggle_translate": { "message": "번역 켜기" }, "toggle_style": { "message": "스타일 전환" }, "open_options": { "message": "설정 열기" }, "open_tranbox": { "message": "팝업 열기" }, "open_separate_window": { "message": "독립 창으로 열기" } } ================================================ FILE: public/_locales/zh_CN/messages.json ================================================ { "app_name": { "message": "简约翻译" }, "app_description": { "message": "一个简约的双语对照翻译扩展,支持网页、划词、视频字幕翻译等功能,支持多种翻译服务及AI翻译接口,拥有丰富灵活的自定义选项。" }, "toggle_translate": { "message": "开启翻译" }, "toggle_style": { "message": "切换样式" }, "open_options": { "message": "打开设置" }, "open_tranbox": { "message": "打开弹窗" }, "open_separate_window": { "message": "打开独立窗口" } } ================================================ FILE: public/_locales/zh_TW/messages.json ================================================ { "app_name": { "message": "簡約翻譯" }, "app_description": { "message": "一個簡約的雙語對照翻譯擴充功能,支持網頁、劃詞、影片字幕翻譯等功能,支持多種翻譯服務及 AI 翻譯接口,擁有豐富靈活的自定義選項。" }, "toggle_translate": { "message": "開啟翻譯" }, "toggle_style": { "message": "切換樣式" }, "open_options": { "message": "開啟設定" }, "open_tranbox": { "message": "開啟彈出視窗" }, "open_separate_window": { "message": "打開獨立視窗" } } ================================================ FILE: public/content.html ================================================ %REACT_APP_NAME%
================================================ FILE: public/index.html ================================================ %REACT_APP_NAME% v%REACT_APP_VERSION%
================================================ FILE: public/manifest.firefox.json ================================================ { "manifest_version": 2, "name": "__MSG_app_name__", "description": "__MSG_app_description__", "version": "2.0.20", "default_locale": "en", "author": "Gabe", "homepage_url": "https://github.com/fishjar/kiss-translator", "background": { "scripts": [ "background.js" ] }, "content_scripts": [ { "js": [ "content.js" ], "matches": [ "", "file://*/*" ], "all_frames": true } ], "web_accessible_resources": [ "injector-subtitle.js", "injector-shadowroot.js" ], "commands": { "_execute_browser_action": { "suggested_key": { "default": "Alt+K" } }, "toggleTranslate": { "suggested_key": { "default": "Alt+Q" }, "description": "__MSG_toggle_translate__" }, "openTranbox": { "suggested_key": { "default": "Alt+S" }, "description": "__MSG_open_tranbox__" }, "openSeparateWindow": { "description": "__MSG_open_separate_window__" }, "toggleStyle": { "suggested_key": { "default": "Alt+C" }, "description": "__MSG_toggle_style__" }, "openOptions": { "description": "__MSG_open_options__" } }, "permissions": [ "", "storage", "contextMenus", "scripting", "declarativeNetRequest" ], "icons": { "16": "images/logo16.png", "32": "images/logo32.png", "48": "images/logo48.png", "128": "images/logo128.png" }, "browser_action": { "default_icon": { "128": "images/logo128.png" }, "default_title": "__MSG_app_name__", "default_popup": "popup.html" }, "options_ui": { "page": "options.html", "open_in_tab": true } } ================================================ FILE: public/manifest.json ================================================ { "manifest_version": 3, "name": "__MSG_app_name__", "description": "__MSG_app_description__", "version": "2.0.20", "default_locale": "en", "author": "Gabe", "homepage_url": "https://github.com/fishjar/kiss-translator", "background": { "service_worker": "background.js", "type": "module" }, "content_scripts": [ { "js": [ "content.js" ], "matches": [ "", "file://*/*" ], "all_frames": true } ], "web_accessible_resources": [ { "resources": [ "injector-subtitle.js" ], "matches": [ "https://www.youtube.com/*" ] }, { "resources": [ "injector-shadowroot.js" ], "matches": [ "" ] } ], "commands": { "_execute_action": { "suggested_key": { "default": "Alt+K" } }, "toggleTranslate": { "suggested_key": { "default": "Alt+Q" }, "description": "__MSG_toggle_translate__" }, "openTranbox": { "suggested_key": { "default": "Alt+S" }, "description": "__MSG_open_tranbox__" }, "openSeparateWindow": { "description": "__MSG_open_separate_window__" }, "toggleStyle": { "suggested_key": { "default": "Alt+C" }, "description": "__MSG_toggle_style__" }, "openOptions": { "description": "__MSG_open_options__" } }, "permissions": [ "storage", "contextMenus", "scripting", "declarativeNetRequest", "declarativeNetRequestWithHostAccess" ], "host_permissions": [ "" ], "icons": { "16": "images/logo16.png", "32": "images/logo32.png", "48": "images/logo48.png", "128": "images/logo128.png" }, "action": { "default_icon": { "128": "images/logo128.png" }, "default_title": "__MSG_app_name__", "default_popup": "popup.html" }, "options_ui": { "page": "options.html", "open_in_tab": true } } ================================================ FILE: public/manifest.thunderbird.json ================================================ { "manifest_version": 2, "name": "__MSG_app_name__", "description": "__MSG_app_description__", "version": "2.0.20", "default_locale": "en", "author": "Gabe", "homepage_url": "https://github.com/fishjar/kiss-translator", "browser_specific_settings": { "gecko": { "id": "yugang2002@gmail.com", "strict_min_version": "78.0" } }, "background": { "scripts": [ "background.js" ] }, "content_scripts": [ { "js": [ "content.js" ], "matches": [ "", "file://*/*" ], "all_frames": true } ], "web_accessible_resources": [ "injector-subtitle.js", "injector-shadowroot.js" ], "commands": { "_execute_browser_action": { "suggested_key": { "default": "Alt+K" } }, "toggleTranslate": { "suggested_key": { "default": "Alt+Q" }, "description": "__MSG_toggle_translate__" }, "openTranbox": { "suggested_key": { "default": "Alt+S" }, "description": "__MSG_open_tranbox__" }, "openSeparateWindow": { "description": "__MSG_open_separate_window__" }, "toggleStyle": { "suggested_key": { "default": "Alt+C" }, "description": "__MSG_toggle_style__" }, "openOptions": { "description": "__MSG_open_options__" } }, "permissions": [ "", "storage", "menus", "messagesModify", "scripting", "declarativeNetRequest" ], "icons": { "16": "images/logo16.png", "32": "images/logo32.png", "48": "images/logo48.png", "128": "images/logo128.png" }, "browser_action": { "default_icon": { "128": "images/logo128.png" }, "default_title": "__MSG_app_name__", "default_popup": "popup.html" }, "options_ui": { "page": "options.html", "open_in_tab": true } } ================================================ FILE: src/apis/baidu.js ================================================ import { DEFAULT_USER_AGENT } from "../config"; export const genBaidu = ({ texts, from, to }) => { const body = { from, to, query: texts.join(" "), source: "txt", }; const url = "https://fanyi.baidu.com/transapi"; const headers = { // Origin: "https://fanyi.baidu.com", "content-type": "application/x-www-form-urlencoded; charset=UTF-8", "User-Agent": DEFAULT_USER_AGENT, }; return { url, body, headers }; }; ================================================ FILE: src/apis/deepl.js ================================================ let id = 1e4 * Math.round(1e4 * Math.random()); export const genDeeplFree = ({ texts, from, to }) => { const text = texts.join(" "); const iCount = (text.match(/[i]/g) || []).length + 1; let timestamp = Date.now(); timestamp = timestamp + (iCount - (timestamp % iCount)); id++; const url = "https://www2.deepl.com/jsonrpc"; const body = { jsonrpc: "2.0", method: "LMT_handle_texts", params: { splitting: "newlines", lang: { target_lang: to, source_lang_user_selected: from, }, commonJobParams: { wasSpoken: false, transcribe_as: "", }, id, timestamp, texts: [ { text, requestAlternatives: 3, }, ], }, }; const headers = { "Content-Type": "application/json", Accept: "*/*", "x-app-os-name": "iOS", "x-app-os-version": "16.3.0", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate, br", "x-app-device": "iPhone13,2", "User-Agent": "DeepL-iOS/2.9.1 iOS 16.3.0 (iPhone13,2)", "x-app-build": "510265", "x-app-version": "2.9.1", }; return { url, body, headers }; }; ================================================ FILE: src/apis/history.js ================================================ import { DEFAULT_CONTEXT_SIZE } from "../config"; const historyMap = new Map(); const MsgHistory = (maxSize = DEFAULT_CONTEXT_SIZE) => { const messages = []; const add = (...msgs) => { messages.push(...msgs.filter(Boolean)); const extra = messages.length - maxSize; if (extra > 0) { messages.splice(0, extra); } }; const getAll = () => { return [...messages]; }; const clear = () => { messages.length = 0; }; return { add, getAll, clear, }; }; export const getMsgHistory = (apiSlug, maxSize) => { if (historyMap.has(apiSlug)) { return historyMap.get(apiSlug); } const msgHistory = MsgHistory(maxSize); historyMap.set(apiSlug, msgHistory); return msgHistory; }; ================================================ FILE: src/apis/index.js ================================================ import queryString from "query-string"; import { fetchData } from "../libs/fetch"; import { URL_CACHE_TRAN, URL_CACHE_DELANG, URL_CACHE_BINGDICT, KV_SALT_SYNC, OPT_LANGS_TO_SPEC, OPT_LANGS_SPEC_DEFAULT, API_SPE_TYPES, DEFAULT_API_SETTING, OPT_TRANS_MICROSOFT, MSG_BUILTINAI_DETECT, MSG_BUILTINAI_TRANSLATE, OPT_TRANS_BUILTINAI, URL_CACHE_SUBTITLE, OPT_LANGS_TO_CODE, } from "../config"; import { sha256, withTimeout } from "../libs/utils"; import { handleTranslate, handleSubtitle, handleMicrosoftLangdetect, } from "./trans"; import { getHttpCachePolyfill, putHttpCachePolyfill } from "../libs/cache"; import { getBatchQueue } from "../libs/batchQueue"; import { isBuiltinAIAvailable } from "../libs/browser"; import { chromeDetect, chromeTranslate } from "../libs/builtinAI"; import { fnPolyfill } from "../libs/fetch"; import { getFetchPool } from "../libs/pool"; /** * 同步数据 * @param {*} url * @param {*} key * @param {*} data * @returns */ export const apiSyncData = async (url, key, data) => fetchData(url, { headers: { "Content-type": "application/json", Authorization: `Bearer ${await sha256(key, KV_SALT_SYNC)}`, }, method: "POST", body: JSON.stringify(data), }); /** * 下载数据 * @param {*} url * @returns */ export const apiFetch = (url) => fetchData(url); /** * Microsoft token * @returns */ export const apiMsAuth = async () => fetchData("https://edge.microsoft.com/translate/auth"); /** * Google语言识别 * @param {*} text * @returns */ export const apiGoogleLangdetect = async (text) => { const params = { client: "gtx", dt: "t", dj: 1, ie: "UTF-8", sl: "auto", tl: "zh-CN", q: text, }; const input = `https://translate.googleapis.com/translate_a/single?${queryString.stringify(params)}`; const init = { headers: { "Content-type": "application/json", }, }; const res = await fetchData(input, init, { useCache: true }); if (res?.src) { await putHttpCachePolyfill(input, init, res); return res.src; } return ""; }; /** * Microsoft语言识别 * @param {*} text * @returns */ export const apiMicrosoftLangdetect = async (text) => { const cacheOpts = { text, detector: OPT_TRANS_MICROSOFT }; const cacheInput = `${URL_CACHE_DELANG}?${queryString.stringify(cacheOpts)}`; const cache = await getHttpCachePolyfill(cacheInput); if (cache) { return cache; } const key = `${URL_CACHE_DELANG}_${OPT_TRANS_MICROSOFT}`; const queue = getBatchQueue(key, handleMicrosoftLangdetect, { batchInterval: 200, batchSize: 20, batchLength: 100000, }); const lang = await queue.addTask(text); if (lang) { putHttpCachePolyfill(cacheInput, null, lang); return lang; } return ""; }; /** * Microsoft词典 * @param {*} text * @returns */ export const apiMicrosoftDict = async (text) => { const cacheOpts = { text }; const cacheInput = `${URL_CACHE_BINGDICT}?${queryString.stringify(cacheOpts)}`; const cache = await getHttpCachePolyfill(cacheInput); if (cache) { return cache; } const host = "https://www.bing.com"; const url = `${host}/dict/search?q=${text}&FORM=BDVSP6&cc=cn`; const str = await fetchData( url, { credentials: "include" }, { useCache: false } ); if (!str) { return null; } const parser = new DOMParser(); const doc = parser.parseFromString(str, "text/html"); const word = doc.querySelector("#headword > h1")?.textContent.trim(); if (!word) { return null; } const trs = []; doc.querySelectorAll("div.qdef > ul > li").forEach(($li) => { const pos = $li.querySelector(".pos")?.textContent?.trim(); const def = $li.querySelector(".def")?.textContent?.trim(); trs.push({ pos, def }); }); // 时态 const presents = []; doc.querySelectorAll("div.hd_div1>.hd_if>.p1-5").forEach(($li) => { const present = $li.textContent?.trim(); presents.push(present); }); // 英汉双解 const ecs = []; doc.querySelectorAll(".each_seg>.li_pos").forEach(($li) => { const pos = $li.querySelector(".pos_lin>.pos")?.textContent?.trim(); const lis = []; $li.querySelectorAll(".de_seg>.se_lis").forEach(($l) => { lis.push($l.querySelector(".de_co")?.textContent?.trim()); }); ecs.push({ pos, lis }); }); // 添加例句信息 const sentences = []; doc.querySelectorAll("#sentenceSeg .se_li").forEach(($li) => { const eng = $li.querySelector(".sen_en")?.textContent?.trim(); const chs = $li.querySelector(".sen_cn")?.textContent?.trim(); if (eng && chs) { sentences.push({ eng, chs }); } }); const aus = []; const $audioUK = doc.querySelector("#bigaud_uk"); const $audioUS = doc.querySelector("#bigaud_us"); // 检查 UK 音频和音标 if ($audioUK) { const audioUK = host + $audioUK?.dataset?.mp3link; const $phoneticUK = $audioUK.parentElement?.previousElementSibling; const phoneticUK = $phoneticUK?.textContent ?.trim() ?.match(/\[(.*?)\]/)?.[1]; aus.push({ key: "英", audio: audioUK, phonetic: phoneticUK }); } // 检查 US 音频和音标 if ($audioUS) { const audioUS = host + $audioUS?.dataset?.mp3link; const $phoneticUS = $audioUS.parentElement?.previousElementSibling; const phoneticUS = $phoneticUS?.textContent ?.trim() ?.match(/\[(.*?)\]/)?.[1]; aus.push({ key: "美", audio: audioUS, phonetic: phoneticUS }); } // 如果上面的方法没有获取到音标,尝试其他方式 if (aus.length === 0) { const $pronInfo = doc.querySelector(".hd_pr"); const $pronInfoUS = doc.querySelector(".hd_prUS"); if ($pronInfo) { const phoneticText = $pronInfo.textContent?.trim(); // 尝试提取音标部分 const phoneticMatch = phoneticText?.match(/\[([^\]]+)\]/); if (phoneticMatch) { aus.push({ key: "英", phonetic: phoneticMatch[1] }); } } if ($pronInfoUS) { const phoneticText = $pronInfoUS.textContent?.trim(); // 尝试提取音标部分 const phoneticMatch = phoneticText?.match(/\[([^\]]+)\]/); if (phoneticMatch) { aus.push({ key: "美", phonetic: phoneticMatch[1] }); } } } const res = { word, trs, aus, ecs, sentences, presents }; putHttpCachePolyfill(cacheInput, null, res); return res; }; /** * 百度语言识别 * @param {*} text * @returns */ export const apiBaiduLangdetect = async (text) => { const input = "https://fanyi.baidu.com/langdetect"; const init = { headers: { "Content-type": "application/json", }, method: "POST", body: JSON.stringify({ query: text, }), }; const res = await fetchData(input, init, { useCache: true }); if (res?.error === 0) { await putHttpCachePolyfill(input, init, res); return res.lan; } return ""; }; /** * 百度翻译建议 * @param {*} text * @returns */ export const apiBaiduSuggest = async (text) => { const input = "https://fanyi.baidu.com/sug"; const init = { headers: { "Content-type": "application/json", }, method: "POST", body: JSON.stringify({ kw: text, }), }; const res = await fetchData(input, init, { useCache: true }); if (res?.errno === 0) { await putHttpCachePolyfill(input, init, res); return res.data; } return []; }; /** * 有道翻译建议 * @param {*} text * @returns */ export const apiYoudaoSuggest = async (text) => { const params = { num: 5, ver: 3.0, doctype: "json", cache: false, le: "en", q: text, }; const input = `https://dict.youdao.com/suggest?${queryString.stringify(params)}`; const init = { headers: { accept: "application/json, text/plain, */*", "accept-language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,ja;q=0.6", "content-type": "application/x-www-form-urlencoded", }, method: "GET", }; const res = await fetchData(input, init, { useCache: true }); if (res?.result?.code === 200) { await putHttpCachePolyfill(input, init, res); return res.data.entries; } return []; }; /** * 有道词典 * @param {*} text * @returns */ export const apiYoudaoDict = async (text) => { const params = { doctype: "json", jsonversion: 4, }; const input = `https://dict.youdao.com/jsonapi_s?${queryString.stringify(params)}`; const body = queryString.stringify({ q: text, le: "en", t: 3, client: "web", // sign: "", keyfrom: "webdict", }); const init = { headers: { accept: "application/json, text/plain, */*", "accept-language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,ja;q=0.6", "content-type": "application/x-www-form-urlencoded", }, method: "POST", body, }; const res = await fetchData(input, init, { useCache: true }); if (res) { await putHttpCachePolyfill(input, init, res); return res; } return null; }; /** * 百度语音 * @param {*} text * @param {*} lan * @param {*} spd * @returns */ export const apiBaiduTTS = (text, lan = "uk", spd = 3) => { const input = `https://fanyi.baidu.com/gettts?${queryString.stringify({ lan, text, spd })}`; return fetchData(input); }; /** * 腾讯语言识别 * @param {*} text * @returns */ export const apiTencentLangdetect = async (text) => { const input = "https://transmart.qq.com/api/imt"; const body = JSON.stringify({ header: { fn: "text_analysis", client_key: "browser-chrome-110.0.0-Mac OS-df4bd4c5-a65d-44b2-a40f-42f34f3535f2-1677486696487", }, text, }); const init = { headers: { "Content-type": "application/json", "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36", referer: "https://transmart.qq.com/zh-CN/index", }, method: "POST", body, }; const res = await fetchData(input, init, { useCache: true }); if (res?.language) { await putHttpCachePolyfill(input, init, res); return res.language; } return ""; }; /** * 浏览器内置AI语言识别 * @param {*} text * @returns */ export const apiBuiltinAIDetect = async (text) => { if (!isBuiltinAIAvailable) { return ""; } const [lang, error] = await fnPolyfill({ fn: chromeDetect, msg: MSG_BUILTINAI_DETECT, text, }); if (!error) { return lang; } return ""; }; /** * 浏览器内置AI翻译 * @param {*} param0 * @returns */ const apiBuiltinAITranslate = async ({ text, from, to, apiSetting }) => { if (!isBuiltinAIAvailable) { return ["", true]; } const { fetchInterval, fetchLimit, httpTimeout } = apiSetting; const fetchPool = getFetchPool(fetchInterval, fetchLimit); const result = await withTimeout( fetchPool.push(fnPolyfill, { fn: chromeTranslate, msg: MSG_BUILTINAI_TRANSLATE, text, from, to, }), httpTimeout ); if (!result) { throw new Error("apiBuiltinAITranslate got null reault"); } const [trText, srLang, error] = result; if (error) { throw new Error("apiBuiltinAITranslate got error", error); } return [trText, srLang]; }; /** * 统一翻译接口 * @param {*} param0 * @returns */ export const apiTranslate = async ({ text, fromLang = "auto", toLang, apiSetting = DEFAULT_API_SETTING, glossary, useCache = true, usePool = true, }) => { if (!text) { throw new Error("The text cannot be empty."); } const { apiType, apiSlug, useBatchFetch } = apiSetting; const langMap = OPT_LANGS_TO_SPEC[apiType] || OPT_LANGS_SPEC_DEFAULT; const from = langMap.get(fromLang); const to = langMap.get(toLang); if (!to) { throw new Error(`The target lang: ${toLang} not support`); } // todo: 优化缓存失效因素 const [v1, v2] = process.env.REACT_APP_VERSION.split("."); const cacheOpts = { apiSlug, text, fromLang, toLang, version: [v1, v2].join("."), }; const cacheInput = `${URL_CACHE_TRAN}?${queryString.stringify(cacheOpts)}`; // 查询缓存数据 if (useCache) { const cache = await getHttpCachePolyfill(cacheInput); if (cache?.trText) { return cache; } } // 请求接口数据 let translation = []; if (apiType === OPT_TRANS_BUILTINAI) { translation = await apiBuiltinAITranslate({ text, from, to, apiSetting, }); } else if (useBatchFetch && API_SPE_TYPES.batch.has(apiType)) { const { apiSlug, batchInterval, batchSize, batchLength, useStream } = apiSetting; const enableStream = useStream && API_SPE_TYPES.stream.has(apiType); const key = `${apiSlug}_${fromLang}_${toLang}_${enableStream ? "stream" : "batch"}`; const queue = getBatchQueue(key, handleTranslate, { batchInterval, batchSize, batchLength, }); translation = await queue.addTask(text, { from, to, fromLang, toLang, langMap, glossary, apiSetting, usePool, }); } else { const { value } = await handleTranslate([text], { from, to, fromLang, toLang, langMap, glossary, apiSetting, usePool, }).next(); translation = value?.result; } let trText = ""; let srLang = ""; let srCode = ""; if (Array.isArray(translation)) { [trText, srLang = ""] = translation; if (srLang) { srCode = OPT_LANGS_TO_CODE[apiType].get(srLang) || ""; } } else if (typeof translation === "string") { trText = translation; } if (!trText) { throw new Error("tanslate api got empty trtext"); } const isSame = fromLang === "auto" && srLang === to; // 插入缓存 if (useCache) { putHttpCachePolyfill(cacheInput, null, { trText, isSame, srLang, srCode }); } return { trText, srLang, srCode, isSame }; }; // 字幕处理/翻译 export const apiSubtitle = async ({ videoId, chunkSign, fromLang = "auto", toLang, events = [], apiSetting, }) => { const cacheOpts = { apiSlug: apiSetting.apiSlug, videoId, chunkSign, fromLang, toLang, }; const cacheInput = `${URL_CACHE_SUBTITLE}?${queryString.stringify(cacheOpts)}`; const cache = await getHttpCachePolyfill(cacheInput); if (cache) { return cache; } const subtitles = await handleSubtitle({ events, from: fromLang, to: toLang, apiSetting, }); if (subtitles?.length) { putHttpCachePolyfill(cacheInput, null, subtitles); return subtitles; } return []; }; ================================================ FILE: src/apis/trans.js ================================================ import queryString from "query-string"; import { OPT_TRANS_GOOGLE, OPT_TRANS_GOOGLE_2, OPT_TRANS_MICROSOFT, OPT_TRANS_AZUREAI, OPT_TRANS_DEEPL, OPT_TRANS_DEEPLFREE, OPT_TRANS_DEEPLX, OPT_TRANS_NIUTRANS, OPT_TRANS_BAIDU, OPT_TRANS_TENCENT, OPT_TRANS_VOLCENGINE, OPT_TRANS_OPENAI, OPT_TRANS_GEMINI, OPT_TRANS_GEMINI_2, OPT_TRANS_CLAUDE, OPT_TRANS_CLOUDFLAREAI, OPT_TRANS_OLLAMA, OPT_TRANS_OPENROUTER, OPT_TRANS_CUSTOMIZE, API_SPE_TYPES, INPUT_PLACE_FROM, INPUT_PLACE_TO, INPUT_PLACE_TEXT, INPUT_PLACE_KEY, INPUT_PLACE_MODEL, DEFAULT_USER_AGENT, defaultSystemPrompt, defaultSubtitlePrompt, defaultNobatchPrompt, defaultNobatchUserPrompt, INPUT_PLACE_TONE, INPUT_PLACE_TITLE, INPUT_PLACE_DESCRIPTION, INPUT_PLACE_TO_LANG, INPUT_PLACE_FROM_LANG, defaultSystemPromptXml, defaultSystemPromptLines, INPUT_PLACE_SUMMARY, } from "../config"; import { msAuth } from "../libs/auth"; import { genDeeplFree } from "./deepl"; import { genBaidu } from "./baidu"; import { interpreter } from "../libs/interpreter"; import { parseJsonObj, extractJson, stripMarkdownCodeBlock, } from "../libs/utils"; import { parseStreamingSegments, createStreamingJsonParser, detectStreamFormat, getStreamDelta, } from "../libs/stream"; import { kissLog } from "../libs/log"; import { fetchData, fetchStream } from "../libs/fetch"; import { getMsgHistory } from "./history"; import { parseBilingualVtt } from "../subtitle/vtt"; import { getDocInfo } from "../libs/docInfo"; const keyMap = new Map(); const urlMap = new Map(); // 轮询key/url const keyPick = (apiSlug, key = "", cacheMap) => { const keys = key .split(/\n|,/) .map((item) => item.trim()) .filter(Boolean); if (keys.length === 0) { return ""; } const preIndex = cacheMap.get(apiSlug) ?? -1; const curIndex = (preIndex + 1) % keys.length; cacheMap.set(apiSlug, curIndex); return keys[curIndex]; }; const genSystemPrompt = ({ systemPrompt, tone, from, to, fromLang, toLang, texts, docInfo: { title = "", description = "", summary = "" } = {}, }) => systemPrompt .replaceAll(INPUT_PLACE_TITLE, title) .replaceAll(INPUT_PLACE_DESCRIPTION, description) .replaceAll(INPUT_PLACE_SUMMARY, summary) .replaceAll(INPUT_PLACE_TONE, tone) .replaceAll(INPUT_PLACE_FROM, from) .replaceAll(INPUT_PLACE_TO, to) .replaceAll(INPUT_PLACE_FROM_LANG, fromLang) .replaceAll(INPUT_PLACE_TO_LANG, toLang) .replaceAll(INPUT_PLACE_TEXT, texts[0]); const genUserPrompt = ({ nobatchUserPrompt, useBatchFetch, tone, glossary, from, to, fromLang, toLang, texts, docInfo: { title = "", description = "", summary = "" } = {}, }) => { if (useBatchFetch) { const promptObj = { targetLanguage: toLang, segments: texts.map((text, i) => ({ id: i, text })), }; title && (promptObj.title = title); description && (promptObj.description = description); glossary && Object.keys(glossary).length !== 0 && (promptObj.glossary = glossary); tone && (promptObj.tone = tone); return JSON.stringify(promptObj); } return nobatchUserPrompt .replaceAll(INPUT_PLACE_TITLE, title) .replaceAll(INPUT_PLACE_DESCRIPTION, description) .replaceAll(INPUT_PLACE_SUMMARY, summary) .replaceAll(INPUT_PLACE_TONE, tone) .replaceAll(INPUT_PLACE_FROM, from) .replaceAll(INPUT_PLACE_TO, to) .replaceAll(INPUT_PLACE_FROM_LANG, fromLang) .replaceAll(INPUT_PLACE_TO_LANG, toLang) .replaceAll(INPUT_PLACE_TEXT, texts[0]); }; const genSubtitlePrompt = ({ subtitlePrompt, tone, from, to, fromLang, toLang, docInfo: { title = "", description = "", summary = "" } = {}, }) => subtitlePrompt .replaceAll(INPUT_PLACE_TITLE, title) .replaceAll(INPUT_PLACE_DESCRIPTION, description) .replaceAll(INPUT_PLACE_SUMMARY, summary) .replaceAll(INPUT_PLACE_TONE, tone) .replaceAll(INPUT_PLACE_FROM, from) .replaceAll(INPUT_PLACE_TO, to) .replaceAll(INPUT_PLACE_FROM_LANG, fromLang) .replaceAll(INPUT_PLACE_TO_LANG, toLang); const parseAIRes = (raw, useBatchFetch = true) => { if (!raw) { return []; } if (!useBatchFetch) { return [[raw]]; } // try { // const jsonString = extractJson(raw); // if (!jsonString) return []; // const data = JSON.parse(jsonString); // if (Array.isArray(data.translations)) { // // todo: 考虑序号id可能会打乱 // return data.translations.map((item) => [ // item?.text ?? "", // item?.sourceLanguage ?? "", // ]); // } // } catch (err) { // kissLog("parse AI Res", err); // } // return []; let content = stripMarkdownCodeBlock(raw).trim(); // JSON try { const start = content.search(/(\{|\[)/); const end = content.lastIndexOf(content.includes("}") ? "}" : "]"); if (start > -1 && end > -1) { const jsonStr = content.substring(start, end + 1); const parsed = JSON.parse(jsonStr); const list = Array.isArray(parsed) ? parsed : parsed.translations || (parsed.result ? [parsed.result] : [parsed]); if ( list.length > 0 && (list[0].text !== undefined || list[0].translations) ) { return list.map((item) => [ String(item.text || ""), String(item.sourceLanguage || ""), ]); } } } catch (e) { // } // XML const xmlTagPattern = /<(t|item|seg)\b/i; if (xmlTagPattern.test(content)) { try { const parser = new DOMParser(); const doc = parser.parseFromString(content, "text/html"); const elements = doc.querySelectorAll("t, item, seg"); if (elements.length > 0) { return Array.from(elements).map((el) => [ el.innerHTML.trim(), el.getAttribute("sourceLanguage") || "", ]); } } catch (e) { // } } // 纯文本换行 return content.split("\n").map((line) => { const pipeMatch = line.match(/^\d+\s*\|\s*(.*)/); if (pipeMatch) { return [pipeMatch[1].trim(), ""]; } const text = line.replace(//gi, "\n").trim(); return [text, ""]; }); }; const parseSTRes = (raw) => { if (!raw) { return []; } try { // const jsonString = extractJson(raw); // const data = JSON.parse(jsonString); const data = parseBilingualVtt(raw); if (Array.isArray(data)) { return data; } } catch (err) { kissLog("parse AI Res: subtitle", err); } return []; }; const genGoogle = ({ texts, from, to, url, key }) => { const params = queryString.stringify({ client: "gtx", dt: "t", dj: 1, ie: "UTF-8", sl: from, tl: to, q: texts.join(" "), }); url = `${url}?${params}`; const headers = { "Content-type": "application/json", }; if (key) { headers.Authorization = `Bearer ${key}`; } return { url, headers, method: "GET" }; }; const genGoogle2 = ({ texts, from, to, url, key }) => { const body = [[texts, from, to], "wt_lib"]; const headers = { "Content-Type": "application/json+protobuf", "X-Goog-API-Key": key, }; return { url, body, headers }; }; const genMicrosoft = ({ texts, from, to, token }) => { const params = queryString.stringify({ from, to, "api-version": "3.0", }); const url = `https://api-edge.cognitive.microsofttranslator.com/translate?${params}`; const headers = { "Content-type": "application/json", Authorization: `Bearer ${token}`, }; const body = texts.map((text) => ({ Text: text })); return { url, body, headers }; }; const genAzureAI = ({ texts, from, to, url, key, region }) => { const params = queryString.stringify({ from, to, }); url = url.endsWith("&") ? `${url}${params}` : `${url}&${params}`; const headers = { "Content-type": "application/json", "Ocp-Apim-Subscription-Key": key, "Ocp-Apim-Subscription-Region": region, }; const body = texts.map((text) => ({ Text: text })); return { url, body, headers }; }; const genDeepl = ({ texts, from, to, url, key }) => { const body = { text: texts, target_lang: to, source_lang: from, // split_sentences: "0", }; const headers = { "Content-type": "application/json", Authorization: `DeepL-Auth-Key ${key}`, }; return { url, body, headers }; }; const genDeeplX = ({ texts, from, to, url, key }) => { const body = { text: texts.join(" "), target_lang: to, source_lang: from, }; const headers = { "Content-type": "application/json", }; if (key) { headers.Authorization = `Bearer ${key}`; } return { url, body, headers }; }; const genNiuTrans = ({ texts, from, to, url, key, dictNo, memoryNo }) => { const body = { from, to, apikey: key, src_text: texts.join(" "), dictNo, memoryNo, }; const headers = { "Content-type": "application/json", }; return { url, body, headers }; }; const genTencent = ({ texts, from, to }) => { const body = { header: { fn: "auto_translation", client_key: "browser-chrome-110.0.0-Mac OS-df4bd4c5-a65d-44b2-a40f-42f34f3535f2-1677486696487", }, type: "plain", model_category: "normal", source: { text_list: texts, lang: from, }, target: { lang: to, }, }; const url = "https://transmart.qq.com/api/imt"; const headers = { "Content-Type": "application/json", "user-agent": DEFAULT_USER_AGENT, referer: "https://transmart.qq.com/zh-CN/index", }; return { url, body, headers }; }; const genVolcengine = ({ texts, from, to }) => { const body = { source_language: from, target_language: to, text: texts.join(" "), }; const url = "https://translate.volcengine.com/crx/translate/v1"; const headers = { "Content-type": "application/json", }; return { url, body, headers }; }; const genOpenAI = ({ url, key, systemPrompt, userPrompt, model, temperature, maxTokens, hisMsgs = [], useStream = false, }) => { const userMsg = { role: "user", content: userPrompt, }; const body = { model, messages: [ { role: "system", content: systemPrompt, }, ...hisMsgs, userMsg, ], temperature, max_completion_tokens: maxTokens, stream: useStream, }; const headers = { "Content-type": "application/json", Authorization: `Bearer ${key}`, // OpenAI // "api-key": key, // Azure OpenAI }; return { url, body, headers, userMsg }; }; const genGemini = ({ url, key, systemPrompt, userPrompt, model, temperature, maxTokens, hisMsgs = [], useStream = false, }) => { url = url .replaceAll(INPUT_PLACE_MODEL, model) .replaceAll(INPUT_PLACE_KEY, key); // 流式传输使用 streamGenerateContent 端点 if (useStream) { url = url.replace(":generateContent", ":streamGenerateContent"); url += (url.includes("?") ? "&" : "?") + "alt=sse"; } const userMsg = { role: "user", parts: [{ text: userPrompt }] }; const body = { // system_instruction: { // parts: { // text: systemPrompt, // }, // }, contents: [ { role: "model", parts: [{ text: systemPrompt }], }, ...hisMsgs, userMsg, ], generationConfig: { maxOutputTokens: maxTokens, temperature, // topP: 0.8, // topK: 10, }, // thinkingConfig: { // thinkingBudget: 0, // }, safetySettings: [ { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_NONE", }, ], }; const headers = { "Content-type": "application/json", "x-goog-api-key": key, }; return { url, body, headers, userMsg }; }; const genGemini2 = ({ url, key, systemPrompt, userPrompt, model, temperature, maxTokens, hisMsgs = [], useStream = false, }) => { const userMsg = { role: "user", content: userPrompt, }; const body = { model, messages: [ { role: "system", content: systemPrompt, }, ...hisMsgs, userMsg, ], temperature, max_tokens: maxTokens, stream: useStream, }; const headers = { "Content-type": "application/json", Authorization: `Bearer ${key}`, }; return { url, body, headers, userMsg }; }; const genClaude = ({ url, key, systemPrompt, userPrompt, model, temperature, maxTokens, hisMsgs = [], useStream = false, }) => { const userMsg = { role: "user", content: userPrompt, }; const body = { model, system: systemPrompt, messages: [...hisMsgs, userMsg], temperature, max_tokens: maxTokens, stream: useStream, }; const headers = { "Content-type": "application/json", "anthropic-version": "2023-06-01", "anthropic-dangerous-direct-browser-access": "true", "x-api-key": key, }; return { url, body, headers, userMsg }; }; const genOpenRouter = ({ url, key, systemPrompt, userPrompt, model, temperature, maxTokens, hisMsgs = [], useStream = false, }) => { const userMsg = { role: "user", content: userPrompt, }; const body = { model, messages: [ { role: "system", content: systemPrompt, }, ...hisMsgs, userMsg, ], temperature, max_tokens: maxTokens, stream: useStream, }; const headers = { "Content-type": "application/json", Authorization: `Bearer ${key}`, }; return { url, body, headers, userMsg }; }; const genOllama = ({ // think, url, key, systemPrompt, userPrompt, model, temperature, maxTokens, hisMsgs = [], useStream = false, }) => { const userMsg = { role: "user", content: userPrompt, }; const body = { model, messages: [ { role: "system", content: systemPrompt, }, ...hisMsgs, userMsg, ], temperature, max_tokens: maxTokens, // think, stream: useStream, }; const headers = { "Content-type": "application/json", }; if (key) { headers.Authorization = `Bearer ${key}`; } return { url, body, headers, userMsg }; }; const genCloudflareAI = ({ texts, from, to, url, key }) => { const body = { text: texts.join(" "), source_lang: from, target_lang: to, }; const headers = { "Content-type": "application/json", Authorization: `Bearer ${key}`, }; return { url, body, headers }; }; const genCustom = ({ texts, fromLang, toLang, url, key, useBatchFetch }) => { const body = useBatchFetch ? { texts, from: fromLang, to: toLang } : { text: texts[0], from: fromLang, to: toLang }; const headers = { "Content-type": "application/json", Authorization: `Bearer ${key}`, }; return { url, body, headers }; }; const genReqFuncs = { [OPT_TRANS_GOOGLE]: genGoogle, [OPT_TRANS_GOOGLE_2]: genGoogle2, [OPT_TRANS_MICROSOFT]: genMicrosoft, [OPT_TRANS_AZUREAI]: genAzureAI, [OPT_TRANS_DEEPL]: genDeepl, [OPT_TRANS_DEEPLFREE]: genDeeplFree, [OPT_TRANS_DEEPLX]: genDeeplX, [OPT_TRANS_NIUTRANS]: genNiuTrans, [OPT_TRANS_BAIDU]: genBaidu, [OPT_TRANS_TENCENT]: genTencent, [OPT_TRANS_VOLCENGINE]: genVolcengine, [OPT_TRANS_OPENAI]: genOpenAI, [OPT_TRANS_GEMINI]: genGemini, [OPT_TRANS_GEMINI_2]: genGemini2, [OPT_TRANS_CLAUDE]: genClaude, [OPT_TRANS_CLOUDFLAREAI]: genCloudflareAI, [OPT_TRANS_OLLAMA]: genOllama, [OPT_TRANS_OPENROUTER]: genOpenRouter, [OPT_TRANS_CUSTOMIZE]: genCustom, }; const genInit = ({ url = "", body = null, headers = {}, userMsg = null, method = "POST", }) => { if (!url) { throw new Error("genInit: url is empty"); } const init = { method, headers, }; if (method !== "GET" && method !== "HEAD" && body) { let payload = JSON.stringify(body); const id = body?.params?.id; if (id) { payload = payload.replace( 'method":"', (id + 3) % 13 === 0 || (id + 5) % 29 === 0 ? 'method" : "' : 'method": "' ); } Object.assign(init, { body: payload }); } return [url, init, userMsg]; }; /** * 构造翻译接口请求参数 * @param {*} * @returns */ export const genTransReq = async ({ reqHook, ...args }) => { const { apiType, apiSlug, key, systemPrompt, subtitlePrompt, // userPrompt, nobatchPrompt = defaultNobatchPrompt, nobatchUserPrompt = defaultNobatchUserPrompt, useBatchFetch, from, to, fromLang, toLang, texts, glossary, customHeader, customBody, events, tone, } = args; if (API_SPE_TYPES.mulkeys.has(apiType)) { args.key = keyPick(apiSlug, key, keyMap); } if (apiType === OPT_TRANS_DEEPLX) { args.url = keyPick(apiSlug, args.url, urlMap); } if (API_SPE_TYPES.ai.has(apiType)) { const docInfo = getDocInfo(); args.systemPrompt = events ? genSubtitlePrompt({ subtitlePrompt, from, to, fromLang, toLang, texts, docInfo, tone, }) : genSystemPrompt({ systemPrompt: useBatchFetch ? systemPrompt : nobatchPrompt, from, to, fromLang, toLang, texts, docInfo, tone, }); args.userPrompt = events ? JSON.stringify(events) : genUserPrompt({ nobatchUserPrompt, useBatchFetch, from, to, fromLang, toLang, texts, docInfo, tone, glossary, }); } const { url = "", body = null, headers = {}, userMsg = null, method = "POST", } = genReqFuncs[apiType](args); // 合并用户自定义headers和body if (customHeader?.trim()) { Object.assign(headers, parseJsonObj(customHeader)); } if (customBody?.trim()) { Object.assign(body, parseJsonObj(customBody)); } // 执行 request hook if (reqHook?.trim() && !events) { try { const req = { url, body, headers, userMsg, method, }; interpreter.run(`exports.reqHook = ${reqHook}`); const hookResult = await interpreter.exports.reqHook( { ...args, defaultSystemPrompt, defaultSystemPromptXml, defaultSystemPromptLines, defaultSubtitlePrompt, defaultNobatchPrompt, defaultNobatchUserPrompt, req, }, req ); if (hookResult && hookResult.url) { return genInit(hookResult); } } catch (err) { kissLog("run req hook", err); throw new Error(`Request hook error: ${err.message}`); } } return genInit({ url, body, headers, userMsg, method }); }; /** * 解析翻译接口返回数据 * @param {*} res * @param {*} param3 * @returns */ export const parseTransRes = async ( res, { texts, from, to, fromLang, toLang, langMap, resHook, // thinkIgnore, history, userMsg, apiType, useBatchFetch, } ) => { // 执行 response hook if (resHook?.trim()) { try { interpreter.run(`exports.resHook = ${resHook}`); const hookResult = await interpreter.exports.resHook({ apiType, userMsg, res, texts, from, to, fromLang, toLang, langMap, extractJson, parseAIRes, }); if (hookResult && Array.isArray(hookResult.translations)) { if (history && userMsg && hookResult.modelMsg) { history.add(userMsg, hookResult.modelMsg); } return hookResult.translations; } else if (Array.isArray(hookResult)) { return hookResult; } } catch (err) { kissLog("run res hook", err); throw new Error(`Response hook error: ${err.message}`); } } let modelMsg = ""; // todo: 根据结果抛出实际异常信息 switch (apiType) { case OPT_TRANS_GOOGLE: return [[res?.sentences?.map((item) => item.trans).join(" "), res?.src]]; case OPT_TRANS_GOOGLE_2: return res?.[0]?.map((_, i) => [res?.[0]?.[i], res?.[1]?.[i]]); case OPT_TRANS_MICROSOFT: case OPT_TRANS_AZUREAI: return res?.map((item) => [ item.translations.map((item) => item.text).join(" "), item.detectedLanguage?.language, ]); case OPT_TRANS_DEEPL: return res?.translations?.map((item) => [ item.text, item.detected_source_language, ]); case OPT_TRANS_DEEPLFREE: return [ [ res?.result?.texts?.map((item) => item.text).join(" "), res?.result?.lang, ], ]; case OPT_TRANS_DEEPLX: return [[res?.data, res?.source_lang]]; case OPT_TRANS_NIUTRANS: const json = JSON.parse(res); if (json.error_msg) { throw new Error(json.error_msg); } return [[json.tgt_text, json.from]]; case OPT_TRANS_BAIDU: if (res.type === 1) { return [ [ Object.keys(JSON.parse(res.result).content[0].mean[0].cont)[0], res.from, ], ]; } else if (res.type === 2) { return [[res.data.map((item) => item.dst).join(" "), res.from]]; } break; case OPT_TRANS_TENCENT: return res?.auto_translation?.map((text) => [text, res?.src_lang]); case OPT_TRANS_VOLCENGINE: return [[res?.translation, res?.detected_language]]; case OPT_TRANS_OPENAI: case OPT_TRANS_GEMINI_2: case OPT_TRANS_OPENROUTER: modelMsg = res?.choices?.[0]?.message; if (history && userMsg && modelMsg) { history.add(userMsg, { role: modelMsg.role, content: modelMsg.content, }); } return parseAIRes(modelMsg?.content, useBatchFetch); case OPT_TRANS_GEMINI: modelMsg = res?.candidates?.[0]?.content; if (history && userMsg && modelMsg) { history.add(userMsg, modelMsg); } return parseAIRes(modelMsg?.parts?.[0]?.text ?? "", useBatchFetch); case OPT_TRANS_CLAUDE: modelMsg = { role: res?.role, content: res?.content?.text }; if (history && userMsg && modelMsg) { history.add(userMsg, { role: modelMsg.role, content: modelMsg.content, }); } return parseAIRes(res?.content?.[0]?.text ?? "", useBatchFetch); case OPT_TRANS_CLOUDFLAREAI: return [[res?.result?.translated_text]]; case OPT_TRANS_OLLAMA: modelMsg = res?.choices?.[0]?.message; // const deepModels = thinkIgnore // .split(",") // .filter((model) => model?.trim()); // if (deepModels.some((model) => res?.model?.startsWith(model))) { // modelMsg?.content.replace(/[\s\S]*<\/think>/i, ""); // } if (history && userMsg && modelMsg) { history.add(userMsg, { role: modelMsg.role, content: modelMsg.content, }); } return parseAIRes(modelMsg?.content, useBatchFetch); case OPT_TRANS_CUSTOMIZE: if (useBatchFetch) { return (res?.translations ?? res)?.map((item) => [item.text, item.src]); } return [[res.text, res.src || res.from]]; default: } throw new Error("parse translate result: apiType not matched", apiType); }; /** * 发送翻译请求并解析 * 支持流式和非流式两种模式 * @param {*} texts 待翻译文本数组 * @param {*} options 翻译选项 * @yields {{id: number, result: [string, string]}} 流式模式下逐个返回结果 * @returns {Promise} 非流式模式下返回完整结果数组 */ export async function* handleTranslate( texts = [], { from, to, fromLang, toLang, langMap, glossary, apiSetting, usePool } ) { let history = null; let hisMsgs = []; const { apiType, apiSlug, contextSize, useContext, fetchInterval, fetchLimit, httpTimeout, useStream, } = apiSetting; if (useContext && API_SPE_TYPES.context.has(apiType)) { history = getMsgHistory(apiSlug, contextSize); hisMsgs = history.getAll(); } const enableStream = useStream && API_SPE_TYPES.stream.has(apiType); let token = ""; if (apiType === OPT_TRANS_MICROSOFT) { token = await msAuth(); if (!token) { throw new Error("got msauth error"); } } const [input, init, userMsg] = await genTransReq({ texts, from, to, fromLang, toLang, langMap, glossary, hisMsgs, token, useStream: enableStream, ...apiSetting, }); if (enableStream) { yield* handleTranslateStreamInternal(texts, input, init, { apiType, history, userMsg, usePool, fetchInterval, fetchLimit, httpTimeout, }); } else { const response = await fetchData(input, init, { useCache: false, usePool, fetchInterval, fetchLimit, httpTimeout, }); if (!response) { throw new Error("translate got empty response"); } const result = await parseTransRes(response, { texts, from, to, fromLang, toLang, langMap, history, userMsg, ...apiSetting, }); if (!result?.length) { throw new Error("translate got an unexpected result"); } for (let i = 0; i < result.length; i++) { yield { id: i, result: result[i] }; } } } /** * 内部流式翻译处理 */ async function* handleTranslateStreamInternal( texts, input, init, { apiType, history, userMsg, usePool, fetchInterval, fetchLimit, httpTimeout } ) { const results = new Array(texts.length).fill(null); let fullContent = ""; const processedIds = new Set(); const jsonParser = createStreamingJsonParser(); let isJsonFormat = false; let formatDetected = false; try { for await (const rawData of fetchStream(input, init, { useCache: false, usePool, fetchInterval, fetchLimit, httpTimeout, })) { try { const json = JSON.parse(rawData); const delta = getStreamDelta(json, apiType); if (delta) { fullContent += delta; fullContent = stripMarkdownCodeBlock(fullContent, true); if (!formatDetected) { const { isJson, detected } = detectStreamFormat(fullContent); if (detected) { formatDetected = true; isJsonFormat = isJson; // 格式检测成功后,将累积的内容写入解析器 if (isJsonFormat) { for (const { id, translation } of jsonParser.write( fullContent )) { results[id] = translation; yield { id, result: translation }; } } } } else if (isJsonFormat) { for (const { id, translation } of jsonParser.write(delta)) { results[id] = translation; yield { id, result: translation }; } } else { for (const { id, translation } of parseStreamingSegments( fullContent, processedIds )) { results[id] = translation; yield { id, result: translation }; } } } } catch (e) { // 忽略解析错误 } } if (isJsonFormat) { jsonParser.end(); } } catch (error) { kissLog("handleTranslateStream error", error); throw error; } // 最终再解析一次,捕获可能遗漏的段落 const hasEmpty = results.some((r) => !r); if (hasEmpty) { const parsed = parseAIRes(fullContent, true); for (let i = 0; i < texts.length && i < parsed.length; i++) { if (!results[i]) { results[i] = parsed[i]; yield { id: i, result: results[i] }; } } } if (history && userMsg) { if (apiType === OPT_TRANS_GEMINI) { history.add(userMsg, { role: "model", parts: [{ text: fullContent }], }); } else { history.add(userMsg, { role: "assistant", content: fullContent, }); } } } /** * Microsoft语言识别聚合及解析 * @param {*} texts * @returns */ export const handleMicrosoftLangdetect = async (texts = []) => { const token = await msAuth(); const input = "https://api-edge.cognitive.microsofttranslator.com/detect?api-version=3.0"; const init = { headers: { "Content-type": "application/json", Authorization: `Bearer ${token}`, }, method: "POST", body: JSON.stringify(texts.map((text) => ({ Text: text }))), }; const res = await fetchData(input, init, { useCache: false, }); if (Array.isArray(res)) { return res.map((r) => r.language); } return []; }; /** * 字幕翻译 * @param {*} param0 * @returns */ export const handleSubtitle = async ({ events, from, to, apiSetting }) => { const { apiType, fetchInterval, fetchLimit, httpTimeout } = apiSetting; const [input, init] = await genTransReq({ ...apiSetting, events, from, to, }); const res = await fetchData(input, init, { useCache: false, usePool: true, fetchInterval, fetchLimit, httpTimeout, }); if (!res) { kissLog("subtitle got empty response"); return []; } switch (apiType) { case OPT_TRANS_OPENAI: case OPT_TRANS_GEMINI_2: case OPT_TRANS_OPENROUTER: case OPT_TRANS_OLLAMA: return parseSTRes(res?.choices?.[0]?.message?.content ?? ""); case OPT_TRANS_GEMINI: return parseSTRes(res?.candidates?.[0]?.content?.parts?.[0]?.text ?? ""); case OPT_TRANS_CLAUDE: return parseSTRes(res?.content?.[0]?.text ?? ""); case OPT_TRANS_CUSTOMIZE: return res; default: } return []; }; ================================================ FILE: src/background.js ================================================ import browser from "webextension-polyfill"; import { MSG_FETCH, MSG_GET_HTTPCACHE, MSG_PUT_HTTPCACHE, MSG_TRANS_TOGGLE, MSG_OPEN_OPTIONS, MSG_SAVE_RULE, MSG_TRANS_TOGGLE_STYLE, MSG_OPEN_TRANBOX, MSG_CONTEXT_MENUS, MSG_COMMAND_SHORTCUTS, MSG_INJECT_JS, MSG_INJECT_CSS, MSG_UPDATE_CSP, MSG_BUILTINAI_DETECT, MSG_BUILTINAI_TRANSLATE, CMD_TOGGLE_TRANSLATE, CMD_TOGGLE_STYLE, CMD_OPEN_OPTIONS, CMD_OPEN_TRANBOX, CMD_OPEN_SEPARATE_WINDOW, CLIENT_THUNDERBIRD, MSG_SET_LOGLEVEL, MSG_CLEAR_CACHES, MSG_OPEN_SEPARATE_WINDOW, STOKEY_SEPARATE_WINDOW, PORT_STREAM_FETCH, MSG_UPDATE_ICON, } from "./config"; import { getSettingWithDefault, tryInitDefaultData } from "./libs/storage"; import { trySyncSettingAndRules } from "./libs/sync"; import { fetchHandle, fetchStreamNative } from "./libs/fetch"; import { tryClearCaches, getHttpCache, putHttpCache } from "./libs/cache"; import { sendTabMsg } from "./libs/msg"; import { trySyncAllSubRules } from "./libs/subRules"; import { saveRule } from "./libs/rules"; import { getCurTabId } from "./libs/msg"; import { injectInlineJsBg, injectInternalCss } from "./libs/injector"; import { kissLog, logger } from "./libs/log"; import { chromeDetect, chromeTranslate } from "./libs/builtinAI"; globalThis.__KISS_CONTEXT__ = "background"; async function updateIcon(isActive, tabId) { const suffix = isActive ? "_active" : ""; const path = { 16: `images/logo16${suffix}.png`, 32: `images/logo32${suffix}.png`, 48: `images/logo48${suffix}.png`, 128: `images/logo128${suffix}.png`, 192: `images/logo192${suffix}.png`, }; try { // 兼容 v2 清单下的 Firefox if (browser.action) { await browser.action.setIcon({ path, tabId }); } else { await browser.browserAction.setIcon({ path, tabId }); } } catch (err) { kissLog("updateIcon error", err); } } const CSP_RULE_START_ID = 1; const ORI_RULE_START_ID = 10000; const CSP_REMOVE_HEADERS = [ `content-security-policy`, `content-security-policy-report-only`, `x-webkit-csp`, `x-content-security-policy`, ]; // 独立窗口ID let separateWindowId = null; // 记录窗口最后一次有效的位置和大小 let lastKnownBounds = null; const DEFAULT_SEPARATE_WINDOW_BOUNDS = { left: 100, top: 100, width: 400, height: 400, }; /** * 将独立窗口数据写入存储 */ async function persistSeparateWindowBounds(bounds) { if (!bounds) return; try { await browser.storage.local.set({ [STOKEY_SEPARATE_WINDOW]: bounds }); kissLog("Final separate window bounds saved to storage", bounds); } catch (err) { kissLog("Save separate window bounds error", err); } } /** * 打开独立窗口 */ async function openSeparateWindowWithSavedBounds() { try { // 如果窗口已存在,则聚焦它而不是重复创建 if (separateWindowId !== null) { const allWindows = await browser.windows.getAll(); const existingWin = allWindows.find((w) => w.id === separateWindowId); if (existingWin) { await browser.windows.update(separateWindowId, { focused: true }); kissLog("Separate window is ready"); return existingWin; } } const stored = await browser.storage.local.get(STOKEY_SEPARATE_WINDOW); const saved = stored && stored[STOKEY_SEPARATE_WINDOW]; const bounds = Object.assign( {}, DEFAULT_SEPARATE_WINDOW_BOUNDS, saved || {} ); const win = await browser.windows.create({ url: "popup.html#tranbox", type: "popup", left: Math.round(bounds.left), top: Math.round(bounds.top), width: Math.round(bounds.width), height: Math.round(bounds.height), focused: true, }); separateWindowId = win.id; lastKnownBounds = { left: win.left, top: win.top, width: win.width, height: win.height, }; return win; } catch (err) { kissLog("open separate window error", err); } } /** * 更新内存中的坐标缓存 */ async function updateCacheFromActual(windowId) { try { const win = await browser.windows.get(windowId); if (win && win.state === "normal") { lastKnownBounds = { left: Math.round(win.left), top: Math.round(win.top), width: Math.round(win.width), height: Math.round(win.height), }; kissLog("Bounds cached via fallback:", lastKnownBounds); // todo: 获取到的left和top均为0? // todo: firefox 每重新打开一次,窗口愈来愈大? } } catch (e) { // 窗口可能已关闭 } } /** * 监听焦点变化(兼容桌面Firefox) * Firefox 移动端不支持 */ browser.windows?.onFocusChanged?.addListener?.(async (windowId) => { if (separateWindowId !== null) { await updateCacheFromActual(separateWindowId); } }); /** * 监听位置变化:仅更新内存,不操作 Storage * Firefox 不支持 browser.windows.onBoundsChanged */ browser.windows?.onBoundsChanged?.addListener?.((win) => { if (separateWindowId !== null && win.id === separateWindowId) { lastKnownBounds = { left: win.left ?? lastKnownBounds.left, top: win.top ?? lastKnownBounds.top, width: win.width ?? lastKnownBounds.width, height: win.height ?? lastKnownBounds.height, }; // todo: 获取到的left和top均为0? } }); /** * 监听窗口关闭:此时执行持久化 * Firefox 移动端不支持 */ browser.windows?.onRemoved?.addListener?.(async (windowId) => { if (windowId === separateWindowId) { if (lastKnownBounds) { await persistSeparateWindowBounds(lastKnownBounds); } separateWindowId = null; lastKnownBounds = null; } }); /** * 添加右键菜单 */ async function addContextMenus(contextMenuType = 1) { // 添加前先删除,避免重复ID的错误 try { await browser.contextMenus.removeAll(); } catch (err) { kissLog("remove contextMenus", err); } switch (contextMenuType) { case 1: browser.contextMenus.create({ id: CMD_TOGGLE_TRANSLATE, title: browser.i18n.getMessage("app_name"), contexts: ["page", "selection"], }); break; case 2: browser.contextMenus.create({ id: CMD_TOGGLE_TRANSLATE, title: browser.i18n.getMessage("toggle_translate"), contexts: ["page", "selection"], }); browser.contextMenus.create({ id: CMD_TOGGLE_STYLE, title: browser.i18n.getMessage("toggle_style"), contexts: ["page", "selection"], }); browser.contextMenus.create({ id: CMD_OPEN_TRANBOX, title: browser.i18n.getMessage("open_tranbox"), contexts: ["page", "selection"], }); browser.contextMenus.create({ id: "options_separator", type: "separator", contexts: ["page", "selection"], }); browser.contextMenus.create({ id: CMD_OPEN_OPTIONS, title: browser.i18n.getMessage("open_options"), contexts: ["page", "selection"], }); break; default: } } /** * 更新CSP策略 * @param {*} csplist */ async function updateCspRules({ csplist, orilist }) { try { const oldRules = await browser.declarativeNetRequest.getDynamicRules(); const rulesToAdd = []; const idsToRemove = []; if (csplist !== undefined) { let processedCspList = csplist; if (typeof processedCspList === "string") { processedCspList = processedCspList .split(/\n|,/) .map((url) => url.trim()) .filter(Boolean); } const oldCspRuleIds = oldRules .filter( (rule) => rule.id >= CSP_RULE_START_ID && rule.id < ORI_RULE_START_ID ) .map((rule) => rule.id); idsToRemove.push(...oldCspRuleIds); const newCspRules = processedCspList.map((url, index) => ({ id: CSP_RULE_START_ID + index, action: { type: "modifyHeaders", responseHeaders: CSP_REMOVE_HEADERS.map((header) => ({ operation: "remove", header, })), }, condition: { urlFilter: url, resourceTypes: ["main_frame", "sub_frame"], }, })); rulesToAdd.push(...newCspRules); } if (orilist !== undefined) { let processedOriList = orilist; if (typeof processedOriList === "string") { processedOriList = processedOriList .split(/\n|,/) .map((url) => url.trim()) .filter(Boolean); } const oldOriRuleIds = oldRules .filter((rule) => rule.id >= ORI_RULE_START_ID) .map((rule) => rule.id); idsToRemove.push(...oldOriRuleIds); const newOriRules = processedOriList.map((url, index) => ({ id: ORI_RULE_START_ID + index, action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: url }], }, condition: { urlFilter: url, resourceTypes: ["xmlhttprequest"], }, })); rulesToAdd.push(...newOriRules); } if (idsToRemove.length > 0 || rulesToAdd.length > 0) { await browser.declarativeNetRequest.updateDynamicRules({ removeRuleIds: idsToRemove, addRules: rulesToAdd, }); } } catch (err) { kissLog("update csp rules", err); } } /** * 注册邮件显示脚本 */ async function registerMsgDisplayScript() { await messenger.messageDisplayScripts.register({ js: [{ file: "/content.js" }], }); } /** * 插件安装 */ browser.runtime.onInstalled.addListener(async () => { await tryInitDefaultData(); //在thunderbird中注册脚本 if (process.env.REACT_APP_CLIENT === CLIENT_THUNDERBIRD) { registerMsgDisplayScript(); } const { contextMenuType, csplist, orilist, subrulesList } = await getSettingWithDefault(); // 右键菜单 addContextMenus(contextMenuType); // 禁用CSP updateCspRules({ csplist, orilist }); // 同步订阅规则 trySyncAllSubRules({ subrulesList }); }); /** * 浏览器启动 */ browser.runtime.onStartup.addListener(async () => { const { clearCache, contextMenuType, subrulesList, csplist, orilist, logLevel, } = await getSettingWithDefault(); // 设置日志 logger.setLevel(logLevel); // 清除缓存 if (clearCache) { tryClearCaches(); } //在thunderbird中注册脚本 if (process.env.REACT_APP_CLIENT === CLIENT_THUNDERBIRD) { registerMsgDisplayScript(); } // 右键菜单 // firefox重启后菜单会消失,故重复添加 addContextMenus(contextMenuType); // 禁用CSP updateCspRules({ csplist, orilist }); // 同步数据 trySyncSettingAndRules(); // 同步订阅规则 trySyncAllSubRules({ subrulesList }); }); /** * 向当前活动标签页注入脚本或CSS */ const injectToCurrentTab = async (func, args) => { const tabId = await getCurTabId(); return browser.scripting.executeScript({ target: { tabId, allFrames: true }, func: func, args: [args], world: "MAIN", }); }; // 动作处理器映射表 const messageHandlers = { [MSG_FETCH]: (args) => fetchHandle(args), [MSG_GET_HTTPCACHE]: (args) => getHttpCache(args), [MSG_PUT_HTTPCACHE]: (args) => putHttpCache(args), [MSG_OPEN_OPTIONS]: () => browser.runtime.openOptionsPage(), [MSG_SAVE_RULE]: (args) => saveRule(args), [MSG_INJECT_JS]: (args) => injectToCurrentTab(injectInlineJsBg, args), [MSG_INJECT_CSS]: (args) => injectToCurrentTab(injectInternalCss, args), [MSG_UPDATE_CSP]: (args) => updateCspRules(args), [MSG_CONTEXT_MENUS]: (args) => addContextMenus(args), [MSG_COMMAND_SHORTCUTS]: () => browser.commands.getAll(), [MSG_BUILTINAI_DETECT]: (args) => chromeDetect(args), [MSG_BUILTINAI_TRANSLATE]: (args) => chromeTranslate(args), [MSG_SET_LOGLEVEL]: (args) => logger.setLevel(args), [MSG_CLEAR_CACHES]: () => tryClearCaches(), [MSG_OPEN_SEPARATE_WINDOW]: () => openSeparateWindowWithSavedBounds(), [MSG_UPDATE_ICON]: (args, sender) => updateIcon(args, sender?.tab?.id), }; /** * 监听消息 * todo: 返回含错误的结构化信息 */ browser.runtime.onMessage.addListener(async ({ action, args }, sender) => { const handler = messageHandlers[action]; if (!handler) { throw new Error(`Message action is unavailable: ${action}`); } return handler(args, sender); }); /** * 监听快捷键 * Firefox 移动端不支持 */ browser.commands?.onCommand?.addListener?.((command) => { // console.log(`Command: ${command}`); switch (command) { case CMD_TOGGLE_TRANSLATE: sendTabMsg(MSG_TRANS_TOGGLE); break; case CMD_OPEN_TRANBOX: sendTabMsg(MSG_OPEN_TRANBOX); break; case CMD_TOGGLE_STYLE: sendTabMsg(MSG_TRANS_TOGGLE_STYLE); break; case CMD_OPEN_OPTIONS: browser.runtime.openOptionsPage(); break; case CMD_OPEN_SEPARATE_WINDOW: // invoke the handler to open the independent window if (messageHandlers[MSG_OPEN_SEPARATE_WINDOW]) { messageHandlers[MSG_OPEN_SEPARATE_WINDOW](); } break; default: } }); /** * 监听右键菜单 * Firefox 移动端不支持 */ browser?.contextMenus?.onClicked?.addListener?.(({ menuItemId }) => { switch (menuItemId) { case CMD_TOGGLE_TRANSLATE: sendTabMsg(MSG_TRANS_TOGGLE); break; case CMD_TOGGLE_STYLE: sendTabMsg(MSG_TRANS_TOGGLE_STYLE); break; case CMD_OPEN_TRANBOX: sendTabMsg(MSG_OPEN_TRANBOX); break; case CMD_OPEN_OPTIONS: browser.runtime.openOptionsPage(); break; default: } }); /** * 处理通用流式请求 * 通过端口连接实现流式数据传输 */ async function handleStreamFetch(port, args) { const { input, init, opts } = args; try { for await (const chunk of fetchStreamNative( input, init, opts.httpTimeout )) { port.postMessage({ type: "delta", data: chunk }); } port.postMessage({ type: "done" }); } catch (error) { if (error.name !== "AbortError") { port.postMessage({ type: "error", error: error.message }); } } } /** * 监听端口连接(用于流式请求) */ browser.runtime.onConnect.addListener((port) => { if (port.name === PORT_STREAM_FETCH) { port.onMessage.addListener((message) => { if (message.action === "start") { handleStreamFetch(port, message.args); } }); } }); ================================================ FILE: src/common.js ================================================ import { OPT_HIGHLIGHT_WORDS_DISABLE } from "./config"; import { getFabWithDefault, getSettingWithDefault, getWordsWithDefault, } from "./libs/storage"; import { isIframe } from "./libs/iframe"; import { genEventName } from "./libs/utils"; import { handlePing, injectScript } from "./libs/gm"; import { matchRule } from "./libs/rules"; import { trySyncAllSubRules } from "./libs/subRules"; import { isInBlacklist } from "./libs/blacklist"; import { runSubtitle } from "./subtitle/subtitle"; import { logger } from "./libs/log"; import { injectInlineJs } from "./libs/injector"; import TranslatorManager from "./libs/translatorManager"; /** * 油猴脚本设置页面 */ function runSettingPage() { if (GM.info?.script?.grant?.includes("unsafeWindow")) { unsafeWindow.GM = GM; unsafeWindow.APP_INFO = { name: process.env.REACT_APP_NAME, version: process.env.REACT_APP_VERSION, }; } else { const ping = genEventName(); window.addEventListener(ping, handlePing); // window.eval(`(${injectScript})("${ping}")`); // eslint-disable-line injectInlineJs( `(${injectScript})("${ping}")`, "kiss-translator-options-injector" ); } } /** * 显示错误信息到页面顶部 * @param {*} message */ function showErr(message) { const bannerId = "KISS-Translator-Message"; const existingBanner = document.getElementById(bannerId); if (existingBanner) { existingBanner.remove(); } const banner = document.createElement("div"); banner.id = bannerId; Object.assign(banner.style, { position: "fixed", top: "0", left: "0", width: "100%", backgroundColor: "#f44336", color: "white", textAlign: "center", padding: "8px 16px", zIndex: "1001", boxSizing: "border-box", fontSize: "16px", boxShadow: "0 2px 5px rgba(0,0,0,0.2)", }); const closeButton = document.createElement("span"); closeButton.textContent = "×"; Object.assign(closeButton.style, { position: "absolute", top: "50%", right: "20px", transform: "translateY(-50%)", cursor: "pointer", fontSize: "22px", fontWeight: "bold", }); const messageText = document.createTextNode(`KISS-Translator: ${message}`); banner.appendChild(messageText); banner.appendChild(closeButton); document.body.appendChild(banner); const removeBanner = () => { banner.style.transition = "opacity 0.5s ease"; banner.style.opacity = "0"; setTimeout(() => { if (banner && banner.parentNode) { banner.parentNode.removeChild(banner); } }, 500); }; closeButton.onclick = removeBanner; setTimeout(removeBanner, 10000); } async function getFavWords(rule) { if ( rule.highlightWords && rule.highlightWords !== OPT_HIGHLIGHT_WORDS_DISABLE ) { try { return Object.keys(await getWordsWithDefault()); } catch (err) { logger.info("get fav words", err); } } return []; } /** * 入口函数 */ export async function run(isUserscript = false) { try { // 读取设置信息 const setting = await getSettingWithDefault(); // 日志 logger.setLevel(setting.logLevel); // if (document?.documentElement?.tagName?.toUpperCase() !== "HTML") { // return; // } const contentType = document?.contentType?.toLowerCase() || ""; if (!contentType.includes("text") && !contentType.includes("html")) { logger.info("Skip running in document content type: ", contentType); return; } const href = document?.location?.href || ""; // 油猴脚本 if (isUserscript) { if (!globalThis.GM) { globalThis.GM = { xmlHttpRequest: globalThis.GM_xmlhttpRequest, registerMenuCommand: globalThis.GM_registerMenuCommand, unregisterMenuCommand: globalThis.GM_unregisterMenuCommand, setValue: globalThis.GM_setValue, getValue: globalThis.GM_getValue, deleteValue: globalThis.GM_deleteValue, info: globalThis.GM_info, }; } // 设置页面 if ( href.includes(process.env.REACT_APP_OPTIONSPAGE_DEV) || href.includes(process.env.REACT_APP_OPTIONSPAGE) ) { runSettingPage(); return; } } // 黑名单 if (isInBlacklist(href, setting)) { return; } // 翻译网页 const rule = await matchRule(href, setting); const favWords = await getFavWords(rule); const fabConfig = await getFabWithDefault(); const translatorManager = new TranslatorManager({ setting, rule, fabConfig, favWords, isIframe, isUserscript, }); translatorManager.start(); if (isIframe) { return; } // 字幕翻译 runSubtitle({ href, setting, rule, isUserscript }); if (isUserscript) { trySyncAllSubRules(setting); } } catch (err) { console.error("[KISS-Translator]", err); showErr(err.message); } } ================================================ FILE: src/components/Logo/icon.base64.js ================================================ export const FAVICON_BASE64 = "data:image/x-icon;base64,AAABAAMAMDAAAAEAIACoJQAANgAAACAgAAABACAAqBAAAN4lAAAQEAAAAQAgAGgEAACGNgAAKAAAADAAAABgAAAAAQAgAAAAAAAAJAAAAAAAAAAAAAAAAAAAAAAAAAAAAADunB8Q7pwgd+6cINTunCD67pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD67pwg1O6cIHfunR8QAAAAAO6cHxDunCCW7pwg+e6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIPnunCCW7pwfEO6cIHfunCD47pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD47pwgd+6cINPunCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg0+6cIPnunCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg+e6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6bHv/umx3/7pse/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unB//7psd/+6bHf/umx7/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pse/+6bHf/umx7/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7p0i//GsRP/ytlv/8a9K/+6eJf/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cH//vnyj/8bFP//K2Wv/wqj7/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCH/8Kk9//KzVf/xrUb/7p4k/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx7/8rVY//3y4v/++/f//fbq//S+bP/umx7/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/2yIT//vjw//779//87db/8axF/+6bHv/unCD/7pwg/+6cIP/unCD/7pwg/+6bHv/xsEz//O7Z//769P/99en/9MBx/+6bHv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9smG//////////////////jVn//unB//7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6fJv/64r7//////////////v3/9L9v/+6aHP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHP/1xHn/////////////////+Nmq/+6dIf/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suK//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/++fKP/65MH///////////////7/9MF0/+6aHP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dut/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/++fJ//65MH//////////////v7/9MFz/+6aHP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwf/++gKf/75cX//////////////v7/9MFz/+6aHP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pod//GuSP/98+T///////////////7/9MF0/+6aHP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx7/8a1G//rkw////////////////////vz/9L5t/+6aHP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwf/+6cH//ytlz//O3W///////////////////////98eH/8axE/+6bHv/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/umx//7p4k//TCdv/99ej///////////////////////3x4P/zu2b/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jWpP/unCD/7pwg/+6bHv/voSz/98+S//769P//////////////////////++jK//KxUP/umx7/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpf/unB//7psd//CnOf/5267///37///////////////////9+//53K//8Kg7/+6bHf/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////vmx//wpjj/8rFQ//vmx////////////////////////vrz//fOkf/voSz/7pse/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ///////////////////9+v/87df//fXo///////////////////////99Ob/9MFz/+6dI//umx//7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////////////////////////////////////zr0v/ytVj/7psf/+6cH//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ////////////////////////////////////////////++fJ//GrQv/umx3/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ////////////////////////////////////////////9cN3/+6aG//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ////////////////////////////////////////////+Nai/+6eJf/unB//7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ/////////////////////////////////////////////vv2//bKiP/uniT/7pwf/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ///////////////////+/f/+9+3///z5//////////////////769f/2yYX/7p0j/+6cH//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////zr0v/xsE7/9siE//748P/////////////////++vT/9siD/+6dI//unB//7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jYp//umx7/7pwh//XFfP/++fH//////////////////vrz//XHgP/unSL/7pwf/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jWpP/unCD/7pwf/+6dIv/1xn3//vny//////////////////758v/1xn7/7p0i/+6cH//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cH//unSL/9cZ///758//////////////////++fH/9cV7/+6dIv/unB//7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unB//7p0j//XHgf/++vP//////////////////vjw//XDef/unSH/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwf/+6dI//1yIP//vr0//////////////////747//0wXP/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHf/1xn7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cH//uniP/9smF//769f/////////////////98d//8KpB/+6bHv/unCD/7pwg/+6cIP/unCD/7pwg/+6aHP/1xX7/////////////////+dqt/+6dIv/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unB//7p4k//fOkf///vz//////////////fz/9L1q/+6aHP/unB//7psf/+6bH//umx//7pse/+6aG//2yof/////////////////+d60/+6dIv/umx7/7psf/+6bH//umx//7pwf/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pse//CnOP/879r//////////////v7/9MFz/+6aHP/vnyf/8KY2//CmN//wpjf/8KY3//KxT//76Mv//////////////////fPk//O6ZP/wpjj/8KY3//CmN//wpjb/76Er/+6cH//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suJ//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwf/++gKP/65MP//////////////v7/9MBy/++kMf/5267//PDe//3x3//88d///fHf//737v////7///////////////////////768//98uH//PHf//3x3//88d//+uTB//GuSP/umx7/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9suK//////////////////jXpP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/++fKP/65MH///////////////7/9MBw//O4X////fr///////////////////////////////////////////////////////////////////////////////////////bJhv/umx3/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx3/9smF//////////////////jUnv/unB//7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6fJv/64r3//////////////v3/9L1q//O6Y////fv///////////////////////////////////////////////////////////////////////////////////////bLif/umx3/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx7/8rNU//zv3P/++vP//fTl//O7Z//umx7/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cH//1xn7//fbr//768//76tD/8KpA//CoO//75sj//vjw//748P/++PD//vjw//748P/++PD//vjw//748P/++PD//vjw//748P/++PD//vjw//748P/++PD//O7a//KzVf/umx7/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwh//CpPv/ys1P/8axD/+6dI//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unib/8a5I//KyUv/wpzn/7pwg/+6cIP/wpTT/8a9M//GwTf/xsE3/8bBN//GwTf/xsE3/8bBN//GwTf/xsE3/8bBN//GwTf/xsE3/8bBN//GwTf/xsE3/8Kg7/+6cIf/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6bHv/umx3/7pse/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pse/+6bHf/umx7/7pwg/+6cIP/umx//7psd/+6bHf/umx3/7psd/+6bHf/umx3/7psd/+6bHf/umx3/7psd/+6bHf/umx3/7psd/+6bHf/umx3/7pse/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIPnunCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg+e6cINTunCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg1O6cIHfunCD47pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD47pwgd+6cHxDunCCW7pwg+e6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIPnunCCW7pwfEAAAAADunB8Q7pwgd+6cINTunCD67pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD67pwg1O6cIHfunB8QAAAAAOAAAAAABwAAgAAAAAABAACAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAQAAgAAAAAABAADgAAAAAAcAACgAAAAgAAAAQAAAAAEAIAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAADunB8S7pwgie6cIOvunCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIOvunCCJ7pwgEu6cIInunCD57pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIPnunCCJ7pwg6u6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIOrunCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cH//uniX/7p8m/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cH//uniT/758n/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6dIf/vnyf/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx7/8KpA//netf/64bz/8a5J/+6bHv/unCD/7pwg/+6cIP/umx//76Qz//jZqf/65MP/87Zc/+6bHv/unCD/7pwg/+6cIP/uniX/9syL//vlxP/0wnb/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHP/zvGn///78///////1xHr/7poc/+6cIP/unCD/7pwg/+6bHf/yslP//vr0///////30pr/7pse/+6cIP/unCD/7psf/++kM//87tr///////rjwP/unyb/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7poc//S+bP///v3///////XFff/umhz/7pwg/+6cIP/unCD/7psd//KzVP/++vX///////jUnf/umx7/7pwg/+6cIP/umx//8KU1//zv3P//////+uTC/++fJ//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umhz/9L1s///+/f//////9cV9/+6aHP/unCD/7pwg/+6cIP/umhv/87de//78+P//////+NSd/+6bHv/unCD/7pwg/+6bH//wpTX//O/c///////65ML/758n/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHP/0vWz///79///////1xX3/7poc/+6cIP/unCD/7psd//CmN//637j////////////30pn/7pse/+6cIP/unCD/7psf//ClNf/879z///////rkwv/vnyf/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7poc//S9bP///v3///////XFff/umhz/7pwg/+6bHv/xr0r/++bG/////////////vft//O5Yf/umx3/7pwg/+6cIP/umx//8KU1//zv3P//////+uTC/++fJ//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umhz/9L1s///+/f//////9cV8/+6aHP/unCD/87lh//zv3P////////////3x3//zvGf/7p0h/+6cIP/unCD/7pwg/+6bH//wpTX//O/c///////65ML/758n/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHP/0vWz///79///////2yIP/7p0i//XFfP/+9uz////////////758n/8bBN/+6bHv/unCD/7pwg/+6cIP/unCD/7psf//ClNf/879z///////rkwv/vnyf/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7poc//S9bP///v3///////zt1v/53rT//vv1/////////fv/+dut//CnOf/umx3/7pwg/+6cIP/unCD/7pwg/+6cIP/umx//8KU1//zv3P//////+uTC/++fJ//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umhz/9L1s///+/f///////////////////////vr1//bOkP/voSv/7pse/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6bH//wpTX//O/c///////65ML/758n/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHP/0vWz///79///////////////////////526//7p0i/+6bH//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7psf//ClNf/879z///////rkwv/vnyf/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7poc//S9bP///v3///////////////////////voy//wpzj/7pse/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umx//8KU1//zv3P//////+uTC/++fJ//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umhz/9L1s///+/f///////vv3//768/////////////rguv/wpjb/7pse/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6bH//wpTX//O/c///////65ML/758n/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHP/0vWz///79///////41J7/87hf//zv2/////////////rft//wpTX/7pse/+6cIP/unCD/7pwg/+6cIP/unCD/7psf//ClNf/879z///////rkwv/vnyf/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7poc//S9bP///v3///////XFfP/tmRn/8rNV//zw3P////////////netf/wpTT/7pse/+6cIP/unCD/7pwg/+6cIP/umx//8KU1//zv3P//////+uTC/++fJ//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umhz/9L1s///+/f//////9cV9/+6aHP/umx7/8rRW//zw3v/////////+//nds//vpDP/7pse/+6cIP/unCD/7pwg/+6bH//wpTX//O/c///////65ML/758n/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6aHP/0vWz///79///////1xX3/7poc/+6cIP/umx7/8rVY//3x3//////////+//ncsP/vozD/7pwf/+6cIP/unCD/7psf//ClNf/879z///////rkwv/vnyf/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7poc//S9bP///v3///////XFff/umhz/7pwg/+6cIP/umx7/8rVZ//3y4v////////79//XEev/umx3/7psf/+6bHv/umx3/76Qz//zw3P//////+uTC/+6eJf/umx7/7pse/+6cH//unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/umhz/9L1s///+/f//////9cV9/+6aHP/unCD/7pwg/+6cIP/umx3/9cV7///+/f//////99Ob/+6cIf/wpTT/8Kc4//CmN//zu2b//vjv///////88N7/8rJT//CmN//wpzj/76Mx/+6cIf/unCD/7pwg/+6cIP/unCD/7pwg/+6aHP/0vmz///79///////1xX3/7poc/+6cIP/unCD/7pwg/+6bHf/ytFX//vv1///////30pv/87tn//zv2//98uH//fLh//758f///////////////v/+9+3//fHh//3y4f/76tD/8bBM/+6bHv/unCD/7pwg/+6cIP/unCD/7poc//O8af///vz///////XEef/umhz/7pwg/+6cIP/unCD/7psd//KyUv/++vP///////fRlv/3zpH////////////////////////////////////////////////////////+/P/0vWv/7poc/+6cIP/unCD/7pwg/+6cIP/umx7/8Kk+//ncsP/537b/8a1H/+6bHv/unCD/7pwg/+6cIP/unB//76Qx//jWo//64r7/8rRW//KyUv/537f/+uO///riv//64r//+uK///riv//64r//+uK///riv//647//+dqs//CpPv/umx7/7pwg/+6cIP/unCD/7pwg/+6cIP/unB//7p4k/+6eJP/unB//7pwg/+6cIP/unCD/7pwg/+6cIP/unB//7p0j/+6eJf/unB//7pwf/+6eJP/unib/7p4l/+6eJf/uniX/7p4l/+6eJf/uniX/7p4l/+6eJv/unSP/7pwf/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg6u6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIOrunCCJ7pwg+e6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD57pwgie6cIBLunCCJ7pwg6+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg6+6cIInunB8SgAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAEoAAAAEAAAACAAAAABACAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAA7pwgnu6cIPjunCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD47pwgnu6cIPfunB//7psd/+6cH//unCD/7pwg/+6bHv/umx3/7pwg/+6cIP/umx7/7psd/+6cIP/unCD/7pwg/+6cIPfunB//76Mv//S9a//vpDH/7pwf/+6cH//xsE7/87dc/+6cIf/umx//8axD//O5Yf/unSP/7pwg/+6cIP/unCD/7psd//KxT//99en/8rRX/+6bHf/umx7/+NWh//vmxv/vnyf/7poc//bLiv/87NX/76Iu/+6cH//unCD/7pwg/+6bHf/yslH//fbr//K1Wf/umhr/76Iu//rkw//75sb/7p8n/+6aHP/2zIz//O3X/++jL//unB//7pwg/+6cIP/umx3/8rJR//326//ytFX/8Kc4//netv/+9+3/9cN4/+6cIP/umh3/9syM//zt1//voy//7pwf/+6cIP/unCD/7psd//KyUf/++O//99Oc//voy//98eD/87tl/+6cIf/unCD/7psd//bMjP/87df/76Mv/+6cH//unCD/7pwg/+6bHf/ysVD//vrz///////98uL/8bBO/+6bHf/unCD/7pwg/+6aHf/2zIz//O3X/++jL//unB//7pwg/+6cIP/umx3/8rFQ//768//++/b//fXo//O3Xf/umx7/7pwg/+6cIP/umh3/9syM//zt1//voy//7pwf/+6cIP/unCD/7psd//KyUf/+9+3/9cV9//rguf/98+P/8rZb/+6bHv/unCD/7pod//bMjP/87df/76Mv/+6cH//unCD/7pwg/+6bHf/yslH//fbr//K0Vv/vpTT/+uK+//3y4f/ytFf/7pse/+6aG//2y4v//O3X/++iLf/umx7/7pwg/+6cIP/umx3/8rJR//326//ytVn/7pka//CnOf/869L/+uG7/++kM//wpjj/+NWh//3y4f/xr0z/8KU1/+6dI//unCD/7psd//KxT//99en/8rRX/+6bHf/umx3/+Nai//vnyv/52ar//fTm//779f///v3//fXp//zs0//xrET/7psd/+6cH//voi7/87xo/++jMf/unB//7pwf//GvTP/ytlv/8rZZ//XEev/1w3j/9cN3//XEef/0v3D/76Iu/+6cH//unCD37pwf/+6bHf/unB//7pwg/+6cIP/umx7/7psd/+6bHf/umhz/7poc/+6aHP/umhz/7poc/+6cH//unCD37pwgnu6cIPjunCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD/7pwg/+6cIP/unCD47pwgngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; export default FAVICON_BASE64; ================================================ FILE: src/components/Logo/index.js ================================================ import React from "react"; import { FAVICON_BASE64 } from "./icon.base64.js"; const Logo = ({ size = 16, className = "", style = {}, onClick }) => { return ( Logo ); }; export { FAVICON_BASE64 }; export default Logo; ================================================ FILE: src/config/api.js ================================================ export const DEFAULT_HTTP_TIMEOUT = 10000; // 调用超时时间 export const DEFAULT_FETCH_LIMIT = 10; // 默认最大任务数量 export const DEFAULT_FETCH_INTERVAL = 100; // 默认任务间隔时间 export const DEFAULT_BATCH_INTERVAL = 400; // 批处理请求间隔时间 export const DEFAULT_BATCH_SIZE = 10; // 每次最多发送段落数量 export const DEFAULT_BATCH_LENGTH = 10000; // 每次发送最大文字数量 export const DEFAULT_CONTEXT_SIZE = 3; // 上下文会话数量 export const INPUT_PLACE_URL = "{{url}}"; // 占位符 export const INPUT_PLACE_FROM = "{{from}}"; // 占位符 export const INPUT_PLACE_TO = "{{to}}"; // 占位符 export const INPUT_PLACE_FROM_LANG = "{{fromLang}}"; // 占位符 export const INPUT_PLACE_TO_LANG = "{{toLang}}"; // 占位符 export const INPUT_PLACE_TEXT = "{{text}}"; // 占位符 export const INPUT_PLACE_TONE = "{{tone}}"; // 占位符 export const INPUT_PLACE_TITLE = "{{title}}"; // 标题 export const INPUT_PLACE_DESCRIPTION = "{{description}}"; // 描述 export const INPUT_PLACE_SUMMARY = "{{summary}}"; // 摘要 export const INPUT_PLACE_KEY = "{{key}}"; // 占位符 export const INPUT_PLACE_MODEL = "{{model}}"; // 占位符 // export const OPT_DICT_BAIDU = "Baidu"; export const OPT_DICT_BING = "Bing"; export const OPT_DICT_YOUDAO = "Youdao"; export const OPT_DICT_ALL = [OPT_DICT_BING, OPT_DICT_YOUDAO]; export const OPT_DICT_MAP = new Set(OPT_DICT_ALL); export const OPT_SUG_BAIDU = "Baidu"; export const OPT_SUG_YOUDAO = "Youdao"; export const OPT_SUG_ALL = [OPT_SUG_BAIDU, OPT_SUG_YOUDAO]; export const OPT_SUG_MAP = new Set(OPT_SUG_ALL); export const OPT_TRANS_BUILTINAI = "BuiltinAI"; export const OPT_TRANS_GOOGLE = "Google"; export const OPT_TRANS_GOOGLE_2 = "Google2"; export const OPT_TRANS_MICROSOFT = "Microsoft"; export const OPT_TRANS_AZUREAI = "AzureAI"; export const OPT_TRANS_DEEPL = "DeepL"; export const OPT_TRANS_DEEPLX = "DeepLX"; export const OPT_TRANS_DEEPLFREE = "DeepLFree"; export const OPT_TRANS_NIUTRANS = "NiuTrans"; export const OPT_TRANS_BAIDU = "Baidu"; export const OPT_TRANS_TENCENT = "Tencent"; export const OPT_TRANS_VOLCENGINE = "Volcengine"; export const OPT_TRANS_OPENAI = "OpenAI"; export const OPT_TRANS_GEMINI = "Gemini"; export const OPT_TRANS_GEMINI_2 = "Gemini2"; export const OPT_TRANS_CLAUDE = "Claude"; export const OPT_TRANS_CLOUDFLAREAI = "CloudflareAI"; export const OPT_TRANS_OLLAMA = "Ollama"; export const OPT_TRANS_OPENROUTER = "OpenRouter"; export const OPT_TRANS_CUSTOMIZE = "Custom"; // 内置支持的翻译引擎 export const OPT_ALL_TRANS_TYPES = [ OPT_TRANS_BUILTINAI, OPT_TRANS_GOOGLE, OPT_TRANS_GOOGLE_2, OPT_TRANS_MICROSOFT, OPT_TRANS_AZUREAI, // OPT_TRANS_BAIDU, OPT_TRANS_TENCENT, OPT_TRANS_VOLCENGINE, OPT_TRANS_DEEPL, OPT_TRANS_DEEPLFREE, OPT_TRANS_DEEPLX, OPT_TRANS_NIUTRANS, OPT_TRANS_OPENAI, OPT_TRANS_GEMINI, OPT_TRANS_GEMINI_2, OPT_TRANS_CLAUDE, OPT_TRANS_CLOUDFLAREAI, OPT_TRANS_OLLAMA, OPT_TRANS_OPENROUTER, OPT_TRANS_CUSTOMIZE, ]; export const OPT_LANGDETECTOR_ALL = [ OPT_TRANS_BUILTINAI, OPT_TRANS_GOOGLE, OPT_TRANS_MICROSOFT, OPT_TRANS_BAIDU, OPT_TRANS_TENCENT, ]; export const OPT_LANGDETECTOR_MAP = new Set(OPT_LANGDETECTOR_ALL); // 翻译引擎特殊集合 export const API_SPE_TYPES = { // 内置翻译 builtin: new Set(OPT_ALL_TRANS_TYPES), // 机器翻译 machine: new Set([ OPT_TRANS_MICROSOFT, OPT_TRANS_DEEPLFREE, OPT_TRANS_BAIDU, OPT_TRANS_TENCENT, OPT_TRANS_VOLCENGINE, ]), // AI翻译 ai: new Set([ OPT_TRANS_OPENAI, OPT_TRANS_GEMINI, OPT_TRANS_GEMINI_2, OPT_TRANS_CLAUDE, OPT_TRANS_OLLAMA, OPT_TRANS_OPENROUTER, OPT_TRANS_CUSTOMIZE, ]), // 支持多key mulkeys: new Set([ OPT_TRANS_AZUREAI, OPT_TRANS_DEEPL, OPT_TRANS_OPENAI, OPT_TRANS_GEMINI, OPT_TRANS_GEMINI_2, OPT_TRANS_CLAUDE, OPT_TRANS_CLOUDFLAREAI, OPT_TRANS_OLLAMA, OPT_TRANS_OPENROUTER, OPT_TRANS_NIUTRANS, OPT_TRANS_CUSTOMIZE, ]), // 支持批处理 batch: new Set([ OPT_TRANS_AZUREAI, OPT_TRANS_GOOGLE_2, OPT_TRANS_MICROSOFT, OPT_TRANS_TENCENT, OPT_TRANS_DEEPL, OPT_TRANS_OPENAI, OPT_TRANS_GEMINI, OPT_TRANS_GEMINI_2, OPT_TRANS_CLAUDE, OPT_TRANS_OLLAMA, OPT_TRANS_OPENROUTER, OPT_TRANS_CUSTOMIZE, ]), // 支持上下文 context: new Set([ OPT_TRANS_OPENAI, OPT_TRANS_GEMINI, OPT_TRANS_GEMINI_2, OPT_TRANS_CLAUDE, OPT_TRANS_OLLAMA, OPT_TRANS_OPENROUTER, OPT_TRANS_CUSTOMIZE, ]), // 支持流式传输 stream: new Set([ OPT_TRANS_OPENAI, OPT_TRANS_GEMINI, OPT_TRANS_GEMINI_2, OPT_TRANS_CLAUDE, OPT_TRANS_OLLAMA, OPT_TRANS_OPENROUTER, ]), }; export const BUILTIN_STONES = [ "formal", // 正式风格 "casual", // 口语风格 "neutral", // 中性风格 "technical", // 技术风格 "marketing", // 营销风格 "Literary", // 文学风格 "academic", // 学术风格 "legal", // 法律风格 "literal", // 直译风格 "idiomatic", // 意译风格 "transcreation", // 创译风格 "machine-like", // 机器风格 "concise", // 简明风格 ]; export const BUILTIN_PLACEHOLDERS = ["{ }", "{{ }}", "[ ]", "[[ ]]"]; export const BUILTIN_PLACETAGS = ["i", "a", "b", "x", "span"]; export const PLACETAG_FORMATS = ["compact", "attribute"]; // 占位符格式:简洁格式、属性格式 export const OPT_LANGS_TO = [ ["en", "English - English"], ["zh-CN", "Simplified Chinese - 简体中文"], ["zh-TW", "Traditional Chinese - 繁體中文"], ["ar", "Arabic - العربية"], ["bg", "Bulgarian - Български"], ["ca", "Catalan - Català"], ["hr", "Croatian - Hrvatski"], ["cs", "Czech - Čeština"], ["da", "Danish - Dansk"], ["nl", "Dutch - Nederlands"], ["fa", "Persian - فارسی"], ["fi", "Finnish - Suomi"], ["fr", "French - Français"], ["de", "German - Deutsch"], ["el", "Greek - Ελληνικά"], ["hi", "Hindi - हिन्दी"], ["hu", "Hungarian - Magyar"], ["id", "Indonesian - Indonesia"], ["it", "Italian - Italiano"], ["ja", "Japanese - 日本語"], ["ko", "Korean - 한국어"], ["ms", "Malay - Melayu"], ["mt", "Maltese - Malti"], ["nb", "Norwegian - Norsk Bokmål"], ["pl", "Polish - Polski"], ["pt", "Portuguese - Português"], ["ro", "Romanian - Română"], ["ru", "Russian - Русский"], ["sk", "Slovak - Slovenčina"], ["sl", "Slovenian - Slovenščina"], ["es", "Spanish - Español"], ["sv", "Swedish - Svenska"], ["ta", "Tamil - தமிழ்"], ["te", "Telugu - తెలుగు"], ["th", "Thai - ไทย"], ["tr", "Turkish - Türkçe"], ["uk", "Ukrainian - Українська"], ["vi", "Vietnamese - Tiếng Việt"], ]; export const OPT_LANGS_LIST = OPT_LANGS_TO.map(([lang]) => lang); export const OPT_LANGS_FROM = [["auto", "Auto-detect"], ...OPT_LANGS_TO]; export const OPT_LANGS_MAP = new Map(OPT_LANGS_TO); // CODE->名称 export const OPT_LANGS_SPEC_NAME = new Map( OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]]) ); export const OPT_LANGS_SPEC_DEFAULT = new Map( OPT_LANGS_FROM.map(([key]) => [key, key]) ); export const OPT_LANGS_SPEC_DEFAULT_UC = new Map( OPT_LANGS_FROM.map(([key]) => [key, key.toUpperCase()]) ); export const OPT_LANGS_TO_SPEC = { [OPT_TRANS_BUILTINAI]: new Map([ ...OPT_LANGS_SPEC_DEFAULT, ["zh-CN", "zh"], ["zh-TW", "zh"], ]), [OPT_TRANS_GOOGLE]: OPT_LANGS_SPEC_DEFAULT, [OPT_TRANS_GOOGLE_2]: OPT_LANGS_SPEC_DEFAULT, [OPT_TRANS_MICROSOFT]: new Map([ ...OPT_LANGS_SPEC_DEFAULT, ["auto", ""], ["zh-CN", "zh-Hans"], ["zh-TW", "zh-Hant"], ]), [OPT_TRANS_AZUREAI]: new Map([ ...OPT_LANGS_SPEC_DEFAULT, ["auto", ""], ["zh-CN", "zh-Hans"], ["zh-TW", "zh-Hant"], ]), [OPT_TRANS_DEEPL]: new Map([ ...OPT_LANGS_SPEC_DEFAULT_UC, ["auto", ""], ["zh-CN", "ZH"], ["zh-TW", "ZH"], ]), [OPT_TRANS_DEEPLFREE]: new Map([ ...OPT_LANGS_SPEC_DEFAULT_UC, ["auto", "auto"], ["zh-CN", "ZH"], ["zh-TW", "ZH"], ]), [OPT_TRANS_DEEPLX]: new Map([ ...OPT_LANGS_SPEC_DEFAULT_UC, ["auto", "auto"], ["zh-CN", "ZH"], ["zh-TW", "ZH"], ]), [OPT_TRANS_NIUTRANS]: new Map([ ...OPT_LANGS_SPEC_DEFAULT, ["auto", "auto"], ["zh-CN", "zh"], ["zh-TW", "cht"], ]), [OPT_TRANS_VOLCENGINE]: new Map([ ...OPT_LANGS_SPEC_DEFAULT, ["auto", "auto"], ["zh-CN", "zh"], ["zh-TW", "zh-Hant"], ]), [OPT_TRANS_BAIDU]: new Map([ ...OPT_LANGS_SPEC_DEFAULT, ["zh-CN", "zh"], ["zh-TW", "cht"], ["ar", "ara"], ["bg", "bul"], ["ca", "cat"], ["hr", "hrv"], ["da", "dan"], ["fi", "fin"], ["fr", "fra"], ["hi", "mai"], ["ja", "jp"], ["ko", "kor"], ["ms", "may"], ["mt", "mlt"], ["nb", "nor"], ["ro", "rom"], ["ru", "ru"], ["sl", "slo"], ["es", "spa"], ["sv", "swe"], ["ta", "tam"], ["te", "tel"], ["uk", "ukr"], ["vi", "vie"], ]), [OPT_TRANS_TENCENT]: new Map([ ["auto", "auto"], ["zh-CN", "zh"], ["zh-TW", "zh"], ["en", "en"], ["ar", "ar"], ["de", "de"], ["ru", "ru"], ["fr", "fr"], ["fi", "fil"], ["ko", "ko"], ["ms", "ms"], ["pt", "pt"], ["ja", "ja"], ["th", "th"], ["tr", "tr"], ["es", "es"], ["it", "it"], ["hi", "hi"], ["id", "id"], ["vi", "vi"], ]), [OPT_TRANS_OPENAI]: OPT_LANGS_SPEC_NAME, [OPT_TRANS_GEMINI]: OPT_LANGS_SPEC_NAME, [OPT_TRANS_GEMINI_2]: OPT_LANGS_SPEC_NAME, [OPT_TRANS_CLAUDE]: OPT_LANGS_SPEC_NAME, [OPT_TRANS_OLLAMA]: OPT_LANGS_SPEC_NAME, [OPT_TRANS_OPENROUTER]: OPT_LANGS_SPEC_NAME, [OPT_TRANS_CLOUDFLAREAI]: new Map([ ...OPT_LANGS_SPEC_DEFAULT, ["auto", "en"], ["zh-CN", "zh"], ["zh-TW", "zh"], ]), [OPT_TRANS_CUSTOMIZE]: OPT_LANGS_SPEC_NAME, }; const specToCode = (m) => new Map( Array.from(m.entries()).map(([k, v]) => { if (v === "") { return ["auto", "auto"]; } if (v === "zh" || v === "ZH") { return [v, "zh-CN"]; } return [v, k]; }) ); // 名称->CODE export const OPT_LANGS_TO_CODE = {}; Object.entries(OPT_LANGS_TO_SPEC).forEach(([t, m]) => { OPT_LANGS_TO_CODE[t] = specToCode(m); }); export const defaultNobatchPrompt = `You are a professional, authentic machine translation engine.`; export const defaultNobatchUserPrompt = `# Context Title: ${INPUT_PLACE_TITLE} Description: ${INPUT_PLACE_DESCRIPTION} Summary: ${INPUT_PLACE_SUMMARY} Tone: ${INPUT_PLACE_TONE} # Task Translate the Source Text below to ${INPUT_PLACE_TO}. 1. Use the Context to ensure accuracy. 2. Adapt the wording to match the specified Tone. 3. Output ONLY the translated text. No markdown, no explanations. Source Text: ${INPUT_PLACE_TEXT} Translated Text:`; export const defaultSystemPrompt = `Act as a translation API. Output a single raw JSON object only. No extra text or fences. Input: {"targetLanguage":"","title":"","description":"","summary":"","segments":[{"id":1,"text":"..."}],"glossary":{"sourceTerm":"targetTerm"},"tone":""} Output: {"translations":[{"id":1,"text":"...","sourceLanguage":""}]} Rules: 1. Use title/description for context only; do not output them. 2. Keep id, order, and count of segments. 3. Preserve whitespace, HTML entities, and all HTML-like tags (e.g., , ). Translate inner text only. 4. Highest priority: Follow 'glossary'. Use value for translation; if value is "", keep the key. 5. Do not translate: content in ,
, text enclosed in backticks, or placeholders like {1}, {{1}}, [1], [[1]].
6.  Apply the specified tone to the translation.
7.  Detect sourceLanguage for each segment.
8.  Return empty or unchanged inputs as is.

Example:
Input: {"targetLanguage":"zh-CN","segments":[{"id":1,"text":"A React component."}],"glossary":{"component":"组件","React":""}}
Output: {"translations":[{"id":1,"text":"一个React组件","sourceLanguage":"en"}]}

Fail-safe: On any error, return {"translations":[]}.`;

export const defaultSystemPromptXml = `Act as a translation API. Output raw XML-like format only. No Markdown fences (xml). No conversational filler.

Input:
{"targetLanguage":"","title":"","description":"","summary":"","segments":[{"id":1,"text":"..."}],"glossary":{"sourceTerm":"targetTerm"},"tone":""}

Output Format:

    Translated text content...
    Translated text content...


Rules:
1.  **Strict Format**: Output ONLY the  element and its children. Do not include "xml" version declarations or markdown code blocks.
2.  **Structure**: Maintain the exact "id" from the input in the "id" attribute. Detect the source language for the "sourceLanguage" attribute.
3.  **HTML & Whitespace**: Preserve all HTML tags (e.g., , , 
) and whitespace exactly as they appear in the structure. Only translate the text content inside them. 4. **Glossary**: Highest priority. Use the glossary value for translation. If the value is "", keep the source term as is. 5. **Do Not Translate**: Content inside ,
, text in backticks ("code"), and placeholders like {1}, {{1}}, [1], [[1]].
6.  **Context**: Use the "title" and "description" fields to understand the context for better translation accuracy, but do not output them.
7.  **Tone**: Apply the specified "tone" (formal/casual).

Example:
Input:
{"targetLanguage":"zh-CN","segments":[{"id":0,"text":"Hello World!"}],"glossary":{"World":"世界"},"tone":"formal"}

Output:

    你好 世界
`;

export const defaultSystemPromptLines = `Act as a translation API. Output raw text lines in "ID | Text" format. No Markdown. No conversational filler.

Input:
{"targetLanguage":"","title":"","description":"","summary":"","segments":[{"id":1,"text":"..."}],"glossary":{"sourceTerm":"targetTerm"},"tone":""}

Output Format:
 | 
 | 
...

Rules:
1.  **Strict Format**: Output exactly one line per segment using the format: "{id} | {translated_text}".
2.  **ID Mapping**: You MUST copy the exact "id" from the input segment to the output line.
3.  **Newline Handling**: If the translated text contains a newline, replace it with the HTML tag "
" to ensure it stays on a single line. 4. **Separator**: Use the pipe symbol " | " strictly to separate the ID and the text. 5. **Context**: Use title/description for context only; do not output them. 6. **HTML/Tags**: Preserve whitespace, HTML entities, and all HTML-like tags (e.g., , ). Translate inner text only. 7. **Glossary**: Highest priority. Follow 'glossary'. Use value for translation; if value is "", keep the key. 8. **Do Not Translate**: content in ,
, text enclosed in backticks, or placeholders like {1}, {{1}}, [1].
9.  **Tone**: Apply the specified tone.

Example:
Input: {"targetLanguage":"zh-CN","segments":[{"id":0,"text":"Hello."},{"id":1,"text":"Line 1\nLine 2"}],"glossary":{}}
Output:
0 | 你好。
1 | 第一行
第二行 Fail-safe: On error, return "{id} | {original_text}" line by line.`; // const defaultSubtitlePrompt = `Goal: Convert raw subtitle event JSON into a clean, sentence-based JSON array. // Output (valid JSON array, output ONLY this array): // [{ // "text": "string", // Full sentence with correct punctuation // "translation": "string", // Translation in ${INPUT_PLACE_TO} // "start": int, // Start time (ms) // "end": int, // End time (ms) // }] // Guidelines: // 1. **Segmentation**: Merge sequential 'utf8' strings from 'segs' into full sentences, merging groups logically. // 2. **Punctuation**: Ensure proper sentence-final punctuation (., ?, !); add if missing. // 3. **Translation**: Translate 'text' into ${INPUT_PLACE_TO}, place result in 'translation'. // 4. **Special Cases**: '[Music]' (and similar cues) are standalone entries. Translate appropriately (e.g., '[音乐]', '[Musique]'). // `; export const defaultSubtitlePrompt = `# Context Title: ${INPUT_PLACE_TITLE} Description: ${INPUT_PLACE_DESCRIPTION} Summary: ${INPUT_PLACE_SUMMARY} Tone: ${INPUT_PLACE_TONE} # Task Convert the input word-level timestamp JSON into a bilingual VTT file. Target Language: ${INPUT_PLACE_TO}. # Rules 1. Merge words into complete sentences first. 2. Split long sentences into readable cues (max 42 chars/line, natural pauses). 3. Translate using the provided Context and Tone. Keep non-speech sounds (e.g., [Music]) as is. 4. Convert timestamps to standard VTT format (MM:SS.mmm). 5. Output ONLY the raw VTT content. No markdown, no notes. # VTT Format Example WEBVTT 1000 --> 3500 Hello world! 你好,世界! 4000 --> 6000 Good morning. 早上好。`; const defaultRequestHook = `async (args, { url, body, headers, userMsg, method } = {}) => { console.log("request hook args:", { args, url, body, headers, userMsg, method }); // return { url, body, headers, userMsg, method }; };`; const defaultResponseHook = `async ({ res, ...args }) => { console.log("reaponse hook args:", { res, args }); // const translations = [["你好", "zh"]]; // const modelMsg = ""; // return { translations, modelMsg }; };`; // 翻译接口默认参数 const defaultApi = { apiSlug: "", // 唯一标识 apiName: "", // 接口名称 apiType: "", // 接口类型 url: "", key: "", model: "", // 模型名称 systemPrompt: defaultSystemPromptXml, subtitlePrompt: defaultSubtitlePrompt, nobatchPrompt: defaultNobatchPrompt, nobatchUserPrompt: defaultNobatchUserPrompt, userPrompt: "", tone: BUILTIN_STONES[0], // 翻译风格 placeholder: BUILTIN_PLACEHOLDERS[0], // 占位符 placetag: BUILTIN_PLACETAGS[0], // 占位标签 // aiTerms: false, // AI智能专业术语 (todo: 备用) customHeader: "", customBody: "", reqHook: "", // request 钩子函数 resHook: "", // response 钩子函数 fetchLimit: DEFAULT_FETCH_LIMIT, // 最大请求数量 fetchInterval: DEFAULT_FETCH_INTERVAL, // 请求间隔时间 httpTimeout: DEFAULT_HTTP_TIMEOUT * 3, // 请求超时时间 batchInterval: DEFAULT_BATCH_INTERVAL, // 批处理请求间隔时间 batchSize: DEFAULT_BATCH_SIZE, // 每次最多发送段落数量 batchLength: DEFAULT_BATCH_LENGTH, // 每次发送最大文字数量 useBatchFetch: false, // 是否启用聚合发送请求 useStream: false, // 是否启用流式传输 useContext: false, // 是否启用智能上下文 contextSize: DEFAULT_CONTEXT_SIZE, // 智能上下文保留会话数 temperature: 0.0, maxTokens: 20480, // think: false, // (OpenAI 兼容接口未支持,暂时移除) // thinkIgnore: "qwen3,deepseek-r1", // (OpenAI 兼容接口未支持,暂时移除) isDisabled: false, // 是否不显示, region: "", // Azure 专用 sortOrder: 0, // 排序权重,数值越小越靠前 placetagFormat: "compact", // 占位符格式:compact() 或 attribute() }; const defaultApiOpts = { [OPT_TRANS_BUILTINAI]: defaultApi, [OPT_TRANS_GOOGLE]: { ...defaultApi, url: "https://translate.googleapis.com/translate_a/single", }, [OPT_TRANS_GOOGLE_2]: { ...defaultApi, url: "https://translate-pa.googleapis.com/v1/translateHtml", key: "AIzaSyATBXajvzQLTDHEQbcpq0Ihe0vWDHmO520", useBatchFetch: true, placetag: "a", placetagFormat: "attribute", }, [OPT_TRANS_MICROSOFT]: { ...defaultApi, useBatchFetch: true, }, [OPT_TRANS_AZUREAI]: { ...defaultApi, url: "https://api.cognitive.microsofttranslator.com/translate?api-version=3.0", useBatchFetch: true, }, [OPT_TRANS_BAIDU]: { ...defaultApi, }, [OPT_TRANS_TENCENT]: { ...defaultApi, useBatchFetch: true, }, [OPT_TRANS_VOLCENGINE]: { ...defaultApi, }, [OPT_TRANS_DEEPL]: { ...defaultApi, url: "https://api-free.deepl.com/v2/translate", useBatchFetch: true, }, [OPT_TRANS_DEEPLFREE]: { ...defaultApi, fetchLimit: 1, }, [OPT_TRANS_DEEPLX]: { ...defaultApi, url: "http://localhost:1188/translate", }, [OPT_TRANS_NIUTRANS]: { ...defaultApi, url: "https://api.niutrans.com/NiuTransServer/translation", dictNo: "", memoryNo: "", }, [OPT_TRANS_OPENAI]: { ...defaultApi, url: "https://api.openai.com/v1/chat/completions", model: "gpt-4", useBatchFetch: true, }, [OPT_TRANS_GEMINI]: { ...defaultApi, url: `https://generativelanguage.googleapis.com/v1/models/${INPUT_PLACE_MODEL}:generateContent`, model: "gemini-2.5-flash", useBatchFetch: true, }, [OPT_TRANS_GEMINI_2]: { ...defaultApi, url: `https://generativelanguage.googleapis.com/v1beta/openai/chat/completions`, model: "gemini-2.0-flash", useBatchFetch: true, }, [OPT_TRANS_CLAUDE]: { ...defaultApi, url: "https://api.anthropic.com/v1/messages", model: "claude-3-haiku-20240307", useBatchFetch: true, }, [OPT_TRANS_CLOUDFLAREAI]: { ...defaultApi, url: "https://api.cloudflare.com/client/v4/accounts/{{ACCOUNT_ID}}/ai/run/@cf/meta/m2m100-1.2b", }, [OPT_TRANS_OLLAMA]: { ...defaultApi, url: "http://localhost:11434/v1/chat/completions", model: "llama3.1", useBatchFetch: true, }, [OPT_TRANS_OPENROUTER]: { ...defaultApi, url: "https://openrouter.ai/api/v1/chat/completions", model: "openai/gpt-4o", useBatchFetch: true, }, [OPT_TRANS_CUSTOMIZE]: { ...defaultApi, reqHook: defaultRequestHook, resHook: defaultResponseHook, }, }; // 内置翻译接口列表(带参数) export const DEFAULT_API_LIST = OPT_ALL_TRANS_TYPES.map((apiType) => ({ ...defaultApiOpts[apiType], apiSlug: apiType, apiName: apiType, apiType, })); export const DEFAULT_API_TYPE = OPT_TRANS_MICROSOFT; export const DEFAULT_API_SETTING = DEFAULT_API_LIST.find( (a) => a.apiType === DEFAULT_API_TYPE ); ================================================ FILE: src/config/app.js ================================================ export const APP_NAME = process.env.REACT_APP_NAME.trim() .split(/\s+/) .join("-"); export const APP_LCNAME = APP_NAME.toLowerCase(); export const APP_UPNAME = APP_NAME.toUpperCase(); export const APP_CONSTS = { fabID: `${APP_LCNAME}-fab`, boxID: `${APP_LCNAME}-box`, popupID: `${APP_LCNAME}-popup`, }; export const APP_VERSION = process.env.REACT_APP_VERSION.split("."); export const THEME_LIGHT = "light"; export const THEME_DARK = "dark"; ================================================ FILE: src/config/client.js ================================================ export const CLIENT_WEB = "web"; export const CLIENT_CHROME = "chrome"; export const CLIENT_EDGE = "edge"; export const CLIENT_FIREFOX = "firefox"; export const CLIENT_USERSCRIPT = "userscript"; export const CLIENT_THUNDERBIRD = "thunderbird"; export const CLIENT_EXTS = [ CLIENT_CHROME, CLIENT_EDGE, CLIENT_FIREFOX, CLIENT_THUNDERBIRD, ]; export const DEFAULT_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"; ================================================ FILE: src/config/i18n.js ================================================ export const UI_LANGS = [ ["en", "English"], ["zh", "简体中文"], ["zh_TW", "繁體中文"], ["ja", "日本語"], ["ko", "한국어"], ]; const customApiLangs = `["en", "English - English"], ["zh-CN", "Simplified Chinese - 简体中文"], ["zh-TW", "Traditional Chinese - 繁體中文"], ["ar", "Arabic - العربية"], ["bg", "Bulgarian - Български"], ["ca", "Catalan - Català"], ["hr", "Croatian - Hrvatski"], ["cs", "Czech - Čeština"], ["da", "Danish - Dansk"], ["nl", "Dutch - Nederlands"], ["fi", "Finnish - Suomi"], ["fr", "French - Français"], ["de", "German - Deutsch"], ["el", "Greek - Ελληνικά"], ["hi", "Hindi - हिन्दी"], ["hu", "Hungarian - Magyar"], ["id", "Indonesian - Indonesia"], ["it", "Italian - Italiano"], ["ja", "Japanese - 日本語"], ["ko", "Korean - 한국어"], ["ms", "Malay - Melayu"], ["mt", "Maltese - Malti"], ["nb", "Norwegian - Norsk Bokmål"], ["pl", "Polish - Polski"], ["pt", "Portuguese - Português"], ["ro", "Romanian - Română"], ["ru", "Russian - Русский"], ["sk", "Slovak - Slovenčina"], ["sl", "Slovenian - Slovenščina"], ["es", "Spanish - Español"], ["sv", "Swedish - Svenska"], ["ta", "Tamil - தமிழ்"], ["te", "Telugu - తెలుగు"], ["th", "Thai - ไทย"], ["tr", "Turkish - Türkçe"], ["uk", "Ukrainian - Українська"], ["vi", "Vietnamese - Tiếng Việt"], `; const customApiHelpZH = `// 请求数据默认格式 { "url": "{{url}}", "method": "POST", "headers": { "Content-type": "application/json", "Authorization": "Bearer {{key}}" }, "body": { "text": "{{text}}", // 待翻译文字 "from": "{{from}}", // 文字的语言(可能为空) "to": "{{to}}", // 目标语言 }, } // 返回数据默认格式 { text: "", // 翻译后的文字 from: "", // 识别的源语言 to: "", // 目标语言(可选) } // Hook 范例 // URL https://translate.googleapis.com/translate_a/single?client=gtx&dj=1&dt=t&ie=UTF-8&q={{text}}&sl=en&tl=zh-CN // Request Hook (text, from, to, url, key) => [url, { headers: { "Content-type": "application/json", }, method: "GET", body: null, }] // Response Hook // 其中返回数组第一个值表示译文字符串,第二个值为布尔值,表示原文语言与目标语言是否相同 (res, text, from, to) => [res.sentences.map((item) => item.trans).join(" "), to === res.src] // 支持的语言代码如下 ${customApiLangs} `; const customApiHelpEN = `// Default request { "url": "{{url}}", "method": "POST", "headers": { "Content-type": "application/json", "Authorization": "Bearer {{key}}" }, "body": { "text": "{{text}}", // Text to be translated "from": "{{from}}", // The language of the text (may be empty) "to": "{{to}}", // Target language }, } // Default response { text: "", // translated text from: "", // Recognized source language to: "", // Target language (optional) } /// Hook Example // URL https://translate.googleapis.com/translate_a/single?client=gtx&dj=1&dt=t&ie=UTF-8&q={{text}}&sl=en&tl=zh-CN // Request Hook (text, from, to, url, key) => [url, { headers: { "Content-type": "application/json", }, method: "GET", body: null, }] // Response Hook // In the returned array, the first value is the translated string, while the second value is a boolean // that indicates whether the source language is the same as the target language. (res, text, from, to) => [res.sentences.map((item) => item.trans).join(" "), to === res.src] // The supported language codes are as follows ${customApiLangs} `; const requestHookHelperZH = `1、第一个参数包含如下字段:'texts', 'from', 'to', 'url', 'key', 'model', 'systemPrompt', ... 2、返回值必须是包含以下字段的对象: 'url', 'body', 'headers', 'method' 3、如返回空值,则hook函数不会产生任何效果。 // 示例 async (args, { url, body, headers, userMsg, method } = {}) => { return { url, body, headers, userMsg, method }; }`; const requestHookHelperEN = `1. The first parameter contains the following fields: 'texts', 'from', 'to', 'url', 'key', 'model', 'systemPrompt', ... 2. The return value must be an object containing the following fields: 'url', 'body', 'headers', 'method' 3. If a null value is returned, the hook function will have no effect. // Example async (args, { url, body, headers, userMsg, method } = {}) => { return { url, body, headers, userMsg, method }; }`; const responsetHookHelperZH = `1、第一个参数包含如下字段:'res', ... 2、返回值必须是包含以下字段的对象: 'translations' ('translations' 应为一个二维数组:[[译文, 原文语言]]) 3、如返回空值,则hook函数不会产生任何效果。 // 示例 async ({ res, ...args }) => { const translations = [["你好", "en"]]; const modelMsg = {}; // 用于AI上下文 return { translations, modelMsg }; }`; const responsetHookHelperEN = `1. The first parameter contains the following fields: 'res', ... 2. The return value must be an object containing the following fields: 'translations' ('translations' should be a two-dimensional array: [[translation, source language]]). 3. If a null value is returned, the hook function will have no effect. // Example async ({ res, ...args }) => { const translations = [["你好", "en"]]; const modelMsg = {}; // For AI context return { translations, modelMsg }; }`; export const I18N = { app_name: { zh: `简约翻译`, en: `KISS Translator`, zh_TW: `簡約翻譯`, ja: `KISS Translator`, ko: `KISS Translator`, }, translate: { zh: `翻译`, en: `Translate`, zh_TW: `翻譯`, ja: `翻訳`, ko: `번역`, }, custom_api_help: { zh: customApiHelpZH, en: customApiHelpEN, zh_TW: customApiHelpZH, ja: customApiHelpEN, ko: customApiHelpEN, }, request_hook_helper: { zh: requestHookHelperZH, en: requestHookHelperEN, zh_TW: requestHookHelperZH, ja: requestHookHelperEN, ko: requestHookHelperEN, }, response_hook_helper: { zh: responsetHookHelperZH, en: responsetHookHelperEN, zh_TW: responsetHookHelperZH, ja: responsetHookHelperEN, ko: responsetHookHelperEN, }, translate_alt: { zh: `翻译`, en: `Translate`, zh_TW: `翻譯`, ja: `翻訳`, ko: `번역`, }, basic_setting: { zh: `基本设置`, en: `Basic Setting`, zh_TW: `基本設定`, ja: `基本設定`, ko: `기본 설정`, }, rules_setting: { zh: `规则设置`, en: `Rules Setting`, zh_TW: `規則設定`, ja: `ルール設定`, ko: `규칙 설정`, }, apis_setting: { zh: `接口设置`, en: `Apis Setting`, zh_TW: `API設定`, ja: `API設定`, ko: `API 설정`, }, sync_setting: { zh: `同步设置`, en: `Sync Setting`, zh_TW: `同步設定`, ja: `同期設定`, ko: `동기화 설정`, }, patch_setting: { zh: `补丁设置`, en: `Patch Setting`, zh_TW: `修補設定`, ja: `パッチ設定`, ko: `패치 설정`, }, patch_setting_help: { zh: `针对一些特殊网站的修正脚本,以便翻译软件得到更好的展示效果。`, en: `Corrected scripts for some special websites so that the translation software can get better display results.`, zh_TW: `針對某些特殊網站的修正腳本,讓翻譯軟體有更好的顯示效果。`, ja: `一部の特殊なウェブサイト用の修正スクリプトで、翻訳ソフトウェアの表示効果を向上させます。`, ko: `일부 특수 웹사이트를 위한 수정 스크립트로, 번역 소프트웨어의 표시 효과를 개선합니다.`, }, inject_webfix: { zh: `注入修复补丁`, en: `Inject Webfix`, zh_TW: `注入修正補丁`, ja: `Webfixを注入`, ko: `웹 수정 패치 주입`, }, about: { zh: `关于`, en: `About`, zh_TW: `關於`, ja: `概要`, ko: `정보`, }, about_md: { zh: `README.md`, en: `README.en.md`, zh_TW: `README.md`, ja: `README.ja.md`, // 假设的文件名 ko: `README.ko.md`, // 假设的文件名 }, about_md_local: { zh: `请 [点击这里](${process.env.REACT_APP_HOMEPAGE}) 查看详情。`, en: `Please [click here](${process.env.REACT_APP_HOMEPAGE}) for details.`, zh_TW: `請 [點這裡](${process.env.REACT_APP_HOMEPAGE}) 查看詳細內容。`, ja: `詳細は [こちら](${process.env.REACT_APP_HOMEPAGE}) をクリックしてください。`, ko: `자세한 내용은 [여기](${process.env.REACT_APP_HOMEPAGE})를 클릭하세요.`, }, ui_lang: { zh: `界面语言`, en: `Interface Language`, zh_TW: `介面語言`, ja: `インターフェース言語`, ko: `인터페이스 언어`, }, fetch_limit: { zh: `最大并发请求数量 (1-100)`, en: `Maximum Number Of Concurrent Requests (1-100)`, zh_TW: `最大同時請求數量 (1-100)`, ja: `最大同時リクエスト数 (1-100)`, ko: `최대 동시 요청 수 (1-100)`, }, if_think: { zh: `启用或禁用模型的深度思考能力`, en: `Enable or disable the model’s thinking behavior `, zh_TW: `啟用或停用模型的深度思考能力`, ja: `モデルの思考行動を有効または無効にする`, ko: `모델의 사고 행동 활성화 또는 비활성화`, }, think: { zh: `启用深度思考`, en: `enable thinking`, zh_TW: `啟用深度思考`, ja: `思考を有効にする`, ko: `사고 활성화`, }, nothink: { zh: `禁用深度思考`, en: `disable thinking`, zh_TW: `停用深度思考`, ja: `思考を無効にする`, ko: `사고 비활성화`, }, think_ignore: { zh: `忽略以下模型的输出,逗号(,)分割,当模型支持思考但ollama不支持时需要填写本参数`, en: `Ignore the block for the following models, comma (,) separated`, zh_TW: `忽略以下模型的 輸出,以逗號 (,) 分隔;當模型支援思考但 ollama 不支援時需要填寫此參數`, ja: `以下のモデルの出力を無視する (コンマ(,)区切り)。モデルが思考をサポートしているが、ollamaがサポートしていない場合に記入が必要です`, ko: `다음 모델의 블록 무시 (쉼표(,)로 구분), 모델이 사고를 지원하지만 ollama가 지원하지 않는 경우 이 매개변수를 입력해야 합니다`, }, fetch_interval: { zh: `每次请求间隔时间 (0-5000ms)`, en: `Time Between Requests (0-5000ms)`, zh_TW: `每次請求間隔時間 (0-5000ms)`, ja: `リクエスト間隔 (0-5000ms)`, ko: `요청 간 시간 (0-5000ms)`, }, translate_interval: { zh: `翻译间隔时间 (1-2000ms)`, en: `Translation Interval (1-2000ms)`, zh_TW: `翻譯間隔時間 (1-2000ms)`, ja: `翻訳間隔 (1-2000ms)`, ko: `번역 간격 (1-2000ms)`, }, http_timeout: { zh: `请求超时时间 (100-6000000ms)`, en: `Request Timeout Time (100-6000000ms)`, zh_TW: `請求逾時時間 (100-60000ms)`, ja: `リクエストタイムアウト (100-6000000ms)`, ko: `요청 시간 초과 (100-6000000ms)`, }, custom_header: { zh: `自定义Header参数`, en: `Custom Header Params`, zh_TW: `自訂 Header 參數`, ja: `カスタムヘッダー`, ko: `사용자 지정 헤더`, }, custom_header_help: { zh: `使用JSON格式,例如 "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0"`, en: `Use JSON format, for example "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0"`, zh_TW: `使用JSON格式,例如 "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0"`, ja: `JSON形式を使用してください。例: "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0"`, ko: `JSON 형식을 사용하세요. 예: "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0"`, }, custom_body: { zh: `自定义Body参数`, en: `Custom Body Params`, zh_TW: `自訂 Body 參數`, ja: `カスタムボディ`, ko: `사용자 지정 바디`, }, custom_body_help: { zh: `使用JSON格式,例如 "top_p": 0.7`, en: `Use JSON format, for example "top_p": 0.7`, zh_TW: `使用JSON格式,例如 "top_p": 0.7`, ja: `JSON形式を使用してください。例: "top_p": 0.7`, ko: `JSON 형식을 사용하세요. 예: "top_p": 0.7`, }, min_translate_length: { zh: `最小翻译字符数 (1-100)`, en: `Minimum number Of Translated Characters (1-100)`, zh_TW: `最小翻譯字元數 (1-100)`, ja: `最小翻訳文字数 (1-100)`, ko: `최소 번역 문자 수 (1-100)`, }, max_translate_length: { zh: `最大翻译字符数 (100-100000)`, en: `Maximum number Of Translated Characters (100-100000)`, zh_TW: `最大翻譯字元數 (100-100000)`, ja: `最大翻訳文字数 (100-100000)`, ko: `최대 번역 문자 수 (100-100000)`, }, num_of_newline_characters: { zh: `换行字符数 (1-1000)`, en: `Number of Newline Characters (1-1000)`, zh_TW: `換行字元數 (1-1000)`, ja: `改行文字数 (1-1000)`, ko: `줄바꿈 문자 수 (1-1000)`, }, translate_service: { zh: `翻译服务`, en: `Translate Service`, zh_TW: `翻譯服務`, ja: `翻訳サービス`, ko: `번역 서비스`, }, translate_service_multiple: { zh: `翻译服务 (支持多选)`, en: `Translation service (multiple supported)`, zh_TW: `翻譯服務 (支援多選)`, ja: `翻訳サービス (複数選択可)`, ko: `번역 서비스 (다중 선택 지원)`, }, translate_timing: { zh: `翻译时机`, en: `Translate Timing`, zh_TW: `翻譯時機`, ja: `翻訳タイミング`, ko: `번역 시점`, }, mk_pagescroll: { zh: `滚动加载翻译(推荐)`, en: `Rolling Loading (Suggested)`, zh_TW: `滾動載入翻譯(建議)`, ja: `スクロール翻訳 (推奨)`, ko: `스크롤 번역 (권장)`, }, mk_pageopen: { zh: `立即全部翻译`, en: `Translate all now`, zh_TW: `立即全部翻譯`, ja: `すぐにすべて翻訳`, ko: `즉시 모두 번역`, }, mk_mouseover: { zh: `鼠标悬停翻译`, en: `Mouseover`, zh_TW: `滑鼠懸停翻譯`, ja: `マウスオーバー翻訳`, ko: `마우스오버 번역`, }, mk_ctrlKey: { zh: `Control + 鼠标悬停`, en: `Control + Mouseover`, zh_TW: `Control + 滑鼠懸停`, ja: `Control + マウスオーバー`, ko: `Control + 마우스오버`, }, mk_shiftKey: { zh: `Shift + 鼠标悬停`, en: `Shift + Mouseover`, zh_TW: `Shift + 滑鼠懸停`, ja: `Shift + マウスオーバー`, ko: `Shift + 마우스오버`, }, mk_altKey: { zh: `Alt + 鼠标悬停`, en: `Alt + Mouseover`, zh_TW: `Alt + 滑鼠懸停`, ja: `Alt + マウスオーバー`, ko: `Alt + 마우스오버`, }, from_lang: { zh: `原文语言`, en: `Source Language`, zh_TW: `原文語言`, ja: `原文の言語`, ko: `원본 언어`, }, to_lang: { zh: `目标语言`, en: `Target Language`, zh_TW: `目標語言`, ja: `翻訳先の言語`, ko: `대상 언어`, }, to_lang2: { zh: `第二目标语言`, en: `Target Language 2`, zh_TW: `第二目標語言`, ja: `第二翻訳先の言語`, ko: `두 번째 대상 언어`, }, to_lang2_helper: { zh: `设定后,与目标语言产生互译效果,但依赖远程语言识别。`, en: `After setting, it will produce mutual translation effect with the target language, but it relies on remote language recognition.`, zh_TW: `設定後會與目標語言互譯,但依賴遠端語言識別。`, ja: `設定後、ターゲット言語との相互翻訳が可能になりますが、リモート言語認識に依存します。`, ko: `설정 후, 대상 언어와 상호 번역 효과가 발생하지만, 원격 언어 인식에 의존합니다.`, }, text_style: { zh: `译文样式`, en: `Text Style`, zh_TW: `譯文樣式`, ja: `翻訳テキストスタイル`, ko: `번역 텍스트 스타일`, }, text_style_alt: { zh: `译文样式`, en: `Text Style`, zh_TW: `譯文樣式`, ja: `翻訳テキストスタイル`, ko: `번역 텍스트 스타일`, }, bg_color: { zh: `样式颜色`, en: `Style Color`, zh_TW: `樣式顏色`, ja: `スタイルカラー`, ko: `스타일 색상`, }, remain_unchanged: { zh: `保留不变`, en: `Remain Unchanged`, zh_TW: `保留不變`, ja: `変更しない`, ko: `변경하지 않음`, }, google_api: { zh: `谷歌翻译接口`, en: `Google Translate API`, zh_TW: `Google 翻譯介面`, ja: `Google 翻訳 API`, ko: `Google 번역 API`, }, default_selector: { zh: `默认选择器`, en: `Default selector`, zh_TW: `預設選擇器`, ja: `デフォルトセレクタ`, ko: `기본 선택자`, }, selector_rules: { zh: `选择器规则`, en: `Selector Rules`, zh_TW: `選擇器規則`, ja: `セレクタールール`, ko: `선택자 규칙`, }, save: { zh: `保存`, en: `Save`, zh_TW: `儲存`, ja: `保存`, ko: `저장`, }, edit: { zh: `编辑`, en: `Edit`, zh_TW: `編輯`, ja: `編集`, ko: `수정`, }, cancel: { zh: `取消`, en: `Cancel`, zh_TW: `取消`, ja: `キャンセル`, ko: `취소`, }, delete: { zh: `删除`, en: `Delete`, zh_TW: `刪除`, ja: `削除`, ko: `삭제`, }, reset: { zh: `重置`, en: `Reset`, zh_TW: `重設`, ja: `リセット`, ko: `초기화`, }, add: { zh: `添加`, en: `Add`, zh_TW: `新增`, ja: `追加`, ko: `추가`, }, copy_api: { zh: `复制接口`, en: `Copy Interface`, zh_TW: `複製介面`, ja: `インターフェースをコピー`, ko: `인터페이스 복사`, }, inject_rules: { zh: `注入订阅规则`, en: `Inject Subscribe Rules`, zh_TW: `注入訂閱規則`, ja: `購読ルールを注入`, ko: `구독 규칙 주입`, }, personal_rules: { zh: `个人规则`, en: `Rules`, zh_TW: `個人規則`, ja: `個人ルール`, ko: `개인 규칙`, }, subscribe_rules: { zh: `订阅规则`, en: `Subscribe`, zh_TW: `訂閱規則`, ja: `購読ルール`, ko: `구독 규칙`, }, overwrite_subscribe_rules: { zh: `覆写订阅规则`, en: `Overwrite`, zh_TW: `覆寫訂閱規則`, ja: `購読ルールを上書き`, ko: `구독 규칙 덮어쓰기`, }, subscribe_url: { zh: `订阅地址`, en: `Subscribe URL`, zh_TW: `訂閱網址`, ja: `購読URL`, ko: `구독 URL`, }, rules_warn_1: { zh: `1、规则生效的优先级依次为:个人规则 > 订阅规则 > 全局规则。"全局规则"相当于兜底规则。`, en: `1. The priority of rules is: personal rules > subscription rules > global rules. "Global rules" are like a fallback rule.`, zh_TW: `1.規則生效的優先順序依序為:個人規則 > 訂閱規則 > 全域規則。 "全域規則"相當於兜底規則。`, ja: `1. ルールの優先順位: 個人ルール > 購読ルール > グローバルルール。「グローバルルール」はフォールバックルールのようなものです。`, ko: `1. 규칙 우선순위: 개인 규칙 > 구독 규칙 > 전역 규칙. "전역 규칙"은 일종의 폴백(fallback) 규칙입니다.`, }, rules_warn_2: { zh: `2、“订阅规则”选择注入后才会生效。`, en: `2. "Subscription rules" will take effect only after injection is selected.`, zh_TW: `2、「訂閱規則」選擇注入後才會生效。`, ja: `2. 「購読ルール」は注入を選択した後にのみ有効になります。`, ko: `2. "구독 규칙"은 주입을 선택한 후에만 적용됩니다.`, }, rules_warn_3: { zh: `3、关于规则填写:输入框留空或下拉框选“*”表示采用全局规则。CSS选择器支持 + 号前缀表示在全局规则基础上追加,- 号表示剔除。`, en: `3. Regarding filling in the rules: Leave the input box blank or select "*" in the drop-down box to use global rule. CSS selectors support prefixes: "+" means add to the global rules, "-" means exclude.`, zh_TW: `3. 規則填寫說明:輸入框留空或下拉選擇「*」表示使用全域規則。CSS 選擇器支援使用前綴:「+」表示在全域規則基礎上追加,「-」表示剔除。`, ja: `3. ルールの記入について: 入力ボックスを空白にするか、ドロップダウンで「*」を選択すると、グローバルルールが使用されます。CSS セレクターはプレフィックスに対応しています。「+」はグローバルルールへの追加、「-」は除外を意味します。`, ko: `3. 규칙 작성 관련: 입력란을 비워두거나 드롭다운에서 "*"를 선택하면 전역 규칙이 사용됩니다. CSS 선택자는 접두사를 지원합니다. "+"는 전역 규칙에 추가, "-"는 제외를 의미합니다.`, }, sync_warn: { zh: `涉及隐私数据的同步请谨慎选择第三方同步服务,建议自行搭建 kiss-worker 或 WebDAV 服务。`, en: `When synchronizing data that involves privacy, please be cautious about choosing third-party sync services. It is recommended to set up your own sync service using kiss-worker or WebDAV.`, zh_TW: `同步涉及隱私資料時,請謹慎選擇第三方同步服務;建議自建 kiss-worker 或 WebDAV 服務。`, ja: `プライバシーに関わるデータを同期する場合、サードパーティの同期サービスは慎重に選択してください。kiss-worker や WebDAV サービスを自己ホスティングすることをお勧めします。`, ko: `개인정보가 포함된 데이터를 동기화할 경우, 타사 동기화 서비스 선택에 신중을 기하십시오. 자체 kiss-worker 또는 WebDAV 서비스를 구축하는 것을 권장합니다.`, }, sync_warn_2: { zh: `如果服务器存在其他客户端同步的数据,第一次同步将直接覆盖本地配置,后面则根据修改时间,新的覆盖旧的。`, en: `If the server has data synchronized by other clients, the first synchronization will directly overwrite the local configuration, and later, according to the modification time, the new one will overwrite the old one.`, zh_TW: `若伺服器上存在其他用戶端同步的資料,第一次同步會直接覆蓋本機設定;之後則依修改時間,由新的覆蓋舊的。`, ja: `サーバーに他のクライアントによって同期されたデータがある場合、最初の同期はローカル設定を直接上書きし、その後は変更時間に応じて新しいものが古いものを上書きします。`, ko: `서버에 다른 클라이언트가 동기화한 데이터가 있는 경우, 첫 번째 동기화는 로컬 구성을 직접 덮어쓰며, 이후에는 수정 시간에 따라 새 항목이 기존 항목을 덮어씁니다.`, }, about_sync_api: { zh: `自建kiss-wroker数据同步服务`, en: `Self-hosting a Kiss-worker data sync service`, zh_TW: `自建 kiss-wroker 資料同步服務`, ja: `Kiss-worker データ同期サービスをセルフホストする`, ko: `Kiss-worker 데이터 동기화 서비스 자체 호스팅`, }, about_api: { zh: `1、其中 BuiltinAI 为浏览器内置AI翻译,目前仅 Chrome 138 及以上版本得到支持。`, en: `1. BuiltinAI is the browser's built-in AI translation, which is currently only supported by Chrome 138 and above.`, zh_TW: `1.其中 BuiltinAI 為瀏覽器內建AI翻譯,目前僅 Chrome 138 以上版本支援。`, ja: `1. BuiltinAI はブラウザ内蔵のAI翻訳で、現在 Chrome 138 以降のバージョンでのみサポートされています。`, ko: `1. BuiltinAI는 브라우저 내장 AI 번역으로, 현재 Chrome 138 이상 버전에서만 지원됩니다.`, }, about_api_2: { zh: `2、大部分AI接口都与OpenAI兼容,因此选择OpenAI类型即可。“是否聚合发送翻译请求”所对应的 Prompt 并不相同,并且不是所有接口都支持聚合翻译。`, en: `2. Most AI interfaces are compatible with OpenAI, so you can simply select the OpenAI type. The prompts corresponding to “Whether to aggregate translation requests” are different, and not all interfaces support aggregated translation.`, zh_TW: `2. 大部分的 AI 介面都與 OpenAI 相容,因此選擇 OpenAI 類型即可。「是否聚合發送翻譯請求」所對應的 Prompt 並不相同,並且不是所有介面都支援聚合翻譯。`, ja: `2. ほとんどの AI インターフェースは OpenAI と互換性があるため、OpenAI タイプを選択すれば問題ありません。「翻訳リクエストをまとめて送信するかどうか」に対応するプロンプトは異なり、すべてのインターフェースが集約翻訳をサポートしているわけではありません。`, ko: `2. 대부분의 AI 인터페이스는 OpenAI와 호환되므로 OpenAI 유형을 선택하면 됩니다. “번역 요청을 집합적으로 보낼지 여부”에 대응하는 프롬프트는 서로 다르며, 모든 인터페이스가 집합 번역을 지원하는 것은 아닙니다.`, }, about_api_3: { zh: `3、理论上,所有翻译接口,都可以通过自定义接口 (Custom) 的形式使用。`, en: `3. In theory, all translation interfaces can be used by configuring them as a custom interface.`, zh_TW: `3. 理論上,所有翻譯介面都可以透過自訂介面(Custom)的方式來使用。`, ja: `3. 理論的には、すべての翻訳インターフェースはカスタム(Custom)インターフェースとして設定することで利用できます。`, ko: `3. 이론적으로 모든 번역 인터페이스는 커스텀(Custom) 인터페이스로 설정하여 사용할 수 있습니다.`, }, about_api_proxy: { zh: `查看自建一个翻译接口代理`, en: `Check out the self-built translation interface proxy`, zh_TW: `查看如何自建翻譯介面 Proxy`, ja: `自作の翻訳インターフェースプロキシをチェックする`, ko: `자체 구축 번역 인터페이스 프록시 확인하기`, }, setting_helper: { zh: `新旧配置并不兼容,导出的旧版配置,勿再次导入。`, en: `The old and new configurations are not compatible. Do not import the exported old configuration again.`, zh_TW: `新舊配置並不相容,匯出的舊版配置,勿再次匯入。`, ja: `新旧の設定に互換性はありません。エクスポートした古い設定を再度インポートしないでください。`, ko: `이전 구성과 새 구성은 호환되지 않습니다. 내보낸 이전 구성을 다시 가져오지 마십시오.`, }, style_none: { zh: `无`, en: `None`, zh_TW: `無`, ja: `なし`, ko: `없음`, }, under_line: { zh: `下划直线`, en: `Underline`, zh_TW: `下劃直線`, ja: `下線`, ko: `밑줄`, }, dot_line: { zh: `下划点状线`, en: `Dotted Underline`, zh_TW: `下劃點狀線`, ja: `点線の下線`, ko: `점선 밑줄`, }, dash_line: { zh: `下划虚线`, en: `Dashed Underline`, zh_TW: `下劃虛線`, ja: `破線の下線`, ko: `파선 밑줄`, }, dash_box: { zh: `虚线框`, en: `Dashed Box`, zh_TW: `虛線框`, ja: `破線ボックス`, ko: `파선 상자`, }, dash_line_bold: { zh: `下划虚线加粗`, en: `Dashed Underline Bold`, zh_TW: `下劃虛線`, ja: `破線の下線 (太字)`, ko: `굵은 파선 밑줄`, }, dash_box_bold: { zh: `虚线框加粗`, en: `Dashed Box Bold`, zh_TW: `虛線框加粗`, ja: `破線ボックス (太字)`, ko: `굵은 파선 상자`, }, marker: { zh: `马克笔`, en: `Marker`, zh_TW: `馬克筆`, ja: `マーカー`, ko: `마커`, }, gradient_marker: { zh: `渐变马克笔`, en: `Gradient Marker`, zh_TW: `漸層馬克筆`, ja: `グラデーションマーカー`, ko: `그라데이션 마커`, }, wavy_line: { zh: `下划波浪线`, en: `Wavy Underline`, zh_TW: `下劃波浪線`, ja: `波線の下線`, ko: `물결 밑줄`, }, wavy_line_bold: { zh: `下划波浪线加粗`, en: `Wavy Underline Bold`, zh_TW: `下劃波浪線加粗`, ja: `波線の下線 (太字)`, ko: `굵은 물결 밑줄`, }, fuzzy: { zh: `模糊`, en: `Fuzzy`, zh_TW: `模糊`, ja: `ぼかし`, ko: `흐림`, }, highlight: { zh: `高亮`, en: `Highlight`, zh_TW: `反白標示`, ja: `ハイライト`, ko: `하이라이트`, }, blockquote: { zh: `引用`, en: `Blockquote`, zh_TW: `引用`, ja: `引用`, ko: `인용`, }, gradient: { zh: `渐变`, en: `Gradient`, zh_TW: `漸變`, ja: `グラデーション`, ko: `그라데이션`, }, blink: { zh: `闪现`, en: `Blink`, zh_TW: `閃現`, ja: `点滅`, ko: `깜박임`, }, glow: { zh: `发光`, en: `Glow`, zh_TW: `發光`, ja: `発光`, ko: `발광`, }, colorful: { zh: `多彩`, en: `Colorful`, zh_TW: `多彩`, ja: `カラフル`, ko: `다채롭게`, }, setting: { zh: `设置`, en: `Setting`, zh_TW: `設定`, ja: `設定`, ko: `설정`, }, pattern: { zh: `匹配网址`, en: `URL pattern`, zh_TW: `匹配網址`, ja: `URLパターン`, ko: `URL 패턴`, }, pattern_helper: { zh: `1、支持星号(*)通配符。2、多个URL用换行或英文逗号“,”分隔。`, en: `1. Supports the asterisk (*) wildcard character. 2. Separate multiple URLs with newlines or English commas ",".`, zh_TW: `1. 支援星號 (*) 萬用字元。2. 多個 URL 請以換行或英文逗號「,」分隔。`, ja: `1. アスタリスク (*) ワイルドカードをサポートします。 2. 複数のURLは改行または英語のコンマ「,」で区切ります。`, ko: `1. 별표(*) 와일드카드 문자를 지원합니다. 2. 여러 URL은 줄바꿈 또는 영어 쉼표 ","로 구분합니다.`, }, selector_helper: { zh: `1、需要翻译的目标元素。2、开启自动扫描页面后,本设置无效。3、遵循CSS选择器语法。`, en: `1. The target element to be translated. 2. This setting is invalid when automatic page scanning is enabled. 3. Follow the CSS selector syntax.`, zh_TW: `1、需要翻譯的目標元素。 2.開啟自動掃描頁面後,本設定無效。 3.遵循CSS選擇器語法。`, ja: `1. 翻訳対象の要素。 2. ページの自動スキャンを有効にすると、この設定は無効になります。 3. CSSセレクタ構文に従ってください。`, ko: `1. 번역할 대상 요소입니다. 2. 자동 페이지 스캔이 활성화되면 이 설정은 무효화됩니다. 3. CSS 선택자 구문을 따릅니다.`, }, translate_switch: { zh: `开启翻译`, en: `Translate Switch`, zh_TW: `開啟翻譯`, ja: `翻訳を有効にする`, ko: `번역 켜기`, }, default_enabled: { zh: `默认开启`, en: `Enabled`, zh_TW: `預設開啟`, ja: `デフォルトで有効`, ko: `기본으로 사용`, }, default_disabled: { zh: `默认关闭`, en: `Disabled`, zh_TW: `預設關閉`, ja: `デフォルトで無効`, ko: `기본으로 사용 안함`, }, selector: { zh: `选择器`, en: `Selector`, zh_TW: `選擇器`, ja: `セレクタ`, ko: `선택자`, }, target_selector: { zh: `目标元素选择器`, en: `Target element selector`, zh_TW: `目標元素選擇器`, ja: `対象要素セレクタ`, ko: `대상 요소 선택자`, }, keep_selector: { zh: `保留元素选择器`, en: `Keep unchanged selector`, zh_TW: `保留元素選擇器`, ja: `保持要素セレクタ`, ko: `유지할 요소 선택자`, }, keep_selector_helper: { zh: `1、目标元素下面需要原样保留的子节点。2、遵循CSS选择器语法。`, en: `1. The child nodes under the target element need to remain intact. 2. Follow the CSS selector syntax.`, zh_TW: `1. 目標元素下的子節點需要保持原樣。 2. 遵循 CSS 選擇器語法。`, ja: `1. 対象要素の下にある、そのまま保持する必要がある子ノード。 2. CSSセレクタ構文に従ってください。`, ko: `1. 대상 요소 아래의 자식 노드 중 그대로 유지해야 하는 노드. 2. CSS 선택자 구문을 따릅니다.`, }, root_selector: { zh: `根节点选择器`, en: `Root node selector`, zh_TW: `根節點選擇器`, ja: `ルートノードセレクタ`, ko: `루트 노드 선택자`, }, root_selector_helper: { zh: `1、用于缩小页面翻译范围。2、遵循CSS选择器语法。`, en: `1. Used to narrow the translation scope of the page. 2. Follow the CSS selector syntax.`, zh_TW: `1.用於縮小頁面翻譯範圍。 2、遵循CSS選擇器語法。`, ja: `1. ページの翻訳範囲を絞り込むために使用します。 2. CSSセレクタ構文に従ってください。`, ko: `1. 페이지의 번역 범위를 좁히는 데 사용됩니다. 2. CSS 선택자 구문을 따릅니다.`, }, ignore_selector: { zh: `不翻译节点选择器`, en: `Ignore node selectors`, zh_TW: `不翻譯節點選擇器`, ja: `翻訳しないノードセレクタ`, ko: `번역 무시 노드 선택자`, }, ignore_selector_helper: { zh: `1、需要忽略的节点。2、遵循CSS选择器语法。`, en: `1. Nodes to be ignored. 2. Follow CSS selector syntax.`, zh_TW: `1、需要忽略的節點。 2、遵循CSS選擇器語法。`, ja: `1. 無視するノード。 2. CSSセレクタ構文に従ってください。`, ko: `1. 무시할 노드. 2. CSS 선택자 구문을 따릅니다.`, }, terms: { zh: `专业术语`, en: `Terms`, zh_TW: `專業術語`, ja: `専門用語`, ko: `전문 용어`, }, terms_helper: { zh: `1、支持正则表达式匹配,无需斜杆,不支持修饰符。2、多条术语用换行或分号“;”隔开。3、术语和译文用英文逗号“,”隔开。4、没有译文视为不翻译术语。`, en: `1. Supports regular expression matching, no slash required, and no modifiers are supported. 2. Separate multiple terms with newlines or semicolons ";". 3. Terms and translations are separated by English commas ",". 4. If there is no translation, the term will be deemed not to be translated.`, zh_TW: `1. 支援正則表達式比對,無需斜線,且不支援修飾符。2. 多條術語以換行或分號「;」分隔。3. 術語與譯文以英文逗號「,」分隔。4. 無譯文者視為不翻譯該術語。`, ja: `1. 正規表現マッチングをサポート (スラッシュ不要、修飾子非対応)。 2. 複数の用語は改行またはセミコロン「;」で区切ります。 3. 用語と翻訳は英語のコンマ「,」で区切ります。 4. 翻訳がない場合は、その用語を翻訳しないものとみなします。`, ko: `1. 정규식 일치를 지원하며, 슬래시가 필요 없고 수정자는 지원되지 않습니다. 2. 여러 용어는 줄바꿈 또는 세미콜론 ";"으로 구분합니다. 3. 용어와 번역은 영어 쉼표 ","로 구분합니다. 4. 번역이 없는 경우 해당 용어를 번역하지 않는 것으로 간주합니다.`, }, ai_terms: { zh: `AI专业术语`, en: `AI Terms`, zh_TW: `AI專業術語`, ja: `AI専門用語`, ko: `AI 전문 용어`, }, ai_terms_helper: { zh: `1、AI智能替换,不支持正则表达式。2、多条术语用换行或分号“;”隔开。3、术语和译文用英文逗号“,”隔开。4、没有译文视为不翻译术语。`, en: `1. AI intelligent replacement does not support regular expressions.2. Separate multiple terms with newlines or semicolons ";". 3. Terms and translations are separated by English commas ",". 4. If there is no translation, the term will be deemed not to be translated.`, zh_TW: `1.AI智能替換,不支援正規表示式。2. 多條術語以換行或分號「;」分隔。3. 術語與譯文以英文逗號「,」分隔。4. 無譯文者視為不翻譯該術語。`, ja: `1. AIによるインテリジェントな置換 (正規表現非対応)。 2. 複数の用語は改行またはセミコロン「;」で区切ります。 3. 用語と翻訳は英語のコンマ「,」で区切ります。 4. 翻訳がない場合は、その用語を翻訳しないものとみなします。`, ko: `1. AI 지능형 대체, 정규식을 지원하지 않습니다. 2. 여러 용어는 줄바꿈 또는 세미콜론 ";"으로 구분합니다. 3. 용어와 번역은 영어 쉼표 ","로 구분합니다. 4. 번역이 없는 경우 해당 용어를 번역하지 않는 것으로 간주합니다.`, }, text_ext_style: { zh: `译文附加样式`, en: `Translation additional styles`, zh_TW: `譯文附加樣式`, ja: `翻訳の追加スタイル`, ko: `번역 추가 스타일`, }, selector_style: { zh: `选择器节点样式`, en: `Selector Style`, zh_TW: `選擇器節點樣式`, ja: `セレクタノードスタイル`, ko: `선택자 노드 스타일`, }, terms_style: { zh: `专业术语样式`, en: `Terms Style`, zh_TW: `專業術語樣式`, ja: `専門用語スタイル`, ko: `전문 용어 스타일`, }, highlight_style: { zh: `词汇高亮样式`, en: `Fav Words highlight style`, zh_TW: `詞彙高亮樣式`, ja: `単語ハイライトスタイル`, ko: `단어 하이라이트 스타일`, }, selector_style_helper: { zh: `开启翻译时注入。`, en: `It is injected when translation is turned on.`, zh_TW: `在開啟翻譯時注入。`, ja: `翻訳が有効なときに注入されます。`, ko: `번역이 켜져 있을 때 주입됩니다.`, }, selector_parent_style: { zh: `选择器父节点样式`, en: `Parent Selector Style`, zh_TW: `選擇器父節點樣式`, ja: `親セレクタスタイル`, ko: `부모 선택자 스타일`, }, selector_grand_style: { zh: `选择器祖节点样式`, en: `Grand Selector Style`, zh_TW: `選擇器祖節點樣式`, ja: `祖先セレクタスタイル`, ko: `상위 선택자 스타일`, }, inject_js: { zh: `注入JS`, en: `Inject JS`, zh_TW: `注入 JS`, ja: `JSを注入`, ko: `JS 주입`, }, inject_js_helper: { zh: `预加载时注入,一个页面仅运行一次。内置全局对象 KT: { apiTranslate, apiDectect, apiSetting, apisMap, toLang, docInfo, glossary, }`, en: `Injected during preload, runs only once per page. Built-in global object KT: { apiTranslate, apiDectect, apiSetting, apisMap, toLang, docInfo, glossary, }`, zh_TW: `預先載入時注入,一個頁面僅運行一次。內建全域物件 KT: { apiTranslate, apiDectect, apiSetting, apisMap, toLang, docInfo, glossary, }`, ja: `プリロード時に注入され、ページごとに1回だけ実行されます。組み込みグローバルオブジェクト KT: { apiTranslate, apiDectect, apiSetting, apisMap, toLang, docInfo, glossary, }`, ko: `미리 로드 시 주입되며 페이지당 한 번만 실행됩니다. 내장 전역 객체 KT: { apiTranslate, apiDectect, apiSetting, apisMap, toLang, docInfo, glossary, }`, }, inject_css: { zh: `注入CSS`, en: `Inject CSS`, zh_TW: `注入 CSS`, ja: `CSSを注入`, ko: `CSS 주입`, }, inject_css_helper: { zh: `预加载时注入,一个页面仅运行一次。`, en: `Injected during preload, runs only once per page.`, zh_TW: `預先載入時注入,一個頁面僅運行一次。`, ja: `プリロード時に注入され、ページごとに1回だけ実行されます。`, ko: `미리 로드 시 주입되며 페이지당 한 번만 실행됩니다.`, }, fixer_function: { zh: `修复函数`, en: `Fixer Function`, zh_TW: `修復函式`, ja: `修正関数`, ko: `수정 함수`, }, fixer_function_helper: { zh: `1、br是将
换行替换成

。2、bn是将\\n换行替换成

。3、brToDiv和bnToDiv是替换成

。`, en: `1. br replaces
line breaks with

. 2. bn replaces \\n newline with

. 3. brToDiv and bnToDiv are replaced with

.`, zh_TW: `1. br 會將
換行替換為

。2. bn 會將 \\n 換行替換為

。3. brToDiv 與 bnToDiv 會替換為

。`, ja: `1. br は
改行を

に置き換えます。 2. bn は \\n 改行を

に置き換えます。 3. brToDiv と bnToDiv は

に置き換えます。`, ko: `1. br은
줄바꿈을

로 대체합니다. 2. bn은 \\n 줄바꿈을

로 대체합니다. 3. brToDiv 및 bnToDiv는

로 대체됩니다.`, }, import: { zh: `导入`, en: `Import`, zh_TW: `匯入`, ja: `インポート`, ko: `가져오기`, }, export: { zh: `导出`, en: `Export`, zh_TW: `匯出`, ja: `エクスポート`, ko: `내보내기`, }, export_translation: { zh: `导出释义`, en: `Export Translation`, zh_TW: `匯出釋義`, ja: `訳文のエクスポート`, ko: `번역 내보내기`, }, error_cant_be_blank: { zh: `不能为空`, en: `Can not be blank`, zh_TW: `不可為空`, ja: `空白にすることはできません`, ko: `비워둘 수 없습니다`, }, error_duplicate_values: { zh: `存在重复的值`, en: `There are duplicate values`, zh_TW: `存在重複的值`, ja: `重複する値が存在します`, ko: `중복된 값이 있습니다`, }, error_wrong_file_type: { zh: `错误的文件类型`, en: `Wrong file type`, zh_TW: `檔案類型錯誤`, ja: `不正なファイルタイプです`, ko: `잘못된 파일 형식입니다`, }, error_fetch_url: { zh: `请检查url地址是否正确或稍后再试。`, en: `Please check if the url address is correct or try again later.`, zh_TW: `請檢查 URL 是否正確或稍後再試。`, ja: `URLアドレスが正しいか確認するか、後でもう一度お試しください。`, ko: `URL 주소가 올바른지 확인하거나 나중에 다시 시도하십시오.`, }, deepl_api: { zh: `DeepL 接口`, en: `DeepL API`, zh_TW: `DeepL 介面`, ja: `DeepL API`, ko: `DeepL API`, }, deepl_key: { zh: `DeepL 密钥`, en: `DeepL Key`, zh_TW: `DeepL 金鑰`, ja: `DeepL キー`, ko: `DeepL 키`, }, openai_api: { zh: `OpenAI 接口`, en: `OpenAI API`, zh_TW: `OpenAI 介面`, ja: `OpenAI API`, ko: `OpenAI API`, }, openai_key: { zh: `OpenAI 密钥`, en: `OpenAI Key`, zh_TW: `OpenAI 金鑰`, ja: `OpenAI キー`, ko: `OpenAI 키`, }, openai_model: { zh: `OpenAI 模型`, en: `OpenAI Model`, zh_TW: `OpenAI 模型`, ja: `OpenAI モデル`, ko: `OpenAI 모델`, }, openai_prompt: { zh: `OpenAI 提示词`, en: `OpenAI Prompt`, zh_TW: `OpenAI 提示詞`, ja: `OpenAI プロンプト`, ko: `OpenAI 프롬프트`, }, if_clear_cache: { zh: `是否清除缓存(默认缓存7天)`, en: `Whether clear cache (Default cache is 7 days)`, zh_TW: `是否清除快取(預設快取7天)`, ja: `キャッシュをクリアしますか(デフォルトのキャッシュ期間は7日間です)`, ko: `캐시를 지우시겠습니까 (기본 캐시 7일)`, }, clear_cache_never: { zh: `不清除缓存`, en: `Never clear cache`, zh_TW: `不清除快取`, ja: `キャッシュをクリアしない`, ko: `캐시 지우지 않음`, }, clear_cache_restart: { zh: `重启浏览器时清除缓存`, en: `Clear cache when restarting browser`, zh_TW: `重新啟動瀏覽器時清除快取`, ja: `ブラウザ再起動時にキャッシュをクリア`, ko: `브라우저 재시작 시 캐시 지우기`, }, data_sync_type: { zh: `数据同步方式`, en: `Data Sync Type`, zh_TW: `資料同步方式`, ja: `データ同期タイプ`, ko: `데이터 동기화 유형`, }, data_sync_url: { zh: `数据同步接口`, en: `Data Sync API`, zh_TW: `資料同步介面`, ja: `データ同期API`, ko: `데이터 동기화 API`, }, data_sync_user: { zh: `数据同步账户`, en: `Data Sync User`, zh_TW: `資料同步帳號`, ja: `データ同期アカウント`, ko: `데이터 동기화 계정`, }, data_sync_key: { zh: `数据同步密钥`, en: `Data Sync Key`, zh_TW: `資料同步金鑰`, ja: `データ同期キー`, ko: `데이터 동기화 키`, }, sync_now: { zh: `立即同步`, en: `Sync Now`, zh_TW: `立即同步`, ja: `今すぐ同期`, ko: `지금 동기화`, }, sync_success: { zh: `同步成功!`, en: `Sync Success`, zh_TW: `同步成功!`, ja: `同期成功!`, ko: `동기화 성공!`, }, sync_failed: { zh: `同步失败!`, en: `Sync Error`, zh_TW: `同步失敗!`, ja: `同期失敗!`, ko: `동기화 실패!`, }, error_got_some_wrong: { zh: `抱歉,出错了!`, en: `Sorry, something went wrong!`, zh_TW: `抱歉,發生錯誤!`, ja: `申し訳ありません、エラーが発生しました!`, ko: `죄송합니다, 오류가 발생했습니다!`, }, error_sync_setting: { zh: `您的同步类型必须为“KISS-Worker”,且需填写完整`, en: `Your sync type must be "KISS-Worker" and must be filled in completely`, zh_TW: `您的同步型態必須為「KISS-Worker」,且需填寫完整。`, ja: `同期タイプは「KISS-Worker」である必要があり、すべて入力する必要があります。`, ko: `동기화 유형은 "KISS-Worker"여야 하며, 모든 항목을 빠짐없이 입력해야 합니다.`, }, click_test: { zh: `点击测试`, en: `Click Test`, zh_TW: `點擊測試`, ja: `クリックしてテスト`, ko: `클릭 테스트`, }, test_success: { zh: `测试成功`, en: `Test success`, zh_TW: `測試成功`, ja: `テスト成功`, ko: `테스트 성공`, }, test_failed: { zh: `测试失败`, en: `Test failed`, zh_TW: `測試失敗`, ja: `テスト失敗`, ko: `테스트 실패`, }, clear_all_cache_now: { zh: `立即清除全部缓存`, en: `Clear all cache now`, zh_TW: `立即清除全部快取`, ja: `すべてのキャッシュを今すぐクリア`, ko: `모든 캐시 지금 지우기`, }, clear_cache: { zh: `清除缓存`, en: `Clear Cache`, zh_TW: `清除快取`, ja: `キャッシュをクリア`, ko: `캐시 지우기`, }, disable_on_mobile: { zh: `移动端禁用`, en: `Disable on Mobile`, zh_TW: `行動裝置停用`, ja: `モバイルで無効にする`, ko: `모바일에서 비활성화`, }, clear_success: { zh: `清除成功`, en: `Clear success`, zh_TW: `清除成功`, ja: `クリア成功`, ko: `지우기 성공`, }, clear_failed: { zh: `清除失败`, en: `Clear failed`, zh_TW: `清除失敗`, ja: `クリア失敗`, ko: `지우기 실패`, }, share: { zh: `分享`, en: `Share`, zh_TW: `分享`, ja: `共有`, ko: `공유`, }, clear_all: { zh: `清空`, en: `Clear All`, zh_TW: `清空`, ja: `すべてクリア`, ko: `모두 지우기`, }, help: { zh: `求助`, en: `Help`, zh_TW: `求助`, ja: `ヘルプ`, ko: `도움말`, }, restore_default: { zh: `恢复默认`, en: `Restore Default`, zh_TW: `恢復預設`, ja: `デフォルトに戻す`, ko: `기본값 복원`, }, shortcuts_setting: { zh: `快捷键设置`, en: `Shortcuts Setting`, zh_TW: `快捷鍵設定`, ja: `ショートカット設定`, ko: `단축키 설정`, }, toggle_translate_shortcut: { zh: `"开启翻译"快捷键`, en: `"Toggle Translate" Shortcut`, zh_TW: `「開啟翻譯」快捷鍵`, ja: `「翻訳切り替え」ショートカット`, ko: `"번역 켜기" 단축키`, }, toggle_style_shortcut: { zh: `"切换样式"快捷键`, en: `"Toggle Style" Shortcut`, zh_TW: `「切換樣式」快捷鍵`, ja: `「スタイル切り替え」ショートカット`, ko: `"스타일 전환" 단축키`, }, toggle_popup_shortcut: { zh: `"打开弹窗"快捷键`, en: `"Open Popup" Shortcut`, zh_TW: `「開啟彈窗」快捷鍵`, ja: `「ポップアップを開く」ショートカット`, ko: `"팝업 열기" 단축키`, }, open_setting_shortcut: { zh: `"打开设置"快捷键`, en: `"Open Setting" Shortcut`, zh_TW: `「開啟設定」快捷鍵`, ja: `「設定を開く」ショートカット`, ko: `"설정 열기" 단축키`, }, hide_fab_button: { zh: `隐藏悬浮按钮`, en: `Hide Fab Button`, zh_TW: `隱藏懸浮按鈕`, ja: `フローティングボタンを隠す`, ko: `플로팅 버튼 숨기기`, }, fab_click_action: { zh: `单击悬浮按钮动作`, en: `Single Click Fab Action`, zh_TW: `單擊懸浮按钮動作`, ja: `フローティングボタンのクリック動作`, ko: `플로팅 버튼 클릭 동작`, }, fab_click_menu: { zh: `弹出菜单`, en: `Popup Menu`, zh_TW: `彈出選單`, ja: `メニューを開く`, ko: `팝업 메뉴`, }, fab_click_translate: { zh: `直接翻译`, en: `Translate`, zh_TW: `直接翻譯`, ja: `直接翻訳`, ko: `바로 번역`, }, hide_tran_button: { zh: `隐藏翻译按钮`, en: `Hide Translate Button`, zh_TW: `隱藏翻譯按鈕`, ja: `翻訳ボタンを隠す`, ko: `번역 버튼 숨기기`, }, hide_click_away: { zh: `点击外部关闭弹窗`, en: `Click outside to close the pop-up window`, zh_TW: `點擊外部關閉彈窗`, ja: `外部クリックでポップアップを閉じる`, ko: `바깥쪽 클릭 시 팝업 닫기`, }, use_simple_style: { zh: `使用简洁界面`, en: `Use a simple interface`, zh_TW: `使用簡潔介面`, ja: `シンプルUIを使用`, ko: `간단한 인터페이스 사용`, }, show: { zh: `显示`, en: `Show`, zh_TW: `顯示`, ja: `表示`, ko: `표시`, }, hide: { zh: `隐藏`, en: `Hide`, zh_TW: `隱藏`, ja: `非表示`, ko: `숨기기`, }, save_rule: { zh: `保存本站规则`, en: `Save this site rule`, zh_TW: `保存本站規則`, ja: `このサイトのルールを保存`, ko: `이 사이트 규칙 저장`, }, domain: { zh: `网域`, en: `Domain`, zh_TW: `網域`, ja: `ドメイン`, ko: `도메인`, }, global_rule: { zh: `全局规则`, en: `Global Rule`, zh_TW: `全域規則`, ja: `グローバルルール`, ko: `전역 규칙`, }, input_translate: { zh: `输入框翻译`, en: `Input Box Translation`, zh_TW: `輸入框翻譯`, ja: `入力ボックス翻訳`, ko: `입력창 번역`, }, use_input_box_translation: { zh: `启用输入框翻译`, en: `Input Box Translation`, zh_TW: `啟用輸入框翻譯`, ja: `入力ボックス翻訳を有効にする`, ko: `입력창 번역 사용`, }, input_selector: { zh: `输入框选择器`, en: `Input Selector`, zh_TW: `輸入框選擇器`, ja: `入力ボックスセレクタ`, ko: `입력창 선택자`, }, input_selector_helper: { zh: `用于输入框翻译。`, en: `Used for input box translation.`, zh_TW: `用於輸入框翻譯。`, ja: `入力ボックスの翻訳に使用します。`, ko: `입력창 번역에 사용됩니다.`, }, trigger_trans_shortcut: { zh: `触发翻译快捷键`, en: `Trigger Translation Shortcut Keys`, zh_TW: `觸發翻譯快捷鍵`, ja: `翻訳ショートカットキー`, ko: `번역 실행 단축키`, }, trigger_trans_shortcut_help: { zh: `默认为单击“AltLeft+KeyI”`, en: `Default is "AltLeft+KeyI"`, zh_TW: `預設為按下「AltLeft+KeyI」`, ja: `デフォルトは「AltLeft+KeyI」です`, ko: `기본값 "AltLeft+KeyI"`, }, shortcut_press_count: { zh: `快捷键连击次数`, en: `Shortcut Press Number`, zh_TW: `快捷鍵連擊次數`, ja: `ショートカットの連続プレス回数`, ko: `단축키 연속 입력 횟수`, }, combo_timeout: { zh: `连击超时时间 (10-1000ms)`, en: `Combo Timeout (10-1000ms)`, zh_TW: `連擊逾時 (10-1000ms)`, ja: `連続プレスタイムアウト (10-1000ms)`, ko: `연속 입력 시간 초과 (10-1000ms)`, }, input_trans_start_sign: { zh: `翻译起始标识`, en: `Translation Start Sign`, zh_TW: `翻譯起始標記`, ja: `翻訳開始記号`, ko: `번역 시작 표시`, }, input_trans_start_sign_help: { zh: `标识后面可以加目标语言代码,如: “/en 你好”、“/zh hello”`, en: `The target language code can be added after the sign, such as: "/en 你好", "/zh hello"`, zh_TW: `標記後可加上目標語言代碼,例如:「/en 你好」、「/zh hello」`, ja: `記号の後に対象言語コードを追加できます。例:「/en 你好」、「/zh hello」`, ko: `표시 뒤에 대상 언어 코드를 추가할 수 있습니다. 예: "/en 你好", "/zh hello"`, }, detect_lang_remote: { zh: `远程语言检测`, en: `Remote language detection`, zh_TW: `遠端語言偵測`, ja: `リモート言語検出`, ko: `원격 언어 감지`, }, detect_lang_remote_help: { zh: `启用后检测准确度增加,但会降低翻译速度,请酌情开启`, en: `After enabling, the detection accuracy will increase, but it will reduce the translation speed. Please enable it as appropriate.`, zh_TW: `啟用後可提升偵測準確度,但會降低翻譯速度,請視需要開啟。`, ja: `有効にすると検出精度が向上しますが、翻訳速度が低下する可能性があります。必要に応じて有効にしてください。`, ko: `활성화하면 감지 정확도가 높아지지만 번역 속도가 느려질 수 있습니다. 적절히 활성화하십시오.`, }, detect_lang_service: { zh: `语言检测服务`, en: `Language detect service`, zh_TW: `語言檢測服務`, ja: `言語検出サービス`, ko: `언어 감지 서비스`, }, disable: { zh: `禁用`, en: `Disable`, zh_TW: `停用`, ja: `無効`, ko: `비활성화`, }, enable: { zh: `启用`, en: `Enable`, zh_TW: `啟用`, ja: `有効`, ko: `활성화`, }, selection_translate: { zh: `划词翻译`, en: `Selection Translation`, zh_TW: `劃詞翻譯`, ja: `選択翻訳`, ko: `선택 번역`, }, toggle_selection_translate: { zh: `启用划词翻译`, en: `Use Selection Translate`, zh_TW: `啟用劃詞翻譯`, ja: `選択翻訳を有効にする`, ko: `선택 번역 사용`, }, trigger_tranbox_shortcut: { zh: `显示翻译框/翻译选中文字快捷键`, en: `Open Translate Popup/Translate Selected Shortcut`, zh_TW: `顯示翻譯框/翻譯選中文字快捷鍵`, ja: `翻訳ポップアップ表示/選択翻訳ショートカット`, ko: `번역창 표시/선택 번역 단축키`, }, tranbtn_offset_x: { zh: `翻译按钮偏移X(±200)`, en: `Translate Button Offset X (±200)`, zh_TW: `翻譯按鈕位移 X(±200)`, ja: `翻訳ボタンオフセットX (±200)`, ko: `번역 버튼 오프셋 X (±200)`, }, tranbtn_offset_y: { zh: `翻译按钮偏移Y(±200)`, en: `Translate Button Offset Y (±200)`, zh_TW: `翻譯按鈕位移 Y(±200)`, ja: `翻訳ボタンオフセットY (±200)`, ko: `번역 버튼 오프셋 Y (±200)`, }, tranbox_offset_x: { zh: `翻译框偏移X(±200)`, en: `Translate Box Offset X (±200)`, zh_TW: `翻譯框位移 X(±200)`, ja: `翻訳ボックスオフセットX (±200)`, ko: `번역창 오프셋 X (±200)`, }, tranbox_offset_y: { zh: `翻译框偏移Y(±200)`, en: `Translate Box Offset Y (±200)`, zh_TW: `翻譯框位移 Y(±200)`, ja: `翻訳ボックスオフセットY (±200)`, ko: `번역창 오프셋 Y (±200)`, }, translated_text: { zh: `译文`, en: `Translated Text`, zh_TW: `譯文`, ja: `翻訳済みテキスト`, ko: `번역된 텍스트`, }, original_text: { zh: `原文`, en: `Original Text`, zh_TW: `原文`, ja: `原文`, ko: `원본 텍스트`, }, favorite_words: { zh: `收藏词汇`, en: `Favorite Words`, zh_TW: `收藏詞彙`, ja: `お気に入り単語`, ko: `즐겨찾는 단어`, }, touch_setting: { zh: `触屏设置`, en: `Touch Setting`, zh_TW: `觸控設定`, ja: `タッチ設定`, ko: `터치 설정`, }, touch_translate_shortcut: { zh: `触屏翻译快捷方式 (支持多选)`, en: `Touch Translate Shortcut (multiple supported)`, zh_TW: `觸控翻譯捷徑 (支援多選)`, ja: `タッチ翻訳ショートカット (複数選択可)`, ko: `터치 번역 단축키 (다중 선택 지원)`, }, touch_tap_0: { zh: `禁用`, en: `Disable`, zh_TW: `停用`, ja: `無効`, ko: `비활성화`, }, touch_tap_2: { zh: `双指轻触`, en: `Two finger tap`, zh_TW: `雙指輕觸`, ja: `2本指タップ`, ko: `두 손가락 탭`, }, touch_tap_3: { zh: `三指轻触`, en: `Three finger tap`, zh_TW: `三指輕觸`, ja: `3本指タップ`, ko: `세 손가락 탭`, }, touch_tap_4: { zh: `四指轻触`, en: `Four finger tap`, zh_TW: `四指輕觸`, ja: `4本指タップ`, ko: `네 손가락 탭`, }, touch_tap_5: { zh: `单指双击`, en: `Double-click`, zh_TW: `單指雙擊`, ja: `ダブルクリック`, ko: `더블 클릭`, }, touch_tap_6: { zh: `单指三击`, en: `Triple-click`, zh_TW: `單指三擊`, ja: `トリプルクリック`, ko: `트리플 클릭`, }, touch_tap_7: { zh: `双指双击`, en: `Two-finger double-click`, zh_TW: `雙指雙擊`, ja: `2本指ダブルクリック`, ko: `두 손가락 더블 클릭`, }, translate_blacklist: { zh: `禁用翻译名单`, en: `Translate Blacklist`, zh_TW: `停用翻譯名單`, ja: `翻訳ブラックリスト`, ko: `번역 블랙리스트`, }, disabled_orilist: { zh: `禁用Origin名单`, en: `Disabled Origin List`, zh_TW: `停用 Origin 名單`, ja: `無効化Originリスト`, ko: `비활성화된 Origin 목록`, }, disabled_csplist: { zh: `禁用CSP名单`, en: `Disabled CSP List`, zh_TW: `停用 CSP 名單`, ja: `無効化CSPリスト`, ko: `비활성화된 CSP 목록`, }, disabled_csplist_helper: { zh: `3、通过调整CSP策略,使得某些页面能够注入JS/CSS/Media,请谨慎使用,除非您已知晓相关风险。`, en: `3. By adjusting the CSP policy, some pages can inject JS/CSS/Media. Please use it with caution unless you are aware of the related risks.`, zh_TW: `3. 透過調整 CSP 政策,使部分頁面可注入 JS/CSS/Media。請謹慎使用,除非您已知悉相關風險。`, ja: `3. CSPポリシーを調整することにより、一部のページでJS/CSS/Mediaの注入が可能になります。関連するリスクを承知していない限り、慎重に使用してください。`, ko: `3. CSP 정책을 조정하여 일부 페이지에서 JS/CSS/Media를 주입할 수 있습니다. 관련된 위험을 인지하고 있는 경우가 아니라면 주의해서 사용하십시오.`, }, skip_langs: { zh: `不翻译的语言`, en: `Disable Languages`, zh_TW: `不翻譯的語言`, ja: `翻訳しない言語`, ko: `번역하지 않을 언어`, }, skip_langs_helper: { zh: `此功能依赖准确的语言检测,建议启用远程语言检测。`, en: `This feature relies on accurate language detection. It is recommended to enable remote language detection.`, zh_TW: `此功能仰賴準確的語言偵測,建議啟用遠端語言偵測。`, ja: `この機能は正確な言語検出に依存しているため、リモート言語検出を有効にすることをお勧めします。`, ko: `이 기능은 정확한 언어 감지에 의존하므로 원격 언어 감지를 활성화하는 것이 좋습니다.`, }, context_menus: { zh: `右键菜单`, en: `Context Menus`, zh_TW: `右鍵選單`, ja: `コンテキストメニュー`, ko: `컨텍스트 메뉴`, }, hide_context_menus: { zh: `隐藏右键菜单`, en: `Hide Context Menus`, zh_TW: `隱藏右鍵選單`, ja: `コンテキストメニューを隠す`, ko: `컨텍스트 메뉴 숨기기`, }, simple_context_menus: { zh: `简单右键菜单`, en: `Simple_context_menus Context Menus`, zh_TW: `簡易右鍵選單`, ja: `シンプルコンテキストメニュー`, ko: `간단한 컨텍스트 메뉴`, }, secondary_context_menus: { zh: `二级右键菜单`, en: `Secondary Context Menus`, zh_TW: `次級右鍵選單`, ja: `サブコンテキストメニュー`, ko: `보조 컨텍스트 메뉴`, }, mulkeys_help: { zh: `支持用换行或英文逗号“,”分隔,轮询调用。`, en: `Supports polling calls separated by newlines or English commas ",".`, zh_TW: `支援以換行或英文逗號「,」分隔,輪詢呼叫。`, ja: `改行または英語のコンマ「,」で区切ってポーリングコールをサポートします。`, ko: `줄바꿈 또는 영어 쉼표 ","로 구분된 폴링 호출을 지원합니다.`, }, translation_element_tag: { zh: `译文元素标签`, en: `Translation Element Tag`, zh_TW: `譯文元素標籤`, ja: `翻訳要素タグ`, ko: `번역 요소 태그`, }, show_only_translations: { zh: `仅显示译文`, en: `Show Only Translations`, zh_TW: `僅顯示譯文`, ja: `翻訳のみ表示`, ko: `번역만 보기`, }, show_only_translations_help: { zh: `非完美实现,某些页面可能有样式等问题。`, en: `It is not a perfect implementation and some pages may have style issues.`, zh_TW: `此為非完美實作,部分頁面可能出現樣式等問題。`, ja: `完全な実装ではなく、一部のページでスタイルの問題が発生する可能性があります。`, ko: `완벽한 구현이 아니며 일부 페이지에서 스타일 문제가 발생할 수 있습니다.`, }, translate_page_title: { zh: `是否翻译页面标题`, en: `Translate Page Title`, zh_TW: `是否翻譯頁面標題`, ja: `ページタイトルを翻訳する`, ko: `페이지 제목 번역`, }, more: { zh: `更多`, en: `More`, zh_TW: `更多`, ja: `もっと見る`, ko: `더보기`, }, less: { zh: `更少`, en: `Less`, zh_TW: `更少`, ja: `少なく`, ko: `줄이기`, }, fixer_selector: { zh: `网页修复选择器`, en: `Fixer Selector`, zh_TW: `網頁修復選擇器`, ja: `Web修正セレクタ`, ko: `웹페이지 수정 선택자`, }, reg_niutrans: { zh: `获取小牛翻译密钥【简约翻译专属新用户注册赠送300万字符】`, en: `Get NiuTrans APIKey [KISS Translator Exclusive New User Registration Free 3 Million Characters]`, zh_TW: `取得小牛翻譯金鑰【簡約翻譯專屬新用戶註冊贈送 300 萬字元】`, ja: `NiuTrans APIキーを取得 [KISS翻訳 専用 新規ユーザー登録で300万文字無料]`, ko: `NiuTrans API 키 받기 [KISS 번역기 신규 사용자 등록 시 300만 자 무료 제공]`, }, trigger_mode: { zh: `触发方式`, en: `Trigger Mode`, zh_TW: `觸發方式`, ja: `トリガーモード`, ko: `트리거 모드`, }, trigger_click: { zh: `点击触发`, en: `Click Trigger`, zh_TW: `點擊觸發`, ja: `クリックトリガー`, ko: `클릭 트리거`, }, trigger_hover: { zh: `鼠标悬停触发`, en: `Hover Trigger`, zh_TW: `滑鼠懸停觸發`, ja: `ホバートリガー`, ko: `호버 트리거`, }, trigger_select: { zh: `选中触发`, en: `Select Trigger`, zh_TW: `選取觸發`, ja: `選択トリガー`, ko: `선택 트리거`, }, extend_styles: { zh: `附加样式`, en: `Extend Styles`, zh_TW: `附加樣式`, ja: `拡張スタイル`, ko: `확장 스타일`, }, custom_option: { zh: `自定义选项`, en: `Custom Option`, zh_TW: `自訂選項`, ja: `カスタムオプション`, ko: `사용자 지정 옵션`, }, translate_selected_text: { zh: `翻译选中文字`, en: `Translate Selected Text`, zh_TW: `翻譯選取文字`, ja: `選択したテキストを翻訳`, ko: `선택한 텍스트 번역`, }, toggle_style: { zh: `切换样式`, en: `Toggle Style`, zh_TW: `切換樣式`, ja: `スタイルを切り替え`, ko: `스타일 전환`, }, open_menu: { zh: `打开弹窗菜单`, en: `Open Popup Menu`, zh_TW: `開啟彈窗選單`, ja: `ポップアップメニューを開く`, ko: `팝업 메뉴 열기`, }, open_setting: { zh: `打开设置`, en: `Open Setting`, zh_TW: `開啟設定`, ja: `設定を開く`, ko: `설정 열기`, }, follow_selection: { zh: `翻译框跟随选中文本`, en: `Transbox Follow Selection`, zh_TW: `翻譯框跟隨選取文字`, ja: `翻訳ボックスを選択範囲に追従`, ko: `번역 상자가 선택 항목 따라가기`, }, tranbox_auto_height: { zh: `翻译框自适应高度`, en: `Translation box adaptive height`, zh_TW: `翻譯框自適應高度`, ja: `翻訳ボックスの高さ自動調整`, ko: `번역 상자 높이 자동 조절`, }, translate_start_hook: { zh: `翻译开始钩子函数`, en: `Translate Start Hook`, zh_TW: `翻譯開始 Hook`, ja: `翻訳開始フック`, ko: `번역 시작 후크`, }, translate_start_hook_helper: { zh: `翻译前时运行,入参为: {text, fromLang, toLang, apiSetting, docInfo, glossary,}`, en: `Run before translation, input parameters are: {text, fromLang, toLang, apiSetting, docInfo, glossary,}`, zh_TW: `翻譯前時運行,入參為: {text, fromLang, toLang, apiSetting, docInfo, glossary,}`, ja: `翻訳前に実行、入力パラメータ: {text, fromLang, toLang, apiSetting, docInfo, glossary,}`, ko: `번역 전 실행, 입력 매개변수: {text, fromLang, toLang, apiSetting, docInfo, glossary,}`, }, translate_end_hook: { zh: `翻译完成钩子函数`, en: `Translate End Hook`, zh_TW: `翻譯完成 Hook`, ja: `翻訳完了フック`, ko: `번역 완료 후크`, }, translate_end_hook_helper: { zh: `翻译完成时运行,入参为: ({hostNode, parentNode, nodes, wrapperNode, innerNode})`, en: `Run when translation is complete, input parameters are: ({hostNode, parentNode, nodes, wrapperNode, innerNode})`, zh_TW: `翻譯完成時運行,入參為: ({hostNode, parentNode, nodes, wrapperNode, innerNode})`, ja: `翻訳完了時に実行、入力パラメータ: ({hostNode, parentNode, nodes, wrapperNode, innerNode})`, ko: `번역 완료 시 실행, 입력 매개변수: ({hostNode, parentNode, nodes, wrapperNode, innerNode})`, }, translate_remove_hook: { zh: `翻译移除钩子函数`, en: `Translate Removed Hook`, zh_TW: `翻譯移除 Hook`, ja: `翻訳削除フック`, ko: `번역 제거 후크`, }, translate_remove_hook_helper: { zh: `翻译移除时运行,入参为: 翻译节点。`, en: `Run when translation is removed, the input parameters are: translation node.`, zh_TW: `移除翻譯時執行,入參為:翻譯節點。`, ja: `翻訳削除時に実行、入力パラメータ: 翻訳ノード。`, ko: `번역 제거 시 실행, 입력 매개변수: 번역 노드.`, }, english_dict: { zh: `英文词典`, en: `English Dictionary`, zh_TW: `英文字典`, ja: `英語辞書`, ko: `영어 사전`, }, english_suggest: { zh: `英文建议`, en: `English Suggest`, zh_TW: `英文建議`, ja: `英語サジェスト`, ko: `영어 제안`, }, api_name: { zh: `接口名称`, en: `API Name`, zh_TW: `介面名稱`, ja: `API名`, ko: `API 이름`, }, sort_order: { zh: `排序权重`, en: `Sort Order`, zh_TW: `排序權重`, ja: `ソート順序`, ko: `정렬 순서`, }, sort_order_help: { zh: `数值越小越靠前`, en: `Smaller values appear first`, zh_TW: `數值越小越靠前`, ja: `小さい値が先に表示されます`, ko: `작은 값이 먼저 표시됩니다`, }, is_disabled: { zh: `是否禁用`, en: `Is Disabled`, zh_TW: `是否停用`, ja: `無効にする`, ko: `비활성화 여부`, }, translate_selected: { zh: `是否启用划词翻译`, en: `If translate selected`, zh_TW: `是否啟用劃詞翻譯`, ja: `選択範囲の翻訳を有効にする`, ko: `선택 번역 사용 여부`, }, use_batch_fetch: { zh: `是否聚合发送翻译请求`, en: `Whether to aggregate and send translation requests`, zh_TW: `是否聚合發送翻譯請求`, ja: `翻訳リクエストをまとめて送信`, ko: `번역 요청 일괄 전송 여부`, }, batch_interval: { zh: `聚合请求等待时间(10-10000)`, en: `Aggregation request waiting time (10-10000)`, zh_TW: `聚合請求等待時間(10-10000)`, ja: `一括リクエストの待機時間(10-10000)`, ko: `일괄 요청 대기 시간(10-10000)`, }, batch_size: { zh: `聚合请求最大段落数(1-100)`, en: `Maximum number of paragraphs in an aggregation request (1-100)`, zh_TW: `聚合請求最大段落數(1-100)`, ja: `一括リクエストの最大段落数(1-100)`, ko: `일괄 요청 최대 단락 수(1-100)`, }, batch_length: { zh: `聚合请求最大文本长度(1000-100000)`, en: `Maximum text length for aggregation requests (1000-100000)`, zh_TW: `聚合請求最大文字長度(1000-100000)`, ja: `一括リクエストの最大テキスト長(1000-100000)`, ko: `일괄 요청 최대 텍스트 길이(1000-100000)`, }, use_stream: { zh: `是否启用流式传输`, en: `Whether to enable streaming`, zh_TW: `是否啟用串流傳輸`, ja: `ストリーミングを有効にする`, ko: `스트리밍 활성화 여부`, }, use_context: { zh: `是否启用智能上下文`, en: `Whether to enable AI context`, zh_TW: `是否啟用智慧上下文`, ja: `AIコンテキストを有効にする`, ko: `AI 컨텍스트 활성화 여부`, }, context_size: { zh: `上下文会话数量(1-20)`, en: `Number of context sessions(1-20)`, zh_TW: `上下文會話數量(1-20)`, ja: `コンテキストセッション数(1-20)`, ko: `컨텍스트 세션 수(1-20)`, }, auto_scan_page: { zh: `自动扫描页面`, en: `Auto scan page`, zh_TW: `自動掃描頁面`, ja: `ページを自動スキャン`, ko: `페이지 자동 스캔`, }, has_rich_text: { zh: `启用富文本翻译`, en: `Enable rich text translation`, zh_TW: `啟用富文本翻譯`, ja: `リッチテキスト翻訳を有効にする`, ko: `리치 텍스트 번역 활성화`, }, has_shadowroot: { zh: `扫描Shadowroot`, en: `Scan Shadowroot`, zh_TW: `掃描Shadowroot`, ja: `Shadowrootをスキャン`, ko: `Shadowroot 스캔`, }, mousehover_translate: { zh: `鼠标悬停翻译`, en: `Mouseover Translation`, zh_TW: `滑鼠懸停翻譯`, ja: `マウスオーバー翻訳`, ko: `마우스오버 번역`, }, use_mousehover_translation: { zh: `启用鼠标悬停翻译`, en: `Enable mouseover translation`, zh_TW: `啟用滑鼠懸停翻譯`, ja: `マウスオーバー翻訳を有効にする`, ko: `마우스오버 번역 활성화`, }, selected_translation_alert: { zh: `划词翻译的开启和关闭请到“规则设置”里面设置。`, en: `To turn selected translation on or off, please go to "Rule Settings".`, zh_TW: `劃詞翻譯的開啟和關閉請到「規則設定」裡面設定。`, ja: `選択翻訳のオン/オフは「ルール設定」で行ってください。`, ko: `선택 번역 활성화/비활성화는 "규칙 설정"에서 하십시오.`, }, mousehover_key_help: { zh: `当快捷键置空时表示鼠标懸停直接翻译`, en: `When the shortcut key is empty, it means that the mouse hovers to translate directly`, zh_TW: `當快捷鍵置空時表示滑鼠懸停直接翻譯`, ja: `ショートカットキーが空の場合、マウスオーバーで直接翻訳します`, ko: `단축키가 비어 있으면 마우스오버 시 바로 번역합니다`, }, autoscan_alt: { zh: `自动扫描`, en: `Auto Scan`, zh_TW: `自動掃描`, ja: `自動スキャン`, ko: `자동 스캔`, }, scan_all_nodes: { zh: `扫描全部节点`, en: `Scan All Nodes`, zh_TW: `掃描全部節點`, ja: `すべてのノードをスキャン`, ko: `모든 노드 스캔`, }, shadowroot_alt: { zh: `ShadowRoot`, en: `ShadowRoot`, zh_TW: `ShadowRoot`, ja: `ShadowRoot`, ko: `ShadowRoot`, }, richtext_alt: { zh: `保留富文本`, en: `Rich Text`, zh_TW: `保留富文本`, ja: `リッチテキスト`, ko: `리치 텍스트`, }, transonly_alt: { zh: `隐藏原文`, en: `Hide Original`, zh_TW: `隱藏原文`, ja: `原文を隠す`, ko: `원문 숨기기`, }, confirm_title: { zh: `确认`, en: `Confirm`, zh_TW: `確認`, ja: `確認`, ko: `확인`, }, confirm_message: { zh: `确定操作吗?`, en: `Are you sure you want to proceed?`, zh_TW: `確定操作嗎?`, ja: `操作を続行しますか?`, ko: `계속하시겠습니까?`, }, confirm_action: { zh: `确定`, en: `Confirm`, zh_TW: `確定`, ja: `確認`, ko: `확인`, }, cancel_action: { zh: `取消`, en: `Cancel`, zh_TW: `取消`, ja: `キャンセル`, ko: `취소`, }, pls_press_shortcut: { zh: `请按下快捷键组合`, en: `Please press the shortcut key combination`, zh_TW: `請按下快速鍵組合`, ja: `ショートカットキーを押してください`, ko: `단축키 조합을 누르세요`, }, load_setting_err: { zh: `数据加载出错,请刷新页面或卸载后重新安装。`, en: `Please press the shortcut key combination`, // 注意:这里的英文和繁体是用户上次错误的拷贝 zh_TW: `請按下快速鍵組合`, // 注意:这里的英文和繁体是用户上次错误的拷贝 ja: `データ読み込みエラー。ページを更新するか、アンインストール後に再インストールしてください。`, // 翻译自 "zh" ko: `데이터 로딩 오류. 페이지를 새로 고치거나 제거 후 다시 설치하세요.`, // 翻译自 "zh" }, translation_style: { zh: `翻译风格`, en: `Translation style`, zh_TW: `翻譯風格`, ja: `翻訳スタイル`, ko: `번역 스타일`, }, placeholder: { zh: `占位符`, en: `Placeholder`, zh_TW: `佔位符`, ja: `プレースホルダー`, ko: `플레이스홀더`, }, tag_name: { zh: `占位标签名`, en: `Placeholder tag name`, zh_TW: `佔位標名`, ja: `プレースホルダータグ名`, ko: `플레이스홀더 태그 이름`, }, system_prompt_helper_1: { zh: `1. 根据实际情况选择AI支持的聚合格式:`, en: `1. Select the aggregation format supported by the AI according to your needs:`, zh_TW: `1. 請依實際情況選擇 AI 支援的聚合格式:`, ja: `1. 実際の状況に応じて、AI が対応している集約形式を選択してください:`, ko: `1. 상황에 맞게 AI에서 지원하는 집계 형식을 선택하세요:`, }, json_output: { zh: `点击切换 “JSON 格式“`, en: `Click to switch to "JSON Format"`, zh_TW: `點擊切換「JSON 格式」`, ja: `クリックして「JSON 形式」に切り替え`, ko: `클릭하여 "JSON 형식"으로 전환`, }, xml_output: { zh: `点击切换 “XML 格式“`, en: `Click to switch to "XML Format"`, zh_TW: `點擊切換「XML 格式」`, ja: `クリックして「XML 形式」に切り替え`, ko: `클릭하여 "XML 형식"으로 전환`, }, textlines_output: { zh: `点击切换 “多行文本格式“`, en: `Click to switch to "Multi-line Text Format"`, zh_TW: `點擊切換「多行文字格式」`, ja: `クリックして「複数行テキスト形式」に切り替え`, ko: `클릭하여 "여러 줄 텍스트 형식"으로 전환`, }, system_prompt_helper_2: { zh: `2. 在未完全理解默认Prompt的情况下,请勿随意修改,否则可能无法工作。`, en: `2. Do not modify the default prompt without fully understanding it, otherwise it may not work.`, zh_TW: `2. 在未完全理解預設Prompt的情況下,請勿隨意修改,否則可能無法運作。`, ja: `2. デフォルトのプロンプトを完全に理解せずに変更しないでください。動作しなくなる可能性があります。`, ko: `2. 기본 프롬프트를 완전히 이해하지 않고 수정하지 마십시오. 작동하지 않을 수 있습니다.`, }, if_pre_init: { zh: `是否预初始化`, en: `Whether to pre-initialize`, zh_TW: `是否預初始化`, ja: `事前初期化するか`, ko: `사전 초기화 여부`, }, export_old: { zh: `导出旧版`, en: `Export old version`, zh_TW: `匯出舊版`, ja: `旧バージョンをエクスポート`, ko: `이전 버전 내보내기`, }, favorite_words_helper: { zh: `导入词汇请使用txt文件,每一行一个单词。`, en: `To import vocabulary, please use a txt file with one word per line.`, zh_TW: `匯入詞彙請使用txt文件,每一行一個單字。`, ja: `単語をインポートするには、1行に1単語ずつ記述したtxtファイルを使用してください。`, ko: `단어를 가져오려면 한 줄에 한 단어씩 .txt 파일을 사용하세요.`, }, btn_tip_click_away: { zh: `失焦隐藏/显示`, en: `Loss of focus hide/show`, zh_TW: `失焦隱藏/顯示`, ja: `フォーカスを失った時に非表示/表示`, ko: `포커스 잃을 시 숨기기/표시`, }, btn_tip_follow_selection: { zh: `跟随/固定模式`, en: `Follow/Fixed Mode`, zh_TW: `跟隨/固定模式`, ja: `追従/固定モード`, ko: `따라가기/고정 모드`, }, btn_tip_simple_style: { zh: `迷你/常规模式`, en: `Mini/Regular Mode`, zh_TW: `迷你/常規模式`, ja: `ミニ/通常モード`, ko: `미니/일반 모드`, }, api_placeholder: { zh: `占位符`, en: `Placeholder`, zh_TW: `佔位符`, ja: `プレースホルダー`, ko: `플레이스홀더`, }, api_placetag: { zh: `占位标签`, en: `Placeholder tags`, zh_TW: `佔位標`, ja: `プレースホルダタグ`, ko: `플레이스홀더 태그`, }, placetag_format: { zh: `占位符格式`, en: `Placeholder Format`, zh_TW: `佔位符格式`, ja: `プレースホルダー形式`, ko: `자리 표시자 형식`, }, format_compact: { zh: `简洁格式 `, en: `Compact Format `, zh_TW: `簡潔格式 `, ja: `簡潔形式 `, ko: `간결 형식 `, }, format_attribute: { zh: `属性格式 `, en: `Attribute Format `, zh_TW: `屬性格式 `, ja: `属性形式 `, ko: `속성 형식 `, }, detected_lang: { zh: `语言检测`, en: `Language detection`, zh_TW: `語言偵測`, ja: `言語検出`, ko: `언어 감지`, }, detected_result: { zh: `检测结果`, en: `Detect result`, zh_TW: `檢測結果`, ja: `検出結果`, ko: `감지 결과`, }, subtitle_translate: { zh: `字幕翻译`, en: `Subtitle Translation`, zh_TW: `字幕翻譯`, ja: `字幕翻訳`, ko: `자막 번역`, }, toggle_subtitle_translate: { zh: `启用字幕翻译`, en: `Enable subtitle translation`, zh_TW: `啟用字幕翻譯`, ja: `字幕翻訳を有効にする`, ko: `자막 번역 활성화`, }, is_bilingual_view: { zh: `双语显示`, en: `Enable bilingual display`, zh_TW: `雙語顯示`, ja: `バイリンガル表示`, ko: `이중 언어 표시`, }, is_skip_ad: { zh: `快进广告`, en: `Skip AD`, zh_TW: `快轉廣告`, ja: `広告をスキップ`, ko: `광고 건너뛰기`, }, download_subtitles: { zh: `下载字幕`, en: `Download subtitles`, zh_TW: `下载字幕`, ja: `字幕をダウンロード`, ko: `자막 다운로드`, }, background_styles: { zh: `背景样式`, en: `DBackground Style`, zh_TW: `背景樣式`, ja: `背景スタイル`, ko: `배경 스타일`, }, origin_styles: { zh: `原文样式`, en: `Original style`, zh_TW: `原文樣式`, ja: `原文スタイル`, ko: `원문 스타일`, }, translation_styles: { zh: `译文样式`, en: `Translation style`, zh_TW: `譯文樣式`, ja: `翻訳スタイル`, ko: `번역문 스타일`, }, ai_segmentation: { zh: `AI智能断句`, en: `AI intelligent punctuation`, zh_TW: `AI智慧斷句`, ja: `AIによるインテリジェントな文分割`, ko: `AI 지능형 문장 분리`, }, ai_chunk_length: { zh: `AI处理切割长度(200-20000)`, en: `AI processing chunk length(200-20000)`, zh_TW: `AI处理切割长度(200-20000)`, ja: `AI処理のチャンク長(200-20000)`, ko: `AI 처리 청크 길이(200-20000)`, }, subtitle_helper_1: { zh: `1、目前仅支持Youtube桌面网站。`, en: `1. Currently only supports Youtube desktop website.`, zh_TW: `1.目前僅支援Youtube桌面網站,且僅支援瀏覽器擴充功能。`, ja: `1. 現在、Youtubeのデスクトップサイトのみサポートしています。`, ko: `1. 현재 Youtube 데스크톱 웹사이트만 지원합니다.`, }, subtitle_helper_2: { zh: `2、插件内置基础的字幕合并、断句算法,可满足大部分情况。`, en: `2. The plug-in has built-in basic subtitle merging and sentence segmentation algorithms, which can meet most situations.`, zh_TW: `2.插件內建基礎的字幕合併、斷句演算法,可滿足大部分情況。`, ja: `2. プラグインには基本的な字幕結合と文分割アルゴリズムが組み込まれており、ほとんどの状況に対応できます。`, ko: `2. 플러그인에는 기본적인 자막 병합 및 문장 분리 알고리즘이 내장되어 있어 대부분의 상황에 대응할 수 있습니다.`, }, subtitle_helper_3: { zh: `3、亦可以启用AI智能断句,但需考虑切割长度及AI接口能力,可能处理时间会很长,甚至处理失败,导致无法看到字幕。`, en: `3. You can also enable AI intelligent segmentation, but you need to consider the segmentation length and AI interface capabilities. The processing time may be very long or even fail, resulting in the inability to see subtitles.`, zh_TW: `3.亦可啟用AI智能斷句,但需考慮切割長度及AI介面能力,可能處理時間會很長,甚至處理失敗,導致無法看到字幕。`, ja: `3. AIインテリジェント文分割を有効にすることもできますが、分割長とAIインターフェースの能力を考慮する必要があり、処理時間が長くなったり、失敗して字幕が表示されなくなる可能性があります。`, ko: `3. AI 지능형 분리를 활성화할 수도 있지만, 분리 길이와 AI 인터페이스의 능력을 고려해야 하며, 처리 시간이 매우 길거나 실패하여 자막을 볼 수 없게 될 수도 있습니다.`, }, show_subtitle_list: { zh: `显示字幕列表`, en: `Show Subtitle List`, zh_TW: `顯示字幕列表`, ja: `字幕リストを表示`, ko: `자막 목록 표시`, }, default_styles_example: { zh: `默认样式参考:`, en: `Default styles reference:`, zh_TW: `認樣式參考:`, ja: `デフォルトスタイルの例:`, ko: `기본 스타일 예시:`, }, subtitle_load_succeed: { zh: `双语字幕加载成功!`, en: `Bilingual subtitles loaded successfully!`, zh_TW: `双语字幕加载成功!`, ja: `バイリンガル字幕の読み込みに成功しました!`, ko: `이중 언어 자막 로딩 성공!`, }, subtitle_load_failed: { zh: `双语字幕加载失败!`, en: `Failed to load bilingual subtitles!`, zh_TW: `双语字幕加载失败!`, ja: `バイリンガル字幕の読み込みに失敗しました!`, ko: `이중 언어 자막 로딩 실패!`, }, try_get_subtitle_data: { zh: `尝试获取字幕数据,请稍候...`, en: `Trying to get subtitle data, please wait...`, zh_TW: `尝试获取字幕数据,请稍候...`, ja: `字幕データを取得しています。お待ちください...`, ko: `자막 데이터를 가져오는 중입니다. 잠시 기다려주세요...`, }, subtitle_data_processing: { zh: `字幕数据处理中...`, en: `Subtitle data processing...`, zh_TW: `字幕数据处理中...`, ja: `字幕データを処理中...`, ko: `자막 데이터 처리 중...`, }, starting_to_process_subtitle: { zh: `开始处理字幕数据...`, en: `Starting to process subtitle data...`, zh_TW: `开始处理字幕数据...`, ja: `字幕データの処理を開始します...`, ko: `자막 데이터 처리를 시작합니다...`, }, subtitle_data_is_ready: { zh: `字幕数据已准备就绪,请点击KT按钮加载`, en: `The subtitle data is ready, please click the KT button to load it`, zh_TW: `字幕資料已準備就緒,請點擊KT按鈕加載`, ja: `字幕データの準備ができました。KTボタンをクリックして読み込んでください`, ko: `자막 데이터가 준비되었습니다. KT 버튼을 클릭하여 로드하세요`, }, starting_reprocess_events: { zh: `重新处理字幕数据...`, en: `Reprocess the subtitle data...`, zh_TW: `重新处理字幕数据...`, ja: `字幕データを再処理しています...`, ko: `자막 데이터를 다시 처리 중...`, }, waitting_for_subtitle: { zh: `请等待字幕数据`, en: `Please wait for the subtitle data.`, zh_TW: `请等待字幕数据`, ja: `字幕データを待機中`, ko: `자막 데이터를 기다려주세요`, }, ai_processing_pls_wait: { zh: `AI处理中,请稍等...`, en: `AI processing in progress, please wait...`, zh_TW: `AI处理中,请稍等...`, ja: `AI処理中です。お待ちください...`, ko: `AI 처리 중입니다. 잠시 기다려주세요...`, }, processing_subtitles: { zh: `字幕处理中...`, en: `Subtitle processing...`, zh_TW: `字幕处理中...`, ja: `字幕処理中...`, ko: `자막 처리 중...`, }, waiting_subtitles: { zh: `等待字幕中`, en: `Waiting for subtitles`, zh_TW: `等待字幕中`, ja: `字幕待機中`, ko: `자막 대기 중`, }, subtitle_is_not_yet_ready: { zh: `字幕数据尚未准备好`, en: `Subtitle is not yet ready.`, zh_TW: `字幕数据尚未准备好`, ja: `字幕データの準備がまだできていません。`, ko: `자막 데이터가 아직 준비되지 않았습니다.`, }, log_level: { zh: `日志级别`, en: `Log Level`, zh_TW: `日誌等級`, ja: `ログレベル`, ko: `로그 레벨`, }, goto_custom_api_example: { zh: `点击查看【自定义接口示例】`, en: `Click to view [Custom Interface Example]`, zh_TW: `點選查看【自訂介面範例】`, ja: `【カスタムインターフェースの例】を見る`, ko: `[사용자 지정 인터페이스 예시] 보기`, }, split_paragraph: { zh: `切分长段落`, en: `Split long paragraph`, zh_TW: `切分長段落`, ja: `長い段落を分割`, ko: `긴 단락 나누기`, }, split_length: { zh: `切分长度 (0-10000)`, en: `Segmentation length(0-10000)`, zh_TW: `切分長度(0-10000)`, ja: `分割長(0-10000)`, ko: `분할 길이(0-10000)`, }, highlight_words: { zh: `高亮收藏词汇`, en: `Highlight favorite words`, zh_TW: `高亮收藏詞彙`, ja: `お気に入り単語をハイライト`, ko: `즐겨찾는 단어 하이라이트`, }, split_disable: { zh: `禁用`, en: `Disable`, zh_TW: `停用`, ja: `無効`, ko: `비활성화`, }, split_textlength: { zh: `按照长度切分`, en: `Split by length`, zh_TW: `依長度切分`, ja: `長さで分割`, ko: `길이로 나누기`, }, split_punctuation: { zh: `按照句子切分`, en: `Split by sentence`, zh_TW: `按照句子切分`, ja: `文で分割`, ko: `문장으로 나누기`, }, highlight_disable: { zh: `禁用`, en: `Disable`, zh_TW: `停用`, ja: `無効`, ko: `비활성화`, }, highlight_beforetrans: { zh: `翻译前高亮`, en: `Highlight before translation`, zh_TW: `翻譯前高亮`, ja: `翻訳前にハイライト`, ko: `번역 전 하이라이트`, }, highlight_aftertrans: { zh: `翻译后高亮`, en: `Highlight after translation`, zh_TW: `翻譯後高亮`, ja: `翻訳後にハイライト`, ko: `번역 후 하이라이트`, }, pagescroll_root_margin: { zh: `滚动加载提前触发 (0-10000px)`, en: `Early triggering of scroll loading (0-10000px)`, zh_TW: `滾動載入提前觸發 (0-10000px)`, ja: `スクロール読み込みの事前トリガー (0-10000px)`, ko: `스크롤 로딩 미리 트리거 (0-10000px)`, }, styles_setting: { zh: `样式设置`, en: `Style Setting`, zh_TW: `樣式設定`, ja: `スタイル設定`, ko: `스타일 설정`, }, style_name: { zh: `样式名称`, en: `Style Name`, zh_TW: `樣式名稱`, ja: `スタイル名`, ko: `스타일 이름`, }, style_code: { zh: `样式代码`, en: `Style Code`, zh_TW: `樣式程式碼`, ja: `スタイルコード`, ko: `스타일 코드`, }, pre_trans_seconds: { zh: `提前翻译时长 (10-36000s)`, en: `Pre translation seconds (10-36000s)`, zh_TW: `提前翻译时长 (10-36000s)`, ja: `事前翻訳時間 (10-36000s)`, ko: `미리 번역 시간 (10-36000s)`, }, throttle_trans_interval: { zh: `节流翻译间隔 (1-3600s)`, en: `Throttling translation interval (1-3600s)`, zh_TW: `节流翻译间隔 (1-3600s)`, ja: `翻訳間隔のスロットリング (1-3600s)`, ko: `번역 간격 조절 (1-3600s)`, }, show_origin_subtitle: { zh: `显示原字幕`, en: `Show original subtitles`, zh_TW: `显示原字幕`, ja: `原字幕を表示`, ko: `원본 자막 표시`, }, subtitle_same_lang: { zh: `原语言与目标语言相同,字幕不予处理`, en: `The source language is the same as the target language, subtitles will not be processed`, zh_TW: `原語言與目標語言相同時,字幕不予處理`, ja: `原言語と目標言語が同じ場合、字幕は処理されません`, ko: `원본 언어와 대상 언어가 동일한 경우, 자막은 처리되지 않습니다`, }, plain_text_translate: { zh: `纯文本翻译`, en: `Plain text translation`, zh_TW: `純文字翻譯`, ja: `プレーンテキスト翻訳`, ko: `순수 텍스트 번역`, }, is_enable_enhance: { zh: `启用增强功能`, en: `Enable Enhancement Features`, zh_TW: `啟用增強功能`, ja: `強化機能を有効にする`, ko: `향상 기능 활성화`, }, open_separate_window: { zh: `独立窗口打开`, en: `Open in Separate Window`, zh_TW: `在獨立視窗中開啟`, ja: `別ウィンドウで開く`, ko: `별도 창에서 열기`, }, comment_support: { zh: `好评支持`, en: `Leave a Positive Review`, zh_TW: `好評支持`, ja: `高評価で応援`, ko: `좋은 평가로 응원`, }, appreciate_support: { zh: `赞赏支持`, en: `Support with a Tip`, zh_TW: `贊賞支持`, ja: `投げ銭で応援`, ko: `후원하기`, }, toggle_transbox: { zh: `切换翻译窗`, en: `Toggle Translation Box`, zh_TW: `切換翻譯視窗`, ja: `翻訳ウィンドウを切り替え`, ko: `번역 창 전환`, }, copy: { zh: `复制`, en: `Copy`, zh_TW: `複製`, ja: `コピー`, ko: `복사`, }, paste: { zh: `黏贴`, en: `Paste`, zh_TW: `貼上`, ja: `貼り付け`, ko: `붙여넣기`, }, submit: { zh: `提交`, en: `Submit`, zh_TW: `提交`, ja: `送信`, ko: `제출`, }, collect: { zh: `收藏`, en: `Save`, zh_TW: `收藏`, ja: `保存`, ko: `저장`, }, show_translation_dot: { zh: `显示翻译圆点`, en: `Show Translation Dot`, zh_TW: `顯示翻譯圓點`, ja: `翻訳ドットを表示`, ko: `번역 점 표시`, }, show_dot_mobile: { zh: `仅移动端`, en: `Mobile Only`, zh_TW: `僅移動端`, ja: `モバイルのみ`, ko: `모바일 전용`, }, show_dot_always: { zh: `总是显示`, en: `Always`, zh_TW: `總是顯示`, ja: `常に表示`, ko: `항상 표시`, }, show_dot_disable: { zh: `禁用`, en: `Disable`, zh_TW: `禁用`, ja: `無効`, ko: `사용 안 함`, }, }; export const newI18n = (lang) => (key) => I18N[key]?.[lang] || ""; ================================================ FILE: src/config/index.js ================================================ export * from "./app"; export * from "./rules"; export * from "./api"; export * from "./setting"; export * from "./i18n"; export * from "./storage"; export * from "./url"; export * from "./msg"; export * from "./client"; export * from "./styles"; ================================================ FILE: src/config/msg.js ================================================ export const CMD_TOGGLE_TRANSLATE = "toggleTranslate"; export const CMD_TOGGLE_STYLE = "toggleStyle"; export const CMD_OPEN_OPTIONS = "openOptions"; export const CMD_OPEN_TRANBOX = "openTranbox"; export const CMD_OPEN_SEPARATE_WINDOW = "openSeparateWindow"; export const MSG_FETCH = "kiss_fetch"; export const MSG_GET_HTTPCACHE = "get_httpcache"; export const MSG_PUT_HTTPCACHE = "put_httpcache"; export const MSG_OPEN_OPTIONS = "open_options"; export const MSG_SAVE_RULE = "save_rule"; export const MSG_TRANS_TOGGLE = "toggle_translate"; export const MSG_TRANS_TOGGLE_STYLE = "toggle_styles"; export const MSG_OPEN_TRANBOX = "open_tranbox"; export const MSG_TRANS_GETRULE = "trans_getrule"; export const MSG_TRANS_PUTRULE = "trans_putrule"; export const MSG_TRANS_CURRULE = "trans_currule"; export const MSG_TRANSBOX_TOGGLE = "toggle_transbox"; export const MSG_POPUP_TOGGLE = "toggle_popup"; export const MSG_MOUSEHOVER_TOGGLE = "toggle_mousehover"; export const MSG_HOVERNODE_TOGGLE = "toggle_hover_node"; export const MSG_TRANSINPUT_TOGGLE = "toggle_input_translation"; export const MSG_INPUT_TRANSLATE = "input_translate"; export const MSG_CONTEXT_MENUS = "context_menus"; export const MSG_COMMAND_SHORTCUTS = "command_shortcuts"; export const MSG_INJECT_JS = "inject_js"; export const MSG_INJECT_CSS = "inject_css"; export const MSG_UPDATE_CSP = "update_csp"; export const MSG_BUILTINAI_DETECT = "builtinai_detect"; export const MSG_BUILTINAI_TRANSLATE = "builtinai_translte"; export const MSG_SET_LOGLEVEL = "set_loglevel"; export const MSG_CLEAR_CACHES = "clear_caches"; export const MSG_OPEN_SEPARATE_WINDOW = "open_separate_window"; export const PORT_STREAM_FETCH = "kiss_stream_fetch"; export const MSG_UPDATE_ICON = "update_icon"; export const EVENT_KISS_INNER = "kiss_translator_inner"; export const EVENT_KISS_TRANSLATOR = "kiss_translator"; export const MSG_XHR_DATA_YOUTUBE = "KISS_XHR_DATA_YOUTUBE"; // export const MSG_GLOBAL_VAR_FETCH = "KISS_GLOBAL_VAR_FETCH"; // export const MSG_GLOBAL_VAR_BACK = "KISS_GLOBAL_VAR_BACK"; export const MSG_MENUS_PROGRESSED = "progressed"; export const MSG_MENUS_UPDATEFORM = "updateFormData"; ================================================ FILE: src/config/quotes.js ================================================ const quotes = [ { en: "The unexamined life is not worth living.", zh: "未经审视的人生不值得过。", zh_TW: "未經審視的人生不值得過。", ja: "吟味されない人生は生きるに値しない。", ko: "성찰하지 않는 삶은 살 가치가 없다。", }, { en: "I think, therefore I am.", zh: "我思故我在。", zh_TW: "我思故我在。", ja: "我思う、ゆえに我あり。", ko: "나는 생각한다, 고로 존재한다。", }, { en: "He who has a why to live for can bear almost any how.", zh: "知道为何而活的人,几乎能忍受任何一种生活。", zh_TW: "知道為何而活的人,幾乎能忍受任何一種生活。", ja: "生きるための「なぜ」を持つ者は、ほとんどあらゆる「どのように」にも耐えることができる。", ko: "살아야 할 이유를 아는 사람은 거의 모든 상황을 견딜 수 있다。", }, { en: "Life is what happens when you're busy making other plans.", zh: "生活就是当你忙着制定其他计划时所发生的事情。", zh_TW: "生活就是當你忙著制定其他計劃時所發生的事情。", ja: "人生とは、他の計画を立てるのに忙しいときに起こるものだ。", ko: "인생은 다른 계획을 세우느라 바쁠 때 일어나는 일이다。", }, { en: "Get busy living or get busy dying.", zh: "要么忙着活,要么忙着死。", zh_TW: "要么忙著活,要么忙著死。", ja: "必死に生きるか、必死に死ぬかだ。", ko: "바쁘게 살거나, 바쁘게 죽거나。", }, { en: "We are what we repeatedly do. Excellence, then, is not an act, but a habit.", zh: "我们由我们反复做的事情构成的。因此,卓越不是一种行为,而是一种习惯。", zh_TW: "我們由我們反覆做的事情構成的。因此,卓越不是一種行為,而是一種習慣。", ja: "我々は繰り返し行うことの集大成である。卓越とは行為ではなく、習慣なのだ。", ko: "우리는 우리가 반복적으로 하는 일의 결과물이다. 그렇다면 탁월함은 행동이 아니라 습관이다。", }, { en: "Man is condemned to be free.", zh: "人注定是自由的。", zh_TW: "人註定是自由的。", ja: "人間は自由であるように呪われている。", ko: "인간은 자유롭도록 저주받았다。", }, { en: "To be, or not to be: that is the question.", zh: "生存还是毁灭,这是一个问题。", zh_TW: "生存還是毀滅,這是一個問題。", ja: "生きるべきか、死ぬべきか、それが問題だ。", ko: "죽느냐 사느냐, 그것이 문제로다。", }, { en: "The purpose of life is not to be happy. It is to be useful, to be honorable, to be compassionate, to have it make some difference that you have lived and lived well.", zh: "人生的目的不是快乐,而是有用、高尚、富有同情心,让你活过并且活得好,从而使世界有所不同。", zh_TW: "人生的目的不是快樂,而是有用、高尚、富有同情心,讓你活過並且活得好,從而使世界有所不同。", ja: "人生(じんせい)の目的(もくてき)は幸(しあわ)せになることではない。役(やく)に立(た)つこと、名誉(めいよ)あること、思(おも)いやりを持(も)つこと、そして自分(じぶん)が生(い)きてきたこと、よく生(い)きたことが何(なに)かの違(ちが)いをもたらすようにすることだ。", ko: "삶의 목적은 행복해지는 것이 아니다. 유용하고, 명예롭고, 자비로우며, 당신이 살았고 잘 살았다는 것이 어떤 차이를 만들도록 하는 것이다。", }, { en: "Life is 10% what happens to us and 90% how we react to it.", zh: "生活 10% 取决于发生在我们身上的事,90% 取决于我们如何反应。", zh_TW: "生活 10% 取決於發生在我們身上的事,90% 取決於我們如何反應。", ja: "人生は、我々に起こることが10%で、それにどう反応するかが90%だ。", ko: "인생은 우리에게 일어나는 일이 10%이고, 그 일에 대해 우리가 어떻게 반응하느냐가 90%이다。", }, { en: "The two most important days in your life are the day you are born and the day you find out why.", zh: "你一生中最重要的两天是:你出生的那天和你明白你为何出生的那天。", zh_TW: "你一生中最重要的兩天是:你出生的那天和你明白你為何出生的那天。", ja: "人生で最も重要な日は二日ある。生まれた日と、なぜ生まれたかを悟る日だ。", ko: "당신의 인생에서 가장 중요한 날은 두 번이다. 당신이 태어난 날과 그 이유를 깨닫는 날이다。", }, { en: "In three words I can sum up everything I've learned about life: it goes on.", zh: "关于人生,我所学到的一切可以总结为三个词:它在继续。", zh_TW: "關於人生,我所學到的一切可以總結為三個詞:它在繼續。", ja: "人生について学んだすべてを3語でまとめることができる。それは「それでも続く」ということだ。", ko: "내가 인생에 대해 배운 모든 것을 세 단어로 요약할 수 있다: '삶은 계속된다'는 것이다。", }, { en: "Not all those who wander are lost.", zh: "并非所有流浪者都迷失了方向。", zh_TW: "並非所有流浪者都迷失了方向。", ja: "さまよう者がすべて道に迷っているわけではない。", ko: "방황하는 자가 다 길을 잃은 것은 아니다。", }, { en: "Life is simple, but we insist on making it complicated.", zh: "生活本简单,但我们坚持要把它弄复杂。", zh_TW: "生活本簡單,但我們堅持要把它弄複雜。", ja: "人生はシンプルだ。だが我々はそれを複雑にしようと躍起になる。", ko: "인생은 단순하지만, 우리가 복잡하게 만들기를 고집한다。", }, { en: "Our life is what our thoughts make it.", zh: "我们的生活是由我们的思想造成的。", zh_TW: "我們的生活是由我們的思想造成的。", ja: "我々の人生は、我々の思考が作るものだ。", ko: "우리의 삶은 우리의 생각이 만드는 것이다。", }, { en: "Find purpose, the means will follow.", zh: "找到目标,方法自会随之而来。", zh_TW: "找到目標,方法自會隨之而來。", ja: "目的を見つけよ、手段は後からついてくる。", ko: "목적을 찾으라, 수단은 따라올 것이다。", }, { en: "The goal of life is living in agreement with nature.", zh: "生活的目标是与自然和谐相处。", zh_TW: "生活的目標是與自然和諧相處。", ja: "人生の目標は、自然と調和して生きることである。", ko: "삶의 목표는 자연과 조화를 이루며 사는 것이다。", }, { en: "The only true wisdom is in knowing you know nothing.", zh: "唯一的真正智慧在于知道自己一无所有。", zh_TW: "唯一的真正智慧在於知道自己一無所有。", ja: "唯一真の知恵は、自分が何も知らないことを知ることにある。", ko: "유일한 참된 지혜는 자신이 아무것도 모른다는 것을 아는 것이다。", }, { en: "Knowledge is power.", zh: "知识就是力量。", zh_TW: "知識就是力量。", ja: "知識は力なり。", ko: "아는 것이 힘이다。", }, { en: "Knowing yourself is the beginning of all wisdom.", zh: "了解自己是所有智慧的开端。", zh_TW: "了解自己是所有智慧的開端。", ja: "自分自身を知ることが、すべての知恵の始まりである。", ko: "자신을 아는 것이 모든 지혜의 시작이다。", }, { en: "The journey of a thousand miles begins with a single step.", zh: "千里之行,始于足下。", zh_TW: "千里之行,始於足下。", ja: "千里の道も一歩から。", ko: "천 리 길도 한 걸음부터。", }, { en: "The only source of knowledge is experience.", zh: "知识的唯一来源是经验。", zh_TW: "知識的唯一來源是經驗。", ja: "知識の唯一の源泉は経験である。", ko: "지식의 유일한 원천은 경험이다。", }, { en: "A fool thinks himself to be wise, but a wise man knows himself to be a fool.", zh: "愚者自以为聪明,智者自知愚蠢。", zh_TW: "愚者自以為聰明,智者自知愚蠢。", ja: "愚か者は自分を賢いと思うが、賢い者は自分が愚かであることを知っている。", ko: "바보는 자신이 현명하다고 생각하지만, 현명한 사람은 자신이 바보라는 것을 안다。", }, { en: "We learn from failure, not from success!", zh: "我们从失败中学习,而不是从成功中!", zh_TW: "我們從失敗中學習,而不是從成功中!", ja: "我々は成功からではなく、失敗から学ぶ!", ko: "우리는 성공이 아닌, 실패로부터 배운다!", }, { en: "The wise man is one who knows what he does not know.", zh: "智者,知其所不知。", zh_TW: "智者,知其所不知。", ja: "賢い者とは、自分が何を知らないかを知っている者である。", ko: "현명한 사람은 자신이 모르는 것을 아는 사람이다。", }, { en: "To know that we know what we know, and that we do not know what we do not know, that is true knowledge.", zh: "知之为知之,不知为不知,是知也。", zh_TW: "知之為知之,不知為不知,是知也。", ja: "知るを知るとなし、知らざるを知らずとなす、これ知るなり。", ko: "아는 것을 안다고 하고, 모르는 것을 모른다고 하는 것, 그것이 참된 앎이다。", }, { en: "Curiosity is the wick in the candle of learning.", zh: "好奇心是学习这支蜡烛的灯芯。", zh_TW: "好奇心是學習這支蠟燭的燈芯。", ja: "好奇心は、学習というロウソクの芯である。", ko: "호기심은 배움이라는 촛불의 심지이다。", }, { en: "It is the mark of an educated mind to be able to entertain a thought without accepting it.", zh: "能够容纳一种思想而不同意它,这是一个受过教育的头脑的标志。", zh_TW: "能夠容納一種思想而不同意它,這是一個受過教育的頭腦的標誌。", ja: "ある考えを受け入れずに、その考えを持ち続けることができるのが、教育ある精神の証である。", ko: "어떤 생각을 받아들이지 않고도 그 생각을 해볼 수 있는 것이 교육받은 마음의 특징이다。", }, { en: "Never stop questioning.", zh: "永远不要停止提问。", zh_TW: "永遠不要停止提問。", ja: "疑問を持つことを決してやめるな。", ko: "질문하는 것을 절대 멈추지 마라。", }, { en: "The man who asks a question is a fool for a minute, the man who does not ask is a fool for life.", zh: "问问题的人,只傻一分钟;不问的人,傻一生。", zh_TW: "問問題的人,只傻一分鐘;不問的人,傻一生。", ja: "問う者は一時の恥、問わぬ者は一生の恥。", ko: "질문하는 사람은 1분 동안 바보가 되지만, 질문하지 않는 사람은 평생 바보가 된다。", }, { en: "Wisdom is not a product of schooling but of the lifelong attempt to acquire it.", zh: "智慧不是学校教育的产物,而是终生努力获得的产物。", zh_TW: "智慧不是學校教育的產物,而是終生努力獲得的產物。", ja: "知恵とは学校教育の産物ではなく、生涯をかけて獲得しようと試みることで得られるものである。", ko: "지혜는 학교 교육의 산물이 아니라, 평생에 걸쳐 그것을 얻으려는 노력의 산물이다。", }, { en: "The greatest enemy of knowledge is not ignorance, it is the illusion of knowledge.", zh: "知识最大的敌人不是无知,而是自以为拥有知识的幻觉。", zh_TW: "知識最大的敵人不是無知,而是自以為擁有知識的幻覺。", ja: "知識の最大の敵は無知ではなく、知っているという幻想である。", ko: "지식의 가장 큰 적은 무지가 아니라, 안다는 착각이다。", }, { en: "True wisdom comes to each of us when we realize how little we understand about life, ourselves, and the world around us.", zh: "当我们认识到自己对生命、对自身、对周围世界了解得多么少时,真正的智慧才会降临到我们每个人身上。", zh_TW: "當我們認識到自己對生命、對自身、對周圍世界了解得多麼少時,真正的智慧才會降臨到我們每個人身上。", ja: "真の知恵は、我々が人生や自分自身、そして我々を取り巻く世界について、いかにわずかしか理解していないかを悟ったときに訪れる。", ko: "진정한 지혜는 우리가 삶과 우리 자신, 그리고 우리를 둘러싼 세계에 대해 얼마나 아는 것이 없는지를 깨달을 때 찾아온다。", }, { en: "Beware of false knowledge; it is more dangerous than ignorance.", zh: "谨防虚假的知识;它比无知更危险。", zh_TW: "謹防虛假的知識;它比無知更危險。", ja: "偽りの知識に用心せよ。それは無知よりも危険である。", ko: "거짓된 지식을 경계하라. 그것은 무지보다 더 위험하다。", }, { en: "What does not kill me makes me stronger.", zh: "杀不死我的,使我更强大。", zh_TW: "殺不死我的,使我更強大。", ja: "私を殺さないものは、私をより強くする。", ko: "나를 죽이지 못하는 것은 나를 더 강하게 만든다。", }, { en: "The only constant in life is change.", zh: "生活中唯一不变的就是变化。", zh_TW: "生活中唯一不變的就是變化。", ja: "人生で唯一変わらないものは、変化そのものである。", ko: "삶에서 유일하게 변하지 않는 것은 변화뿐이다。", }, { en: "If you are going through hell, keep going.", zh: "如果你正在经历地狱,那就继续走下去。", zh_TW: "如果你正在經歷地獄,那就繼續走下去。", ja: "地獄を経験しているなら、進み続けろ。", ko: "지옥을 겪고 있다면, 계속 나아가라。", }, { en: "In the middle of difficulty lies opportunity.", zh: "机会蕴藏在困难之中。", zh_TW: "機會蘊藏在困難之中。", ja: "困難の真っ只中に、好機がある。", ko: "어려움의 한가운데에 기회가 있다。", }, { en: "It is not the strongest of the species that survive, nor the most intelligent, but the one most responsive to change.", zh: "存活下来的物种不是最强壮的,也不是最聪明的,而是最能适应变化的。", zh_TW: "存活下來的物種不是最强壯的,也不是最聰明的,而是最能適應變化的。", ja: "生き残る種とは、最も強いものでも、最も知的なものでもない。最も変化に対応できるものである。", ko: "살아남는 종은 가장 강한 종도, 가장 지능이 높은 종도 아니다. 변화에 가장 잘 적응하는 종이다。", }, { en: "We must become the change we wish to see in the world.", zh: "我们必须成为我们希望在世界上看到的改变。", zh_TW: "我們必須成為我們希望在世界上看到的改變。", ja: "世界に変化を望むなら、まず自らがその変化となれ。", ko: "우리는 세상에서 보고 싶은 변화가 되어야 한다。", }, { en: "A smooth sea never made a skilled sailor.", zh: "平静的大海练不出熟练的水手。", zh_TW: "平靜的大海練不出熟練的水手。", ja: "穏やかな海は、熟練した船乗りを育てない。", ko: "순탄한 바다는 노련한 뱃사공을 만들지 못한다。", }, { en: "Obstacles don't block the path, they are the path.", zh: "障碍不是挡住了路,障碍本身就是路。", zh_TW: "障礙不是擋住了路,障礙本身就是路。", ja: "障害は道を塞ぐものではなく、道そのものである。", ko: "장애물은 길을 막는 것이 아니라, 그 자체가 길이다。", }, { en: "Fall seven times, stand up eight.", zh: "七次跌倒,八次站起。", zh_TW: "七次跌倒,八次站起。", ja: "七転び八起き。", ko: "일곱 번 넘어져도, 여덟 번 일어선다。", }, { en: "The art of life lies in a constant readjustment to our surroundings.", zh: "生活的艺术在于不断地调整自己以适应环境。", zh_TW: "生活的藝術在於不斷地調整自己以適應環境。", ja: "人生(じんせい)の芸術(げいじゅつ)は、我々(われわれ)の環境(かんきょう)に対(たい)する絶(た)え間(ま)ない再調整(さいちょうせい)にある。", ko: "삶의 기술은 우리를 둘러싼 환경에 끊임없이 재적응하는 데 있다。", }, { en: "Adversity introduces a man to himself.", zh: "逆境使人认识自己。", zh_TW: "逆境使人認識自己。", ja: "逆境は、人に自分自身を教えてくれる。", ko: "역경은 사람에게 자기 자신을 소개한다。", }, { en: "The wound is the place where the Light enters you.", zh: "伤口是光进入你内心的入口。", zh_TW: "傷口是光進入你內心的入口。", ja: "傷口は、光があなたの中に入る場所だ。", ko: "상처는 빛이 당신에게 들어오는 곳이다。", }, { en: "When we are no longer able to change a situation, we are challenged to change ourselves.", zh: "当我们无法改变现状时,我们就需要改变自己。", zh_TW: "當我們無法改變現狀時,我們就需要改變自己。", ja: "状況を変えることができなくなったとき、我々は自分自身を変えることを求められる。", ko: "상황을 더 이상 바꿀 수 없을 때, 우리는 자신을 바꿔야 하는 도전에 직면한다。", }, { en: "Be the change you wish to see in the world.", zh: "成为你希望在世界上看到的改变。", zh_TW: "成為你希望在世界上看到的改變。", ja: "あなたが世界に見たいと願う変化に、あなた自身がなりなさい。", ko: "세상에서 보고 싶은 변화가 있다면, 당신 자신이 그 변화가 되어라。", }, { en: "Do not pray for an easy life, pray for the strength to endure a difficult one.", zh: "不要祈祷生活安逸,要祈祷有力量去忍受艰难的生活。", zh_TW: "不要祈禱生活安逸,要祈禱有力量去忍受艱難的生活。", ja: "楽な人生を祈るな。困難な人生を耐え抜く強さを祈れ。", ko: "편안한 삶을 기도하지 말고, 어려운 삶을 견뎌낼 힘을 기도하라。", }, { en: "A pessimist sees the difficulty in every opportunity; an optimist sees the opportunity in every difficulty.", zh: "悲观者在每个机会中都看到困难;乐观者在每个困难中都看到机会。", zh_TW: "悲觀者在每個機會中都看到困難;樂觀者在每個困難中都看到機會。", ja: "悲観主義者はあらゆる好機の中に困難を見る。楽観主義者はあらゆる困難の中に好機を見る。", ko: "비관론자는 모든 기회에서 어려움을 보고, 낙관론자는 모든 어려움에서 기회를 본다。", }, { en: "It's not what happens to you, but how you react to it that matters.", zh: "重要的不是发生在你身上的事,而是你如何应对它。", zh_TW: "重要的不是發生在你身上的事,而是你如何應對它。", ja: "あなたに何が起こるかではなく、それにどう反応するかが重要だ。", ko: "당신에게 무슨 일이 일어났는지가 중요한 것이 아니라, 당신이 그것에 어떻게 반응하는지가 중요하다。", }, { en: "To love oneself is the beginning of a lifelong romance.", zh: "爱自己是终身浪漫的开始。", zh_TW: "愛自己是終身浪漫的開始。", ja: "自分自身を愛することは、一生続くロマンスの始まりだ。", ko: "자신을 사랑하는 것은 평생 지속되는 로맨스의 시작이다。", }, { en: "Love is composed of a single soul inhabiting two bodies.", zh: "爱是栖息于两个身体中的同一个灵魂。", zh_TW: "愛是棲息於兩個身體中的同一個靈魂。", ja: "愛とは、二つの体に宿る一つの魂で構成されている。", ko: "사랑은 두 개의 몸에 깃든 하나의 영혼으로 이루어져 있다。", }, { en: "Man is the measure of all things.", zh: "人是万物的尺度。", zh_TW: "人是萬物的尺度。", ja: "人間は万物の尺度である。", ko: "인간은 만물의 척도이다。", }, { en: "The best and most beautiful things in this world cannot be seen or even heard, but must be felt with the heart.", zh: "世界上最好最美的东西是看不见也听不见的,必须用心去感受。", zh_TW: "世界上最好最美的東西是看不見也聽不見的,必須用心去感受。", ja: "この世で最も素晴らしく、最も美しいものは、目で見たり聞いたりすることはできない。心で感じなければならない。", ko: "이 세상에서 가장 좋고 가장 아름다운 것들은 보이거나 들리지 않는다. 오직 마음으로만 느껴야 한다。", }, { en: "Where there is love there is life.", zh: "有爱的地方就有生命。", zh_TW: "有愛的地方就有生命。", ja: "愛があるところに人生がある。", ko: "사랑이 있는 곳에 삶이 있다。", }, { en: "If you want to be loved, be lovable.", zh: "如果你想被爱,就要变得可爱。", zh_TW: "如果你想被愛,就要變得可愛。", ja: "愛されたいなら、愛らしくあれ。", ko: "사랑받고 싶다면, 사랑스러워져라。", }, { en: "We are all in the gutter, but some of us are looking at the stars.", zh: "我们都身处沟渠,但仍有人仰望星空。", zh_TW: "我們都身處溝渠,但仍有人仰望星空。", ja: "我々はみな溝の中にいる。だが、そこから星を見上げている者もいるのだ。", ko: "우리는 모두 시궁창에 있지만, 우리 중 일부는 별을 바라보고 있다。", }, { en: "The only thing we have to fear is fear itself.", zh: "我们唯一需要恐惧的就是恐惧本身。", zh_TW: "我們唯一需要恐懼的就是恐懼本身。", ja: "我々が恐れるべき唯一のものは、恐れそのものである。", ko: "우리가 두려워해야 할 유일한 것은 두려움 그 자체이다。", }, { en: "Be kind, for everyone you meet is fighting a hard battle.", zh: "要友善,因为你遇到的每个人都在打一场艰苦的战斗。", zh_TW: "要友善,因為你遇到的每個人都在打一場艱苦的戰鬥。", ja: "親切にしなさい。あなたが出会う誰もが、困難な戦いを戦っているのだから。", ko: "친절하라. 당신이 만나는 모든 사람은 힘겨운 싸움을 하고 있기 때문이다。", }, { en: "Man is born free, and everywhere he is in chains.", zh: "人生而自由,却无往不在枷锁之中。", zh_TW: "人生而自由,卻無往不在枷鎖之中。", ja: "人は生まれながらにして自由だが、いたるところで鎖につながれている。", ko: "인간은 자유롭게 태어났으나, 어디에서나 쇠사슬에 묶여 있다。", }, { en: "We love the things we love for what they are.", zh: "我们爱我们所爱之物,只因它们本来的样子。", zh_TW: "我們愛我們所愛之物,只因它們本來的樣子。", ja: "我々が愛するものを愛するのは、それがそれであるからだ。", ko: "우리는 우리가 사랑하는 것들을 그 자체로 사랑한다。", }, { en: "Darkness cannot drive out darkness; only light can do that. Hate cannot drive out hate; only love can do that.", zh: "黑暗无法驱逐黑暗,只有光明可以;仇恨无法驱逐仇恨,只有爱可以。", zh_TW: "黑暗無法驅逐黑暗,只有光明可以;仇恨無法驅逐仇恨,只有愛可以。", ja: "闇は闇を追い払うことはできない。光だけがそれを可能にする。憎しみは憎しみを追い払うことはできない。愛だけがそれを可能にする。", ko: "어둠은 어둠을 몰아낼 수 없다. 오직 빛만이 할 수 있다. 증오는 증오를 몰아낼 수 없다. 오직 사랑만이 할 수 있다。", }, { en: "An eye for an eye only ends up making the whole world blind.", zh: "以眼还眼,只会让整个世界都盲目。", zh_TW: "以眼還眼,只會讓整個世界都盲目。", ja: "「目には目を」は、全世界を盲目にするだけだ。", ko: "'눈에는 눈'은 결국 온 세상을 눈멀게 할 뿐이다。", }, { en: "Hell is other people.", zh: "他人即地狱。", zh_TW: "他人即地獄。", ja: "地獄とは、他人である。", ko: "타인은 지옥이다。", }, { en: "You will not be punished for your anger, you will be punished by your anger.", zh: "你不会因为你的愤怒而受到惩罚,你会被你的愤怒所惩罚。", zh_TW: "你不會因為你的憤怒而受到懲罰,你會被你的憤怒所懲罰。", ja: "あなたは怒りのために罰せられるのではない。怒りによって罰せられるのだ。", ko: "당신은 당신의 분노 때문에 벌을 받는 것이 아니라, 당신의 분노에 의해 벌을 받을 것이다。", }, { en: "To err is human, to forgive divine.", zh: "犯错是人性,宽恕是神性。", zh_TW: "犯錯是人性,寬恕是神性。", ja: "過つは人の常、許すは神の業。", ko: "실수하는 것은 인간이고, 용서하는 것은 신이다。", }, { en: "Man is the only creature who refuses to be what he is.", zh: "人是唯一拒绝承认自己本质的生物。", zh_TW: "人是唯一拒絕承認自己本質的生物。", ja: "人間は、自分が何者であるかを拒否する唯一の生き物である。", ko: "인간은 자신이 무엇인지를 거부하는 유일한 생물이다。", }, { en: "Beauty is in the eye of the beholder.", zh: "情人眼里出西施。", zh_TW: "情人眼裡出西施。", ja: "美は見る人の目の中にある。", ko: "아름다움은 보는 사람의 눈에 달려 있다。", }, { en: "All that we see or seem is but a dream within a dream.", zh: "我们所见所感,皆如梦中之梦。", zh_TW: "我們所見所感,皆如夢中之夢。", ja: "我々が見たり感じたりするすべては、夢の中の夢にすぎない。", ko: "우리가 보거나 보이는 모든 것은 꿈속의 꿈일 뿐이다。", }, { en: "Everything you can imagine is real.", zh: "你能想象的一切都是真实的。", zh_TW: "你能想像的一切都是真實的。", ja: "想像できることは、すべて現実なのだ。", ko: "당신이 상상할 수 있는 모든 것은 현실이다。", }, { en: "The map is not the territory.", zh: "地图并非领土。", zh_TW: "地圖並非領土。", ja: "地図は領土ではない。", ko: "지도는 영토가 아니다。", }, { en: "We don't see things as they are, we see them as we are.", zh: "我们看到的不是事物的原貌,而是我们自己的样子。", zh_TW: "我們看到的不是事物的原貌,而是我們自己的樣子。", ja: "我々は物事をあるがままに見ているのではない。我々があるがままに見ているのだ。", ko: "우리는 사물을 있는 그대로 보지 않고, 우리 자신(의 모습)대로 본다。", }, { en: "There are two ways to be fooled. One is to believe what isn't true; the other is to refuse to believe what is true.", zh: "被愚弄有两种方式。一种是相信不真实的东西;另一种是拒绝相信真实的东西。", zh_TW: "被愚弄有兩種方式。一種是相信不真實的東西;另一種是拒絕相信真實的東西。", ja: "騙される方法は二つある。一つは真実でないことを信じること。もう一つは真実であることを信じようとしないことだ。", ko: "속는 방법에는 두 가지가 있다. 하나는 사실이 아닌 것을 믿는 것이고, 다른 하나는 사실인 것을 믿기를 거부하는 것이다。", }, { en: "Simplicity is the ultimate sophistication.", zh: "简约是极致的复杂。", zh_TW: "簡約是極致的複雜。", ja: "シンプルさは、究極の洗練である。", ko: "단순함은 궁극의 정교함이다。", }, { en: "The truth will set you free.", zh: "真相将使你自由。", zh_TW: "真相將使你自由。", ja: "真実は、あなたを自由にする。", ko: "진리가 너희를 자유롭게 하리라。", }, { en: "Reality is merely an illusion, albeit a very persistent one.", zh: "现实只是一种幻觉,尽管是一种非常持久的幻觉。", zh_TW: "現實只是一種幻覺,儘管是一種非常持久的幻覺。", ja: "現実とは、非常に根強いただの幻想にすぎない。", ko: "현실은 단지 환상일 뿐이다. 비록 매우 집요한 환상이긴 하지만。", }, { en: "What is rational is actual and what is actual is rational.", zh: "凡是合乎理性的东西都是现实的,凡是现实的东西都是合乎理性的。", zh_TW: "凡是合乎理性的東西都是現實的,凡是現實的東西都是合乎理性的。", ja: "理性的なものは現実的であり、現実的なものは理性的である。", ko: "이성적인 것은 현실적이고, 현실적인 것은 이성적이다。", }, { en: "Truth is like the sun. You can shut it out for a time, but it ain't goin' away.", zh: "真相就像太阳。你可以暂时将它遮住,但它不会消失。", zh_TW: "真相就像太陽。你可以暫時將它遮住,但它不會消失。", ja: "真実は太陽のようなものだ。一時的に隠すことはできても、決してなくなりはしない。", ko: "진실은 태양과 같다. 잠시 가릴 수는 있지만, 사라지게 할 수는 없다。", }, { en: "Everything we hear is an opinion, not a fact. Everything we see is a perspective, not the truth.", zh: "我们听到的一切都只是观点,而非事实。我们看到的一切都只是视角,而非真相。", zh_TW: "我們聽到的一切都只是觀點,而非事實。我們看到的一切都只是視角,而非真相。", ja: "我々が聞くことすべてが意見であり、事実ではない。我々が見ることすべてが視点であり、真実ではない。", ko: "우리가 듣는 모든 것은 의견이지, 사실이 아니다. 우리가 보는 모든 것은 관점이지, 진실이 아니다。", }, { en: "There is no truth. There is only perception.", zh: "没有真相,只有认知。", zh_TW: "沒有真相,只有認知。", ja: "真実などない。ただ認識があるだけだ。", ko: "진실은 없다. 오직 인식만이 있을 뿐이다。", }, { en: "If you look deep enough into anything, you will find mathematics.", zh: "如果你对任何事物看得足够深入,你都会发现数学。", zh_TW: "如果你對任何事物看得足夠深入,你都會發現數學。", ja: "何事も深く見つめれば、そこには数学がある。", ko: "무엇이든 충분히 깊이 들여다보면, 수학을 발견하게 될 것이다。", }, { en: "The medium is the message.", zh: "媒介即信息。", zh_TW: "媒介即訊息。", ja: "メディアはメッセージである。", ko: "미디어는 메시지다。", }, { en: "Nothing is true, everything is permitted.", zh: "没有什么是真实的,一切都被允许。", zh_TW: "沒有什麼是真實的,一切都被允許。", ja: "真実などない、すべては許されている。", ko: "진실은 없으며, 모든 것이 허용된다。", }, { en: "We are what we believe we are.", zh: "我们相信自己是什么,我们就是什么。", zh_TW: "我們相信自己是什麼,我們就是什麼。", ja: "我々は、我々が信じる通りの人間である。", ko: "우리는 우리가 그렇다고 믿는 존재이다。", }, { en: "Yesterday is history, tomorrow is a mystery, but today is a gift. That is why it is called the present.", zh: "昨天是历史,明天是谜团,但今天是礼物。这就是为什么它被称为‘现在’(Present)。", zh_TW: "昨天是歷史,明天是謎團,但今天是禮物。這就是為什麼它被稱為‘現在’(Present)。", ja: "昨日は歴史、明日はミステリー、しかし今日は贈り物だ。だからこそ、それは『プレゼント (現在)』と呼ばれる。", ko: "어제는 역사이고, 내일은 미스터리이며, 오늘은 선물이다. 그래서 오늘을 '선물(present)'이라고 부른다。", }, { en: "Time is money.", zh: "时间就是金钱。", zh_TW: "時間就是金錢。", ja: "時は金なり。", ko: "시간은 돈이다。", }, { en: "The only thing necessary for the triumph of evil is for good men to do nothing.", zh: "邪恶得逞的唯一条件是好人袖手旁观。", zh_TW: "邪惡得逞的唯一條件是好人袖手旁觀。", ja: "悪が勝利するために必要なのは、善人が何もしないことだけである。", ko: "악의 승리를 위해 필요한 유일한 것은 선한 사람들이 아무것도 하지 않는 것이다。", }, { en: "Carpe diem.", zh: "活在当下。", zh_TW: "活在當下。", ja: "今を生きよ(カルペ・ディエム)。", ko: "현재를 즐겨라 (카르페 디엠)。", }, { en: "Do not dwell in the past, do not dream of the future, concentrate the mind on the present moment.", zh: "不要沉湎于过去,不要幻想未来,集中精神活在当下。", zh_TW: "不要沉湎於過去,不要幻想未來,集中精神活在當下。", ja: "過去に生きるな、未来を夢見るな、現在の瞬間に心を集中させよ。", ko: "과거에 머물지 말고, 미래를 꿈꾸지 말며, 현재 이 순간에 마음을 집중하라。", }, { en: "The best time to plant a tree was 20 years ago. The second best time is now.", zh: "种树的最佳时机是20年前。其次是现在。", zh_TW: "種樹的最佳時機是20年前。其次是現在。", ja: "木を植えるのに最適な時期は20年前だった。二番目に最適な時期は、今だ。", ko: "나무를 심기에 가장 좋은 때는 20년 전이었다. 두 번째로 좋은 때는 바로 지금이다。", }, { en: "Action speaks louder than words.", zh: "事实胜于雄辩。", zh_TW: "事實勝於雄辯。", ja: "行動は言葉よりも雄弁である。", ko: "말보다 행동이 더 중요하다。", }, { en: "Honesty is the first chapter in the book of wisdom.", zh: "诚实是智慧之书的第一章。", zh_TW: "誠實是智慧之書的第一章。", ja: "誠実さは、知恵という本の第一章である。", ko: "정직은 지혜라는 책의 첫 장이다。", }, { en: "Two things are infinite: the universe and human stupidity; and I'm not sure about the universe.", zh: "有两样东西是无限的:宇宙和人类的愚蠢;而且我不太确定宇宙是否无限。", zh_TW: "有兩樣東西是無限的:宇宙和人類的愚蠢;而且我不太確定宇宙是否無限。", ja: "無限なものは二つある。宇宙と人間の愚かさだ。ただ、宇宙については私にもよく分からない。", ko: "무한한 것은 두 가지뿐이다. 우주와 인간의 어리석음. 그런데 우주에 대해선 나도 확신이 없다。", }, { en: "You cannot step twice into the same river.", zh: "人不能两次踏进同一条河流。", zh_TW: "人不能兩次踏進同一條河流。", ja: "同(おな)じ川(かわ)に二度(にど)入(はい)ることはできない。", ko: "같은 강물에 두 번 발을 담글 수 없다。", }, { en: "The future belongs to those who believe in the beauty of their dreams.", zh: "未来属于那些相信梦想之美的人。", zh_TW: "未來屬於那些相信夢想之美的人。", ja: "未来は、自分の夢の美しさを信じる者のものである。", ko: "미래는 자신의 꿈의 아름다움을 믿는 사람들의 것이다。", }, { en: "Procrastination is the thief of time.", zh: "拖延是时间的大敌。", zh_TW: "拖延是時間的大敵。", ja: "先延ばしは時間泥棒である。", ko: "미루는 습관은 시간 도둑이다。", }, { en: "An investment in knowledge pays the best interest.", zh: "投资知识,收益最佳。", zh_TW: "投資知識,收益最佳。", ja: "知識への投資は、最良の利息を生む。", ko: "지식에 대한 투자는 최고의 이자를 지불한다。", }, { en: "I have not failed. I've just found 10,000 ways that won't work.", zh: "我没有失败。我只是找到了一万种行不通的方法。", zh_TW: "我沒有失敗。我只是找到了一萬種行不通的方法。", ja: "私は失敗したことがない。ただ、うまくいかない1万通りの方法を見つけただけだ。", ko: "나는 실패하지 않았다. 단지 작동하지 않는 1만 가지 방법을 찾았을 뿐이다。", }, { en: "That which is done, is done.", zh: "木已成舟。", zh_TW: "木已成舟。", ja: "なされたことは、なされたことだ。(覆水盆に返らず)", ko: "일어난 일은 일어난 일이다. (이미 엎질러진 물이다.)", }, ]; export function getRandomQuote() { const randomIndex = Math.floor(Math.random() * quotes.length); return quotes[randomIndex]; } ================================================ FILE: src/config/rules.js ================================================ import { OPT_TRANS_MICROSOFT } from "./api"; import { OPT_STYLE_NONE } from "./styles"; export const GLOBAL_KEY = "*"; export const REMAIN_KEY = "-"; export const SHADOW_KEY = ">>>"; export const DEFAULT_COLOR = "#209CEE"; // 默认高亮背景色/线条颜色 export const DEFAULT_TRANS_TAG = "font"; export const DEFAULT_SELECT_STYLE = "-webkit-line-clamp: unset; max-height: none; height: auto;"; export const OPT_TIMING_PAGESCROLL = "mk_pagescroll"; // 滚动加载翻译 export const OPT_TIMING_PAGEOPEN = "mk_pageopen"; // 直接翻译到底 export const OPT_TIMING_MOUSEOVER = "mk_mouseover"; export const OPT_TIMING_CONTROL = "mk_ctrlKey"; export const OPT_TIMING_SHIFT = "mk_shiftKey"; export const OPT_TIMING_ALT = "mk_altKey"; export const OPT_TIMING_ALL = [ OPT_TIMING_PAGESCROLL, OPT_TIMING_PAGEOPEN, OPT_TIMING_MOUSEOVER, OPT_TIMING_CONTROL, OPT_TIMING_SHIFT, OPT_TIMING_ALT, ]; export const OPT_SPLIT_PARAGRAPH_DISABLE = "split_disable"; export const OPT_SPLIT_PARAGRAPH_TEXTLENGTH = "split_textlength"; export const OPT_SPLIT_PARAGRAPH_PUNCTUATION = "split_punctuation"; export const OPT_SPLIT_PARAGRAPH_ALL = [ OPT_SPLIT_PARAGRAPH_DISABLE, OPT_SPLIT_PARAGRAPH_PUNCTUATION, OPT_SPLIT_PARAGRAPH_TEXTLENGTH, ]; export const OPT_HIGHLIGHT_WORDS_DISABLE = "highlight_disable"; export const OPT_HIGHLIGHT_WORDS_BEFORETRANS = "highlight_beforetrans"; export const OPT_HIGHLIGHT_WORDS_AFTERTRANS = "highlight_aftertrans"; export const OPT_HIGHLIGHT_WORDS_ALL = [ OPT_HIGHLIGHT_WORDS_DISABLE, OPT_HIGHLIGHT_WORDS_BEFORETRANS, OPT_HIGHLIGHT_WORDS_AFTERTRANS, ]; export const DEFAULT_SELECTOR = "h1, h2, h3, h4, h5, h6, li, p, dd, blockquote, figcaption, label, legend"; export const DEFAULT_IGNORE_SELECTOR = "button, footer, pre, mark, nav"; export const DEFAULT_KEEP_SELECTOR = `code, cite, math, .math, a:has(code)`; export const DEFAULT_RULE = { pattern: "", // 匹配网址 selector: "", // 选择器 keepSelector: "", // 保留元素选择器 terms: "", // 专业术语 aiTerms: "", // AI专业术语 apiSlug: GLOBAL_KEY, // 翻译服务 fromLang: GLOBAL_KEY, // 源语言 toLang: GLOBAL_KEY, // 目标语言 textStyle: GLOBAL_KEY, // 译文样式 transOpen: GLOBAL_KEY, // 开启翻译 // bgColor: "", // 译文颜色 (作废) // textDiyStyle: "", // 自定义译文样式 (作废) textExtStyle: "", // 译文附加样式 termsStyle: "", // 专业术语样式 highlightStyle: "", // 高亮词汇样式 selectStyle: "", // 选择器节点样式 parentStyle: "", // 选择器父节点样式 grandStyle: "", // 选择器父节点样式 injectJs: "", // 注入JS // injectCss: "", // 注入CSS (作废) transOnly: GLOBAL_KEY, // 是否仅显示译文 // transTiming: GLOBAL_KEY, // 翻译时机/鼠标悬停翻译 (暂时作废) transTag: GLOBAL_KEY, // 译文元素标签 transTitle: GLOBAL_KEY, // 是否同时翻译页面标题 // transSelected: GLOBAL_KEY, // 是否启用划词翻译 (移回setting) // detectRemote: GLOBAL_KEY, // 是否使用远程语言检测 (移回setting) // skipLangs: [], // 不翻译的语言 (移回setting) // fixerSelector: "", // 修复函数选择器 (暂时作废) // fixerFunc: GLOBAL_KEY, // 修复函数 (暂时作废) transStartHook: "", // 钩子函数 transEndHook: "", // 钩子函数 // transRemoveHook: "", // 钩子函数 (暂时作废) autoScan: GLOBAL_KEY, // 是否自动识别文本节点 hasRichText: GLOBAL_KEY, // 是否启用富文本翻译 hasShadowroot: GLOBAL_KEY, // 是否包含shadowroot scanAll: GLOBAL_KEY, // 是否扫描全部节点 rootsSelector: "", // 翻译范围选择器 ignoreSelector: "", // 不翻译的选择器 splitParagraph: GLOBAL_KEY, // 切分段落 splitLength: 0, // 切分段落长度 highlightWords: GLOBAL_KEY, // 高亮词汇 }; // 全局规则 export const GLOBLA_RULE = { pattern: "*", // 匹配网址 selector: DEFAULT_SELECTOR, // 选择器 keepSelector: DEFAULT_KEEP_SELECTOR, // 保留元素选择器 terms: "", // 专业术语 aiTerms: "", // AI专业术语 apiSlug: OPT_TRANS_MICROSOFT, // 翻译服务 fromLang: "auto", // 源语言 toLang: "zh-CN", // 目标语言 textStyle: OPT_STYLE_NONE, // 译文样式 transOpen: "false", // 开启翻译 // bgColor: DEFAULT_COLOR, // 译文颜色 (作废) // textDiyStyle: DEFAULT_DIY_STYLE, // 自定义译文样式 (作废) textExtStyle: "", // 译文附加样式 termsStyle: "font-weight: bold;", // 专业术语样式 highlightStyle: "color: red;", // 高亮词汇样式 selectStyle: DEFAULT_SELECT_STYLE, // 选择器节点样式 parentStyle: "", // 选择器父节点样式 grandStyle: "", // 选择器祖节点样式 injectJs: "", // 注入JS injectCss: "", // 注入CSS transOnly: "false", // 是否仅显示译文 // transTiming: OPT_TIMING_PAGESCROLL, // 翻译时机/鼠标悬停翻译 (暂时作废) transTag: DEFAULT_TRANS_TAG, // 译文元素标签 transTitle: "false", // 是否同时翻译页面标题 // transSelected: "true", // 是否启用划词翻译 (移回setting) // detectRemote: "true", // 是否使用远程语言检测 (移回setting) // skipLangs: [], // 不翻译的语言 (移回setting) // fixerSelector: "", // 修复函数选择器 (暂时作废) // fixerFunc: "-", // 修复函数 (暂时作废) transStartHook: "", // 钩子函数 transEndHook: "", // 钩子函数 // transRemoveHook: "", // 钩子函数 (暂时作废) autoScan: "true", // 是否自动识别文本节点 hasRichText: "true", // 是否启用富文本翻译 hasShadowroot: "false", // 是否包含shadowroot scanAll: "false", // 是否扫描全部节点 rootsSelector: "body", // 翻译范围选择器 ignoreSelector: DEFAULT_IGNORE_SELECTOR, // 不翻译的选择器 splitParagraph: OPT_SPLIT_PARAGRAPH_DISABLE, // 切分段落 splitLength: 100, // 切分段落长度 highlightWords: OPT_HIGHLIGHT_WORDS_DISABLE, // 高亮词汇 }; export const DEFAULT_RULES = [GLOBLA_RULE]; // todo: 校验几个内置规则 const RULES_MAP = { // "www.google.com/search": { // rootsSelector: `#rcnt`, // }, "en.wikipedia.org": { ignoreSelector: `.button, code, footer, form, mark, pre, .mwe-math-element, .mw-editsection`, }, "news.ycombinator.com": { selector: `p, .titleline, .commtext, .hn-item-title, .hn-comment-text, .hn-story-title`, keepSelector: `code, img, svg, pre, .sitebit`, ignoreSelector: `button, code, footer, form, header, mark, nav, pre, .reply`, autoScan: `false`, }, "twitter.com, https://x.com": { selector: `[data-testid='tweetText'], [data-testid='twitter-article-title'], [data-testid='UserDescription'], .public-DraftStyleDefault-block, span.text-body, div.css-175oi2r.r-3pj75a div.css-175oi2r>span, div.css-175oi2r.r-3pj75a li>span, div.r-1s2bzr4>div.r-16dba41, div.r-16y2uox>div.r-1jeg54m`, keepSelector: `img, svg, a, span:has(a), div:has(a)`, ignoreSelector: `[data-testid='videoPlayer'], [data-testid^='tweetTextarea']`, autoScan: `false`, selectStyle: `-webkit-line-clamp: unset; max-height: none; height: auto;`, }, "www.youtube.com/live_chat": { rootsSelector: `div#items`, selector: `span.yt-live-chat-text-message-renderer`, autoScan: `false`, }, "www.youtube.com": { rootsSelector: `ytd-page-manager`, ignoreSelector: `aside, button, footer, form, header, pre, mark, nav, #player, #container, .caption-window, .ytp-settings-menu, #kiss-youtube-subtitle-list-container`, selectStyle: `-webkit-line-clamp: unset; max-height: none; height: auto;`, parentStyle: `-webkit-line-clamp: unset; max-height: none; height: auto;`, grandStyle: `-webkit-line-clamp: unset; max-height: none; height: auto;`, }, "web.telegram.org": { autoScan: `false`, selector: ".text-content, .embedded-text-wrapper", rootsSelector: ".Transition", }, "github.com": { autoScan: `false`, selector: `h1, h2, h3, h4, h5, h6, .markdown-body li, p, dd, blockquote, figcaption, label, legend, .user-profile-bio>div, [data-testid="results-list"] .search-match, .Subhead-description, [class^="prc-SelectPanel-Subtitle-"], [class^="prc-ActionList-ItemLabel-"], [role="dialog"] .overflow-auto, .h4, .repos-list-description, .discussion-title, [class*="PinnedIssue-module__Link"] span, .js-wiki-sidebar-page-container :is(.Truncate-text, .Link--primary)`, ignoreSelector: `button, p.pinned-item-desc+p`, }, }; export const BUILTIN_RULES = Object.entries(RULES_MAP).map( ([pattern, rule]) => ({ // ...DEFAULT_RULE, ...rule, pattern, }) ); ================================================ FILE: src/config/setting.js ================================================ import { LogLevel } from "../libs/log"; import { OPT_DICT_BING, OPT_SUG_YOUDAO, DEFAULT_HTTP_TIMEOUT, OPT_TRANS_MICROSOFT, DEFAULT_API_LIST, } from "./api"; import { DEFAULT_CUSTOM_STYLES } from "./styles"; // 默认快捷键 export const OPT_SHORTCUT_TRANSLATE = "toggleTranslate"; export const OPT_SHORTCUT_STYLE = "toggleStyle"; export const OPT_SHORTCUT_POPUP = "togglePopup"; export const OPT_SHORTCUT_SETTING = "openSetting"; export const DEFAULT_SHORTCUTS = { [OPT_SHORTCUT_TRANSLATE]: ["AltLeft", "KeyQ"], [OPT_SHORTCUT_STYLE]: ["AltLeft", "KeyC"], [OPT_SHORTCUT_POPUP]: ["AltLeft", "KeyK"], [OPT_SHORTCUT_SETTING]: ["AltLeft", "KeyO"], }; export const TRANS_MIN_LENGTH = 2; // 最短翻译长度 export const TRANS_MAX_LENGTH = 100000; // 最长翻译长度 export const TRANS_NEWLINE_LENGTH = 20; // 换行字符数 export const DEFAULT_BLACKLIST = [ "https://fishjar.github.io/kiss-translator/options.html", "https://translate.google.com", "https://www.deepl.com/translator", ]; // 禁用翻译名单 export const DEFAULT_CSPLIST = []; // 禁用CSP名单 export const DEFAULT_ORILIST = ["https://dict.youdao.com"]; // 移除Origin名单 // 同步设置 export const OPT_SYNCTYPE_WORKER = "KISS-Worker"; export const OPT_SYNCTYPE_WEBDAV = "WebDAV"; export const OPT_SYNCTOKEN_PERFIX = "kt_"; export const OPT_SYNCTYPE_ALL = [OPT_SYNCTYPE_WORKER, OPT_SYNCTYPE_WEBDAV]; export const DEFAULT_SYNC = { syncType: OPT_SYNCTYPE_WORKER, // 同步方式 syncUrl: "", // 数据同步接口 syncUser: "", // 数据同步用户名 syncKey: "", // 数据同步密钥 syncMeta: {}, // 数据更新及同步信息 subRulesSyncAt: 0, // 订阅规则同步时间 dataCaches: {}, // 缓存同步时间 }; // 输入框图标显示 export const OPT_INPUT_DOT_DISABLE = "-"; export const OPT_INPUT_DOT_MOBILE = "mobile"; export const OPT_INPUT_DOT_ALWAYS = "always"; // 输入框翻译 export const OPT_INPUT_TRANS_SIGNS = ["/", "//", "\\", "\\\\", ">", ">>"]; export const DEFAULT_INPUT_SHORTCUT = ["AltLeft", "KeyI"]; export const DEFAULT_INPUT_RULE = { transOpen: true, apiSlug: OPT_TRANS_MICROSOFT, fromLang: "auto", toLang: "en", triggerShortcut: DEFAULT_INPUT_SHORTCUT, triggerCount: 1, triggerTime: 200, transSign: OPT_INPUT_TRANS_SIGNS[0], showDot: OPT_INPUT_DOT_MOBILE, }; // 划词翻译 export const PHONIC_MAP = { en_phonic: ["英", "uk"], us_phonic: ["美", "en"], }; export const OPT_TRANBOX_TRIGGER_CLICK = "click"; export const OPT_TRANBOX_TRIGGER_HOVER = "hover"; export const OPT_TRANBOX_TRIGGER_SELECT = "select"; export const OPT_TRANBOX_TRIGGER_ALL = [ OPT_TRANBOX_TRIGGER_CLICK, OPT_TRANBOX_TRIGGER_HOVER, OPT_TRANBOX_TRIGGER_SELECT, ]; export const DEFAULT_TRANBOX_SHORTCUT = ["AltLeft", "KeyS"]; export const DEFAULT_TRANBOX_SETTING = { transOpen: true, // 是否启用划词翻译 apiSlugs: [OPT_TRANS_MICROSOFT], fromLang: "auto", toLang: "zh-CN", toLang2: "en", tranboxShortcut: DEFAULT_TRANBOX_SHORTCUT, btnOffsetX: 10, btnOffsetY: 10, boxOffsetX: 0, boxOffsetY: 10, hideTranBtn: false, // 是否隐藏翻译按钮 hideClickAway: false, // 是否点击外部关闭弹窗 simpleStyle: false, // 是否简洁界面 followSelection: false, // 翻译框是否跟随选中文本 autoHeight: false, // 自适应高度 triggerMode: OPT_TRANBOX_TRIGGER_CLICK, // 触发翻译方式 // extStyles: "", // 附加样式 enDict: OPT_DICT_BING, // 英文词典 enSug: OPT_SUG_YOUDAO, // 英文建议 }; const SUBTITLE_WINDOW_STYLE = `padding: 0.5em 1em; background-color: rgba(0, 0, 0, 0.5); color: white; line-height: 1.3; text-shadow: 1px 1px 2px black; display: inline-block`; const SUBTITLE_ORIGIN_STYLE = `font-size: clamp(1rem, 2cqw, 3rem);`; const SUBTITLE_TRANSLATION_STYLE = `font-size: clamp(1rem, 2cqw, 3rem);`; export const OPT_ENHANCE_ON = "on"; export const OPT_ENHANCE_OFF = "off"; export const OPT_ENHANCE_MOBILE_OFF = "mobile_off"; export const DEFAULT_SUBTITLE_SETTING = { enabled: true, // 是否开启 apiSlug: OPT_TRANS_MICROSOFT, segSlug: "-", // AI智能断句 chunkLength: 1000, // AI处理切割长度 preTrans: 90, // 提前翻译时长 throttleTrans: 30, // 节流翻译间隔 // fromLang: "en", toLang: "zh-CN", isBilingual: true, // 是否双语显示 skipAd: false, // 是否快进广告 windowStyle: SUBTITLE_WINDOW_STYLE, // 背景样式 originStyle: SUBTITLE_ORIGIN_STYLE, // 原文样式 translationStyle: SUBTITLE_TRANSLATION_STYLE, // 译文样式 enhanceMode: OPT_ENHANCE_MOBILE_OFF, // 增强功能:on/off/mobile_off showList: true, // 是否显示滚动字幕 }; // 订阅列表 export const DEFAULT_SUBRULES_LIST = [ { url: process.env.REACT_APP_RULESURL, selected: true, }, { url: process.env.REACT_APP_RULESURL_ON, selected: false, }, { url: process.env.REACT_APP_RULESURL_OFF, selected: false, }, ]; export const DEFAULT_MOUSEHOVER_KEY = ["ControlLeft"]; export const DEFAULT_MOUSE_HOVER_SETTING = { useMouseHover: false, // 是否启用鼠标悬停翻译 mouseHoverKey: DEFAULT_MOUSEHOVER_KEY, // 鼠标悬停翻译组合键 }; export const DEFAULT_SETTING = { darkMode: "auto", // 深色模式 uiLang: "en", // 界面语言 // fetchLimit: DEFAULT_FETCH_LIMIT, // 最大任务数量(移至rule,作废) // fetchInterval: DEFAULT_FETCH_INTERVAL, // 任务间隔时间(移至rule,作废) minLength: TRANS_MIN_LENGTH, maxLength: TRANS_MAX_LENGTH, newlineLength: TRANS_NEWLINE_LENGTH, httpTimeout: DEFAULT_HTTP_TIMEOUT, clearCache: false, // 是否在浏览器下次启动时清除缓存 injectRules: true, // 是否注入订阅规则 fabClickAction: 0, // 悬浮按钮点击行为 // injectWebfix: true, // 是否注入修复补丁(作废) // detectRemote: false, // 是否使用远程语言检测 (从rule移回) // contextMenus: true, // 是否添加右键菜单(作废) contextMenuType: 1, // 右键菜单类型(0不显示,1简单菜单,2多级菜单) // transTag: DEFAULT_TRANS_TAG, // 译文元素标签(移至rule,作废) // transOnly: false, // 是否仅显示译文(移至rule,作废) // transTitle: false, // 是否同时翻译页面标题(移至rule,作废) subrulesList: DEFAULT_SUBRULES_LIST, // 订阅列表 // owSubrule: DEFAULT_OW_RULE, // 覆写订阅规则 (作废) transApis: DEFAULT_API_LIST, // 翻译接口 (v2.0 对象改为数组) // mouseKey: OPT_TIMING_PAGESCROLL, // 翻译时机/鼠标悬停翻译(移至rule,作废) shortcuts: DEFAULT_SHORTCUTS, // 快捷键 inputRule: DEFAULT_INPUT_RULE, // 输入框设置 tranboxSetting: DEFAULT_TRANBOX_SETTING, // 划词翻译设置 // touchTranslate: 2, // 触屏翻译 {5:单指双击,6:单指三击,7:双指双击} (作废) touchModes: [2], // 触屏翻译 {5:单指双击,6:单指三击,7:双指双击} (多选) blacklist: DEFAULT_BLACKLIST.join(",\n"), // 禁用翻译名单 csplist: DEFAULT_CSPLIST.join(",\n"), // 禁用CSP名单 orilist: DEFAULT_ORILIST.join(",\n"), // 禁用CSP名单 // disableLangs: [], // 不翻译的语言(移至rule,作废) skipLangs: [], // 不翻译的语言(从rule移回) transInterval: 100, // 翻译等待时间 langDetector: "-", // 远程语言识别服务 mouseHoverSetting: DEFAULT_MOUSE_HOVER_SETTING, // 鼠标悬停翻译 preInit: true, // 是否预加载脚本 transAllnow: false, // 是否立即全部翻译 subtitleSetting: DEFAULT_SUBTITLE_SETTING, // 字幕设置 logLevel: LogLevel.INFO.value, // 日志级别 rootMargin: 500, // 提前触发翻译 customStyles: DEFAULT_CUSTOM_STYLES, // 自定义样式列表 }; ================================================ FILE: src/config/storage.js ================================================ import { APP_NAME, APP_VERSION } from "./app"; export const KV_RULES_KEY = `kiss-rules_v${APP_VERSION[0]}.json`; export const KV_WORDS_KEY = "kiss-words.json"; export const KV_RULES_SHARE_KEY = `kiss-rules-share_v${APP_VERSION[0]}.json`; export const KV_SETTING_KEY = `kiss-setting_v${APP_VERSION[0]}.json`; export const KV_SALT_SYNC = "KISS-Translator-SYNC"; export const KV_SALT_SHARE = "KISS-Translator-SHARE"; export const STOKEY_MSAUTH = `${APP_NAME}_msauth`; export const STOKEY_BDAUTH = `${APP_NAME}_bdauth`; export const STOKEY_SETTING_OLD = `${APP_NAME}_setting`; export const STOKEY_RULES_OLD = `${APP_NAME}_rules`; export const STOKEY_SETTING = `${APP_NAME}_setting_v${APP_VERSION[0]}`; export const STOKEY_RULES = `${APP_NAME}_rules_v${APP_VERSION[0]}`; export const STOKEY_WORDS = `${APP_NAME}_words`; export const STOKEY_SYNC = `${APP_NAME}_sync`; export const STOKEY_FAB = `${APP_NAME}_fab`; export const STOKEY_TRANBOX = `${APP_NAME}_tranbox`; export const STOKEY_SEPARATE_WINDOW = `${APP_NAME}_separate_window`; export const STOKEY_RULESCACHE_PREFIX = `${APP_NAME}_rulescache_`; export const CACHE_NAME = `${APP_NAME}_cache`; export const DEFAULT_CACHE_TIMEOUT = 3600 * 24 * 7; // 缓存超时时间(7天) ================================================ FILE: src/config/styles.js ================================================ export const OPT_STYLE_NONE = "style_none"; // 无 export const OPT_STYLE_LINE = "under_line"; // 下划线 export const OPT_STYLE_DOTLINE = "dot_line"; // 点状线 export const OPT_STYLE_DASHLINE = "dash_line"; // 虚线 export const OPT_STYLE_DASHLINE_BOLD = "dash_line_bold"; // 虚线加粗 export const OPT_STYLE_DASHBOX = "dash_box"; // 虚线框 export const OPT_STYLE_DASHBOX_BOLD = "dash_box_bold"; // 虚线框加粗 export const OPT_STYLE_WAVYLINE = "wavy_line"; // 波浪线 export const OPT_STYLE_WAVYLINE_BOLD = "wavy_line_bold"; // 波浪线加粗 export const OPT_STYLE_MARKER = "marker"; // 马克笔 export const OPT_STYLE_GRADIENT_MARKER = "gradient_marker"; // 渐变马克笔 export const OPT_STYLE_FUZZY = "fuzzy"; // 模糊 export const OPT_STYLE_HIGHLIGHT = "highlight"; // 高亮 export const OPT_STYLE_BLOCKQUOTE = "blockquote"; // 引用 export const OPT_STYLE_GRADIENT = "gradient"; // 渐变 export const OPT_STYLE_BLINK = "blink"; // 闪现 export const OPT_STYLE_GLOW = "glow"; // 发光 export const OPT_STYLE_COLORFUL = "colorful"; // 多彩 export const OPT_STYLE_ALL = [ OPT_STYLE_NONE, OPT_STYLE_LINE, OPT_STYLE_DOTLINE, OPT_STYLE_DASHLINE, OPT_STYLE_DASHLINE_BOLD, OPT_STYLE_WAVYLINE, OPT_STYLE_WAVYLINE_BOLD, OPT_STYLE_DASHBOX, OPT_STYLE_DASHBOX_BOLD, OPT_STYLE_MARKER, OPT_STYLE_GRADIENT_MARKER, OPT_STYLE_FUZZY, OPT_STYLE_HIGHLIGHT, OPT_STYLE_BLOCKQUOTE, OPT_STYLE_GRADIENT, OPT_STYLE_BLINK, OPT_STYLE_GLOW, OPT_STYLE_COLORFUL, ]; export const DEFAULT_CUSTOM_STYLES = [ { styleSlug: "custom", styleName: "Custom Style", styleCode: `color: #209CEE;`, }, ]; ================================================ FILE: src/config/url.js ================================================ import { APP_LCNAME } from "./app"; export const URL_CACHE_TRAN = `https://${APP_LCNAME}/translate`; export const URL_CACHE_SUBTITLE = `https://${APP_LCNAME}/subtitle`; export const URL_CACHE_DELANG = `https://${APP_LCNAME}/detectlang`; export const URL_CACHE_BINGDICT = `https://${APP_LCNAME}/bingdict`; export const URL_KISS_WORKER = "https://github.com/fishjar/kiss-worker"; export const URL_KISS_PROXY = "https://github.com/fishjar/kiss-proxy"; export const URL_KISS_RULES = "https://github.com/fishjar/kiss-rules"; export const URL_KISS_RULES_NEW_ISSUE = "https://github.com/fishjar/kiss-rules/issues/new"; export const URL_RAW_PREFIX = "https://raw.githubusercontent.com/fishjar/kiss-translator/master"; ================================================ FILE: src/content.js ================================================ import { run } from "./common"; globalThis.__KISS_CONTEXT__ = "content"; run(); ================================================ FILE: src/hooks/Alert.js ================================================ import { createContext, useContext, useState, forwardRef, useCallback, useMemo, } from "react"; import Snackbar from "@mui/material/Snackbar"; import MuiAlert from "@mui/material/Alert"; const Alert = forwardRef(function Alert(props, ref) { return ; }); const AlertContext = createContext(null); /** * 左下角提示,注入context后,方便全局调用 * @param {*} param0 * @returns */ export function AlertProvider({ children }) { const vertical = "top"; const horizontal = "center"; const [open, setOpen] = useState(false); const [severity, setSeverity] = useState("info"); const [message, setMessage] = useState(null); const showAlert = useCallback((msg, type) => { // 先关闭当前的alert,然后再打开新的 // 这样可以重置autoHideDuration计时器 setOpen(false); // 使用setTimeout确保状态更新完成后再打开新的alert setTimeout(() => { setMessage(msg); setSeverity(type); setOpen(true); }, 0); }, []); const handleClose = useCallback((_, reason) => { if (reason === "clickaway") { return; } setOpen(false); }, []); const value = useMemo( () => ({ error: (msg) => showAlert(msg, "error"), warning: (msg) => showAlert(msg, "warning"), info: (msg) => showAlert(msg, "info"), success: (msg) => showAlert(msg, "success"), }), [showAlert] ); return ( {children} {message} ); } export function useAlert() { return useContext(AlertContext); } ================================================ FILE: src/hooks/Api.js ================================================ import { useCallback, useEffect, useMemo } from "react"; import { DEFAULT_API_LIST, API_SPE_TYPES } from "../config"; import { useSetting } from "./Setting"; function useApiState() { const { setting, updateSetting } = useSetting(); // 统一排序,所有使用transApis的地方都是排序好的 const transApis = useMemo( () => [...(setting?.transApis || [])].sort( (a, b) => (a.sortOrder || 0) - (b.sortOrder || 0) ), [setting?.transApis] ); return { transApis, updateSetting }; } export function useApiList() { const { transApis, updateSetting } = useApiState(); useEffect(() => { const curSlugs = new Set(transApis.map((api) => api.apiSlug)); const missApis = DEFAULT_API_LIST.filter( (api) => !curSlugs.has(api.apiSlug) ); if (missApis.length > 0) { updateSetting((prev) => ({ ...prev, transApis: [...(prev?.transApis || []), ...missApis], })); } }, [transApis, updateSetting]); const userApis = useMemo( () => transApis .filter((api) => !API_SPE_TYPES.builtin.has(api.apiSlug)) .sort((a, b) => a.apiSlug.localeCompare(b.apiSlug)), [transApis] ); const builtinApis = useMemo( () => transApis.filter((api) => API_SPE_TYPES.builtin.has(api.apiSlug)), [transApis] ); const enabledApis = useMemo( () => transApis.filter((api) => !api.isDisabled), [transApis] ); const aiEnabledApis = useMemo( () => enabledApis.filter((api) => API_SPE_TYPES.ai.has(api.apiType)), [enabledApis] ); const addApi = useCallback( (apiType) => { const defaultApiOpt = DEFAULT_API_LIST.find((da) => da.apiType === apiType) || {}; const uuid = crypto.randomUUID(); const apiSlug = `${apiType}_${crypto.randomUUID()}`; const apiName = `${apiType}_${uuid.slice(0, 8)}`; const newApi = { ...defaultApiOpt, apiSlug, apiName, apiType, }; updateSetting((prev) => ({ ...prev, transApis: [...(prev?.transApis || []), newApi], })); }, [updateSetting] ); const copyApi = useCallback( (sourceApi) => { const uuid = crypto.randomUUID(); const apiSlug = `${sourceApi.apiType}_${uuid}`; const apiName = `${sourceApi.apiName} - copy`; const newApi = { ...sourceApi, apiSlug, apiName, }; updateSetting((prev) => ({ ...prev, transApis: [...(prev?.transApis || []), newApi], })); }, [updateSetting] ); const deleteApi = useCallback( (apiSlug) => { updateSetting((prev) => ({ ...prev, transApis: (prev?.transApis || []).filter( (api) => api.apiSlug !== apiSlug ), })); }, [updateSetting] ); return { transApis, userApis, builtinApis, enabledApis, aiEnabledApis, addApi, copyApi, deleteApi, }; } export function useApiItem(apiSlug) { const { transApis, updateSetting } = useApiState(); const api = useMemo( () => transApis.find((a) => a.apiSlug === apiSlug), [transApis, apiSlug] ); const update = useCallback( (updateData) => { updateSetting((prev) => ({ ...prev, transApis: (prev?.transApis || []).map((item) => item.apiSlug === apiSlug ? { ...item, ...updateData, apiSlug } : item ), })); }, [apiSlug, updateSetting] ); const reset = useCallback(() => { updateSetting((prev) => ({ ...prev, transApis: (prev?.transApis || []).map((item) => { if (item.apiSlug === apiSlug) { const defaultApiOpt = DEFAULT_API_LIST.find((da) => da.apiType === item.apiType) || {}; return { ...defaultApiOpt, apiSlug: item.apiSlug, apiName: item.apiName, apiType: item.apiType, key: item.key, }; } return item; }), })); }, [apiSlug, updateSetting]); return { api, update, reset }; } ================================================ FILE: src/hooks/Audio.js ================================================ import { useCallback, useEffect, useRef, useState } from "react"; import { logger } from "../libs/log"; import { fetchData } from "../libs/fetch"; /** * 声音播放hook * @param {*} src * @returns */ export function useAudio(src) { const audioRef = useRef(null); const [error, setError] = useState(null); const [ready, setReady] = useState(false); const [playing, setPlaying] = useState(false); const [loading, setLoading] = useState(false); const onPlay = useCallback(async () => { if (!audioRef.current) return; try { await audioRef.current.play(); } catch (err) { logger.info("Playback failed:", err); setPlaying(false); } }, []); const onPause = useCallback(() => { audioRef.current?.pause(); }, []); useEffect(() => { if (!src) return; let ignore = false; let objectUrl = null; setReady(false); setError(null); setPlaying(false); setLoading(true); const audio = new Audio(); audioRef.current = audio; const handleCanPlay = () => setReady(true); const handlePlay = () => setPlaying(true); const handlePause = () => setPlaying(false); const handleEnded = () => setPlaying(false); const handleError = (e) => { if (!ignore) { setError(audio.error || e); setReady(false); setLoading(false); } }; audio.addEventListener("canplaythrough", handleCanPlay); audio.addEventListener("play", handlePlay); audio.addEventListener("pause", handlePause); audio.addEventListener("ended", handleEnded); audio.addEventListener("error", handleError); const loadAudio = async () => { try { const data = await fetchData(src, {}, { expect: "audio" }); if (ignore) return; audio.src = data; setLoading(false); } catch (err) { if (!ignore) { logger.info("Audio fetch failed:", err); setError(err); setLoading(false); } } }; loadAudio(); return () => { ignore = true; audio.pause(); audio.removeAttribute("src"); if (objectUrl) { URL.revokeObjectURL(objectUrl); } audio.removeEventListener("canplaythrough", handleCanPlay); audio.removeEventListener("play", handlePlay); audio.removeEventListener("pause", handlePause); audio.removeEventListener("ended", handleEnded); audio.removeEventListener("error", handleError); }; }, [src]); return { loading, error, ready, playing, onPlay, onPause, }; } ================================================ FILE: src/hooks/ColorMode.js ================================================ import { useCallback } from "react"; import { useSetting } from "./Setting"; /** * 深色模式hook * @returns */ export function useDarkMode() { const { setting: { darkMode }, updateSetting, } = useSetting(); const toggleDarkMode = useCallback(() => { const nextMode = { light: "dark", dark: "auto", auto: "light", }; updateSetting({ darkMode: nextMode[darkMode] || "light" }); }, [darkMode, updateSetting]); return { darkMode, toggleDarkMode }; } ================================================ FILE: src/hooks/Confirm.js ================================================ import { useState, useContext, createContext, useCallback, useRef, useMemo, } from "react"; import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; import Button from "@mui/material/Button"; import { useI18n } from "./I18n"; const ConfirmContext = createContext(null); export function ConfirmProvider({ children }) { const [dialogConfig, setDialogConfig] = useState(null); const resolveRef = useRef(null); const i18n = useI18n(); const translatedDefaults = useMemo( () => ({ title: i18n("confirm_title", "Confirm"), message: i18n("confirm_message", "Are you sure you want to proceed?"), confirmText: i18n("confirm_action", "Confirm"), cancelText: i18n("cancel_action", "Cancel"), }), [i18n] ); const confirm = useCallback( (config) => { return new Promise((resolve) => { setDialogConfig({ ...translatedDefaults, ...config }); resolveRef.current = resolve; }); }, [translatedDefaults] ); const handleClose = () => { if (resolveRef.current) { resolveRef.current(false); } setDialogConfig(null); }; const handleConfirm = () => { if (resolveRef.current) { resolveRef.current(true); } setDialogConfig(null); }; return ( {children} {dialogConfig && ( <> {dialogConfig.title} {dialogConfig.message} )} ); } export function useConfirm() { const context = useContext(ConfirmContext); if (!context) { throw new Error("useConfirm must be used within a ConfirmProvider"); } return context; } ================================================ FILE: src/hooks/CustomStyles.js ================================================ import { useCallback, useMemo } from "react"; import { useSetting } from "./Setting"; import { DEFAULT_CUSTOM_STYLES, OPT_STYLE_ALL } from "../config/styles"; import { builtinStylesMap } from "../libs/style"; import { useI18n } from "./I18n"; function useStyleState() { const { setting, updateSetting } = useSetting(); const customStyles = setting?.customStyles || []; return { customStyles, updateSetting }; } export function useStyleList() { const { customStyles, updateSetting } = useStyleState(); const addStyle = useCallback(() => { const defaultStyle = DEFAULT_CUSTOM_STYLES[0]; const uuid = crypto.randomUUID(); const styleSlug = `custom_${crypto.randomUUID()}`; const styleName = `Style_${uuid.slice(0, 8)}`; const newStyle = { ...defaultStyle, styleSlug, styleName, }; updateSetting((prev) => ({ ...prev, customStyles: [...(prev?.customStyles || []), newStyle], })); }, [updateSetting]); const deleteStyle = useCallback( (styleSlug) => { updateSetting((prev) => ({ ...prev, customStyles: (prev?.customStyles || []).filter( (item) => item.styleSlug !== styleSlug ), })); }, [updateSetting] ); const updateStyle = useCallback( (styleSlug, updateData) => { updateSetting((prev) => ({ ...prev, customStyles: (prev?.customStyles || []).map((item) => item.styleSlug === styleSlug ? { ...item, ...updateData } : item ), })); }, [updateSetting] ); return { customStyles, addStyle, deleteStyle, updateStyle, }; } export function useAllTextStyles() { const { customStyles } = useStyleList(); const i18n = useI18n(); const builtinStyles = useMemo( () => OPT_STYLE_ALL.map((styleSlug) => ({ styleSlug, styleName: i18n(styleSlug), styleCode: builtinStylesMap[styleSlug] || "", })), [i18n] ); const allTextStyles = useMemo(() => { return [...builtinStyles, ...customStyles]; }, [builtinStyles, customStyles]); return { builtinStyles, customStyles, allTextStyles }; } ================================================ FILE: src/hooks/DebouncedCallback.js ================================================ import { useMemo, useEffect, useRef } from "react"; import { debounce } from "../libs/utils"; export function useDebouncedCallback(callback, delay) { const callbackRef = useRef(callback); useEffect(() => { callbackRef.current = callback; }, [callback]); const debouncedCallback = useMemo( () => debounce((...args) => callbackRef.current(...args), delay), [delay] ); useEffect(() => { return () => { debouncedCallback.cancel(); }; }, [debouncedCallback]); return debouncedCallback; } ================================================ FILE: src/hooks/Fab.js ================================================ import { STOKEY_FAB } from "../config"; import { useStorage } from "./Storage"; const DEFAULT_FAB = {}; /** * fab hook * @returns */ export function useFab() { const { data, update } = useStorage(STOKEY_FAB, DEFAULT_FAB); return { fab: data, updateFab: update }; } ================================================ FILE: src/hooks/FavWords.js ================================================ import { STOKEY_WORDS, KV_WORDS_KEY } from "../config"; import { useCallback, useMemo } from "react"; import { useStorage } from "./Storage"; import { debounceSyncMeta } from "../libs/storage"; const DEFAULT_FAVWORDS = {}; export function useFavWords() { const { data: favWords, save: saveWords } = useStorage( STOKEY_WORDS, DEFAULT_FAVWORDS, KV_WORDS_KEY ); const save = useCallback( (objOrFn) => { saveWords(objOrFn); debounceSyncMeta(KV_WORDS_KEY); }, [saveWords] ); const toggleFav = useCallback( (word, timestamp = null, phonetic = "", definition = "", examples = []) => { save((prev) => { if (!prev[word]) { // todo: 除 word 外,其他属性暂无传入 const wordData = { createdAt: Date.now(), timestamp, phonetic, definition, examples, }; // 清理空值属性 Object.keys(wordData).forEach((key) => { if ( wordData[key] === null || wordData[key] === undefined || (Array.isArray(wordData[key]) && wordData[key].length === 0) || (typeof wordData[key] === "string" && wordData[key].length === 0) ) { delete wordData[key]; } }); return { ...prev, [word]: wordData }; } const favs = { ...prev }; delete favs[word]; return favs; }); }, [save] ); const mergeWords = useCallback( (words) => { save((prev) => ({ ...words.reduce((acc, key) => { acc[key] = { createdAt: Date.now() }; return acc; }, {}), ...prev, })); }, [save] ); const clearWords = useCallback(() => { save({}); }, [save]); const favList = useMemo( () => Object.entries(favWords || {}).sort((a, b) => a[0].localeCompare(b[0])), [favWords] ); const wordList = useMemo(() => favList.map(([word]) => word), [favList]); return { favWords, favList, wordList, toggleFav, mergeWords, clearWords }; } ================================================ FILE: src/hooks/Fetch.js ================================================ import { useEffect, useState, useCallback } from "react"; export const useAsync = () => { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const execute = useCallback(async (fn, ...args) => { if (!fn) { return; } setLoading(true); setError(null); try { const res = await fn(...args); setData(res); setLoading(false); return res; } catch (err) { setError(err?.message || "An unknown error occurred"); setLoading(false); // throw err; } }, []); const reset = useCallback(() => { setData(null); setLoading(false); setError(null); }, []); return { data, loading, error, execute, reset }; }; export const useAsyncNow = (fn, arg) => { const { execute, ...asyncState } = useAsync(); useEffect(() => { if (fn) { execute(fn, arg); } }, [execute, fn, arg]); return { ...asyncState }; }; export const useFetch = () => { const { execute, ...asyncState } = useAsync(); const requester = useCallback(async (url, options) => { const response = await fetch(url, options); if (!response.ok) { const errorInfo = await response.text(); throw new Error( `Request failed: ${response.status} ${response.statusText} - ${errorInfo}` ); } if (response.status === 204) { return null; } if (response.headers.get("Content-Type")?.includes("json")) { return response.json(); } return response.text(); }, []); const get = useCallback( async (url, options = {}) => { try { const result = await execute(requester, url, { ...options, method: "GET", }); return result; } catch (err) { return null; } }, [execute, requester] ); const post = useCallback( async (url, body, options = {}) => { try { const result = await execute(requester, url, { ...options, method: "POST", headers: { "Content-Type": "application/json", ...options.headers }, body: JSON.stringify(body), }); return result; } catch (err) { return null; } }, [execute, requester] ); const put = useCallback( async (url, body, options = {}) => { try { const result = await execute(requester, url, { ...options, method: "PUT", headers: { "Content-Type": "application/json", ...options.headers }, body: JSON.stringify(body), }); return result; } catch (err) { return null; } }, [execute, requester] ); const del = useCallback( async (url, options = {}) => { try { const result = await execute(requester, url, { ...options, method: "DELETE", }); return result; } catch (err) { return null; } }, [execute, requester] ); return { ...asyncState, get, post, put, del, }; }; export const useGet = (url) => { const { get, ...fetchState } = useFetch(); useEffect(() => { if (url) get(url); }, [url, get]); return { ...fetchState }; }; ================================================ FILE: src/hooks/I18n.js ================================================ import { useSetting } from "./Setting"; import { I18N, URL_RAW_PREFIX } from "../config"; import { useGet } from "./Fetch"; export const getI18n = (uiLang, key, defaultText = "") => { return I18N?.[key]?.[uiLang] ?? defaultText; }; export const useLangMap = (uiLang) => { return (key, defaultText = "") => getI18n(uiLang, key, defaultText); }; /** * 多语言 hook * @returns */ export const useI18n = () => { const { setting: { uiLang }, } = useSetting(); return useLangMap(uiLang); }; export const useI18nMd = (key) => { const i18n = useI18n(); const fileName = i18n(key); const url = fileName ? `${URL_RAW_PREFIX}/${fileName}` : ""; return useGet(url); }; ================================================ FILE: src/hooks/InputRule.js ================================================ import { DEFAULT_INPUT_RULE } from "../config"; import { useSetting } from "./Setting"; export function useInputRule() { const { setting, updateChild } = useSetting(); const inputRule = setting?.inputRule || DEFAULT_INPUT_RULE; const updateInputRule = updateChild("inputRule"); return { inputRule, updateInputRule }; } ================================================ FILE: src/hooks/Loading.js ================================================ import CircularProgress from "@mui/material/CircularProgress"; import Link from "@mui/material/Link"; import Divider from "@mui/material/Divider"; export default function Loading() { return (
{`KISS Translator v${process.env.REACT_APP_VERSION}`}
); } ================================================ FILE: src/hooks/MouseHover.js ================================================ import { DEFAULT_MOUSE_HOVER_SETTING } from "../config"; import { useSetting } from "./Setting"; export function useMouseHoverSetting() { const { setting, updateChild } = useSetting(); const mouseHoverSetting = setting?.mouseHoverSetting || DEFAULT_MOUSE_HOVER_SETTING; const updateMouseHoverSetting = updateChild("mouseHoverSetting"); return { mouseHoverSetting, updateMouseHoverSetting }; } ================================================ FILE: src/hooks/Rules.js ================================================ import { STOKEY_RULES, DEFAULT_RULES, KV_RULES_KEY } from "../config"; import { useStorage } from "./Storage"; import { checkRules } from "../libs/rules"; import { useCallback } from "react"; import { debounceSyncMeta } from "../libs/storage"; /** * 规则 hook * @returns */ export function useRules() { const { data: list = [], save: saveRules } = useStorage( STOKEY_RULES, DEFAULT_RULES, KV_RULES_KEY ); const save = useCallback( (objOrFn) => { saveRules(objOrFn); debounceSyncMeta(KV_RULES_KEY); }, [saveRules] ); const add = useCallback( (rule) => { save((prev) => { if ( rule.pattern === "*" || prev.some((item) => item.pattern === rule.pattern) ) { return prev; } return [rule, ...prev]; }); }, [save] ); const del = useCallback( (pattern) => { save((prev) => { if (pattern === "*") { return prev; } return prev.filter((item) => item.pattern !== pattern); }); }, [save] ); const clear = useCallback(() => { save((prev) => prev.filter((item) => item.pattern === "*")); }, [save]); const put = useCallback( (pattern, obj) => { save((prev) => { // if (pattern !== obj.pattern) { // return prev; // } return prev.map((item) => item.pattern === pattern ? { ...item, ...obj } : item ); }); }, [save] ); const merge = useCallback( (rules) => { save((prev) => { const adds = checkRules(rules); if (adds.length === 0) { return prev; } // const map = new Map(); // // 不进行深度合并 // // [...prev, ...adds].forEach((item) => { // // const k = item.pattern; // // map.set(k, { ...(map.get(k) || {}), ...item }); // // }); // prev.forEach((item) => map.set(item.pattern, item)); // adds.forEach((item) => map.set(item.pattern, item)); // return [...map.values()]; const addsMap = new Map(adds.map((item) => [item.pattern, item])); const prevPatterns = new Set(prev.map((item) => item.pattern)); const updatedPrev = prev.map( (prevItem) => addsMap.get(prevItem.pattern) || prevItem ); const newItems = adds.filter( (addItem) => !prevPatterns.has(addItem.pattern) ); return [...newItems, ...updatedPrev]; }); }, [save] ); return { list, add, del, clear, put, merge }; } ================================================ FILE: src/hooks/Setting.js ================================================ import { createContext, useCallback, useContext, useMemo, useEffect, } from "react"; import Alert from "@mui/material/Alert"; import { STOKEY_SETTING, DEFAULT_SETTING, KV_SETTING_KEY, MSG_SET_LOGLEVEL, } from "../config"; import { useStorage } from "./Storage"; import { debounceSyncMeta } from "../libs/storage"; import Loading from "./Loading"; import { logger } from "../libs/log"; import { sendBgMsg } from "../libs/msg"; import { isExt } from "../libs/client"; const SettingContext = createContext({ setting: DEFAULT_SETTING, updateSetting: () => {}, reloadSetting: () => {}, }); export function SettingProvider({ children, context }) { const isOptionsPage = useMemo(() => context === "options", [context]); const { data: setting, isLoading, update, reload, } = useStorage(STOKEY_SETTING, DEFAULT_SETTING, KV_SETTING_KEY); useEffect(() => { if (typeof setting?.darkMode === "boolean") { update((currentSetting) => ({ ...currentSetting, darkMode: currentSetting.darkMode ? "dark" : "light", })); } }, [setting?.darkMode, update]); useEffect(() => { if (!isOptionsPage) return; (async () => { try { logger.setLevel(setting?.logLevel); if (isExt) { await sendBgMsg(MSG_SET_LOGLEVEL, setting?.logLevel); } } catch (error) { logger.error("Failed to fetch log level, using default.", error); } })(); }, [isOptionsPage, setting?.logLevel]); const updateSetting = useCallback( (objOrFn) => { update(objOrFn); debounceSyncMeta(KV_SETTING_KEY); }, [update] ); const updateChild = useCallback( (key) => async (obj) => { updateSetting((prev) => ({ ...prev, [key]: { ...(prev?.[key] || {}), ...obj }, })); }, [updateSetting] ); const value = useMemo( () => ({ context, setting, updateSetting, updateChild, reloadSetting: reload, }), [context, setting, updateSetting, updateChild, reload] ); if (isLoading) { return isOptionsPage ? : null; } if (!setting) { return isOptionsPage ? (

数据加载出错,请刷新页面或卸载后重新安装。

Data loading error, please refresh the page or uninstall and reinstall.

) : null; } return ( {children} ); } /** * 设置 hook * @returns */ export function useSetting() { return useContext(SettingContext); } ================================================ FILE: src/hooks/Shortcut.js ================================================ import { useCallback } from "react"; import { DEFAULT_SHORTCUTS } from "../config"; import { useSetting } from "./Setting"; export function useShortcut(action) { const { setting, updateSetting } = useSetting(); const shortcuts = setting?.shortcuts || DEFAULT_SHORTCUTS; const shortcut = shortcuts[action] || []; const setShortcut = useCallback( (val) => { updateSetting((prev) => ({ ...prev, shortcuts: { ...(prev?.shortcuts || {}), [action]: val }, })); }, [action, updateSetting] ); return { shortcut, setShortcut }; } ================================================ FILE: src/hooks/Storage.js ================================================ import { useCallback, useEffect, useState } from "react"; import { storage } from "../libs/storage"; import { kissLog } from "../libs/log"; import { syncData } from "../libs/sync"; import { useDebouncedCallback } from "./DebouncedCallback"; import { isOptions } from "../libs/browser"; /** * 用于将组件状态与 Storage 同步 * * @param {string} key 用于在 Storage 中存取值的键 * @param {*} defaultVal 默认值。建议在组件外定义为常量。 * @param {string} [syncKey=""] 用于远端同步的可选键名 * @returns {{ * data: *, * save: (valueOrFn: any | ((prevData: any) => any)) => void, * update: (partialDataOrFn: object | ((prevData: object) => object)) => void, * remove: () => Promise, * reload: () => Promise * }} */ export function useStorage(key, defaultVal = null, syncKey = "") { const [isLoading, setIsLoading] = useState(true); const [data, setData] = useState(defaultVal); // 首次加载数据 useEffect(() => { let isMounted = true; const loadInitialData = async () => { try { const storedVal = await storage.getObj(key); if (storedVal === undefined || storedVal === null) { await storage.setObj(key, defaultVal); } else if (isMounted) { setData(storedVal); } } catch (err) { kissLog(`storage load error for key: ${key}`, err); } finally { if (isMounted) { setIsLoading(false); } } }; loadInitialData(); return () => { isMounted = false; }; }, [key, defaultVal]); // 远端同步 const runSync = useCallback(async (keyToSync, valueToSync) => { try { const res = await syncData(keyToSync, valueToSync); if (res?.isNew) { setData(res.value); } } catch (error) { kissLog("Sync failed", keyToSync); } }, []); const debouncedSync = useDebouncedCallback(runSync, 3000); // 持久化 useEffect(() => { if (isLoading) { return; } if (data === null) { return; } storage.setObj(key, data).catch((err) => { kissLog(`storage save error for key: ${key}`, err); }); // 触发远端同步 if (syncKey && isOptions()) { debouncedSync(syncKey, data); } }, [key, syncKey, isLoading, data, debouncedSync]); /** * 全量替换状态值 * @param {any | ((prevData: any) => any)} valueOrFn 新的值或一个返回新值的函数。 */ const save = useCallback((valueOrFn) => { // kissLog("save storage:", valueOrFn); setData((prevData) => typeof valueOrFn === "function" ? valueOrFn(prevData) : valueOrFn ); }, []); /** * 合并对象到当前状态(假设状态是一个对象)。 * @param {object | ((prevData: object) => object)} partialDataOrFn 要合并的对象或一个返回该对象的函数。 */ const update = useCallback((partialDataOrFn) => { // kissLog("update storage:", partialDataOrFn); setData((prevData) => { const partialData = typeof partialDataOrFn === "function" ? partialDataOrFn(prevData) : partialDataOrFn; // 确保 preData 是一个对象,避免展开 null 或 undefined const baseObj = typeof prevData === "object" && prevData !== null ? prevData : {}; return { ...baseObj, ...partialData }; }); }, []); /** * 从 Storage 中删除该值,并将状态重置为 null。 */ const remove = useCallback(async () => { // kissLog("remove storage:"); try { await storage.del(key); setData(null); } catch (err) { kissLog(`storage remove error for key: ${key}`, err); } }, [key]); /** * 从 Storage 重新加载数据以覆盖当前状态。 */ const reload = useCallback(async () => { // kissLog("reload storage:"); try { const storedVal = await storage.getObj(key); setData(storedVal ?? defaultVal); } catch (err) { kissLog(`storage reload error for key: ${key}`, err); // setData(defaultVal); } }, [key, defaultVal]); return { data, save, update, remove, reload, isLoading }; } ================================================ FILE: src/hooks/SubRules.js ================================================ import { DEFAULT_SUBRULES_LIST } from "../config"; import { useSetting } from "./Setting"; import { useCallback, useEffect, useMemo, useState } from "react"; import { loadOrFetchSubRules } from "../libs/subRules"; import { kissLog } from "../libs/log"; /** * 订阅规则 * @returns */ export function useSubRules() { const [loading, setLoading] = useState(false); const [selectedRules, setSelectedRules] = useState([]); const { setting, updateSetting } = useSetting(); const list = setting?.subrulesList || DEFAULT_SUBRULES_LIST; const selectedSub = useMemo(() => list.find((item) => item.selected), [list]); const selectedUrl = selectedSub.url; const selectSub = useCallback( (url) => { updateSetting((prev) => ({ ...prev, subrulesList: prev.subrulesList.map((item) => ({ ...item, selected: item.url === url, })), })); }, [updateSetting] ); const addSub = useCallback( (url) => { updateSetting((prev) => ({ ...prev, subrulesList: [...prev.subrulesList, { url, selected: false }], })); }, [updateSetting] ); const delSub = useCallback( (url) => { updateSetting((prev) => ({ ...prev, subrulesList: prev.subrulesList.filter((item) => item.url !== url), })); }, [updateSetting] ); useEffect(() => { (async () => { if (selectedUrl) { try { setLoading(true); const rules = await loadOrFetchSubRules(selectedUrl); setSelectedRules(rules); } catch (err) { kissLog("loadOrFetchSubRules", err); } finally { setLoading(false); } } })(); }, [selectedUrl]); return { subList: list, selectSub, addSub, delSub, selectedSub, selectedUrl, selectedRules, setSelectedRules, loading, }; } ================================================ FILE: src/hooks/Subtitle.js ================================================ import { DEFAULT_SUBTITLE_SETTING } from "../config"; import { useSetting } from "./Setting"; export function useSubtitle() { const { setting, updateChild } = useSetting(); const subtitleSetting = setting?.subtitleSetting || DEFAULT_SUBTITLE_SETTING; const updateSubtitle = updateChild("subtitleSetting"); return { subtitleSetting, updateSubtitle }; } ================================================ FILE: src/hooks/Sync.js ================================================ import { useCallback, useMemo } from "react"; import { STOKEY_SYNC, DEFAULT_SYNC } from "../config"; import { useStorage } from "./Storage"; /** * sync hook * @returns */ export function useSync() { const { data, update, reload } = useStorage(STOKEY_SYNC, DEFAULT_SYNC); return { sync: data, updateSync: update, reloadSync: reload }; } /** * update syncmeta hook * @returns */ export function useSyncMeta() { const { updateSync } = useSync(); const updateSyncMeta = useCallback( (key) => { updateSync((prevSync) => { const newSyncMeta = { ...(prevSync?.syncMeta || {}), [key]: { ...(prevSync?.syncMeta?.[key] || {}), updateAt: Date.now(), }, }; return { syncMeta: newSyncMeta }; }); }, [updateSync] ); return { updateSyncMeta }; } /** * caches sync hook * @param {*} url * @returns */ export function useSyncCaches() { const { sync, updateSync, reloadSync } = useSync(); const updateDataCache = useCallback( (url) => { updateSync((prevSync) => ({ dataCaches: { ...(prevSync?.dataCaches || {}), [url]: Date.now(), }, })); }, [updateSync] ); const deleteDataCache = useCallback( (url) => { updateSync((prevSync) => { const newDataCaches = { ...(prevSync?.dataCaches || {}) }; delete newDataCaches[url]; return { dataCaches: newDataCaches }; }); }, [updateSync] ); const dataCaches = useMemo(() => sync?.dataCaches || {}, [sync?.dataCaches]); return { dataCaches, updateDataCache, deleteDataCache, reloadSync, }; } ================================================ FILE: src/hooks/Theme.js ================================================ import { useEffect, useMemo, useState } from "react"; import { ThemeProvider, createTheme } from "@mui/material/styles"; import { CssBaseline, GlobalStyles } from "@mui/material"; import { useDarkMode } from "./ColorMode"; import { THEME_DARK, THEME_LIGHT } from "../config"; /** * mui 主题配置 * @param {*} param0 * @returns */ export default function Theme({ children, options = {}, styles = {} }) { const { darkMode } = useDarkMode(); const [systemMode, setSystemMode] = useState(THEME_LIGHT); useEffect(() => { if (typeof window.matchMedia !== "function") { return; } const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleChange = () => { setSystemMode(mediaQuery.matches ? THEME_DARK : THEME_LIGHT); }; handleChange(); // Set initial value mediaQuery.addEventListener("change", handleChange); return () => mediaQuery.removeEventListener("change", handleChange); }, []); const theme = useMemo(() => { let htmlFontSize = 16; try { const s = window.getComputedStyle(document.documentElement).fontSize; htmlFontSize = parseInt(s.replace("px", "")); } catch (err) { // } const isDarkMode = darkMode === "dark" || (darkMode === "auto" && systemMode === THEME_DARK); return createTheme({ palette: { mode: isDarkMode ? THEME_DARK : THEME_LIGHT, }, typography: { htmlFontSize, }, ...options, }); }, [darkMode, options, systemMode]); return ( {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} {children} ); } ================================================ FILE: src/hooks/Tranbox.js ================================================ import { DEFAULT_TRANBOX_SETTING } from "../config"; import { useSetting } from "./Setting"; export function useTranbox() { const { setting, updateChild } = useSetting(); const tranboxSetting = setting?.tranboxSetting || DEFAULT_TRANBOX_SETTING; const updateTranbox = updateChild("tranboxSetting"); return { tranboxSetting, updateTranbox }; } ================================================ FILE: src/hooks/ValidationInput.js ================================================ import { useState, useEffect } from "react"; import TextField from "@mui/material/TextField"; import { limitNumber, limitFloat } from "../libs/utils"; function ValidationInput({ value, onChange, name, min, max, isFloat = false, ...props }) { const [localValue, setLocalValue] = useState(value); useEffect(() => { setLocalValue(value); }, [value]); const handleLocalChange = (e) => { setLocalValue(e.target.value); }; const handleBlur = () => { const numValue = Number(localValue); if (isNaN(numValue)) { setLocalValue(value); return; } const validatedValue = isFloat ? limitFloat(numValue, min, max) : limitNumber(numValue, min, max); if (validatedValue !== numValue) { setLocalValue(validatedValue); } onChange({ target: { name: name, value: validatedValue, }, preventDefault: () => {}, }); }; return ( ); } export default ValidationInput; ================================================ FILE: src/hooks/WindowSize.js ================================================ import { useState, useEffect } from "react"; import { useDebouncedCallback } from "./DebouncedCallback"; function useWindowSize() { const [windowSize, setWindowSize] = useState({ w: window.innerWidth, h: window.innerHeight, }); const debounceWindowResize = useDebouncedCallback(() => { setWindowSize({ w: window.innerWidth, h: window.innerHeight, }); }, 200); useEffect(() => { debounceWindowResize(); window.addEventListener("resize", debounceWindowResize); return () => { window.removeEventListener("resize", debounceWindowResize); }; }, [debounceWindowResize]); return windowSize; } export default useWindowSize; ================================================ FILE: src/hooks/useAutoHideTranBtn.js ================================================ import { useEffect, useRef } from "react"; export default function useAutoHideTranBtn( showBtn, setShowBtn, position, options = {} ) { const { delay = 5000, distance = 100 } = options; const timerRef = useRef(null); const originRef = useRef({ x: 0, y: 0 }); useEffect(() => { if (!showBtn) return; originRef.current = position; /*等待5 秒自动隐藏翻译按钮*/ timerRef.current = setTimeout(() => { setShowBtn(false); }, delay); /*鼠标移出 100px 自动隐藏翻译按钮*/ const handleMouseMove = (e) => { const { x, y } = originRef.current; const dx = e.clientX - x; const dy = e.clientY - y; if (dx * dx + dy * dy > distance * distance) { setShowBtn(false); } }; /*点击右键,隐藏翻译按钮*/ const handleMouseDown = (e) => { if (e.button === 2) { setShowBtn(false); } }; window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mousedown", handleMouseDown, true); return () => { clearTimeout(timerRef.current); window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mousedown", handleMouseDown, true); }; }, [showBtn, position, delay, distance, setShowBtn]); } ================================================ FILE: src/hooks/useSelectionController.js ================================================ import { useState, useCallback, useMemo, useEffect } from "react"; import { sleep, limitNumber } from "../libs/utils"; import { isMobile } from "../libs/mobile"; import useAutoHideTranBtn from "./useAutoHideTranBtn"; import { OPT_TRANBOX_TRIGGER_HOVER, OPT_TRANBOX_TRIGGER_SELECT, } from "../config"; export default function useSelectionController({ tranboxSetting, followSelection, boxOffsetX, boxOffsetY, boxSize, setBoxPosition, hideClickAway, }) { const { hideTranBtn = false, triggerMode } = tranboxSetting; const [showBox, setShowBox] = useState(false); const [showBtn, setShowBtn] = useState(false); const [selectedText, setSelText] = useState(""); // 当前选中的文本 const [text, setText] = useState(""); // 翻译框中的文本 const [position, setPosition] = useState({ x: 0, y: 0 }); // 划词按钮位置 // 划词按钮自动隐藏 useAutoHideTranBtn(showBtn, setShowBtn, position); // 打开翻译框 const handleOpenTranbox = useCallback( (inputText) => { setShowBtn(false); setText(inputText || selectedText); setShowBox(true); }, [selectedText] ); // 切换翻译框显示状态 const handleToggleTranbox = useCallback(() => { setShowBtn(false); const selection = window.getSelection(); const currentSelectedText = selection?.toString()?.trim() || ""; if (!currentSelectedText) { setShowBox((pre) => !pre); return; } const rect = selection?.getRangeAt(0)?.getBoundingClientRect(); // 如果跟随选中文字,重新设置翻译框位置 if (rect && followSelection) { const x = (rect.left + rect.right) / 2 + boxOffsetX; const y = rect.bottom + boxOffsetY; setBoxPosition({ x: limitNumber(x, 0, window.innerWidth - boxSize.w), y: limitNumber(y, 0, window.innerHeight - 50), }); } setSelText(currentSelectedText); setText(currentSelectedText); setShowBox(true); }, [followSelection, boxOffsetX, boxOffsetY, setBoxPosition, boxSize]); // 翻译按钮绑定事件名称 const btnEvent = useMemo(() => { if (isMobile) { return "onTouchEnd"; } else if (triggerMode === OPT_TRANBOX_TRIGGER_HOVER) { return "onMouseOver"; } return "onMouseUp"; }, [triggerMode]); // 监听划词事件 useEffect(() => { const eventName = isMobile ? "touchend" : "mouseup"; async function handleMouseup(e) { // e.stopPropagation(); if (e.button === 2) return; await sleep(200); const selection = window.getSelection(); const currentSelectedText = selection?.toString()?.trim() || ""; setSelText(currentSelectedText); if (!currentSelectedText) { setShowBtn(false); return; } const rect = selection?.getRangeAt(0)?.getBoundingClientRect(); if (rect && followSelection) { const x = (rect.left + rect.right) / 2 + boxOffsetX; const y = rect.bottom + boxOffsetY; setBoxPosition({ x: limitNumber(x, 0, window.innerWidth - boxSize.w), y: limitNumber(y, 0, window.innerHeight - 50), }); } // 如果触发模式是划词即翻译,直接打开翻译框 if (triggerMode === OPT_TRANBOX_TRIGGER_SELECT) { handleOpenTranbox(currentSelectedText); return; } const { clientX, clientY } = isMobile ? e.changedTouches[0] : e; setShowBtn(!hideTranBtn); setPosition({ x: clientX, y: clientY }); } // window.addEventListener("mouseup", handleMouseup); window.addEventListener(eventName, handleMouseup); return () => { window.removeEventListener(eventName, handleMouseup); }; }, [ hideTranBtn, triggerMode, followSelection, boxOffsetX, boxOffsetY, handleOpenTranbox, boxSize, setBoxPosition, ]); // 点击空白处隐藏翻译框 useEffect(() => { if (hideClickAway) { const handleHideBox = () => { setShowBox(false); }; window.addEventListener("click", handleHideBox); return () => { window.removeEventListener("click", handleHideBox); }; } }, [hideClickAway]); return { showBox, setShowBox, showBtn, setShowBtn, selectedText, setSelText, text, setText, position, setPosition, handleOpenTranbox, handleToggleTranbox, btnEvent, }; } ================================================ FILE: src/hooks/useTranBoxState.js ================================================ import { useState, useEffect } from "react"; import { limitNumber } from "../libs/utils"; import { isMobile } from "../libs/mobile"; import { debouncePutTranBox, getTranBox } from "../libs/storage"; import { isIframe } from "../libs/iframe"; export default function useTranBoxState(tranboxSetting) { const { simpleStyle: initSimpleStyle = false, hideClickAway: initHideClickAway = false, followSelection: initFollowMouse = false, boxOffsetX = 0, boxOffsetY = 10, } = tranboxSetting; const boxWidth = isMobile || initSimpleStyle ? 400 : limitNumber(window.innerWidth, 400, 800); const boxHeight = isMobile || initSimpleStyle ? 200 : limitNumber(window.innerHeight, 200, 600); const [boxSize, setBoxSize] = useState({ w: boxWidth, h: boxHeight, }); const [boxPosition, setBoxPosition] = useState({ x: (window.innerWidth - boxWidth) / 2, y: (window.innerHeight - boxHeight) / 2, }); const [simpleStyle, setSimpleStyle] = useState(initSimpleStyle); const [hideClickAway, setHideClickAway] = useState(initHideClickAway); const [followSelection, setFollowSelection] = useState(initFollowMouse); // 从 storage 恢复位置和大小状态 useEffect(() => { (async () => { try { const { w, h, x, y } = (await getTranBox()) || {}; if (w !== undefined && h !== undefined) { setBoxSize({ w: Math.min(w, window.innerWidth), h: Math.min(h, window.innerHeight), }); } if (x !== undefined && y !== undefined) { setBoxPosition({ x: limitNumber(x, 0, window.innerWidth - w), y: limitNumber(y, 0, window.innerHeight - 50), }); } } catch (err) { // } })(); }, []); // debounce 存储位置和大小状态到 storage useEffect(() => { // 如果是在iframe中,则不执行 if (!isIframe && boxSize.w > 0 && boxSize.h > 0) { debouncePutTranBox({ ...boxSize, ...boxPosition, }); } }, [boxSize, boxPosition]); return { boxSize, setBoxSize, boxPosition, setBoxPosition, simpleStyle, setSimpleStyle, hideClickAway, setHideClickAway, followSelection, setFollowSelection, boxOffsetX, boxOffsetY, }; } ================================================ FILE: src/hooks/useTranboxShortcuts.js ================================================ import { useEffect, useCallback } from "react"; import { shortcutRegister } from "../libs/shortcut"; import { isGm, isExt } from "../libs/client"; import { kissLog } from "../libs/log"; import { useLangMap } from "./I18n"; import { MSG_OPEN_TRANBOX, EVENT_KISS_INNER, DEFAULT_TRANBOX_SHORTCUT, } from "../config"; export default function useTranboxShortcuts({ tranboxSetting, showBox, setShowBox, handleToggleTranbox, contextMenuType, uiLang, }) { const { tranboxShortcut = DEFAULT_TRANBOX_SHORTCUT } = tranboxSetting; const langMap = useLangMap(uiLang); const handleToggle = useCallback(() => { if (showBox) { setShowBox(false); } else { handleToggleTranbox(); } }, [showBox, handleToggleTranbox, setShowBox]); // 注册油猴脚本快捷键 useEffect(() => { if (isExt) { return; } const clearShortcut = shortcutRegister(tranboxShortcut, handleToggle); return () => { clearShortcut(); }; }, [tranboxShortcut, handleToggle]); // 监听打开翻译框的事件 useEffect(() => { const handleStatusUpdate = (event) => { if (event.detail?.action === MSG_OPEN_TRANBOX) { handleToggle(); } }; document.addEventListener(EVENT_KISS_INNER, handleStatusUpdate); return () => { document.removeEventListener(EVENT_KISS_INNER, handleStatusUpdate); }; }, [handleToggle]); // 注册油猴脚本菜单 useEffect(() => { if (!isGm) { return; } // 注册菜单 try { const menuCommandIds = []; contextMenuType !== 0 && menuCommandIds.push( GM.registerMenuCommand?.( langMap("translate_selected_text"), (event) => { handleToggleTranbox(); }, "S" ) ); return () => { menuCommandIds.forEach((id) => { GM.unregisterMenuCommand?.(id); }); }; } catch (err) { kissLog("registerMenuCommand", err); } }, [handleToggleTranbox, contextMenuType, langMap]); } ================================================ FILE: src/index.js ================================================ import React, { useState } from "react"; import ReactDOM from "react-dom/client"; import CircularProgress from "@mui/material/CircularProgress"; import Divider from "@mui/material/Divider"; import ReactMarkdown from "react-markdown"; import Paper from "@mui/material/Paper"; import Stack from "@mui/material/Stack"; import Button from "@mui/material/Button"; import Link from "@mui/material/Link"; import { useGet } from "./hooks/Fetch"; import { I18N, URL_RAW_PREFIX } from "./config"; function App() { const [lang, setLang] = useState("zh"); const { data, loading, error } = useGet( `${URL_RAW_PREFIX}/${I18N?.["about_md"]?.[lang]}` ); return ( {`KISS Translator v${process.env.REACT_APP_VERSION}`} Install/Update Userscript for Tampermonkey/Violentmonkey Install/Update Userscript for iOS Safari Open Options Page {loading ? (
) : ( )}
); } const root = ReactDOM.createRoot(document.getElementById("root")); root.render( ); ================================================ FILE: src/injector-shadowroot.js ================================================ import { shadowRootInjector } from "./injectors/shadowroot"; shadowRootInjector(); ================================================ FILE: src/injector-subtitle.js ================================================ import { XMLHttpRequestInjector } from "./injectors/xmlhttp"; XMLHttpRequestInjector(); ================================================ FILE: src/injectors/index.js ================================================ import { browser } from "../libs/browser"; import { isExt } from "../libs/client"; import { injectExternalJs, injectInlineJs } from "../libs/injector"; import { shadowRootInjector } from "./shadowroot"; import { XMLHttpRequestInjector } from "./xmlhttp"; export const INJECTOR = { subtitle: "injector-subtitle.js", shadowroot: "injector-shadowroot.js", }; const injectorMap = { [INJECTOR.subtitle]: XMLHttpRequestInjector, [INJECTOR.shadowroot]: shadowRootInjector, }; export function injectJs(name, id = "kiss-translator-inject-js") { const injector = injectorMap[name]; if (!injector) return; if (isExt) { const src = browser.runtime.getURL(name); injectExternalJs(src, id); } else { injectInlineJs(`(${injector})()`, id); } } ================================================ FILE: src/injectors/shadowroot.js ================================================ export const shadowRootInjector = () => { try { const orig = Element.prototype.attachShadow; Element.prototype.attachShadow = function (...args) { const root = orig.apply(this, args); window.postMessage({ type: "KISS_SHADOW_ROOT_CREATED" }, "*"); return root; }; } catch (err) { console.log("shadowRootInjector", err); } }; ================================================ FILE: src/injectors/xmlhttp.js ================================================ export const XMLHttpRequestInjector = () => { try { const originalOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (...args) { const url = args[1]; if (typeof url === "string" && url.includes("timedtext")) { this.addEventListener("load", function () { window.postMessage( { type: "KISS_XHR_DATA_YOUTUBE", url: this.responseURL, response: this.responseText, }, window.location.origin ); }); } return originalOpen.apply(this, args); }; } catch (err) { console.log("XMLHttpRequestInjector", err); } }; ================================================ FILE: src/libs/auth.js ================================================ import { getMsauth, setMsauth } from "./storage"; import { kissLog } from "./log"; import { apiMsAuth } from "../apis"; const parseMSToken = (token) => { try { return JSON.parse(atob(token.split(".")[1])).exp; } catch (err) { kissLog("parseMSToken", err); } return 0; }; /** * 闭包缓存token,减少对storage查询 * @returns */ const _msAuth = () => { let tokenPromise = null; const EXPIRATION_MS = 1000; const fetchNewToken = async () => { try { const now = Date.now(); // 1. 查询storage缓存 const storageToken = await getMsauth(); if (storageToken) { const storageExp = parseMSToken(storageToken); const storageExpiresAt = storageExp * 1000; if (storageExpiresAt > now + EXPIRATION_MS) { return { token: storageToken, expiresAt: storageExpiresAt }; } } // 2. 缓存没有或失效,查询接口 const apiToken = await apiMsAuth(); if (!apiToken) { throw new Error("Failed to fetch ms token"); } const apiExp = parseMSToken(apiToken); const apiExpiresAt = apiExp * 1000; await setMsauth(apiToken); return { token: apiToken, expiresAt: apiExpiresAt }; } catch (error) { kissLog("get msauth failed", error); throw error; } }; return async () => { // 检查是否有缓存的 Promise if (tokenPromise) { try { const cachedResult = await tokenPromise; if (cachedResult.expiresAt > Date.now() + EXPIRATION_MS) { return cachedResult.token; } } catch (error) { // } } tokenPromise = fetchNewToken(); const result = await tokenPromise; return result.token; }; }; export const msAuth = _msAuth(); ================================================ FILE: src/libs/batchQueue.js ================================================ import { DEFAULT_BATCH_INTERVAL, DEFAULT_BATCH_SIZE, DEFAULT_BATCH_LENGTH, } from "../config"; /** * 批处理队列 * 支持生成器模式:taskFn 可以是异步生成器,yield {id, result} 逐个返回结果 * @param {*} taskFn * @param {*} options * @returns */ const BatchQueue = ( taskFn, { batchInterval = DEFAULT_BATCH_INTERVAL, batchSize = DEFAULT_BATCH_SIZE, batchLength = DEFAULT_BATCH_LENGTH, } = {} ) => { const queue = []; let isProcessing = false; let timer = null; const processQueue = async () => { if (timer) { clearTimeout(timer); timer = null; } if (queue.length === 0 || isProcessing) { return; } isProcessing = true; let tasksToProcess = []; let currentBatchLength = 0; let endIndex = 0; for (const task of queue) { const textLength = task.payload?.length || 0; if ( endIndex >= batchSize || (currentBatchLength + textLength > batchLength && endIndex > 0) ) { break; } currentBatchLength += textLength; endIndex++; } if (endIndex > 0) { tasksToProcess = queue.splice(0, endIndex); } if (tasksToProcess.length === 0) { isProcessing = false; return; } try { const payloads = tasksToProcess.map((item) => item.payload); const batchArgs = tasksToProcess[0].args; const generator = taskFn(payloads, batchArgs); // 检查是否是异步生成器 if (generator && typeof generator[Symbol.asyncIterator] === "function") { for await (const { id, result } of generator) { const taskItem = tasksToProcess[id]; if (taskItem && !taskItem.resolved) { taskItem.resolved = true; taskItem.resolve(result); } } // 处理没有收到结果的 task tasksToProcess.forEach((taskItem, index) => { if (!taskItem.resolved) { taskItem.reject( new Error(`No response for item at index ${index}`) ); } }); } else { // 非生成器模式(兼容旧的 Promise 模式) const responses = await generator; if (!Array.isArray(responses)) { throw new Error("responses format error"); } tasksToProcess.forEach((taskItem, index) => { const response = responses[index]; if (response) { taskItem.resolve(response); } else { taskItem.reject( new Error(`No response for item at index ${index}`) ); } }); } } catch (error) { tasksToProcess.forEach((taskItem) => { if (!taskItem.resolved) { taskItem.reject(error); } }); } finally { isProcessing = false; if (queue.length > 0) { if (queue.length >= batchSize) { setTimeout(processQueue, 0); } else { scheduleProcessing(); } } } }; const scheduleProcessing = () => { if (!isProcessing && !timer && queue.length > 0) { timer = setTimeout(processQueue, batchInterval); } }; const addTask = (data, args) => { return new Promise((resolve, reject) => { const payload = data; queue.push({ payload, resolve, reject, args }); if (queue.length >= batchSize) { processQueue(); } else { scheduleProcessing(); } }); }; const destroy = () => { if (timer) { clearTimeout(timer); timer = null; } queue.forEach((task) => task.reject(new Error("Queue instance was destroyed.")) ); queue.length = 0; }; return { addTask, destroy }; }; // 实例字典 const queueMap = new Map(); /** * 获取批处理实例 */ export const getBatchQueue = (key, taskFn, options) => { if (queueMap.has(key)) { return queueMap.get(key); } const queue = BatchQueue(taskFn, options); queueMap.set(key, queue); return queue; }; /** * 清除所有任务 */ export const clearAllBatchQueue = () => { for (const queue of queueMap.values()) { queue.destroy(); } }; ================================================ FILE: src/libs/blacklist.js ================================================ import { isMatch } from "./utils"; /** * 检查是否在黑名单中 * @param {*} href * @param {*} param1 * @returns */ export const isInBlacklist = (href, { blacklist }) => blacklist.split(/\n|,/).some((url) => isMatch(href, url.trim())); ================================================ FILE: src/libs/browser.js ================================================ // import { CLIENT_EXTS, CLIENT_USERSCRIPT, CLIENT_WEB } from "../config"; /** * 浏览器兼容插件,另可用于判断是插件模式还是网页模式,方便开发 * @returns */ function _browser() { try { return require("webextension-polyfill"); } catch (err) { // kissLog("browser", err); } } export const browser = _browser(); export const getContext = () => { const context = globalThis.__KISS_CONTEXT__; if (context) return context; // if (typeof window === "undefined" || typeof document === "undefined") { // return "background"; // } // const extensionOrigin = browser.runtime.getURL(""); // if (!window.location.href.startsWith(extensionOrigin)) { // return "content"; // } // const pathname = window.location.pathname; // if (pathname.includes("popup")) return "popup"; // if (pathname.includes("options")) return "options"; // if (pathname.includes("sidepanel")) return "sidepanel"; // if (pathname.includes("background")) return "background"; return "undefined"; }; export const isBg = () => getContext() === "background"; export const isOptions = () => getContext() === "options"; export const isBuiltinAIAvailable = "LanguageDetector" in globalThis && "Translator" in globalThis; ================================================ FILE: src/libs/builtinAI.js ================================================ import { kissLog, logger } from "./log"; /** * Chrome 浏览器内置翻译 */ class ChromeTranslator { #translatorMap = new Map(); #detectorPromise = null; constructor(options = {}) { this.onProgress = options.onProgress || this.#defaultProgressHandler; } #defaultProgressHandler(type, progress) { kissLog(`Downloading ${type} model: ${progress}%`); } #getDetectorPromise() { if (!this.#detectorPromise) { this.#detectorPromise = (async () => { try { const availability = await LanguageDetector.availability(); if (availability === "unavailable") { throw new Error("LanguageDetector unavailable"); } return await LanguageDetector.create({ monitor: (m) => this._monitorProgress(m, "detector"), }); } catch (error) { this.#detectorPromise = null; throw error; } })(); } return this.#detectorPromise; } #createTranslator(sourceLanguage, targetLanguage) { const key = `${sourceLanguage}_${targetLanguage}`; if (this.#translatorMap.has(key)) { return this.#translatorMap.get(key); } const translatorPromise = (async () => { try { const avail = await Translator.availability({ sourceLanguage, targetLanguage, }); if (avail === "unavailable") { throw new Error( `Translator ${sourceLanguage}_${targetLanguage} unavailable` ); } const translator = await Translator.create({ sourceLanguage, targetLanguage, monitor: (m) => this._monitorProgress(m, `translator (${key})`), }); this.#translatorMap.set(key, translator); return translator; } catch (error) { this.#translatorMap.delete(key); throw error; } })(); this.#translatorMap.set(key, translatorPromise); return translatorPromise; } _monitorProgress(monitorable, type) { monitorable.addEventListener("downloadprogress", (e) => { const progress = e.total > 0 ? Math.round((e.loaded / e.total) * 100) : 0; this.onProgress(type, progress); }); } async detectLanguage(text, confidenceThreshold = 0.4) { if (!text) { return ["", "Input text cannot be empty."]; } try { const detector = await this.#getDetectorPromise(); const results = await detector.detect(text); if (!results || results.length === 0) { return ["", "No language could be detected."]; } const { detectedLanguage, confidence } = results[0]; if (confidence < confidenceThreshold) { return [ "", `Confidence of test results (${detectedLanguage} ${confidence.toFixed( 2 )}) below the set threshold ${confidenceThreshold}。`, ]; } return [detectedLanguage, ""]; } catch (error) { kissLog("detectLanguage", error, `(${text})`); return ["", error.message]; } } async translateText(text, targetLanguage, sourceLanguage = "auto") { if (!text || !targetLanguage || typeof text !== "string") { return ["", sourceLanguage, "Input text cannot be empty."]; } try { let finalSourceLanguage = sourceLanguage; if (sourceLanguage === "auto") { const [detectedLanguage, detectionError] = await this.detectLanguage(text); if (detectionError || !detectedLanguage) { const reason = detectionError || "Unable to determine source language."; return [ "", finalSourceLanguage, `Automatic detection of source language failed: ${reason}`, ]; } finalSourceLanguage = detectedLanguage; } if (finalSourceLanguage === targetLanguage) { return ["", finalSourceLanguage, "Same lang"]; } const translator = await this.#createTranslator( finalSourceLanguage, targetLanguage ); const translatedText = await translator.translate(text); return [translatedText, finalSourceLanguage, ""]; } catch (error) { kissLog("translateText", error, `(${text})`); if ( error && error.message && error.message.includes("Other generic failures occurred") ) { logger.info("Generic failure detected, resetting translator cache."); this.#translatorMap.clear(); } return ["", sourceLanguage, error.message]; } } } const chromeTranslator = new ChromeTranslator(); export const chromeDetect = (args) => chromeTranslator.detectLanguage(args.text); export const chromeTranslate = (args) => chromeTranslator.translateText(args.text, args.to, args.from); ================================================ FILE: src/libs/cache.js ================================================ import { CACHE_NAME, DEFAULT_CACHE_TIMEOUT, MSG_CLEAR_CACHES, MSG_GET_HTTPCACHE, MSG_PUT_HTTPCACHE, } from "../config"; import { kissLog } from "./log"; import { isExt } from "./client"; import { isBg } from "./browser"; import { sendBgMsg } from "./msg"; import { blobToBase64 } from "./utils"; /** * 清除缓存数据 */ export const tryClearCaches = async () => { try { if (isExt && !isBg()) { await sendBgMsg(MSG_CLEAR_CACHES); } else { await caches.delete(CACHE_NAME); } } catch (err) { kissLog("clean caches", err); } }; /** * 构造缓存 request * @param {*} input * @param {*} init * @returns */ const newCacheReq = async (input, init) => { let request = new Request(input, init); if (request.method !== "GET") { const body = await request.text(); const cacheUrl = new URL(request.url); cacheUrl.pathname += body; request = new Request(cacheUrl.toString(), { method: "GET" }); } return request; }; /** * 查询 caches * @param {*} input * @param {*} init * @returns */ export const getHttpCache = async ({ input, init, expect }) => { try { const request = await newCacheReq(input, init); const cache = await caches.open(CACHE_NAME); const response = await cache.match(request); if (response) { const res = await parseResponse(response, expect); return res; } } catch (err) { kissLog("get cache", err); } return null; }; /** * 插入 caches * @param {*} input * @param {*} init * @param {*} data */ export const putHttpCache = async ({ input, init, data, maxAge = DEFAULT_CACHE_TIMEOUT, // todo: 从设置里面读取最大缓存时间 }) => { try { const req = await newCacheReq(input, init); const cache = await caches.open(CACHE_NAME); const res = new Response(JSON.stringify(data), { status: 200, headers: { "Content-Type": "application/json", "Cache-Control": `max-age=${maxAge}`, }, }); // res.headers.set("Cache-Control", `max-age=${maxAge}`); await cache.put(req, res); } catch (err) { kissLog("put cache", err); } }; /** * 解析 response * @param {*} res * @returns */ export const parseResponse = async (res, expect = null) => { if (!res) { throw new Error("Response object does not exist"); } if (!res.ok) { const msg = { url: res.url, status: res.status, statusText: res.statusText, }; try { const errorText = await res.clone().text(); try { msg.response = JSON.parse(errorText); } catch { msg.response = errorText; } } catch (e) { msg.response = "Unable to read error body"; } throw new Error(JSON.stringify(msg)); } const contentType = res.headers.get("Content-Type") || ""; if (expect === "blob") return res.blob(); if (expect === "text") return res.text(); if (expect === "json") return res.json(); if ( expect === "audio" || contentType.includes("audio") || contentType.includes("image") || contentType.includes("video") ) { const blob = await res.blob(); return blobToBase64(blob); } const text = await res.text(); if (!text) return null; try { return JSON.parse(text); } catch (err) { return text; } }; /** * getHttpCache 兼容性封装 * @param {*} input * @param {*} init * @returns */ export const getHttpCachePolyfill = (input, init) => { // 插件 if (isExt && !isBg()) { return sendBgMsg(MSG_GET_HTTPCACHE, { input, init }); } // 油猴/网页/BackgroundPage return getHttpCache({ input, init }); }; /** * putHttpCache 兼容性封装 * @param {*} input * @param {*} init * @param {*} data * @returns */ export const putHttpCachePolyfill = (input, init, data) => { // 插件 if (isExt && !isBg()) { return sendBgMsg(MSG_PUT_HTTPCACHE, { input, init, data }); } // 油猴/网页/BackgroundPage return putHttpCache({ input, init, data }); }; ================================================ FILE: src/libs/client.js ================================================ import { CLIENT_EXTS, CLIENT_USERSCRIPT, CLIENT_WEB, CLIENT_FIREFOX, } from "../config"; export const client = process.env.REACT_APP_CLIENT; export const isExt = CLIENT_EXTS.includes(client); export const isGm = client === CLIENT_USERSCRIPT; export const isWeb = client === CLIENT_WEB; export const isFirefox = client === CLIENT_FIREFOX; ================================================ FILE: src/libs/detect.js ================================================ import { OPT_TRANS_GOOGLE, OPT_TRANS_MICROSOFT, OPT_TRANS_BAIDU, OPT_TRANS_TENCENT, OPT_LANGS_TO_CODE, OPT_LANGS_MAP, OPT_TRANS_BUILTINAI, OPT_LANGDETECTOR_MAP, } from "../config"; import { browser } from "./browser"; import { apiGoogleLangdetect, apiMicrosoftLangdetect, apiBaiduLangdetect, apiTencentLangdetect, apiBuiltinAIDetect, } from "../apis"; import { kissLog } from "./log"; const langdetectFns = { [OPT_TRANS_GOOGLE]: apiGoogleLangdetect, [OPT_TRANS_MICROSOFT]: apiMicrosoftLangdetect, [OPT_TRANS_BAIDU]: apiBaiduLangdetect, [OPT_TRANS_TENCENT]: apiTencentLangdetect, [OPT_TRANS_BUILTINAI]: apiBuiltinAIDetect, }; /** * 语言识别 * @param {*} text * @returns */ export const tryDetectLang = async (text, langDetector = "-") => { let deLang = ""; // 内置AI/远程识别 if (OPT_LANGDETECTOR_MAP.has(langDetector)) { try { const lang = await langdetectFns[langDetector](text); if (lang) { deLang = OPT_LANGS_TO_CODE[langDetector].get(lang) || ""; } } catch (err) { kissLog("detect lang remote", err); } } // 本地识别 if (!deLang) { try { const res = await browser?.i18n?.detectLanguage(text); const lang = res?.languages?.[0]?.language; if (res.isReliable && lang && OPT_LANGS_MAP.has(lang)) { deLang = lang; } else if (lang?.startsWith("zh")) { deLang = "zh-CN"; } } catch (err) { kissLog("detect lang local", err); } } return deLang; }; ================================================ FILE: src/libs/docInfo.js ================================================ import { truncateWords } from "./utils"; // 清洗文本,移除换行符 const cleanText = (text) => { if (!text) return ""; return text.trim().replace(/\s+/g, " "); }; const getTitle = () => { try { return truncateWords(cleanText(document.title)); } catch (err) { return ""; } }; const getDescription = () => { try { const meta = document.querySelector('meta[name="description"]'); const description = meta?.getAttribute("content") || ""; return truncateWords(cleanText(description)); } catch (err) { return ""; } }; const getSummary = () => { // todo: 利用AI总结 let summary = ""; try { const href = document?.location?.href || ""; const youtubeUrl = "https://www.youtube.com"; if (href.startsWith(youtubeUrl)) { // YouTube specific logic const $el = document.querySelector("#collapsed-title") || document.querySelector("#description-inline-expander"); // 尝试更多可能的选择器 if ($el) { summary = $el.textContent; } } // 尝试获取通用 Meta 信息作为兜底 if (!summary) { summary = document .querySelector('meta[property="og:description"]') ?.getAttribute("content") || ""; } if (!summary) { summary = document .querySelector('meta[name="keywords"]') ?.getAttribute("content") || ""; } } catch (err) { // ignore } return truncateWords(cleanText(summary)); }; export const getDocInfo = () => { const title = getTitle(); const description = getDescription(); const summary = getSummary(); const info = { title, description, summary }; return info; }; ================================================ FILE: src/libs/domManager.js ================================================ import React from "react"; import ReactDOM from "react-dom/client"; import { CacheProvider } from "@emotion/react"; import createCache from "@emotion/cache"; import { logger } from "./log"; /** * 普通 DOM 管理器,用于管理 React 组件的挂载、更新和销毁 * 与 ShadowDomManager 不同,此管理器直接将组件挂载到普通 DOM 节点,不使用 Shadow DOM * 这样可以让组件直接访问外部数据,无需通过事件通信 */ export default class DomManager { #hostElement = null; #reactRoot = null; #isVisible = false; #isProcessing = false; _id; _className; _ReactComponent; _props; _rootElement; constructor({ id, className = "", reactComponent, props = {}, rootElement = document.body, }) { if (!id || !reactComponent) { throw new Error("ID and a React Component must be provided."); } this._id = id; this._className = className; this._ReactComponent = reactComponent; this._props = props; this._rootElement = rootElement; } get isVisible() { return this.#isVisible; } /** * 显示组件 * @param {Object} props - 可选的新 props,如果不提供则使用构造函数中的 props */ show(props) { if (this.#isVisible || this.#isProcessing) { return; } if (!this.#hostElement) { this.#isProcessing = true; try { this.#mount(props || this._props); } catch (error) { logger.warn(`Failed to mount component with id "${this._id}":`, error); this.#isProcessing = false; return; } finally { this.#isProcessing = false; } } this.#hostElement.style.display = ""; this.#isVisible = true; } /** * 隐藏组件(不销毁) */ hide() { if (!this.#isVisible || !this.#hostElement) { return; } this.#hostElement.style.display = "none"; this.#isVisible = false; } /** * 销毁组件并移除 DOM 节点 */ destroy() { if (!this.#hostElement) { return; } this.#isProcessing = true; if (this.#reactRoot) { this.#reactRoot.unmount(); } this.#hostElement.remove(); this.#hostElement = null; this.#reactRoot = null; this.#isVisible = false; this.#isProcessing = false; logger.info(`Component with id "${this._id}" has been destroyed.`); } /** * 切换组件显示/隐藏状态 * @param {Object} props - 可选的新 props */ toggle(props) { if (this.#isVisible) { this.hide(); } else { this.show(props || this._props); } } /** * 更新组件 props(仅在组件已挂载时有效) * @param {Object} newProps - 新的 props */ updateProps(newProps) { if (this.#reactRoot && this.#hostElement) { const ComponentToRender = this._ReactComponent; const cache = createCache({ key: this._id, prepend: true, }); this.#reactRoot.render( ); } } /** * 挂载组件到 DOM * @private */ #mount(props) { const host = document.createElement("div"); host.id = this._id; if (this._className) { host.className = this._className; } this._rootElement.appendChild(host); this.#hostElement = host; const cache = createCache({ key: this._id, prepend: true, }); const enhancedProps = { ...props, onClose: this.hide.bind(this), }; const ComponentToRender = this._ReactComponent; this.#reactRoot = ReactDOM.createRoot(host); this.#reactRoot.render( ); } } ================================================ FILE: src/libs/fabManager.js ================================================ import ShadowDomManager from "./shadowDomManager"; import { APP_CONSTS } from "../config"; import ContentFab from "../views/Action/ContentFab"; export class FabManager extends ShadowDomManager { constructor({ processActions, fabConfig }) { super({ id: APP_CONSTS.fabID, className: "notranslate", reactComponent: ContentFab, props: { processActions, fabConfig }, }); if (!fabConfig?.isHide) { this.show(); } } } ================================================ FILE: src/libs/fetch.js ================================================ import { isExt, isGm } from "./client"; import { sendBgMsg } from "./msg"; import { getSettingWithDefault } from "./storage"; import { MSG_FETCH, DEFAULT_HTTP_TIMEOUT, PORT_STREAM_FETCH } from "../config"; import { isBg } from "./browser"; import { kissLog } from "./log"; import { getFetchPool } from "./pool"; import { getHttpCachePolyfill, parseResponse } from "./cache"; import { createSSEParser, createAsyncQueue } from "./stream"; import browser from "webextension-polyfill"; /** * 油猴脚本的请求封装 * @param {*} input * @param {*} init * @returns */ export const fetchGM = async ( input, { method = "GET", headers, body, timeout } = {} ) => new Promise((resolve, reject) => { GM.xmlHttpRequest({ method, url: input, headers, data: body, // withCredentials: true, timeout, onload: ({ response, responseHeaders, status, statusText }) => { const headers = {}; responseHeaders.split("\n").forEach((line) => { const [name, value] = line.split(":").map((item) => item.trim()); if (name && value) { headers[name] = value; } }); resolve({ body: response, headers, status, statusText, }); }, onerror: reject, onabort: () => { reject(new Error("GM request onabort.")); }, ontimeout: () => { reject(new Error("GM request timeout.")); }, }); }); /** * 发起请求 * @param {*} input * @param {*} init * @param {*} opts * @returns */ export const fetchPatcher = async (input, init = {}, opts) => { let timeout = opts?.httpTimeout; if (!timeout) { try { timeout = (await getSettingWithDefault()).httpTimeout; } catch (err) { kissLog("getSettingWithDefault", err); } } if (!timeout) { timeout = DEFAULT_HTTP_TIMEOUT; } if (isGm) { // todo: 自定义接口 init 可能包含了 signal Object.assign(init, { timeout }); const { body, headers, status, statusText } = window.KISS_GM ? await window.KISS_GM.fetch(input, init) : await fetchGM(input, init); return new Response(body, { headers: new Headers(headers), status, statusText, }); } if (AbortSignal?.timeout && !init.signal) { Object.assign(init, { signal: AbortSignal.timeout(timeout) }); } return fetch(input, init); }; /** * 处理请求 * @param {*} param0 * @returns */ export const fetchHandle = async ({ input, init, opts }) => { const res = await fetchPatcher(input, init, opts); return parseResponse(res, opts.expect); }; /** * fetch 兼容性封装 * @param {*} args * @returns */ export const fnPolyfill = ({ fn, msg = MSG_FETCH, ...args }) => { // 插件 if (isExt && !isBg()) { return sendBgMsg(msg, { ...args }); } // 油猴/网页/BackgroundPage return fn({ ...args }); }; /** * 数据请求 * @param {*} input * @param {*} init * @param {*} param1 * @returns */ export const fetchData = async ( input, init, { useCache, usePool, fetchInterval, fetchLimit, ...opts } = {} ) => { if (!input?.trim()) { throw new Error("URL is empty"); } // 使用缓存数据 if (useCache) { const resCache = await getHttpCachePolyfill(input, init); if (resCache) { return resCache; } } // 通过任务池发送请求 if (usePool) { const fetchPool = getFetchPool(fetchInterval, fetchLimit); return fetchPool.push(fnPolyfill, { fn: fetchHandle, input, init, opts }); } // 直接请求 return fnPolyfill({ fn: fetchHandle, input, init, opts }); }; /** * 油猴脚本流式请求(带 SSE 处理) * @param {*} input * @param {*} init * @returns {AsyncGenerator} */ async function* fetchStreamGM( input, { method = "GET", headers, body, timeout } = {} ) { const asyncQueue = createAsyncQueue(); const parseSSE = createSSEParser(); const gmRequest = window.KISS_GM?.xmlHttpRequest || GM.xmlHttpRequest; const requestHandle = gmRequest({ method, url: input, headers, data: body, timeout, responseType: "stream", onloadstart: async ({ response }) => { try { const reader = response.getReader(); const decoder = new TextDecoder(); while (true) { const { done: readerDone, value } = await reader.read(); if (readerDone) break; for (const data of parseSSE( decoder.decode(value, { stream: true }) )) { asyncQueue.push(data); } } } catch (e) { asyncQueue.error(e); return; } asyncQueue.finish(); }, onerror: (e) => asyncQueue.error(e), onabort: () => asyncQueue.error(new Error("GM stream request aborted")), ontimeout: () => asyncQueue.error(new Error("GM stream request timeout")), }); try { yield* asyncQueue.iterate(); } finally { requestHandle?.abort?.(); } } /** * 原生 fetch 流式请求(带 SSE 处理) * @param {string} input * @param {Object} init * @param {number} timeout * @returns {AsyncGenerator} */ export async function* fetchStreamNative(input, init, timeout) { const signal = AbortSignal?.timeout?.(timeout); const response = await fetch(input, { ...init, signal }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); const parseSSE = createSSEParser(); while (true) { const { done, value } = await reader.read(); if (done) break; for (const data of parseSSE(decoder.decode(value, { stream: true }))) { yield data; } } } /** * 通过端口连接 background 的流式请求 * @param {string} input * @param {Object} init * @param {Object} opts * @returns {AsyncGenerator} */ async function* fetchStreamViaPort(input, init, opts) { const asyncQueue = createAsyncQueue(); let port; try { port = browser.runtime.connect({ name: PORT_STREAM_FETCH }); } catch (e) { throw new Error("Failed to connect to background: " + e.message); } port.onMessage.addListener((message) => { switch (message.type) { case "delta": asyncQueue.push(message.data); break; case "done": asyncQueue.finish(); break; case "error": asyncQueue.error(new Error(message.error)); break; default: break; } }); port.onDisconnect.addListener(() => { const lastError = browser.runtime.lastError; if (lastError) { asyncQueue.error(new Error(lastError.message || "Port disconnected")); } }); port.postMessage({ action: "start", args: { input, init, opts }, }); try { yield* asyncQueue.iterate(); } finally { port.disconnect(); } } /** * 流式请求处理(油猴/BackgroundPage/Web) * @param {string} input * @param {Object} init * @param {Object} opts * @returns {AsyncGenerator} */ async function* fnPolyfillStream(input, init, opts) { opts = { ...opts, httpTimeout: opts?.httpTimeout || DEFAULT_HTTP_TIMEOUT, }; // 插件 content script,通过端口连接 background if (isExt && !isBg()) { yield* fetchStreamViaPort(input, init, opts); return; } // 油猴脚本环境 if (isGm) { yield* fetchStreamGM(input, { ...init, timeout: opts?.httpTimeout }); return; } // 扩展 background 或 Web 环境,使用原生 fetch yield* fetchStreamNative(input, init, opts?.httpTimeout); } /** * 流式请求统一封装 * @param {string} input 请求 URL * @param {Object} init 请求配置 * @param {Object} options 选项(与 fetchData 一致) * @yields {string} SSE 数据片段 */ export async function* fetchStream( input, init, { useCache, usePool, fetchInterval, fetchLimit, ...opts } = {} ) { if (!input?.trim()) { throw new Error("URL is empty"); } // 使用缓存数据 if (useCache) { const resCache = await getHttpCachePolyfill(input, init); if (resCache) { yield resCache; return; } } // 通过任务池发送请求 if (usePool) { const fetchPool = getFetchPool(fetchInterval, fetchLimit); const asyncQueue = createAsyncQueue(); const streamPromise = fetchPool.push(async () => { try { for await (const chunk of fnPolyfillStream(input, init, opts)) { asyncQueue.push(chunk); } asyncQueue.finish(); } catch (e) { asyncQueue.error(e); } return null; }); yield* asyncQueue.iterate(); await streamPromise; return; } // 直接请求 yield* fnPolyfillStream(input, init, opts); } ================================================ FILE: src/libs/gm.js ================================================ import { fetchGM } from "./fetch"; import { genEventName } from "./utils"; const MSG_GM_xmlHttpRequest = "xmlHttpRequest"; const MSG_GM_setValue = "setValue"; const MSG_GM_getValue = "getValue"; const MSG_GM_deleteValue = "deleteValue"; const MSG_GM_info = "info"; /** * 注入页面的脚本,请求并接受GM接口信息 * @param {*} param0 */ export const injectScript = (ping) => { window.APP_INFO = { name: process.env.REACT_APP_NAME, version: process.env.REACT_APP_VERSION, eventName: ping, }; }; /** * 适配GM脚本 */ export const adaptScript = (ping) => { const promiseGM = (action, args, timeout = 5000) => new Promise((resolve, reject) => { const pong = genEventName(); const handleEvent = (e) => { window.removeEventListener(pong, handleEvent); const { data, error } = e.detail; if (error) { reject(new Error(error)); } else { resolve(data); } }; window.addEventListener(pong, handleEvent); window.dispatchEvent( new CustomEvent(ping, { detail: { action, args, pong } }) ); setTimeout(() => { window.removeEventListener(pong, handleEvent); reject(new Error("timeout")); }, timeout); }); window.KISS_GM = { fetch: (input, init) => promiseGM(MSG_GM_xmlHttpRequest, { input, init }), setValue: (key, val) => promiseGM(MSG_GM_setValue, { key, val }), getValue: (key) => promiseGM(MSG_GM_getValue, { key }), deleteValue: (key) => promiseGM(MSG_GM_deleteValue, { key }), getInfo: async () => { if (!window.GM_info) { window.GM_info = await promiseGM(MSG_GM_info); } return window.GM_info; }, }; }; /** * 监听并回应页面对GM接口的请求 * @param {*} param0 */ export const handlePing = async (e) => { const { action, args, pong } = e.detail; let res; try { switch (action) { case MSG_GM_xmlHttpRequest: const { input, init } = args; res = await fetchGM(input, init); break; case MSG_GM_setValue: const { key, val } = args; await GM.setValue(key, val); res = val; break; case MSG_GM_getValue: res = await GM.getValue(args.key); break; case MSG_GM_deleteValue: await GM.deleteValue(args.key); res = "ok"; break; case MSG_GM_info: res = GM.info; break; default: throw new Error(`message action is unavailable: ${action}`); } window.dispatchEvent(new CustomEvent(pong, { detail: { data: res } })); } catch (err) { window.dispatchEvent( new CustomEvent(pong, { detail: { error: err.message } }) ); } }; ================================================ FILE: src/libs/iframe.js ================================================ export const isIframe = window.self !== window.top; export const sendIframeMsg = (action, args) => { document.querySelectorAll("iframe").forEach((iframe) => { iframe.contentWindow.postMessage({ action, args }, "*"); }); }; export const sendParentMsg = (action, args) => { window.parent.postMessage({ action, args }, "*"); }; ================================================ FILE: src/libs/injector.js ================================================ import { trustedTypesHelper } from "./trustedTypes"; // Function to inject inline JavaScript code export const injectInlineJs = (code, id = "kiss-translator-inline-js") => { if (document.getElementById(id)) { return; } const el = document.createElement("script"); el.setAttribute("data-source", "kiss-inject injectInlineJs"); el.type = "text/javascript"; el.id = id; el.textContent = trustedTypesHelper.createScript(code); (document.head || document.documentElement).appendChild(el); }; export const injectInlineJsBg = (code, id = "kiss-translator-inline-js") => { if (document.getElementById(id)) { return; } const el = document.createElement("script"); el.setAttribute("data-source", "kiss-inject injectInlineJsBg"); el.type = "text/javascript"; el.id = id; el.textContent = code; (document.head || document.documentElement).appendChild(el); }; // Function to inject external JavaScript file export const injectExternalJs = (src, id = "kiss-translator-external-js") => { if (document.getElementById(id)) { return; } const el = document.createElement("script"); el.setAttribute("data-source", "kiss-inject injectExternalJs"); el.type = "text/javascript"; el.id = id; el.src = trustedTypesHelper.createScriptURL(src); (document.head || document.documentElement).appendChild(el); }; // Function to inject internal CSS code export const injectInternalCss = (styles) => { const el = document.createElement("style"); el.setAttribute("data-source", "kiss-inject injectInternalCss"); el.textContent = styles; document.head?.appendChild(el); }; // Function to inject external CSS file export const injectExternalCss = (href) => { const el = document.createElement("link"); el.setAttribute("data-source", "kiss-inject injectExternalCss"); el.setAttribute("rel", "stylesheet"); el.setAttribute("type", "text/css"); el.setAttribute("href", href); document.head?.appendChild(el); }; ================================================ FILE: src/libs/inputTranslate.js ================================================ import { DEFAULT_INPUT_RULE, DEFAULT_INPUT_SHORTCUT, OPT_LANGS_LIST, DEFAULT_API_SETTING, OPT_INPUT_DOT_DISABLE, OPT_INPUT_DOT_MOBILE, } from "../config"; import { isMobile } from "./mobile"; import { genEventName, removeEndchar, matchInputStr, sleep } from "./utils"; import { stepShortcutRegister } from "./shortcut"; import { apiTranslate } from "../apis"; import { createLoadingSVG } from "./svg"; import { logger } from "./log"; // ========================================== // 核心工具函数:DOM 查找与状态判断 // ========================================== /** * 递归查找 Shadow DOM 深处的当前焦点元素 * 解决 Gemini、Discord 等使用 Custom Elements 的网站无法识别焦点的问题 */ function getDeepActiveElement() { let element = document.activeElement; while (element && element.shadowRoot && element.shadowRoot.activeElement) { element = element.shadowRoot.activeElement; } return element; } /** * 判断是否为可编辑区域 * 兼容 input, textarea, 以及 contenteditable="true" 的 div/span */ function isEditableTarget(node) { if (!node) return false; // 1. 标准输入框 const nodeName = node.nodeName?.toUpperCase(); if (nodeName === "INPUT" || nodeName === "TEXTAREA") { return true; } // 2. 检查 contenteditable 属性 (HTML 属性或 DOM 属性) if ( node.isContentEditable || node.getAttribute("contenteditable") === "true" ) { return true; } return false; } /** * 获取节点文本 */ function getNodeText(node) { const nodeName = node.nodeName?.toUpperCase(); if (nodeName === "INPUT" || nodeName === "TEXTAREA") { return node.value || ""; } // 对于 contenteditable,优先取 innerText (也就是视觉可见的文本) return node.innerText || node.textContent || ""; } /** * 针对 React 等框架的特殊赋值 * React 重写了 input 的 value setter,直接 .value = xx 经常无效 * 需要调用原生原型链上的 setter */ function setNativeValue(element, value) { const valueSetter = Object.getOwnPropertyDescriptor(element, "value")?.set; const prototype = Object.getPrototypeOf(element); const prototypeValueSetter = Object.getOwnPropertyDescriptor( prototype, "value" )?.set; if (prototypeValueSetter && valueSetter !== prototypeValueSetter) { prototypeValueSetter.call(element, value); } else { element.value = value; } element.dispatchEvent(new Event("input", { bubbles: true })); } // ========================================== // 核心逻辑:智能替换文本 // ========================================== async function smartReplaceText(node, newText) { node.focus(); await sleep(10); // 判断是否为富文本编辑器 (X.com, Discord, Slack 等通常是 contenteditable 的 div/span) const isRichEditor = node.isContentEditable || node.getAttribute("contenteditable") === "true"; // ------------------------------------------------ // 步骤 1: 全选内容 // ------------------------------------------------ const performSelectAll = () => { if (typeof node.select === "function") { node.select(); return; } try { document.execCommand("selectAll", false, null); } catch (e) { const selection = window.getSelection(); selection.removeAllRanges(); const range = document.createRange(); range.selectNodeContents(node); selection.addRange(range); } }; performSelectAll(); await sleep(50); // ------------------------------------------------ // 步骤 2: 针对富文本编辑器的优先策略 (Clipboard Paste) // ------------------------------------------------ if (isRichEditor) { try { logger.debug("Rich Editor detected: Priority Strategy (Clipboard Paste)"); const dt = new DataTransfer(); dt.setData("text/plain", newText); const pasteEvt = new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true, composed: true, view: window, }); node.dispatchEvent(pasteEvt); // 给 React 一点时间去处理 Paste 事件 await sleep(100); if (checkSuccess(node, newText)) return true; } catch (e) { logger.debug("Strategy Paste failed", e); } } // ------------------------------------------------ // 步骤 3: 原有的 execCommand 策略 (降级 / 普通输入框) // ------------------------------------------------ try { const success = document.execCommand("insertText", false, newText); if (success) { await sleep(20); if (checkSuccess(node, newText)) return true; } } catch (e) { logger.debug("Strategy 1 (insertText) failed", e); } // === 策略 2: 标准 Input 处理 (React/Vue 兼容) === if (node.nodeName === "INPUT" || node.nodeName === "TEXTAREA") { try { setNativeValue(node, newText); return true; } catch (e) { logger.debug("Strategy 2 (Input Value) failed", e); } } return false; } // 辅助验证函数 function checkSuccess(node, targetText) { const currentText = getNodeText(node); return currentText.includes(targetText.trim()); } // ========================================== // UI 辅助函数 // ========================================== function addLoading(node, loadingId) { const rect = node.getBoundingClientRect(); // 如果元素不可见或太小,简单容错 if (rect.width === 0 || rect.height === 0) { // pass } const div = document.createElement("div"); div.id = loadingId; div.appendChild(createLoadingSVG()); div.style.cssText = ` position: fixed; left: ${rect.left}px; top: ${rect.top}px; min-width: 20px; width: ${rect.width || 100}px; height: ${rect.height || 30}px; display: flex; align-items: center; justify-content: center; z-index: 2147483647; pointer-events: none; background: transparent; `; document.body.appendChild(div); } function removeLoading(loadingId) { const div = document.getElementById(loadingId); if (div) div.remove(); } // ========================================== // 主类:InputTranslator // ========================================== export class InputTranslator { #config; #unregisterShortcut = null; #isEnabled = false; #triggerShortcut; // 状态管理 #activeInput = null; // 当前获得焦点的输入框 #floatBtn = null; // 悬浮按钮 DOM #resizeObserver = null; // 监听输入框尺寸变化 #blurTimer = null; // 存储失焦隐藏的定时器 ID // 绑定的事件处理函数 #boundFocusIn; #boundFocusOut; #boundUpdatePos; constructor({ inputRule = DEFAULT_INPUT_RULE, transApis = [] } = {}) { this.#config = { inputRule, transApis }; const { triggerShortcut: initialTriggerShortcut } = this.#config.inputRule; this.#triggerShortcut = initialTriggerShortcut && initialTriggerShortcut.length > 0 ? initialTriggerShortcut : DEFAULT_INPUT_SHORTCUT; this.#boundFocusIn = this.handleFocusIn.bind(this); this.#boundFocusOut = this.handleFocusOut.bind(this); this.#boundUpdatePos = this.updateBtnPosition.bind(this); if (this.#config.inputRule.transOpen) { this.enable(); } } enable() { if (this.#isEnabled) return; // 避免重复开启 // 1. 注册快捷键 const { triggerCount, triggerTime } = this.#config.inputRule; this.#unregisterShortcut = stepShortcutRegister( this.#triggerShortcut, this.handleTranslate.bind(this), triggerCount, triggerTime ); // 2. 注册 DOM 监听 document.addEventListener("focusin", this.#boundFocusIn); document.addEventListener("focusout", this.#boundFocusOut); window.addEventListener("scroll", this.#boundUpdatePos, true); window.addEventListener("resize", this.#boundUpdatePos); if (window.visualViewport) { window.visualViewport.addEventListener("resize", this.#boundUpdatePos); window.visualViewport.addEventListener("scroll", this.#boundUpdatePos); } this.#isEnabled = true; // [修复问题2-B]:开启时,如果当前焦点已经在输入框内,立即触发逻辑 const currentFocus = getDeepActiveElement(); if (isEditableTarget(currentFocus)) { this.handleFocusIn(); } logger.info("Input Translator enabled."); } disable() { if (!this.#isEnabled) return; // 1. 移除快捷键 if (this.#unregisterShortcut) { this.#unregisterShortcut(); this.#unregisterShortcut = null; } // 2. 移除 DOM 监听 document.removeEventListener("focusin", this.#boundFocusIn); document.removeEventListener("focusout", this.#boundFocusOut); window.removeEventListener("scroll", this.#boundUpdatePos, true); window.removeEventListener("resize", this.#boundUpdatePos); if (window.visualViewport) { window.visualViewport.removeEventListener("resize", this.#boundUpdatePos); window.visualViewport.removeEventListener("scroll", this.#boundUpdatePos); } // 3. 清理 UI 和 观察器 // [修复问题2-A]:彻底销毁 DOM,防止僵尸状态 this.removeFloatButton(); if (this.#resizeObserver) { this.#resizeObserver.disconnect(); this.#resizeObserver = null; } this.#activeInput = null; this.#isEnabled = false; logger.info("Input Translator disabled."); } toggle() { this.#isEnabled ? this.disable() : this.enable(); } // ============================ // UI 交互事件处理 // ============================ handleFocusIn() { // [修复问题2-C]:如果刚刚触发了 blur 延时还没执行,立刻清除它,防止按钮闪现后消失 if (this.#blurTimer) { clearTimeout(this.#blurTimer); this.#blurTimer = null; } const target = getDeepActiveElement(); if (isEditableTarget(target)) { this.#activeInput = target; if (this.#resizeObserver) this.#resizeObserver.disconnect(); this.#resizeObserver = new ResizeObserver(() => this.updateBtnPosition()); this.#resizeObserver.observe(target); this.showFloatButton(target); } } handleFocusOut() { // 延时处理,因为点击按钮时会短暂触发 blur this.#blurTimer = setTimeout(() => { const newFocus = getDeepActiveElement(); // 如果焦点转移到了我们的按钮上,或者还在原输入框(某些特殊情况),则不隐藏 if ( newFocus !== this.#activeInput && !this.#floatBtn?.contains(newFocus) ) { this.hideFloatButton(); this.#activeInput = null; if (this.#resizeObserver) { this.#resizeObserver.disconnect(); this.#resizeObserver = null; } } }, 150); } // [修复问题1]:使用参数 inputNode 确保逻辑闭环 showFloatButton(inputNode) { if (!this.#isEnabled) return; const showDot = this.#config.inputRule.showDot || OPT_INPUT_DOT_MOBILE; if (showDot === OPT_INPUT_DOT_DISABLE) return; if (showDot === OPT_INPUT_DOT_MOBILE) { const isTouch = isMobile || navigator.maxTouchPoints > 0; if (!isTouch) return; } // 确保 activeInput 与传入的节点一致 this.#activeInput = inputNode; // 创建按钮 DOM (如果不存在) if (!this.#floatBtn) { this.createFloatButtonDOM(); } this.#floatBtn.style.display = "flex"; this.updateBtnPosition(); } // 将创建逻辑抽离,保持代码整洁 createFloatButtonDOM() { this.#floatBtn = document.createElement("div"); // ... 样式代码保持不变 ... const isTouch = isMobile || navigator.maxTouchPoints > 0; const size = isTouch ? "36px" : "30px"; this.#floatBtn.style.cssText = ` position: fixed; width: ${size}; height: ${size}; background: #209CEE; border-radius: 50%; z-index: 2147483647; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 5px rgba(0,0,0,0.2); transition: opacity 0.2s; font-size: 13px; color: white; user-select: none; -webkit-user-select: none; `; this.#floatBtn.innerText = "译"; const preventFocusLoss = (e) => { e.preventDefault(); e.stopPropagation(); }; // 这里的监听器随 DOM 销毁而销毁,无需手动 removeEventListener this.#floatBtn.addEventListener("mousedown", preventFocusLoss); this.#floatBtn.addEventListener("touchstart", preventFocusLoss, { passive: false, }); const handleTrigger = (e) => { e.preventDefault(); e.stopPropagation(); if (this.#activeInput) this.#activeInput.focus(); this.handleTranslate({ isBtnTrigger: true }); }; this.#floatBtn.addEventListener("click", handleTrigger); this.#floatBtn.addEventListener("touchend", handleTrigger); document.body.appendChild(this.#floatBtn); } // 仅仅隐藏(失焦时用) hideFloatButton() { if (this.#floatBtn) { this.#floatBtn.style.display = "none"; } } // 彻底移除(禁用时用) removeFloatButton() { if (this.#floatBtn) { this.#floatBtn.remove(); // 从 DOM 删除 this.#floatBtn = null; // 清空引用 } } updateBtnPosition() { // 增加对 activeInput 是否还在文档中的检查 if ( !this.#activeInput || !this.#activeInput.isConnected || // 检查元素是否已被移除 !this.#floatBtn || this.#floatBtn.style.display === "none" ) { // 如果输入框都不在了,直接隐藏按钮 if (this.#floatBtn) this.hideFloatButton(); return; } const rect = this.#activeInput.getBoundingClientRect(); // ... 位置计算逻辑保持不变 ... const isTouch = isMobile || navigator.maxTouchPoints > 0; const btnSize = isTouch ? 36 : 30; const padding = 5; let top = rect.bottom - btnSize - padding; let left = rect.right - btnSize - padding; if (rect.height < 60) top = rect.top - btnSize - 2; // 确保按钮不超出屏幕范围 left = Math.max(0, Math.min(left, window.innerWidth - btnSize - 2)); top = Math.max(0, Math.min(top, window.innerHeight - btnSize - 2)); this.#floatBtn.style.top = `${top}px`; this.#floatBtn.style.left = `${left}px`; } // ============================ // 核心业务:翻译处理 // ============================ /** * 执行翻译逻辑 * @param {Object} options * @param {boolean} options.isBtnTrigger 是否由悬浮按钮触发 */ async handleTranslate({ isBtnTrigger = false } = {}) { logger.debug("handle input translate"); // 1. 获取真正的焦点元素 const node = getDeepActiveElement(); // 2. 检查节点是否支持 if (!node || !isEditableTarget(node)) { logger.debug("Active node is not editable"); return; } const { apiSlug, transSign, triggerCount } = this.#config.inputRule; let { fromLang, toLang } = this.#config.inputRule; // 3. 获取文本 let initText = getNodeText(node); // 4. 处理触发字符逻辑 // 修改:仅当非按钮触发(即键盘快捷键触发)时,才移除末尾的触发符 if ( !isBtnTrigger && this.#triggerShortcut.length === 1 && this.#triggerShortcut[0].length === 1 ) { initText = removeEndchar( initText, this.#triggerShortcut[0], triggerCount ); } if (!initText.trim()) return; // 5. 解析语言指令 (例如 "en:你好") let text = initText; if (transSign) { const res = matchInputStr(text, transSign); if (res) { let lang = res[1]; // 简写映射 const langMap = { zh: "zh-CN", cn: "zh-CN", tw: "zh-TW", hk: "zh-TW", jp: "ja", kr: "ko", }; if (langMap[lang.toLowerCase()]) lang = langMap[lang.toLowerCase()]; if (lang && OPT_LANGS_LIST.includes(lang)) { toLang = lang; } text = res[2]; } } const apiSetting = this.#config.transApis.find((api) => api.apiSlug === apiSlug) || DEFAULT_API_SETTING; const loadingId = "kiss-loading-" + genEventName(); try { addLoading(node, loadingId); this.hideFloatButton(); // 翻译期间隐藏按钮 // 调用翻译 API const { trText, isSame } = await apiTranslate({ text, fromLang, toLang, apiSetting, }); const newText = trText?.trim() || ""; if (!newText || isSame) return; // 6. 执行替换 (使用新的智能替换函数) const success = await smartReplaceText(node, newText); if (!success) { logger.warn("Text replacement failed after all strategies."); } } catch (err) { logger.error("Translate input error:", err); } finally { removeLoading(loadingId); // 恢复显示按钮 if (this.#activeInput === node) { this.showFloatButton(node); } } } updateConfig({ inputRule, transApis }) { const wasEnabled = this.#isEnabled; if (wasEnabled) this.disable(); if (inputRule) this.#config.inputRule = inputRule; if (transApis) this.#config.transApis = transApis; const { triggerShortcut } = this.#config.inputRule; this.#triggerShortcut = triggerShortcut && triggerShortcut.length > 0 ? triggerShortcut : DEFAULT_INPUT_SHORTCUT; if (wasEnabled) this.enable(); } } ================================================ FILE: src/libs/interpreter.js ================================================ import Sval from "sval"; export const interpreter = new Sval({ // ECMA Version of the code // 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 // or 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024 // or "latest" ecmaVer: "latest", // Code source type // "script" or "module" sourceType: "script", // Whether the code runs in a sandbox sandBox: true, }); ================================================ FILE: src/libs/log.js ================================================ // 定义日志级别 export const LogLevel = { DEBUG: { value: 0, name: "DEBUG", color: "#6495ED" }, // 宝蓝色 INFO: { value: 1, name: "INFO", color: "#4CAF50" }, // 绿色 WARN: { value: 2, name: "WARN", color: "#FFC107" }, // 琥珀色 ERROR: { value: 3, name: "ERROR", color: "#F44336" }, // 红色 SILENT: { value: 4, name: "SILENT" }, // 特殊级别,用于关闭所有日志 }; function findLogLevelByValue(value) { return Object.values(LogLevel).find((level) => level.value === value); } function findLogLevelByName(name) { if (typeof name !== "string" || name.length === 0) return undefined; const upperCaseName = name.toUpperCase(); return Object.values(LogLevel).find((level) => level.name === upperCaseName); } class Logger { /** * @param {object} [options={}] 配置选项 * @param {LogLevel} [options.level=LogLevel.INFO] 要显示的最低日志级别 * @param {string} [options.prefix='App'] 日志前缀,用于区分模块 */ constructor(options = {}) { this.config = { level: options.level || LogLevel.INFO, prefix: options.prefix || "KISS-Translator", }; } /** * 动态设置日志级别 * @param {LogLevel} level - 新的日志级别 */ setLevel(level) { let newLevelObject; if (typeof level === "string") { newLevelObject = findLogLevelByName(level); if (!newLevelObject) { this.warn( `Invalid log level name provided: "${level}". Keeping current level.` ); return; } } else if (typeof level === "number") { newLevelObject = findLogLevelByValue(level); if (!newLevelObject) { this.warn( `Invalid log level value provided: ${level}. Keeping current level.` ); return; } } else if (level && typeof level.value === "number") { newLevelObject = level; } else { this.warn( "Invalid argument passed to setLevel. Must be a LogLevel object, number, or string." ); return; } if (this.config.level.value !== newLevelObject.value) { this.config.level = newLevelObject; console.log( `[${this.config.prefix}] Log level dynamically set to ${this.config.level.name}` ); } } /** * 核心日志记录方法 * @private * @param {LogLevel} level - 当前消息的日志级别 * @param {...any} args - 要记录的多个参数,可以是任何类型 */ _log(level, ...args) { // 如果当前级别低于配置的最低级别,则不打印 if (level.value < this.config.level.value) { return; } const timestamp = new Date().toISOString(); const prefixStr = `[${this.config.prefix}]`; const levelStr = `[${level.name}]`; // 判断是否在浏览器环境并且浏览器支持 console 样式 const isBrowser = typeof window !== "undefined" && typeof window.document !== "undefined"; if (isBrowser) { // 在浏览器中使用颜色高亮 const consoleMethod = this._getConsoleMethod(level); consoleMethod( `%c${timestamp} %c${prefixStr} %c${levelStr}`, "color: gray; font-weight: lighter;", // 时间戳样式 "color: #7c57e0; font-weight: bold;", // 前缀样式 (紫色) `color: ${level.color}; font-weight: bold;`, // 日志级别样式 ...args ); } else { // 在 Node.js 或不支持样式的环境中,输出纯文本 const consoleMethod = this._getConsoleMethod(level); consoleMethod(timestamp, prefixStr, levelStr, ...args); } } /** * 根据日志级别获取对应的 console 方法 * @private */ _getConsoleMethod(level) { switch (level) { case LogLevel.ERROR: return console.error; case LogLevel.WARN: return console.warn; case LogLevel.INFO: return console.info; default: return console.log; } } /** * 记录 DEBUG 级别的日志 * @param {...any} args */ debug(...args) { this._log(LogLevel.DEBUG, ...args); } /** * 记录 INFO 级别的日志 * @param {...any} args */ info(...args) { this._log(LogLevel.INFO, ...args); } /** * 记录 WARN 级别的日志 * @param {...any} args */ warn(...args) { this._log(LogLevel.WARN, ...args); } /** * 记录 ERROR 级别的日志 * @param {...any} args */ error(...args) { this._log(LogLevel.ERROR, ...args); } } export const logger = new Logger(); export const kissLog = logger.info.bind(logger); // todo:debug日志埋点 ================================================ FILE: src/libs/mobile.js ================================================ export const isMobile = (() => { try { if (typeof navigator === "undefined") return false; const ua = navigator.userAgent; const isAndroid = /Android/i.test(ua); const isiOS = /iPhone|iPad|iPod/i.test(ua); // iPadOS 13+ requests desktop site by default const isiPadDesktop = /Macintosh/i.test(ua) && navigator.maxTouchPoints > 1; const isMobileDevice = isAndroid || isiOS || isiPadDesktop; return isMobileDevice; } catch (error) { return false; } })(); ================================================ FILE: src/libs/msg.js ================================================ import { browser } from "./browser"; /** * 获取当前tab信息 * @returns */ export const getCurTab = async () => { const [tab] = await browser.tabs.query({ active: true, lastFocusedWindow: true, }); return tab; }; export const getCurTabId = async () => { const tab = await getCurTab(); return tab?.id; }; /** * 发送消息给background * @param {*} action * @param {*} args * @returns */ export const sendBgMsg = (action, args) => browser?.runtime.sendMessage({ action, args }); /** * 发送消息给当前页面 * @param {*} action * @param {*} args * @returns */ export const sendTabMsg = async (action, args) => { const tabId = await getCurTabId(); if (!tabId) return; return browser.tabs.sendMessage(tabId, { action, args }).catch((err) => { if ( err?.message?.includes("Could not establish connection") || err?.message?.includes("Receiving end does not exist") ) { console.warn("sendTabMsg warning: ", err?.message); } else { throw err; } }); }; ================================================ FILE: src/libs/pool.js ================================================ import { DEFAULT_FETCH_INTERVAL, DEFAULT_FETCH_LIMIT } from "../config"; import { kissLog } from "./log"; /** * 任务池 */ class TaskPool { #pool = []; #maxRetry = 2; // 最大重试次数 #retryInterval = 1000; // 重试间隔时间 #limit; // 最大并发数 #interval; // 任务最小启动间隔 #currentConcurrent = 0; // 当前正在执行的任务数 #lastExecutionTime = 0; // 上一个任务的启动时间 #schedulerTimer = null; // 用于调度下一个任务的定时器 constructor( interval = DEFAULT_FETCH_INTERVAL, limit = DEFAULT_FETCH_LIMIT, retryInterval = 1000 ) { this.#interval = interval; this.#limit = limit; this.#retryInterval = retryInterval; } /** * 调度器 */ #scheduleNext() { if (this.#schedulerTimer) { return; } if (this.#currentConcurrent >= this.#limit || this.#pool.length === 0) { return; } const now = Date.now(); const timeSinceLast = now - this.#lastExecutionTime; const delay = Math.max(0, this.#interval - timeSinceLast); this.#schedulerTimer = setTimeout(() => { this.#schedulerTimer = null; if (this.#currentConcurrent < this.#limit && this.#pool.length > 0) { const task = this.#pool.shift(); if (task) { this.#lastExecutionTime = Date.now(); this.#execute(task); } } if (this.#pool.length > 0) { this.#scheduleNext(); } }, delay); } /** * 执行单个任务 * @param {object} task - 任务对象 */ async #execute(task) { this.#currentConcurrent++; const { fn, args, resolve, reject, retry } = task; try { const res = await fn(args); resolve(res); } catch (err) { kissLog("task pool", err); if (retry < this.#maxRetry) { setTimeout(() => { this.#pool.unshift({ ...task, retry: retry + 1 }); // unshift 保证重试任务优先 this.#scheduleNext(); }, this.#retryInterval); } else { reject(err); } } finally { this.#currentConcurrent--; this.#scheduleNext(); } } /** * 向任务池中添加一个新任务 * @param {Function} fn - 要执行的异步函数 * @param {*} args - 函数的参数 * @returns {Promise} */ push(fn, args) { return new Promise((resolve, reject) => { this.#pool.push({ fn, args, resolve, reject, retry: 0 }); this.#scheduleNext(); }); } /** * 更新任务池的配置 * @param {number} interval - 新的最小任务间隔 * @param {number} limit - 新的最大并发数 */ update(interval, limit) { if (interval >= 0) { this.#interval = interval; } if (limit >= 1) { this.#limit = limit; } this.#scheduleNext(); } /** * 清空任务池 */ clear() { for (const task of this.#pool) { task.reject("the task pool was cleared"); } this.#pool.length = 0; if (this.#schedulerTimer) { clearTimeout(this.#schedulerTimer); this.#schedulerTimer = null; } } } /** * 请求池实例 */ let fetchPool; /** * 获取请求池实例 * @param interval * @param limit * @returns */ export const getFetchPool = (interval, limit) => { if (!fetchPool) { fetchPool = new TaskPool( interval ?? DEFAULT_FETCH_INTERVAL, limit ?? DEFAULT_FETCH_LIMIT ); } else if (interval && limit) { updateFetchPool(interval, limit); } return fetchPool; }; /** * 更新请求池参数 * @param {*} interval * @param {*} limit */ export const updateFetchPool = (interval, limit) => { fetchPool?.update(interval, limit); }; /** * 清空请求池 */ export const clearFetchPool = () => { fetchPool?.clear(); }; ================================================ FILE: src/libs/popupManager.js ================================================ import ShadowDomManager from "./shadowDomManager"; import { APP_CONSTS, EVENT_KISS_INNER, MSG_POPUP_TOGGLE } from "../config"; import Action from "../views/Action"; export class PopupManager extends ShadowDomManager { constructor({ translator, processActions }) { super({ id: APP_CONSTS.popupID, className: "notranslate", reactComponent: Action, props: { translator, processActions }, }); } toggle(props) { if (this.isVisible) { document.dispatchEvent( new CustomEvent(EVENT_KISS_INNER, { detail: { action: MSG_POPUP_TOGGLE }, }) ); } else { this.show(props || this._props); } } } ================================================ FILE: src/libs/rules.js ================================================ import { matchValue, type, isMatch } from "./utils"; import { GLOBAL_KEY, OPT_LANGS_FROM, OPT_LANGS_TO, DEFAULT_RULE, GLOBLA_RULE, OPT_SPLIT_PARAGRAPH_ALL, OPT_HIGHLIGHT_WORDS_ALL, } from "../config"; import { loadOrFetchSubRules } from "./subRules"; import { getRulesWithDefault, setRules } from "./storage"; import { trySyncRules } from "./sync"; import { kissLog } from "./log"; function mergeSelectors(defaultStr, userStr) { if (!userStr || !userStr.trim()) { return defaultStr; } const defaultList = defaultStr .split(",") .map((s) => s.trim()) .filter(Boolean); const userList = userStr .split(",") .map((s) => s.trim()) .filter(Boolean); const isPatchMode = userList.some( (s) => s.startsWith("+") || s.startsWith("-") ); if (!isPatchMode) { return [...new Set(userList)].join(", "); } let finalSet = new Set(defaultList); let currentMode = "add"; userList.forEach((item) => { let selector = item; if (item.startsWith("+")) { currentMode = "add"; selector = item.slice(1).trim(); } else if (item.startsWith("-")) { currentMode = "remove"; selector = item.slice(1).trim(); } if (!selector) return; if (currentMode === "remove") { finalSet.delete(selector); } else { finalSet.add(selector); } }); return [...finalSet].join(", "); } /** * 在规则列表中查找匹配的规则 * @param {Array} rules 规则列表 * @param {string} href 当前页面URL * @returns {Object|null} 匹配的规则或null */ const findMatchingRule = (rules, href) => { return rules.find( (r) => r.pattern !== GLOBAL_KEY && r.pattern.split(/\n|,/).some((p) => isMatch(href, p.trim())) ); }; /** * 合并规则,应用优先级 * 对于选择器类型的属性,使用mergeSelectors合并 * 对于其他属性,高优先级规则覆盖低优先级规则 * @param {Object} baseRule 基准规则(低优先级) * @param {Object} overrideRule 覆盖规则(高优先级) * @returns {Object} 合并后的规则 */ const mergeRules = (baseRule, overrideRule) => { if (!overrideRule) return { ...baseRule }; if (!baseRule) return { ...overrideRule }; const merged = { ...baseRule }; // 选择器类型的属性需要使用mergeSelectors合并 ["selector", "keepSelector", "rootsSelector", "ignoreSelector"].forEach( (key) => { merged[key] = mergeSelectors( baseRule[key] || "", overrideRule[key] || "" ); } ); // 字符串类型的属性,非空则覆盖 [ "terms", "aiTerms", "termsStyle", "highlightStyle", "textExtStyle", "selectStyle", "parentStyle", "grandStyle", "injectJs", "injectCss", "transStartHook", "transEndHook", // "transRemoveHook", ].forEach((key) => { if (overrideRule[key]?.trim()) { merged[key] = overrideRule[key]; } }); // 枚举类型的属性,非全局值则覆盖 [ "apiSlug", "fromLang", "toLang", "transOpen", "transOnly", "autoScan", "hasRichText", "hasShadowroot", "scanAll", "transTag", "transTitle", "splitParagraph", "highlightWords", "textStyle", ].forEach((key) => { if (overrideRule[key] && overrideRule[key] !== GLOBAL_KEY) { merged[key] = overrideRule[key]; } }); // 数字类型的属性 ["splitLength"].forEach((key) => { if (overrideRule[key]) { merged[key] = overrideRule[key]; } }); // pattern使用高优先级规则的pattern if (overrideRule.pattern) { merged.pattern = overrideRule.pattern; } return merged; }; /** * 根据href匹配规则 * 合并匹配到的个人规则、订阅规则、全局规则 * 优先级:个人规则 > 订阅规则 > 全局规则 * @param {*} rules * @param {string} href * @returns */ export const matchRule = async (href, { injectRules, subrulesList }) => { // 获取个人规则 const personalRules = await getRulesWithDefault(); // 获取全局规则 const globalRule = { ...GLOBLA_RULE, ...(personalRules.find((r) => r.pattern === GLOBAL_KEY) || {}), }; // 查找匹配的个人规则(排除全局规则) const matchedPersonalRule = findMatchingRule(personalRules, href); // 获取订阅规则并查找匹配 let matchedSubRule = null; if (injectRules) { try { const selectedSub = subrulesList.find((item) => item.selected); if (selectedSub?.url) { const subRules = await loadOrFetchSubRules(selectedSub.url); matchedSubRule = findMatchingRule(subRules, href); } } catch (err) { kissLog("load injectRules", err); } } // 如果没有匹配到任何规则,返回全局规则 if (!matchedPersonalRule && !matchedSubRule) { return globalRule; } // 合并规则:全局规则 <- 订阅规则 <- 个人规则 // 优先级:个人规则 > 订阅规则 > 全局规则 let finalRule = { ...globalRule }; finalRule = mergeRules(finalRule, matchedSubRule); finalRule = mergeRules(finalRule, matchedPersonalRule); return finalRule; }; /** * 检查过滤rules * @param {*} rules * @returns */ export const checkRules = (rules) => { if (type(rules) === "string") { rules = JSON.parse(rules); } if (type(rules) !== "array") { throw new Error("data error"); } const fromLangs = OPT_LANGS_FROM.map((item) => item[0]); const toLangs = OPT_LANGS_TO.map((item) => item[0]); const patternSet = new Set(); rules = rules .filter((rule) => type(rule) === "object") .filter(({ pattern }) => { if (type(pattern) !== "string" || patternSet.has(pattern.trim())) { return false; } patternSet.add(pattern.trim()); return true; }) .map( ({ pattern, selector, keepSelector, rootsSelector, ignoreSelector, terms, aiTerms, termsStyle, highlightStyle, textExtStyle, selectStyle, parentStyle, grandStyle, injectJs, injectCss, apiSlug, fromLang, toLang, textStyle, transOpen, transOnly, autoScan, hasRichText, hasShadowroot, scanAll, transTag, transTitle, transStartHook, transEndHook, // transRemoveHook, splitParagraph, splitLength, highlightWords, }) => ({ pattern: pattern.trim(), selector: type(selector) === "string" ? selector : "", keepSelector: type(keepSelector) === "string" ? keepSelector : "", rootsSelector: type(rootsSelector) === "string" ? rootsSelector : "", ignoreSelector: type(ignoreSelector) === "string" ? ignoreSelector : "", terms: type(terms) === "string" ? terms : "", aiTerms: type(aiTerms) === "string" ? aiTerms : "", termsStyle: type(termsStyle) === "string" ? termsStyle : "", highlightStyle: type(highlightStyle) === "string" ? highlightStyle : "", textExtStyle: type(textExtStyle) === "string" ? textExtStyle : "", selectStyle: type(selectStyle) === "string" ? selectStyle : "", parentStyle: type(parentStyle) === "string" ? parentStyle : "", grandStyle: type(grandStyle) === "string" ? grandStyle : "", injectJs: type(injectJs) === "string" ? injectJs : "", injectCss: type(injectCss) === "string" ? injectCss : "", apiSlug: type(apiSlug) === "string" && apiSlug.trim() !== "" ? apiSlug.trim() : GLOBAL_KEY, fromLang: matchValue([GLOBAL_KEY, ...fromLangs], fromLang), toLang: matchValue([GLOBAL_KEY, ...toLangs], toLang), // textStyle: matchValue([GLOBAL_KEY, ...OPT_STYLE_ALL], textStyle), textStyle: type(textStyle) === "string" && textStyle.trim() !== "" ? textStyle.trim() : GLOBAL_KEY, transOpen: matchValue([GLOBAL_KEY, "true", "false"], transOpen), transOnly: matchValue([GLOBAL_KEY, "true", "false"], transOnly), autoScan: matchValue([GLOBAL_KEY, "true", "false"], autoScan), hasRichText: matchValue([GLOBAL_KEY, "true", "false"], hasRichText), hasShadowroot: matchValue([GLOBAL_KEY, "true", "false"], hasShadowroot), scanAll: matchValue([GLOBAL_KEY, "true", "false"], scanAll), transTag: matchValue([GLOBAL_KEY, "span", "font"], transTag), transTitle: matchValue([GLOBAL_KEY, "true", "false"], transTitle), transStartHook: type(transStartHook) === "string" ? transStartHook : "", transEndHook: type(transEndHook) === "string" ? transEndHook : "", // transRemoveHook: // type(transRemoveHook) === "string" ? transRemoveHook : "", splitParagraph: matchValue( [GLOBAL_KEY, ...OPT_SPLIT_PARAGRAPH_ALL], splitParagraph ), splitLength: Number.isInteger(splitLength) ? splitLength : 0, highlightWords: matchValue( [GLOBAL_KEY, ...OPT_HIGHLIGHT_WORDS_ALL], highlightWords ), }) ); return rules; }; /** * 保存或更新rule * @param {*} curRule */ export const saveRule = async (curRule) => { const rules = await getRulesWithDefault(); const index = rules.findIndex( (item) => item.pattern !== GLOBAL_KEY && isMatch(curRule.pattern, item.pattern) ); if (index !== -1) { const rule = rules.splice(index, 1)[0]; curRule = { ...rule, ...curRule, pattern: rule.pattern, selector: rule.selector, keepSelector: rule.keepSelector, rootsSelector: rule.rootsSelector, ignoreSelector: rule.ignoreSelector, }; } const newRule = {}; const globalRule = { ...GLOBLA_RULE, ...(rules.find((r) => r.pattern === GLOBAL_KEY) || {}), }; Object.keys(GLOBLA_RULE).forEach((key) => { newRule[key] = !curRule[key] || curRule[key] === globalRule[key] ? DEFAULT_RULE[key] : curRule[key]; }); rules.unshift(newRule); await setRules(rules); trySyncRules(); }; ================================================ FILE: src/libs/shadowDomManager.js ================================================ import React from "react"; import ReactDOM from "react-dom/client"; import { CacheProvider } from "@emotion/react"; import createCache from "@emotion/cache"; import { logger } from "./log"; export default class ShadowDomManager { #hostElement = null; #reactRoot = null; #isVisible = false; #isProcessing = false; _id; _className; _ReactComponent; _props; constructor({ id, className = "", reactComponent, props = {}, rootElement = document.body, }) { if (!id || !reactComponent) { throw new Error("ID and a React Component must be provided."); } this._id = id; this._className = className; this._ReactComponent = reactComponent; this._props = props; this._rootElement = rootElement; } get isVisible() { return this.#isVisible; } show(props) { if (this.#isVisible || this.#isProcessing) { return; } if (!this.#hostElement) { this.#isProcessing = true; try { this.#mount(props || this._props); } catch (error) { logger.warn(`Failed to mount component with id "${this._id}":`, error); this.#isProcessing = false; return; } finally { this.#isProcessing = false; } } this.#hostElement.style.display = ""; this.#isVisible = true; } hide() { if (!this.#isVisible || !this.#hostElement) { return; } this.#hostElement.style.display = "none"; this.#isVisible = false; } destroy() { if (!this.#hostElement) { return; } this.#isProcessing = true; if (this.#reactRoot) { this.#reactRoot.unmount(); } this.#hostElement.remove(); this.#hostElement = null; this.#reactRoot = null; this.#isVisible = false; this.#isProcessing = false; logger.info(`Component with id "${this._id}" has been destroyed.`); } toggle(props) { if (this.#isVisible) { this.hide(); } else { this.show(props || this._props); } } #mount(props) { const host = document.createElement("div"); host.id = this._id; if (this._className) { host.className = this._className; } this._rootElement.appendChild(host); this.#hostElement = host; const shadowContainer = host.attachShadow({ mode: "open" }); const appRoot = document.createElement("div"); appRoot.className = `${this._id}_wrapper notranslate`; shadowContainer.appendChild(appRoot); const cache = createCache({ key: this._id, prepend: true, container: shadowContainer, }); const enhancedProps = { ...props, onClose: this.hide.bind(this), }; const ComponentToRender = this._ReactComponent; this.#reactRoot = ReactDOM.createRoot(appRoot); this.#reactRoot.render( ); } } ================================================ FILE: src/libs/shortcut.js ================================================ import { isSameSet } from "./utils"; /** * 键盘快捷键监听器 * @param {(pressedKeys: Set, event: KeyboardEvent) => void} onKeyDown - Keydown 回调 * @param {(pressedKeys: Set, event: KeyboardEvent) => void} onKeyUp - Keyup 回调 * @param {EventTarget} target - 监听的目标元素 * @returns {() => void} - 用于注销监听的函数 */ export const shortcutListener = ( onKeyDown = () => {}, onKeyUp = () => {}, target = window ) => { const pressedKeys = new Set(); const handleKeyDown = (e) => { if (!e.code) { return; } if (pressedKeys.has(e.code)) return; pressedKeys.add(e.code); onKeyDown(new Set(pressedKeys), e); }; const handleKeyUp = (e) => { if (!e.code) { return; } // onKeyUp 应该在 key 从集合中移除前触发,以便判断组合键 onKeyUp(new Set(pressedKeys), e); pressedKeys.delete(e.code); }; const handleBlur = () => { pressedKeys.clear(); }; target.addEventListener("keydown", handleKeyDown, true); target.addEventListener("keyup", handleKeyUp, true); window.addEventListener("blur", handleBlur); return () => { target.removeEventListener("keydown", handleKeyDown, true); target.removeEventListener("keyup", handleKeyUp, true); window.removeEventListener("blur", handleBlur); pressedKeys.clear(); }; }; /** * 注册键盘快捷键 * @param {string[]} targetKeys - 目标快捷键数组 * @param {() => void} fn - 匹配成功后执行的回调 * @param {EventTarget} target - 监听目标 * @returns {() => void} - 注销函数 */ export const shortcutRegister = (targetKeys = [], fn, target = window) => { if (targetKeys.length === 0) return () => {}; const targetKeySet = new Set(targetKeys); let hasInterference = false; const onKeyDown = (pressedKeys, event) => { // if (isSameSet(targetKeySet, pressedKeys)) { // // event.preventDefault(); // 阻止浏览器的默认行为 // // event.stopPropagation(); // 阻止事件继续(向父元素)冒泡 // fn(); // } if (!targetKeySet.has(event.code)) { hasInterference = true; } }; const onKeyUp = (pressedKeys, event) => { if (isSameSet(targetKeySet, pressedKeys) && !hasInterference) { fn(); } if (pressedKeys.size === 1) { hasInterference = false; } }; return shortcutListener(onKeyDown, onKeyUp, target); }; /** * 高阶函数:为目标函数增加计次和超时重置功能 * @param {() => void} fn - 需要被包装的函数 * @param {number} step - 需要触发的次数 * @param {number} timeout - 超时毫秒数 * @returns {() => void} - 包装后的新函数 */ const withStepCounter = (fn, step, timeout) => { let count = 0; let timer = null; return () => { timer && clearTimeout(timer); timer = setTimeout(() => { count = 0; }, timeout); count++; if (count === step) { count = 0; clearTimeout(timer); fn(); } }; }; /** * 注册连续快捷键 * @param {string[]} targetKeys - 目标快捷键数组 * @param {() => void} fn - 成功回调 * @param {number} step - 连续触发次数 * @param {number} timeout - 每次触发的间隔超时 * @param {EventTarget} target - 监听目标 * @returns {() => void} - 注销函数 */ export const stepShortcutRegister = ( targetKeys = [], fn, step = 2, timeout = 500, target = window ) => { const steppedFn = withStepCounter(fn, step, timeout); return shortcutRegister(targetKeys, steppedFn, target); }; ================================================ FILE: src/libs/storage.js ================================================ import { STOKEY_SETTING, STOKEY_SETTING_OLD, STOKEY_RULES, STOKEY_RULES_OLD, STOKEY_WORDS, STOKEY_FAB, STOKEY_TRANBOX, STOKEY_SYNC, STOKEY_MSAUTH, STOKEY_BDAUTH, STOKEY_RULESCACHE_PREFIX, DEFAULT_SETTING, DEFAULT_RULES, DEFAULT_SYNC, BUILTIN_RULES, } from "../config"; import { isExt, isGm } from "./client"; import { browser } from "./browser"; import { kissLog } from "./log"; import { debounce } from "./utils"; async function set(key, val) { if (isExt) { await browser.storage.local.set({ [key]: val }); } else if (isGm) { await (window.KISS_GM || GM).setValue(key, val); } else { window.localStorage.setItem(key, val); } } async function get(key) { if (isExt) { const val = await browser.storage.local.get([key]); return val[key]; } else if (isGm) { const val = await (window.KISS_GM || GM).getValue(key); return val; } return window.localStorage.getItem(key); } async function del(key) { if (isExt) { await browser.storage.local.remove([key]); } else if (isGm) { await (window.KISS_GM || GM).deleteValue(key); } else { window.localStorage.removeItem(key); } } async function setObj(key, obj) { await set(key, JSON.stringify(obj)); } async function trySetObj(key, obj) { if (!(await get(key))) { await setObj(key, obj); } } async function getObj(key) { const val = await get(key); if (val === null || val === undefined) return null; try { return JSON.parse(val); } catch (err) { kissLog("parse json in storage err: ", key); } return null; } async function putObj(key, obj) { const cur = (await getObj(key)) ?? {}; await setObj(key, { ...cur, ...obj }); } /** * 对storage的封装 */ export const storage = { get, set, del, setObj, trySetObj, getObj, putObj, // onChanged, }; /** * 设置信息 */ export const getSetting = () => getObj(STOKEY_SETTING); export const getSettingOld = () => getObj(STOKEY_SETTING_OLD); export const getSettingWithDefault = async () => ({ ...DEFAULT_SETTING, ...((await getSetting()) || {}), }); export const setSetting = (val) => setObj(STOKEY_SETTING, val); export const putSetting = (obj) => putObj(STOKEY_SETTING, obj); /** * 规则列表 */ export const getRules = () => getObj(STOKEY_RULES); export const getRulesOld = () => getObj(STOKEY_RULES_OLD); export const getRulesWithDefault = async () => (await getRules()) || DEFAULT_RULES; export const setRules = (val) => setObj(STOKEY_RULES, val); /** * 词汇列表 */ export const getWords = () => getObj(STOKEY_WORDS); export const getWordsWithDefault = async () => (await getWords()) || {}; export const setWords = (val) => setObj(STOKEY_WORDS, val); /** * 订阅规则 */ export const getSubRules = (url) => getObj(STOKEY_RULESCACHE_PREFIX + url); export const getSubRulesWithDefault = async () => (await getSubRules()) || []; export const delSubRules = (url) => del(STOKEY_RULESCACHE_PREFIX + url); export const setSubRules = (url, val) => setObj(STOKEY_RULESCACHE_PREFIX + url, val); /** * fab位置 */ export const getFab = () => getObj(STOKEY_FAB); export const getFabWithDefault = async () => (await getFab()) || {}; export const setFab = (obj) => setObj(STOKEY_FAB, obj); export const putFab = (obj) => putObj(STOKEY_FAB, obj); /** * tranbox位置大小 */ export const getTranBox = () => getObj(STOKEY_TRANBOX); export const putTranBox = (obj) => putObj(STOKEY_TRANBOX, obj); export const debouncePutTranBox = debounce(putTranBox, 300); /** * 数据同步 */ export const getSync = () => getObj(STOKEY_SYNC); export const getSyncWithDefault = async () => (await getSync()) || DEFAULT_SYNC; export const putSync = (obj) => putObj(STOKEY_SYNC, obj); export const putSyncMeta = async (key) => { const { syncMeta = {} } = await getSyncWithDefault(); syncMeta[key] = { ...(syncMeta[key] || {}), updateAt: Date.now() }; await putSync({ syncMeta }); }; export const debounceSyncMeta = debounce(putSyncMeta, 300); /** * ms auth */ export const getMsauth = () => getObj(STOKEY_MSAUTH); export const setMsauth = (val) => setObj(STOKEY_MSAUTH, val); /** * baidu auth */ export const getBdauth = () => getObj(STOKEY_BDAUTH); export const setBdauth = (val) => setObj(STOKEY_BDAUTH, val); /** * 存入默认数据 */ export const tryInitDefaultData = async () => { try { await trySetObj(STOKEY_SETTING, DEFAULT_SETTING); await trySetObj(STOKEY_RULES, DEFAULT_RULES); await trySetObj(STOKEY_SYNC, DEFAULT_SYNC); await trySetObj( `${STOKEY_RULESCACHE_PREFIX}${process.env.REACT_APP_RULESURL}`, BUILTIN_RULES ); } catch (err) { kissLog("init default", err); } }; ================================================ FILE: src/libs/stream.js ================================================ import { JSONParser } from "@streamparser/json"; import { OPT_TRANS_OPENAI, OPT_TRANS_GEMINI, OPT_TRANS_GEMINI_2, OPT_TRANS_OPENROUTER, OPT_TRANS_OLLAMA, OPT_TRANS_CLAUDE, } from "../config"; /** * 创建 SSE 流解析器 * 处理 buffer 管理和 SSE 格式解析,逐条 yield 解析出的数据 * @returns {Function} 生成器函数,接收 chunk 逐条 yield data */ export const createSSEParser = () => { let buffer = ""; return function* (chunk) { buffer += chunk; const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (!line.startsWith("data: ")) continue; const data = line.slice(6); if (data === "[DONE]") continue; yield data; } }; }; /** * 创建异步队列,用于在回调式 API 和异步生成器之间桥接 * @returns {Object} 队列对象 */ export const createAsyncQueue = () => { const queue = []; let resolve = null; let done = false; let error = null; return { push: (data) => { queue.push(data); if (resolve) { resolve(); resolve = null; } }, finish: () => { done = true; if (resolve) { resolve(); resolve = null; } }, error: (e) => { error = e; done = true; if (resolve) { resolve(); resolve = null; } }, async *iterate() { const setResolve = (r) => { resolve = r; }; while (!done || queue.length > 0) { if (queue.length > 0) { yield queue.shift(); } else if (!done) { await new Promise(setResolve); } } if (error) throw error; }, }; }; /** * 从流式响应数据中提取 delta 内容 * @param {Object} json 解析后的 SSE 数据 * @param {string} apiType API 类型 * @returns {string} delta 内容 */ export function getStreamDelta(json, apiType) { switch (apiType) { case OPT_TRANS_OPENAI: case OPT_TRANS_GEMINI_2: case OPT_TRANS_OPENROUTER: case OPT_TRANS_OLLAMA: return json.choices?.[0]?.delta?.content || ""; case OPT_TRANS_GEMINI: return json.candidates?.[0]?.content?.parts?.[0]?.text || ""; case OPT_TRANS_CLAUDE: if (json.type === "content_block_delta") { return json.delta?.text || ""; } return ""; default: return ""; } } /** * 解析 SSE 流式响应中的段落(支持 XML、行格式) * @param {string} content 当前累积的内容 * @param {Set} processedIds 已处理的 ID 集合 * @yields {{ id, translation }} 解析出的段落 */ export function* parseStreamingSegments(content, processedIds) { if (!content) return; // 尝试解析 XML 格式: 翻译内容 const xmlRegex = /<(t|item|seg)\s+id="(\d+)"(?:\s+sourceLanguage="([^"]*)")?[^>]*>([\s\S]*?)<\/\1>/gi; let match; let hasXml = false; while ((match = xmlRegex.exec(content)) !== null) { hasXml = true; const id = parseInt(match[2], 10); if (!processedIds.has(id)) { processedIds.add(id); const sourceLanguage = match[3] || ""; const translation = [match[4].trim(), sourceLanguage]; yield { id, translation }; } } if (hasXml) return; // 尝试解析行格式: 0 | 翻译内容 const endsWithNewline = content.endsWith("\n"); const lines = content.split("\n"); const linesToProcess = endsWithNewline ? lines : lines.slice(0, -1); for (const line of linesToProcess) { const trimmedLine = line.trim(); if (!trimmedLine) continue; const pipeMatch = trimmedLine.match(/^(\d+)\s*\|\s*(.*)/); if (pipeMatch) { const id = parseInt(pipeMatch[1], 10); if (!processedIds.has(id)) { processedIds.add(id); const translation = [ pipeMatch[2].trim().replace(//gi, "\n"), "", ]; yield { id, translation }; } } } } /** * 创建流式 JSON 解析器 * 支持的 JSON 格式: * - { "translations": [{ "id": 0, "text": "翻译" }, ...] } * - [{ "id": 0, "text": "翻译" }, ...] * @returns {{ write: Generator, end: Function }} */ export function createStreamingJsonParser() { const pending = []; const parser = new JSONParser({ paths: ["$.translations.*", "$.*"], keepStack: false, }); parser.onValue = ({ value }) => { if ( value && typeof value === "object" && typeof value.id === "number" && (typeof value.text === "string" || typeof value.translation === "string") ) { const id = value.id; const translation = value.text || value.translation || ""; const sourceLanguage = value.sourceLanguage || value.src || ""; pending.push({ id, translation: [translation, sourceLanguage] }); } }; parser.onError = () => {}; return { *write(delta) { try { parser.write(delta); } catch (e) { // 忽略解析错误 } while (pending.length > 0) { yield pending.shift(); } }, end() { try { parser.end(); } catch (e) { // 忽略 } }, }; } /** * 检测流式内容的格式类型 * @param {string} content 累积的内容 * @returns {{ isJson: boolean, detected: boolean }} */ export function detectStreamFormat(content) { const stripped = content.trim(); // 查找第一个有意义的格式标识符位置 const jsonStart = stripped.search(/[{[]/); const xmlStart = stripped.search(/<(t|item|seg)\s/i); const lineStart = stripped.search(/^\d+\s*\|/m); // 如果都没找到,无法确定格式 if (jsonStart === -1 && xmlStart === -1 && lineStart === -1) { return { isJson: false, detected: false }; } // 找出最先出现的格式 const positions = [ { type: "json", pos: jsonStart }, { type: "xml", pos: xmlStart }, { type: "line", pos: lineStart }, ].filter((p) => p.pos !== -1); if (positions.length === 0) { return { isJson: false, detected: false }; } const first = positions.reduce((a, b) => (a.pos < b.pos ? a : b)); return { isJson: first.type === "json", detected: true }; } ================================================ FILE: src/libs/style.js ================================================ import { css, keyframes } from "@emotion/css"; import { OPT_STYLE_NONE, OPT_STYLE_LINE, OPT_STYLE_DOTLINE, OPT_STYLE_DASHLINE, OPT_STYLE_WAVYLINE, OPT_STYLE_DASHBOX, OPT_STYLE_FUZZY, OPT_STYLE_HIGHLIGHT, OPT_STYLE_BLOCKQUOTE, OPT_STYLE_GRADIENT, OPT_STYLE_BLINK, OPT_STYLE_GLOW, OPT_STYLE_COLORFUL, DEFAULT_COLOR, OPT_STYLE_MARKER, OPT_STYLE_GRADIENT_MARKER, OPT_STYLE_DASHBOX_BOLD, OPT_STYLE_DASHLINE_BOLD, OPT_STYLE_WAVYLINE_BOLD, } from "../config"; const gradientFlow = keyframes` to { background-position: 200% center; } `; const blink = keyframes` 0%, 100% { opacity: 1; } 50% { opacity: 0; } `; const glow = keyframes` from { text-shadow: 0 0 10px #fff, 0 0 20px #fff, 0 0 30px #0073e6, 0 0 40px #0073e6; } to { text-shadow: 0 0 20px #fff, 0 0 30px #ff4da6, 0 0 40px #ff4da6, 0 0 50px #ff4da6; } `; const genLineStyle = (style, color, thickness = 1) => ` text-decoration-line: underline; text-decoration-style: ${style}; text-decoration-color: ${color}; text-decoration-thickness: ${thickness}px; text-underline-offset: 0.3em; -webkit-text-decoration-line: underline; -webkit-text-decoration-style: ${style}; -webkit-text-decoration-color: ${color}; -webkit-text-decoration-thickness: 1px; -webkit-text-underline-offset: 0.3em; opacity: 0.8; -webkit-opacity: 0.8; &:hover { opacity: 1; -webkit-opacity: 1; } `; const genBuiltinStyles = (color = DEFAULT_COLOR) => ({ // 无样式 [OPT_STYLE_NONE]: ``, // 下划线 [OPT_STYLE_LINE]: genLineStyle("solid", color), // 点状线 [OPT_STYLE_DOTLINE]: genLineStyle("dotted", color), // 虚线 [OPT_STYLE_DASHLINE]: genLineStyle("dashed", color), // 虚线加粗 [OPT_STYLE_DASHLINE_BOLD]: genLineStyle("dashed", color, 2), // 波浪线 [OPT_STYLE_WAVYLINE]: genLineStyle("wavy", color), // 波浪线加粗 [OPT_STYLE_WAVYLINE_BOLD]: genLineStyle("wavy", color, 2), // 虚线框 [OPT_STYLE_DASHBOX]: ` border: 1px dashed ${color}; display: block; padding: 0.2em 0.3em; box-sizing: border-box; `, // 虚线框加粗 [OPT_STYLE_DASHBOX_BOLD]: ` border: 2px dashed ${color}; display: block; padding: 0.2em 0.3em; box-sizing: border-box; `, // 马克笔 [OPT_STYLE_MARKER]: ` background: linear-gradient(to top, ${color} 50%, transparent 50%); `, // 渐变马克笔 [OPT_STYLE_GRADIENT_MARKER]: ` background: linear-gradient(to top, transparent, ${color} 20%, transparent 60%); `, // 模糊 [OPT_STYLE_FUZZY]: ` filter: blur(0.2em); -webkit-filter: blur(0.2em); &:hover { filter: none; -webkit-filter: none; } `, // 高亮 [OPT_STYLE_HIGHLIGHT]: ` color: #fff; background-color: ${color}; `, // 引用 [OPT_STYLE_BLOCKQUOTE]: ` opacity: 0.8; -webkit-opacity: 0.8; display: block; padding: 0.25em 0.5em; border-left: 0.25em solid ${color}; background: rgb(32, 156, 238, 0.2); &:hover { opacity: 1; -webkit-opacity: 1; } `, // 渐变 [OPT_STYLE_GRADIENT]: ` background-image: linear-gradient( 90deg, #3b82f6, #9333ea, #ec4899, #3b82f6 ); background-size: 200% auto; color: transparent; -webkit-background-clip: text; background-clip: text; animation: ${gradientFlow} 4s linear infinite; `, // 闪现 [OPT_STYLE_BLINK]: ` animation: ${blink} 1s infinite; `, // 发光 [OPT_STYLE_GLOW]: ` animation: ${glow} 2s ease-in-out infinite alternate; `, // 多彩 [OPT_STYLE_COLORFUL]: ` color: #333; background: linear-gradient( 45deg, LightGreen 20%, LightPink 20% 40%, LightSalmon 40% 60%, LightSeaGreen 60% 80%, LightSkyBlue 80% ); &:hover { color: #111; }; `, }); export const genTextClass = (customStyles = []) => { const styles = genBuiltinStyles(); customStyles.forEach((style) => { styles[style.styleSlug] = style.styleCode; }); const textClass = {}; let textStyles = ""; Object.entries(styles).forEach(([k, v]) => { textClass[k] = css` ${v} `; }); Object.entries(styles).forEach(([k, v]) => { textStyles += ` .${textClass[k]} { ${v} } `; }); return [textClass, textStyles]; }; export const builtinStylesMap = genBuiltinStyles(); ================================================ FILE: src/libs/subRules.js ================================================ import { GLOBAL_KEY } from "../config"; import { getSyncWithDefault, putSync, setSubRules, getSubRules, } from "./storage"; import { apiFetch } from "../apis"; import { checkRules } from "./rules"; import { isAllchar } from "./utils"; import { kissLog } from "./log"; /** * 更新缓存同步时间 * @param {*} url */ const updateSyncDataCache = async (url) => { const { dataCaches = {} } = await getSyncWithDefault(); dataCaches[url] = Date.now(); await putSync({ dataCaches }); }; /** * 同步订阅规则 * @param {*} url * @returns */ export const syncSubRules = async (url) => { const res = await apiFetch(url); const rules = checkRules(res).filter( ({ pattern }) => !isAllchar(pattern, GLOBAL_KEY) ); if (rules.length > 0) { await setSubRules(url, rules); } return rules; }; /** * 同步所有订阅规则 * @param {*} url * @returns */ export const syncAllSubRules = async (subrulesList) => { for (const subrules of subrulesList) { try { await syncSubRules(subrules.url); await updateSyncDataCache(subrules.url); } catch (err) { kissLog(`sync subrule error: ${subrules.url}`, err); } } }; /** * 根据时间同步所有订阅规则 * @param {*} url * @returns */ export const trySyncAllSubRules = async ({ subrulesList }) => { try { const { subRulesSyncAt } = await getSyncWithDefault(); const now = Date.now(); const interval = 24 * 60 * 60 * 1000; // 间隔一天 if (now - subRulesSyncAt > interval) { // 同步订阅规则 await syncAllSubRules(subrulesList); await putSync({ subRulesSyncAt: now }); } } catch (err) { kissLog("try sync all subrules", err); } }; /** * 从缓存或远程加载订阅规则 * @param {*} url * @returns */ export const loadOrFetchSubRules = async (url) => { let rules = await getSubRules(url); if (!rules || rules.length === 0) { rules = await syncSubRules(url); await updateSyncDataCache(url); } return rules || []; }; ================================================ FILE: src/libs/svg.js ================================================ export const loadingSvg = ` `; function createSVGElement(tag, attributes) { const svgNS = "http://www.w3.org/2000/svg"; const el = document.createElementNS(svgNS, tag); for (const key in attributes) { el.setAttribute(key, attributes[key]); } return el; } /** * 创建loding动画 * @returns */ export function createLoadingSVG() { const svg = createSVGElement("svg", { viewBox: "-20 0 100 100", style: "display: inline-block; width: 1em; height: 1em; vertical-align: middle;", }); const circleData = [ { cx: "6", begin: "0.1", values: "0 15 ; 0 -15; 0 15" }, { cx: "30", begin: "0.2", values: "0 10 ; 0 -10; 0 10" }, { cx: "54", begin: "0.3", values: "0 5 ; 0 -5; 0 5" }, ]; circleData.forEach((data) => { const circle = createSVGElement("circle", { fill: "#209CEE", stroke: "none", cx: data.cx, cy: "50", r: "6", }); const animation = createSVGElement("animateTransform", { attributeName: "transform", dur: "1s", type: "translate", values: data.values, repeatCount: "indefinite", begin: data.begin, }); circle.appendChild(animation); svg.appendChild(circle); }); return svg; } /** * 创建logo * @param {*} param0 * @returns */ export function createLogoSVG({ width = "24", height = "24", viewBox = "-5 -5 40 40", isSelected = false, } = {}) { const svg = createSVGElement("svg", { xmlns: "http://www.w3.org/2000/svg", width, height, viewBox, version: "1.1", }); const primaryColor = "#209CEE"; const secondaryColor = "#E9F5FD"; const path1Fill = isSelected ? secondaryColor : primaryColor; const path2Fill = isSelected ? primaryColor : secondaryColor; const path1 = createSVGElement("path", { d: "M0 0 C10.56 0 21.12 0 32 0 C32 10.56 32 21.12 32 32 C21.44 32 10.88 32 0 32 C0 21.44 0 10.88 0 0 Z ", fill: path1Fill, transform: "translate(0,0)", }); const path2 = createSVGElement("path", { d: "M0 0 C0.66 0 1.32 0 2 0 C2 2.97 2 5.94 2 9 C2.969375 8.2575 3.93875 7.515 4.9375 6.75 C5.48277344 6.33234375 6.02804688 5.9146875 6.58984375 5.484375 C8.39053593 3.83283924 8.39053593 3.83283924 9 0 C13.95 0 18.9 0 24 0 C24 0.99 24 1.98 24 3 C22.68 3 21.36 3 20 3 C20 9.27 20 15.54 20 22 C19.01 22 18.02 22 17 22 C17 15.73 17 9.46 17 3 C15.35 3 13.7 3 12 3 C11.731875 3.598125 11.46375 4.19625 11.1875 4.8125 C10.01506533 6.97224808 8.80630718 8.35790256 7 10 C8.01790655 12.27071461 8.77442829 13.80784632 10.6875 15.4375 C11.120625 15.953125 11.55375 16.46875 12 17 C11.6875 19.6875 11.6875 19.6875 11 22 C10.34 22 9.68 22 9 22 C8.773125 21.236875 8.54625 20.47375 8.3125 19.6875 C6.73268318 16.45263699 5.16717283 15.58358642 2 14 C2 16.64 2 19.28 2 22 C1.34 22 0.68 22 0 22 C0 14.74 0 7.48 0 0 Z ", fill: path2Fill, transform: "translate(4,5)", }); svg.appendChild(path1); svg.appendChild(path2); return svg; } ================================================ FILE: src/libs/sync.js ================================================ import { APP_LCNAME, KV_SETTING_KEY, KV_RULES_KEY, KV_WORDS_KEY, KV_RULES_SHARE_KEY, KV_SALT_SHARE, OPT_SYNCTYPE_WEBDAV, } from "../config"; import { getSyncWithDefault, putSync, getSettingWithDefault, getRulesWithDefault, getWordsWithDefault, setSetting, setRules, setWords, } from "./storage"; import { apiSyncData } from "../apis"; import { sha256, removeEndchar } from "./utils"; import { createClient, getPatcher } from "webdav"; import { fetchPatcher } from "./fetch"; import { kissLog } from "./log"; getPatcher().patch("request", (opts) => { return fetchPatcher(opts.url, { method: opts.method, headers: opts.headers, body: opts.data, }); }); const syncByWebdav = async (data, { syncUrl, syncUser, syncKey }) => { const client = createClient(syncUrl, { username: syncUser, password: syncKey, }); const pathname = `/${APP_LCNAME}`; const filename = `/${APP_LCNAME}/${data.key}`; if ((await client.exists(pathname)) === false) { await client.createDirectory(pathname); } const isExist = await client.exists(filename); if (isExist) { const cont = await client.getFileContents(filename, { format: "text" }); const webData = JSON.parse(cont); if (webData.updateAt >= data.updateAt) { return webData; } } await client.putFileContents(filename, JSON.stringify(data, null, 2)); return data; }; const syncByWorker = async (data, { syncUrl, syncKey }) => { syncUrl = removeEndchar(syncUrl, "/"); return await apiSyncData(`${syncUrl}/sync`, syncKey, data); }; export const syncData = async (key, value) => { const { syncType, syncUrl, syncUser, syncKey, syncMeta = {}, } = await getSyncWithDefault(); if (!syncUrl || !syncKey || (syncType === OPT_SYNCTYPE_WEBDAV && !syncUser)) { // throw new Error("sync args err"); return; } let { updateAt = 0, syncAt = 0 } = syncMeta[key] || {}; if (syncAt === 0) { updateAt = 0; // 没有同步过,更新时间置零 } const data = { key, value: JSON.stringify(value), updateAt, }; const args = { syncUrl, syncUser, syncKey, }; const res = syncType === OPT_SYNCTYPE_WEBDAV ? await syncByWebdav(data, args) : await syncByWorker(data, args); if (!res) { throw new Error("sync data got err", key); } const newVal = JSON.parse(res.value); const isNew = res.updateAt > updateAt; syncMeta[key] = { updateAt: res.updateAt, syncAt: Date.now(), }; await putSync({ syncMeta }); return { value: newVal, isNew }; }; /** * 同步设置 * @returns */ const syncSetting = async () => { const value = await getSettingWithDefault(); const res = await syncData(KV_SETTING_KEY, value); if (res?.isNew) { await setSetting(res.value); } }; export const trySyncSetting = async () => { try { await syncSetting(); } catch (err) { kissLog("sync setting", err.message); } }; /** * 同步规则 * @returns */ const syncRules = async () => { const value = await getRulesWithDefault(); const res = await syncData(KV_RULES_KEY, value); if (res?.isNew) { await setRules(res.value); } }; export const trySyncRules = async () => { try { await syncRules(); } catch (err) { kissLog("sync user rules", err.message); } }; /** * 同步词汇 * @returns */ const syncWords = async () => { const value = await getWordsWithDefault(); const res = await syncData(KV_WORDS_KEY, value); if (res?.isNew) { await setWords(res.value); } }; export const trySyncWords = async () => { try { await syncWords(); } catch (err) { kissLog("sync fav words", err.message); } }; /** * 同步分享规则 * @param {*} param0 * @returns */ export const syncShareRules = async ({ rules, syncUrl, syncKey }) => { const data = { key: KV_RULES_SHARE_KEY, value: JSON.stringify(rules, null, 2), updateAt: Date.now(), }; const args = { syncUrl, syncKey, }; await syncByWorker(data, args); const psk = await sha256(syncKey, KV_SALT_SHARE); const shareUrl = `${syncUrl}/rules?psk=${psk}`; return shareUrl; }; /** * 同步个人设置和规则 * @returns */ export const syncSettingAndRules = async () => { await syncSetting(); await syncRules(); await syncWords(); }; export const trySyncSettingAndRules = async () => { await trySyncSetting(); await trySyncRules(); await trySyncWords(); }; ================================================ FILE: src/libs/touch.js ================================================ export function touchTapListener(fn, options = {}) { const config = { taps: 2, fingers: 1, delay: 300, ...options, }; let maxTouches = 0; let tapCount = 0; let tapTimer = null; const handleTouchStart = (e) => { maxTouches = Math.max(maxTouches, e.touches.length); }; const handleTouchend = (e) => { if (e.touches.length === 0) { if (maxTouches === config.fingers) { tapCount++; clearTimeout(tapTimer); if (tapCount === config.taps) { fn(e); tapCount = 0; } else { tapTimer = setTimeout(() => { tapCount = 0; }, config.delay); } } else { tapCount = 0; clearTimeout(tapTimer); } maxTouches = 0; } }; document.addEventListener("touchstart", handleTouchStart, { passive: true }); document.addEventListener("touchend", handleTouchend, { passive: true }); return () => { clearTimeout(tapTimer); document.removeEventListener("touchstart", handleTouchStart); document.removeEventListener("touchend", handleTouchend); }; } ================================================ FILE: src/libs/tranbox.js ================================================ import React from "react"; import ReactDOM from "react-dom/client"; import createCache from "@emotion/cache"; import { CacheProvider } from "@emotion/react"; import Slection from "../views/Selection"; import { DEFAULT_TRANBOX_SETTING, APP_CONSTS } from "../config"; export class TransboxManager { #container = null; #reactRoot = null; #shadowContainer = null; #props = {}; constructor(initialProps = {}) { this.#props = initialProps; const { tranboxSetting = DEFAULT_TRANBOX_SETTING } = this.#props; if (tranboxSetting?.transOpen) { this.enable(); } } isEnabled() { return !!this.#container && document.body.contains(this.#container); } enable() { if (!this.isEnabled()) { this.#container = document.createElement("div"); this.#container.id = APP_CONSTS.boxID; this.#container.className = "notranslate"; document.body.appendChild(this.#container); this.#shadowContainer = this.#container.attachShadow({ mode: "open" }); const shadowRootElement = document.createElement("div"); shadowRootElement.className = `${APP_CONSTS.boxID}_wrapper notranslate`; this.#shadowContainer.appendChild(shadowRootElement); const cache = createCache({ key: APP_CONSTS.boxID, prepend: true, container: this.#shadowContainer, }); this.#reactRoot = ReactDOM.createRoot(shadowRootElement); this.#reactRoot.render( ); } } disable() { if (!this.isEnabled() || !this.#reactRoot) { return; } this.#reactRoot.unmount(); this.#container.remove(); this.#container = null; this.#reactRoot = null; this.#shadowContainer = null; } toggle() { if (this.isEnabled()) { this.disable(); } else { this.enable(); } } update(newProps) { this.#props = { ...this.#props, ...newProps }; if (this.isEnabled()) { if (!this.#props.tranboxSetting?.transOpen) { this.disable(); } else { this.enable(); } } } } ================================================ FILE: src/libs/translator.js ================================================ import { APP_LCNAME, APP_CONSTS, OPT_STYLE_FUZZY, GLOBLA_RULE, DEFAULT_SETTING, // DEFAULT_MOUSEHOVER_KEY, OPT_STYLE_NONE, DEFAULT_API_SETTING, OPT_HIGHLIGHT_WORDS_BEFORETRANS, OPT_HIGHLIGHT_WORDS_AFTERTRANS, OPT_SPLIT_PARAGRAPH_PUNCTUATION, OPT_SPLIT_PARAGRAPH_DISABLE, OPT_SPLIT_PARAGRAPH_TEXTLENGTH, MSG_INJECT_CSS, MSG_UPDATE_ICON, OPT_LANGS_TO_SPEC, } from "../config"; import { interpreter } from "./interpreter"; import { clearFetchPool } from "./pool"; import { debounce, scheduleIdle, genEventName, truncateWords, escapeHTML, } from "./utils"; import { apiTranslate } from "../apis"; import { kissLog } from "./log"; import { clearAllBatchQueue } from "./batchQueue"; import { genTextClass } from "./style"; import { createLoadingSVG } from "./svg"; import { shortcutRegister } from "./shortcut"; import { tryDetectLang } from "./detect"; import { trustedTypesHelper } from "./trustedTypes"; import { injectJs, INJECTOR } from "../injectors"; import { injectInternalCss } from "./injector"; import { isExt } from "./client"; import { sendBgMsg } from "./msg"; import { getDocInfo } from "./docInfo"; /** * @class Translator * @description 翻译核心逻辑封装 */ export class Translator { static displayCache = new WeakMap(); static TAGS = { BREAK_LINE: new Set(["BR", "WBR"]), BLOCK: new Set([ "ADDRESS", "ARTICLE", "ASIDE", "BLOCKQUOTE", "CANVAS", "DD", "DIV", "DL", "DT", "FIELDSET", "FIGCAPTION", "FIGURE", "FOOTER", "FORM", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER", "HR", "LI", "MAIN", "NAV", "NOSCRIPT", "OL", "P", "PRE", "SECTION", "TABLE", "TFOOT", "UL", "VIDEO", ]), INLINE: new Set([ // "A", "ABBR", "ACRONYM", "B", "BDO", "BIG", "BR", "BUTTON", "CITE", "CODE", "DFN", "DEL", "FONT", "EM", "I", "IMG", "INPUT", "INS", "KBD", "LABEL", "MAP", "MARK", "OBJECT", "OUTPUT", "Q", "RUBY", "SAMP", "SCRIPT", "SELECT", "SMALL", // "SPAN", "STRONG", "SUB", "SUP", "TEXTAREA", "TIME", "TT", "U", "VAR", ]), REPLACE: new Set([ "ABBR", "CODE", "DFN", "IMG", "KBD", "OUTPUT", "RP", "RT", "SAMP", "SUB", "SUP", "SVG", "TIME", "VAR", ]), WARP: new Set([ "A", "B", "BDO", "BDI", "BIG", "CITE", "DEL", "EM", "FONT", "I", "INS", "MARK", "Q", "RUBY", "S", "SMALL", "SPAN", "STRONG", "U", ]), }; // 译文相关class static KISS_CLASS = { warpper: `${APP_LCNAME}-wrapper`, inner: `${APP_LCNAME}-inner`, term: `${APP_LCNAME}-term`, br: `${APP_LCNAME}-br`, highlight: `${APP_LCNAME}-highlight`, }; // 内置跳过翻译文本 // todo: 验证有效性 static BUILTIN_SKIP_PATTERNS = [ // 1. URL (覆盖 http, https, ftp, file 协议) /^(?:(?:https?|ftp|file):\/\/|www\.)[^\s/$.?#].[^\s]*$/i, // 2. 邮箱地址 /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, // 3. 文件路径 (为 Unix 和 Windows 做了简化) /^(?:[a-zA-Z]:\\|\/|\\)(?:[\w\-. ]+\/|[\w\-. ]+\\)*[\w\-. ]*\.?[\w\-. ]*$/, // 4. UUID (通用唯一标识符) /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, // 5. 纯数字字符串 (整数, 浮点数, 包含常见分隔符) // 同时也处理单位 (如 px, %, em, rem 等) 和货币符号。 /^[$\u00A2-\u00A5\u20A0-\u20CF]?\s?-?\d{1,3}(?:[.,]\d{3})*(?:[.,]\d+)?\s?(?:px|%|em|rem|pt|vw|vh|deg|s|ms)?$/, // 6. 版本号 (例如 v1.2.3, 10.0.1) /^v?\d+(\.\d+){1,3}$/, // 7. ISO 8601 日期/时间格式 /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/, // 8. 模板占位符 (例如 {{var}}, ${var}, __VAR__) /^({{[^}]+}}|\${[^}]+}|__\w+__|%\w+)$/, // 9. CSS 选择器 (简单的 class/ID) 和十六进制颜色值 /^(?:\.|#)[\w-]+$|^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, // 10. 用户名 (例如 @username, @user.name, @user-name) - [已修改] /^@[\w.-]+$/, // 11. HTML 实体 /^&\w+;$/, // 12. 中括号包裹的序号 (例如 [1], [99]) /^\[\d+\]$/, // 13. 简单时间格式 (例如 12:30, 9:45:30) - [新增] /^\d{1,2}:\d{2}(:\d{2})?$/, // 14. 包含常见扩展名的文件名 (例如: document.pdf, image.jpeg) /^[^\s\\/:]+?\.[a-zA-Z0-9]{2,5}$/, // todo: 数字和特殊字符组成的字符串 ]; static DEFAULT_OPTIONS = DEFAULT_SETTING; // 默认配置 static DEFAULT_RULE = GLOBLA_RULE; // 默认规则 static isElement(el) { return el instanceof Element; } static isElementOrFragment(el) { return el instanceof Element || el instanceof DocumentFragment; } // 判断是否块级元素 static isBlockNode(el) { if (!Translator.isElementOrFragment(el)) return false; if (el.attributes?.display?.value?.includes("inline")) return false; if (Translator.TAGS.INLINE.has(el.nodeName?.toUpperCase())) return false; if (Translator.TAGS.BLOCK.has(el.nodeName?.toUpperCase())) return true; if (Translator.displayCache.has(el)) { return Translator.displayCache.get(el); } const isBlock = !window.getComputedStyle(el).display.startsWith("inline"); Translator.displayCache.set(el, isBlock); return isBlock; } // 判断是否包含块级子元素 static hasBlockNode(el) { if (!Translator.isElementOrFragment(el)) return false; for (const child of el.childNodes) { if (Translator.isBlockNode(child)) { return true; } } return false; } // 判断是否直接包含非空文本节点 static hasTextNode(el) { if (!Translator.isElementOrFragment(el)) return false; for (const child of el.childNodes) { if (child.nodeType === Node.TEXT_NODE && /\S/.test(child.nodeValue)) { return true; } } return false; } // 特殊字符转义 static escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } // 内置忽略元素 static KISS_IGNORE_SELECTOR = `.${Translator.KISS_CLASS.warpper}, .kiss-caption-container, .kiss-subtitle-controls, #kiss-youtube-subtitle-list-container, #${APP_CONSTS.fabID}, .${APP_CONSTS.fabID}_warpper, #${APP_CONSTS.boxID}, .${APP_CONSTS.boxID}_warpper, #${APP_CONSTS.popupID}, .${APP_CONSTS.popupID}_warpper`; static BUILTIN_IGNORE_SELECTOR = `address, area, audio, br, canvas, data, datalist, embed, head, iframe, input, noscript, map, object, option, param, picture, progress, select, script, style, track, textarea, template, video, wbr, .notranslate, [contenteditable='true'], [translate='no']`; #setting; // 设置选项 #rule; // 规则 #isInitialized = false; // 初始化状态 #isJsInjected = false; // 注入用户JS #isShadowRootJsInjected = false; // #mouseHoverEnabled = false; // 鼠标悬停翻译 #enabled = false; // 全局默认状态 #runId = 0; // 用于中止过期的异步请求 #termValues = []; // 按顺序存储术语的替换值 #combinedTermsRegex; // 专业术语正则表达式 #combinedSkipsRegex; // 跳过文本正则表达式 #placeholderCache = null; // 缓存正则对象 #translationTagName = APP_LCNAME; // 翻译容器的标签名 #eventName = ""; // 通信事件名称 #docInfo = {}; // 网页信息 #glossary = {}; // AI词典 #textClass = {}; // 译文样式class #textSheet = ""; // 译文样式字典 #apisMap = new Map(); // 用于接口快速查找 #favWords = []; // 收藏词汇 #observedNodes = new WeakSet(); // 存储所有被识别出的、可翻译的 DOM 节点单元 #translationNodes = new WeakMap(); // 存储所有插入到页面的译文节点 #viewNodes = new Set(); // 当前在可视范围内的单元 #processedNodes = new WeakMap(); // 已处理(已执行翻译DOM操作)的单元 #rootNodes = new Set(); // 已监控的根节点 #skipMoNodes = new WeakSet(); // 忽略变化的节点 #removeKeydownHandler; // 快捷键清理函数 #hoveredNode = null; // 存储当前悬停的可翻译节点 #boundMouseMoveHandler; // 鼠标事件 #boundKeyDownHandler; // 键盘事件 #windowMessageHandler = null; #debouncedFindShadowRoot = null; #io; // IntersectionObserver #mo; // MutationObserver #dmm; // DebounceMouseMover #rescanQueue = new Set(); // “脏容器”队列 #isQueueProcessing = false; // 队列处理状态标志 // 忽略元素 get #ignoreSelector() { if (this.#rule.scanAll === "true" || this.#rule.isPlainText) { return Translator.KISS_IGNORE_SELECTOR; } const selectors = [Translator.KISS_IGNORE_SELECTOR]; if (this.#rule.autoScan !== "false") { selectors.push(Translator.BUILTIN_IGNORE_SELECTOR); } const userSelector = this.#rule.ignoreSelector?.trim(); if (userSelector) { selectors.push(userSelector); } return selectors.join(", "); } // 接口参数 // todo: 不用频繁查找计算 get #apiSetting() { // return ( // this.#setting.transApis.find( // (api) => api.apiSlug === this.#rule.apiSlug // ) || DEFAULT_API_SETTING // ); return this.#apisMap.get(this.#rule.apiSlug) || DEFAULT_API_SETTING; } // 占位符配置(包含正则) get #placeholderConfig() { if (this.#placeholderCache) { return this.#placeholderCache; } const [startDelimiter, endDelimiter] = this.#apiSetting.placeholder.split(" "); // 确保 placetag 始终是字符串(兼容旧配置可能是数组) let tagName = this.#apiSetting.placetag; if (Array.isArray(tagName)) { tagName = tagName[0] || "i"; } if (typeof tagName !== "string") { tagName = "i"; // 默认值 } const format = this.#apiSetting.placetagFormat || "compact"; // 占位符格式 const safeTag = "span"; // 1. 缓存常用还原正则 let openRegex, closeRegex; if (format === "attribute") { openRegex = new RegExp(`<${tagName}\\s+i=(\\d+)>`, "gi"); closeRegex = new RegExp(`<\\/${tagName}>`, "gi"); } else { openRegex = new RegExp(`<${tagName}(\\d+)>`, "gi"); closeRegex = new RegExp(`<\\/${tagName}(\\d+)>`, "gi"); } // 2. 创建普通占位符正则(标签占位符在restoreFromTranslation中单独处理) // 只匹配普通占位符 {{1}}, {{2}} 等 const escapedStart = Translator.escapeRegex(startDelimiter); const escapedEnd = Translator.escapeRegex(endDelimiter); const placeholderPattern = `${escapedStart}\\d+${escapedEnd}`; const placeholderRegex = new RegExp(placeholderPattern, "g"); const result = { startDelimiter, endDelimiter, tagName, format, safeTag, openRegex, closeRegex, placeholderRegex, }; this.#placeholderCache = result; return result; } constructor({ rule = {}, setting = {}, favWords = [] }) { this.#setting = { ...Translator.DEFAULT_OPTIONS, ...setting }; this.#rule = { ...Translator.DEFAULT_RULE, ...rule, isPlainText: false }; this.#favWords = favWords; this.#apisMap = new Map( this.#setting.transApis.map((api) => [api.apiSlug, api]) ); this.#eventName = genEventName(); this.#combinedSkipsRegex = new RegExp( Translator.BUILTIN_SKIP_PATTERNS.map((r) => `(${r.source})`).join("|") ); this.#parseTerms(this.#rule.terms); this.#parseAITerms(this.#rule.aiTerms); this.#createTextStyles(); this.#boundMouseMoveHandler = this.#handleMouseMove.bind(this); this.#boundKeyDownHandler = this.#handleKeyDown.bind(this); this.#io = this.#createIntersectionObserver(); this.#mo = this.#createMutationObserver(); this.#dmm = this.#createDebounceMouseMover(); this.#windowMessageHandler = this.#handleWindowMessage.bind(this); this.#debouncedFindShadowRoot = debounce( this.#findAndObserveShadowRoot.bind(this), 300 ); // 鼠标悬停翻译 if (this.#setting.mouseHoverSetting.useMouseHover) { this.#enableMouseHover(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => this.#run()); } else { this.#run(); } } // 启动 #run() { if (this.#rule.transOpen === "true") { this.enable(); } else if (this.#setting.preInit) { this.#init(); } } // 初始化 #init() { this.#isInitialized = true; // 注入JS/CSS this.#initInjector(); // 纯文本预处理 if (this.#rule.isPlainText) { document .querySelectorAll("pre") .forEach( (pre) => (pre.innerHTML = pre.innerHTML?.replace( /(?:\r\n|\r|\n)/g, "
" )) ); } // 查找根节点并扫描 document .querySelectorAll(this.#rule.rootsSelector || "body") .forEach((root) => { this.#startObserveRoot(root); }); if (this.#rule.scanAll === "true" || this.#rule.hasShadowroot === "true") { this.#attachShadowRootListener(); this.#findAndObserveShadowRoot(); } } #handleWindowMessage(event) { if (event.data?.type === "KISS_SHADOW_ROOT_CREATED") { this.#debouncedFindShadowRoot(); } } #attachShadowRootListener() { if (!this.#isShadowRootJsInjected) { const id = "kiss-translator-inject-shadowroot-js"; injectJs(INJECTOR.shadowroot, id); this.#isShadowRootJsInjected = true; } window.addEventListener("message", this.#windowMessageHandler); } #removeShadowRootListener() { window.removeEventListener("message", this.#windowMessageHandler); } // 查找现有的所有shadowroot #findAndObserveShadowRoot() { try { this.#findAllShadowRoots().forEach((shadowRoot) => { this.#startObserveShadowRoot(shadowRoot); }); } catch (err) { kissLog("findAllShadowRoots", err); } } // 创建样式 #createTextStyles() { const [textClass, textStyles] = genTextClass(this.#setting.customStyles); const textSheet = new CSSStyleSheet(); textSheet.replaceSync(textStyles); this.#textClass = textClass; this.#textSheet = textSheet; } // 注入样式 #injectSheet(shadowRoot) { if (!shadowRoot.adoptedStyleSheets.includes(this.#textSheet)) { shadowRoot.adoptedStyleSheets = [ ...shadowRoot.adoptedStyleSheets, this.#textSheet, ]; } } // 解析专业术语字符串 #parseTerms(termsString) { this.#termValues = []; this.#combinedTermsRegex = null; if (!termsString || typeof termsString !== "string") return; const termPatterns = []; const lines = termsString.split(/\n|;/); // 按换行或分号分割 for (const line of lines) { const trimmedLine = line.trim(); if (!trimmedLine) continue; let lastCommaIndex = trimmedLine.lastIndexOf(","); if (lastCommaIndex === -1) { lastCommaIndex = trimmedLine.length; } const key = trimmedLine.substring(0, lastCommaIndex).trim(); const value = trimmedLine.substring(lastCommaIndex + 1).trim(); if (key) { try { new RegExp(key); termPatterns.push(`(${key})`); this.#termValues.push(value); } catch (err) { kissLog(`Invalid RegExp for term: "${key}"`, err); } } } if (termPatterns.length > 0) { this.#combinedTermsRegex = new RegExp(termPatterns.join("|"), "g"); } } #parseAITerms(termsString) { if (!termsString || typeof termsString !== "string") return; try { this.#glossary = Object.fromEntries( termsString .split(/\n|;/) .map((line) => { const [k = "", v = ""] = line.split(",").map((s) => s.trim()); return [k, v]; }) .filter(([k]) => k) ); } catch (err) { kissLog("parse aiterms", err); } } // todo: 利用AI总结 #getDocDescription() { try { const meta = document.querySelector('meta[name="description"]'); const description = meta?.getAttribute("content") || ""; return truncateWords(description); } catch (err) { kissLog("get description", err); } return ""; } // 监控翻译单元的可见性 #createIntersectionObserver() { const { transInterval, rootMargin = 500 } = this.#setting; const pending = new Set(); const flush = debounce(() => { pending.forEach((node) => this.#performSyncNode(node)); pending.clear(); }, transInterval); return new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { this.#viewNodes.add(entry.target); pending.add(entry.target); flush(); } else { this.#viewNodes.delete(entry.target); } }); }, { threshold: 0.01, rootMargin: `${rootMargin}px 0px ${rootMargin}px 0px` } ); } // 监控页面动态变化 #createMutationObserver() { return new MutationObserver((mutations) => { for (const mutation of mutations) { if ( this.#skipMoNodes.has(mutation.target) || mutation.nextSibling?.tagName?.toLowerCase() === this.#translationTagName ) { continue; } if (mutation.type === "characterData") { if ( mutation.oldValue !== mutation.target.nodeValue && !this.#combinedSkipsRegex.test(mutation.target.nodeValue) ) { this.#queueForRescan(mutation.target.parentElement); } } else if (mutation.type === "childList") { let nodes = new Set(); let hasText = false; mutation.addedNodes.forEach((node) => { if ( this.#skipMoNodes.has(node) || node.nodeName?.toLowerCase() === this.#translationTagName ) { return; } if (node.nodeType === Node.TEXT_NODE) { hasText = true; } else if (Translator.isElementOrFragment(node)) { nodes.add(node); } }); if (hasText) { this.#queueForRescan(mutation.target); } else { nodes.forEach((node) => this.#queueForRescan(node)); } } } }); } // 节流的鼠标悬停事件 #createDebounceMouseMover() { return debounce((targetNode) => { const startNode = targetNode; let foundNode = null; while (targetNode && targetNode !== document.body) { if (this.#observedNodes.has(targetNode)) { foundNode = targetNode; break; } targetNode = targetNode.parentElement; } this.#hoveredNode = foundNode || startNode; const { mouseHoverKey } = this.#setting.mouseHoverSetting; if (mouseHoverKey.length === 0 && !this.#isInitialized) { this.#init(); } if (mouseHoverKey.length === 0 && foundNode) { this.#toggleTargetNode(foundNode); } }, 100); } // 跟踪鼠标下的可翻译节点 #handleMouseMove(event) { let targetNode = event.composedPath()[0]; this.#dmm(targetNode); } // 快捷键按下时的处理器 #handleKeyDown() { if (!this.#isInitialized) { this.#init(); } let targetNode = this.#hoveredNode; if (!targetNode || !this.#observedNodes.has(targetNode)) return; this.#toggleTargetNode(targetNode); } // 触发段落翻译 toggleHoverNode() { this.#handleKeyDown(); } // 切换节点翻译状态 #toggleTargetNode(targetNode) { if (this.#processedNodes.has(targetNode)) { this.#cleanupDirectTranslations(targetNode); } else { this.#processNode(targetNode); } } // 获取元素的 shadowRoot(支持 closed 模式) #getShadowRoot(element) { // Firefox 原生支持 if (element.openOrClosedShadowRoot) { return element.openOrClosedShadowRoot; } // Chrome 扩展 API if ( typeof globalThis !== "undefined" && globalThis.chrome?.dom?.openOrClosedShadowRoot ) { return globalThis.chrome.dom.openOrClosedShadowRoot(element); } // 标准 API(只能获取 open 模式) return element.shadowRoot; } // 找页面所有 ShadowRoot #findAllShadowRoots(root = document.body, results = new Set()) { // const start = performance.now(); try { const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); while (walker.nextNode()) { const node = walker.currentNode; const shadowRoot = this.#getShadowRoot(node); if (shadowRoot) { results.add(shadowRoot); this.#findAllShadowRoots(shadowRoot, results); } } } catch (err) { kissLog("无法访问某个 shadowRoot", err); } // const end = performance.now(); // const duration = end - start; // console.log(`findAllShadowRoots 耗时:${duration} 毫秒`); return results; } // 向上查找发生变化的块级元素 #findChangeContainer(startNode) { if ( !Translator.isElementOrFragment(startNode) || startNode.closest?.(this.#ignoreSelector) ) { return null; } let current = startNode; while (current && current !== document.body) { if (Translator.isBlockNode(current) || this.#observedNodes.has(current)) { // 确保找到的容器在我们监控的根节点内 for (const root of this.#rootNodes) { if (root.contains(current)) { return current; } } } current = current.parentElement; } return null; } // “脏容器”队列 #queueForRescan(target) { this.#rescanQueue.add(target); if (!this.#isQueueProcessing) { this.#isQueueProcessing = true; scheduleIdle(() => { this.#rescanQueue.forEach((t) => this.#rescanContainer(t)); this.#rescanQueue.clear(); this.#isQueueProcessing = false; }, 100); } } // 处理“脏容器” #rescanContainer(changedNode) { const container = this.#findChangeContainer(changedNode); if (!container) return; this.#cleanupAllTranslations(container); this.#scanNode(container); } // 重新观察 #reIO(node) { this.#io.unobserve(node); this.#io.observe(node); } // 重新观察可视范围内全部节点 #reIOViewNodes() { this.#viewNodes.forEach((n) => this.#reIO(n)); } // 监控shadowroot #startObserveShadowRoot(shadowRoot) { if (shadowRoot.host.matches(`#${APP_CONSTS.fabID}, #${APP_CONSTS.boxID}`)) { return; } this.#startObserveRoot(shadowRoot); this.#injectSheet(shadowRoot); } // 监控根节点 #startObserveRoot(root) { if (this.#rootNodes.has(root)) return; this.#rootNodes.add(root); this.#mo.observe(root, { childList: true, subtree: true, characterData: true, characterDataOldValue: true, }); this.#scanNode(root); } // 开始/重新监控节点 #startObserveNode(node) { // todo: DocumentFragment 无法被 this.#io.observe if (!Translator.isElement(node)) return; if (this.#rule.highlightWords === OPT_HIGHLIGHT_WORDS_BEFORETRANS) { this.#highlightWordsDeeply(node); } if ( !this.#observedNodes.has(node) && this.#enabled && this.#setting.transAllnow ) { this.#observedNodes.add(node); this.#processNode(node); return; } // 未监控 if (!this.#observedNodes.has(node)) { this.#observedNodes.add(node); this.#io.observe(node); return; } // 已监控,但未处理状态,且在可视范围 if (!this.#processedNodes.has(node) && this.#viewNodes.has(node)) { this.#reIO(node); } } // 非自动识别文本模式下,快速查询目标节点 #queryNode(rootNode) { // root 也可能是目标节点 if (rootNode.matches?.(this.#rule.selector)) { this.#startObserveNode(rootNode); } rootNode.querySelectorAll(this.#rule.selector).forEach((node) => { if (!node.closest?.(this.#ignoreSelector)) { this.#startObserveNode(node); } }); } // 寻找需要被监控的文本节点 #scanNode(rootNode) { if ( !Translator.isElementOrFragment(rootNode) || // rootNode.matches?.(this.#rule.keepSelector) || rootNode.matches?.(this.#ignoreSelector) ) { return; } if (this.#rule.autoScan === "false") { this.#queryNode(rootNode); return; } const hasText = Translator.hasTextNode(rootNode); if (!hasText && rootNode.children.length === 1) { this.#scanNode(rootNode.children[0]); return; } const hasBlock = Translator.hasBlockNode(rootNode); if (hasText || !hasBlock) { this.#startObserveNode(rootNode); } if (hasBlock) { for (const child of rootNode.children) { const isBlock = Translator.isBlockNode(child); if (!hasText || isBlock) { this.#scanNode(child); } } } } // 处理一个待翻译的节点 async #processNode(node) { if ( this.#processedNodes.has(node) || !Translator.isElementOrFragment(node) ) { return; } this.#processedNodes.set(node, { ...this.#rule }); // 提前检测文本 if (this.#isInvalidText(node.textContent)) { return; } // 提前进行语言检测 let deLang = ""; const { fromLang = "auto", toLang, splitParagraph = OPT_SPLIT_PARAGRAPH_DISABLE, splitLength = 100, } = this.#rule; const { langDetector, skipLangs = [] } = this.#setting; if (fromLang === "auto") { // 与 #translateFetch 使用同一翻译服务,均来自 this.#apiSetting(rule.apiSlug + apisMap) const apiType = this.#apiSetting?.apiType; const langMap = apiType ? OPT_LANGS_TO_SPEC[apiType] : null; const apiSupportsAutoDetect = langMap.get("auto"); // 还是用检测下 google de auto当翻译zh 到葡萄牙语时有问题 deLang = (await tryDetectLang(node.textContent, langDetector)) || apiSupportsAutoDetect; if ( deLang && (toLang.slice(0, 2) === deLang.slice(0, 2) || skipLangs.includes(deLang)) ) { // 保留处理状态,不做删除 // this.#processedNodes.delete(node); return; } } // 切分长段落 if (splitParagraph !== OPT_SPLIT_PARAGRAPH_DISABLE) { this.#splitTextNodesBySentence(node, splitParagraph, splitLength); } let nodeGroup = []; [...node.childNodes].forEach((child) => { const shouldBreak = this.#shouldBreak(child); const shouldGroup = child.nodeType === Node.ELEMENT_NODE || child.nodeType === Node.TEXT_NODE; if (!shouldBreak && shouldGroup) { nodeGroup.push(child); } else if (shouldBreak && nodeGroup.length) { this.#translateNodeGroup(nodeGroup, node, deLang); nodeGroup = []; } }); if (nodeGroup.length) { this.#translateNodeGroup(nodeGroup, node, deLang); } } // 高亮词汇 #highlightTextNode(textNode, wordRegex) { if (textNode.parentNode?.nodeName.toLowerCase() === "b") { return; } if (!wordRegex.test(textNode.textContent)) { return; } wordRegex.lastIndex = 0; const fragments = textNode.textContent.split(wordRegex); const newNodes = []; fragments.forEach((fragment, i) => { if (!fragment) return; if (i % 2 === 1) { // 奇数索引是匹配到的关键词 const bTag = document.createElement("b"); bTag.className = Translator.KISS_CLASS.highlight; bTag.style.cssText = this.#rule.highlightStyle || ""; bTag.textContent = fragment; this.#skipMoNodes.add(bTag); newNodes.push(bTag); } else { // 偶数索引是普通文本 const newTextNode = document.createTextNode(fragment); this.#skipMoNodes.add(newTextNode); newNodes.push(newTextNode); } }); if (newNodes.length > 0) { textNode.replaceWith(...newNodes); } } // 高亮词汇 #highlightWordsDeeply(parentNode) { if (!parentNode || this.#favWords.length === 0) { return; } const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escapedWords = this.#favWords.map(escapeRegex); const wordRegex = new RegExp(`\\b(${escapedWords.join("|")})\\b`, "gi"); if (parentNode.nodeType === Node.ELEMENT_NODE) { const walker = document.createTreeWalker( parentNode, NodeFilter.SHOW_TEXT, null, false ); const nodesToProcess = []; let node; while ((node = walker.nextNode())) { nodesToProcess.push(node); } nodesToProcess.forEach((textNode) => { this.#highlightTextNode(textNode, wordRegex); }); } else if (parentNode.nodeType === Node.TEXT_NODE) { this.#highlightTextNode(parentNode, wordRegex); } } // 切分文本段落 #splitTextNodesBySentence(parentNode, splitParagraph, splitLength) { const sentenceEndRegexForSplit = /[。!?]+|[.?!]+(?=\s+|$)/g; [...parentNode.childNodes].forEach((node) => { if (node.nodeType !== Node.TEXT_NODE || node.textContent.trim() === "") { return; } const text = node.textContent; const parts = []; let lastIndex = 0; let match; while ((match = sentenceEndRegexForSplit.exec(text)) !== null) { let realEndIndex = match.index + match[0].length; while (realEndIndex < text.length && /\s/.test(text[realEndIndex])) { realEndIndex++; } parts.push(text.substring(lastIndex, realEndIndex)); lastIndex = realEndIndex; sentenceEndRegexForSplit.lastIndex = realEndIndex; } if (lastIndex < text.length) { parts.push(text.substring(lastIndex)); } const validParts = parts.filter((part) => part.trim().length > 0); if (validParts.length <= 1) { return; } const newNodes = validParts.map((part) => { const newNode = document.createTextNode(part); this.#skipMoNodes.add(newNode); return newNode; }); node.replaceWith(...newNodes); }); const sentenceEndRegexForTest = /(?:[。!??!]+|(? { textLength += node.textContent.length; const isSentenceEnd = sentenceEndRegexForTest.test(node.textContent); if ( !isSentenceEnd || node.nextSibling?.nodeName?.toUpperCase() === "BR" ) { return; } if ( splitParagraph === OPT_SPLIT_PARAGRAPH_PUNCTUATION || (splitParagraph === OPT_SPLIT_PARAGRAPH_TEXTLENGTH && textLength >= splitLength) ) { textLength = 0; const br = document.createElement("br"); br.className = Translator.KISS_CLASS.br; this.#skipMoNodes.add(br); node.after(br); } }); } // 清除高亮 #removeHighlights(parentNode) { if (!parentNode) return; const highlightedElements = parentNode.querySelectorAll( `.${Translator.KISS_CLASS.highlight}` ); highlightedElements.forEach((element) => { const textNode = document.createTextNode(element.textContent); element.replaceWith(textNode); }); parentNode.normalize(); } // 移除br #removeBrTags(parentNode) { if (!parentNode) return; parentNode .querySelectorAll(`.${Translator.KISS_CLASS.br}`) .forEach((br) => br.remove()); parentNode.normalize(); } // 判断是否需要换行 #shouldBreak(node) { if (!Translator.isElementOrFragment(node)) return false; if (node.matches(this.#rule.keepSelector)) return false; if ( Translator.TAGS.BREAK_LINE.has(node.nodeName?.toUpperCase()) || node.matches?.(this.#ignoreSelector) || node.nodeName?.toLowerCase() === this.#translationTagName ) { return true; } if (this.#rule.autoScan && Translator.isBlockNode(node)) { return true; } if ( !this.#rule.autoScan && (node.matches(this.#rule.selector) || node.querySelector(this.#rule.selector)) ) { return true; } return false; } // 过滤文本 #isInvalidText(text) { if (typeof text !== "string") { return true; } const trimmedText = text.trim(); // 文本长度 if ( trimmedText.length < this.#setting.minLength || trimmedText.length > this.#setting.maxLength ) { return true; } // 单个非字母数字字符。 if (trimmedText.length === 1 && !trimmedText.match(/[a-zA-Z]/)) { return true; } // 只是一个数字 if (!isNaN(parseFloat(trimmedText)) && isFinite(trimmedText)) { return true; } // 正则匹配 if (this.#combinedSkipsRegex.test(trimmedText)) { return true; } return false; } // 翻译内联节点 async #translateNodeGroup(nodes, hostNode, deLang) { const { transTag, textStyle, transEndHook, transOnly, termsStyle, textExtStyle, selectStyle, parentStyle, grandStyle, // detectRemote, toLang, // skipLangs = [], highlightWords, } = this.#rule; const { newlineLength, // langDetector, } = this.#setting; const parentNode = hostNode.parentElement; const hideOrigin = transOnly === "true"; try { const [processedString, placeholderMap] = this.#serializeForTranslation( nodes, termsStyle ); if (this.#isInvalidText(processedString)) return; const wrapper = document.createElement(this.#translationTagName); wrapper.className = `${Translator.KISS_CLASS.warpper} notranslate`; if (processedString.length > newlineLength) { const br = document.createElement("br"); br.hidden = hideOrigin; wrapper.appendChild(br); } const inner = document.createElement(transTag); inner.className = `${Translator.KISS_CLASS.inner} ${this.#textClass[textStyle] || ""}`; if (textExtStyle?.trim()) { inner.style.cssText = textExtStyle; // 附加内联样式 } inner.appendChild(createLoadingSVG()); wrapper.appendChild(inner); nodes[nodes.length - 1].after(wrapper); const currentRunId = this.#runId; const { trText: translatedText, isSame: isSameLang } = await this.#translateFetch(processedString, deLang); if (this.#runId !== currentRunId) { throw new Error("Request terminated"); } if (!translatedText || isSameLang) { wrapper.remove(); return; } const htmlString = this.#restoreFromTranslation( translatedText, placeholderMap ); const trustedHTML = trustedTypesHelper.createHTML(htmlString); // const parser = new DOMParser(); // const doc = parser.parseFromString(trustedHTML, "text/html"); // const innerElement = doc.body.firstChild; // inner.replaceChildren(innerElement); inner.innerHTML = trustedHTML; this.#translationNodes.set(wrapper, { nodes, isHide: hideOrigin, }); if (hideOrigin) { this.#removeNodes(nodes); } // 附加样式 if (selectStyle && hostNode.style) { hostNode.style.cssText += selectStyle; } if (parentStyle && parentNode && parentNode.style) { parentNode.style.cssText += parentStyle; } if (grandStyle && parentNode && parentNode.parentElement) { parentNode.parentElement.style.cssText += grandStyle; } // 高亮词汇 if (highlightWords === OPT_HIGHLIGHT_WORDS_AFTERTRANS) { nodes.forEach((node) => this.#highlightWordsDeeply(node)); } // 翻译完成钩子函数 if (transEndHook?.trim()) { try { interpreter.run(`exports.transEndHook = ${transEndHook}`); interpreter.exports.transEndHook( { hostNode, parentNode, nodes, wrapperNode: wrapper, innerNode: inner, }, { text: processedString, fromLang: deLang || this.#rule.fromLang, toLang, } ); } catch (err) { kissLog("transEndHook", err); } } } catch (err) { // inner.textContent = `[失败]...`; // todo: 失败重试按钮 kissLog("translate group error: ", err.message); this.#cleanupDirectTranslations(hostNode); } } // 处理节点转为翻译字符串 #serializeForTranslation(nodes, termsStyle) { let replaceCounter = 0; // {{n}} let wrapCounter = 0; // const placeholderMap = new Map(); const { startDelimiter, endDelimiter } = this.#placeholderConfig; const pushReplace = (html) => { replaceCounter++; const placeholder = `${startDelimiter}${replaceCounter}${endDelimiter}`; placeholderMap.set(placeholder, html); return placeholder; }; const traverse = (node) => { if ( node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.TEXT_NODE ) { return ""; } // 文本节点 if (node.nodeType === Node.TEXT_NODE) { let text = node.textContent; // 专业术语替换 if (this.#combinedTermsRegex) { this.#combinedTermsRegex.lastIndex = 0; text = text.replace(this.#combinedTermsRegex, (...args) => { const groups = args.slice(1, -2); const matchedIndex = groups.findIndex( (group) => group !== undefined ); const fullMatch = args[0]; const termValue = this.#termValues[matchedIndex]; return pushReplace( `${termValue || fullMatch}` ); }); } return escapeHTML(text); } // 元素节点 if (node.nodeType === Node.ELEMENT_NODE) { if ( (this.#rule.hasRichText === "true" && Translator.TAGS.REPLACE.has(node.tagName)) || node.matches(this.#rule.keepSelector) || // node.matches(this.#ignoreSelector) || !node.textContent.trim() ) { if ( node.tagName?.toUpperCase() === "IMG" || node.tagName?.toUpperCase() === "SVG" ) { node.style.width = `${node.offsetWidth}px`; node.style.height = `${node.offsetHeight}px`; } return pushReplace(node.outerHTML); } let innerContent = ""; node.childNodes.forEach((child) => { innerContent += traverse(child); }); if ( this.#rule.hasRichText === "true" && Translator.TAGS.WARP.has(node.tagName?.toUpperCase()) ) { wrapCounter++; const { tagName, format } = this.#placeholderConfig; // 存储序号对应的原始标签对(使用TAG_前缀避免与普通占位符{{1}}冲突) placeholderMap.set(`TAG_${wrapCounter}`, { openTag: buildOpeningTag(node), closeTag: ``, }); // 生成占位符 let startPlaceholder, endPlaceholder; if (format === "attribute") { // 属性格式:
content startPlaceholder = `<${tagName} i=${wrapCounter}>`; endPlaceholder = ``; } else { // 简洁格式:content startPlaceholder = `<${tagName}${wrapCounter}>`; endPlaceholder = ``; } return `${startPlaceholder}${innerContent}${endPlaceholder}`; } return innerContent; } return ""; }; function buildOpeningTag(node) { const escapeAttr = (str) => str.replace(/"/g, """); let tag = `<${node.tagName.toLowerCase()}`; for (const attr of node.attributes) { tag += ` ${attr.name}="${escapeAttr(attr.value)}"`; } tag += ">"; return tag; } const processedString = nodes.map(traverse).join("").trim(); return [processedString, placeholderMap]; } // 组装恢复html字符串 #restoreFromTranslation(translatedText, placeholderMap) { if (!placeholderMap.size) { return translatedText; } if (!translatedText) return ""; const { safeTag, openRegex, closeRegex } = this.#placeholderConfig; const restoreAttr = "data-kiss-restore"; let textToParse = translatedText; let result = translatedText; try { // 1. 将所有占位符格式统一替换为 textToParse = textToParse.replace( openRegex, `<${safeTag} ${restoreAttr}="$1">` ); textToParse = textToParse.replace(closeRegex, ``); // 2. DOM 解析 const parser = new DOMParser(); const doc = parser.parseFromString(textToParse, "text/html"); // 3. 查找所有标记节点 const selector = `${safeTag}[${restoreAttr}]`; const placeholders = Array.from(doc.querySelectorAll(selector)); // 4. 倒序还原 (自底向上) placeholders.reverse().forEach((node) => { const index = node.getAttribute(restoreAttr); if (index) { const tagPair = placeholderMap.get(`TAG_${index}`); if (tagPair) { // 使用 outerHTML 替换整个临时节点 // node.innerHTML 包含了该节点内部可能已经还原过的原本内容 node.outerHTML = `${tagPair.openTag}${node.innerHTML}${tagPair.closeTag}`; } } }); result = doc.body.innerHTML; } catch (e) { kissLog("DOMParser restore failed, fallback to raw", e); // 如果解析失败,result 仍为 translatedText,继续尝试正则还原其他占位符 } // 还原普通占位符 {{1}}, {{2}} 等 (保留原有逻辑) result = result.replace( this.#placeholderConfig.placeholderRegex, (match) => placeholderMap.get(match) || match ); return result; } // 发起翻译请求 #translateFetch(text, deLang = "") { const { toLang, transStartHook } = this.#rule; const fromLang = deLang || this.#rule.fromLang; const apiSetting = { ...this.#apiSetting }; const glossary = { ...this.#glossary }; const apisMap = this.#apisMap; const args = { text, fromLang, toLang, apiSetting, glossary, }; // 翻译开始钩子函数 if (transStartHook?.trim()) { try { interpreter.run(`exports.transStartHook = ${transStartHook}`); const hookResult = interpreter.exports.transStartHook({ ...args, apisMap, }); if (hookResult) { Object.assign(args, hookResult); } } catch (err) { kissLog("transStartHook", err); } } return apiTranslate(args); } // 查找指定节点下所有译文节点 #findTranslationWrappers(parentNode) { return parentNode.querySelectorAll( `:scope > .${Translator.KISS_CLASS.warpper}` ); } // 清理所有插入的译文dom #cleanupAllNodes() { this.#rootNodes.forEach((root) => this.#cleanupAllTranslations(root)); } // 清理节点下面所有译文dom #cleanupAllTranslations(root) { root .querySelectorAll(`.${Translator.KISS_CLASS.warpper}`) .forEach((el) => this.#removeTranslationElement(el)); } // 清理子节点译文dom #cleanupDirectTranslations(node) { this.#findTranslationWrappers(node).forEach((el) => { this.#removeTranslationElement(el); }); } // 清理译文 #removeTranslationElement(el) { const parentElement = el.parentElement; this.#processedNodes.delete(parentElement); // 如果是仅显示译文模式,先恢复原文 const { nodes, isHide } = this.#translationNodes.get(el) || {}; if (isHide) { this.#restoreOriginal(el, nodes); } this.#translationNodes.delete(el); el.remove(); // todo: 可能不应深度清除 if (this.#rule.highlightWords === OPT_HIGHLIGHT_WORDS_AFTERTRANS) { this.#removeHighlights(parentElement); } this.#removeBrTags(parentElement); } // 恢复原文 #restoreOriginal(el, nodes) { if (nodes) { const frag = document.createDocumentFragment(); nodes.forEach((n) => frag.appendChild(n)); const parent = el.parentElement; parent?.insertBefore(frag, el); } } // 移除多个节点 #removeNodes(nodes) { if (nodes) { const frag = document.createDocumentFragment(); nodes.forEach((n) => frag.appendChild(n)); } } // 切换译文和双语显示 #toggleTranslationOnly(node, transOnly) { this.#findTranslationWrappers(node).forEach((el) => { const br = el.querySelector(":scope > br"); const { nodes } = this.#translationNodes.get(el) || {}; if (transOnly === "true") { // 双语变为仅译文 if (br) br.hidden = true; this.#removeNodes(nodes); this.#translationNodes.set(el, { nodes, isHide: true }); } else { // 仅译文变为双语 if (br) br.hidden = false; this.#restoreOriginal(el, nodes); this.#translationNodes.set(el, { nodes, isHide: false }); } }); } // 更新样式 #updateStyle(node, oldStyle, newStyle) { this.#findTranslationWrappers(node).forEach((el) => { const inner = el.querySelector( `:scope > .${Translator.KISS_CLASS.inner}` ); inner.classList.remove(this.#textClass[oldStyle]); inner.classList.add(this.#textClass[newStyle]); }); } // 刷新节点翻译 #refreshNode(node) { this.#cleanupDirectTranslations(node); this.#processNode(node); } // 使指定节点的状态与当前的全局同步 #performSyncNode(node) { const appliedRule = this.#processedNodes.get(node); if (!appliedRule) { this.#enabled && this.#processNode(node); return; } const { apiSlug, fromLang, toLang, hasRichText, textStyle, transOnly } = this.#rule; const needsRefresh = appliedRule.apiSlug !== apiSlug || appliedRule.fromLang !== fromLang || appliedRule.toLang !== toLang || appliedRule.hasRichText !== hasRichText; // 需要重新翻译 if (needsRefresh) { Object.assign(appliedRule, { apiSlug, fromLang, toLang, hasRichText, textStyle, transOnly, }); this.#refreshNode(node); // 会自动应用新样式 return; } // 样式规则过时 if (appliedRule.textStyle !== textStyle) { const oldStyle = appliedRule.textStyle; appliedRule.textStyle = textStyle; this.#updateStyle(node, oldStyle, textStyle); } // 切换原文显示 if (appliedRule.transOnly !== transOnly) { appliedRule.transOnly = transOnly; this.#toggleTranslationOnly(node, transOnly); } } // 停止监听,重置参数 #resetOptions() { this.#removeShadowRootListener(); this.#io.disconnect(); this.#mo.disconnect(); this.#viewNodes.clear(); this.#rootNodes.clear(); this.#observedNodes = new WeakSet(); this.#translationNodes = new WeakMap(); this.#processedNodes = new WeakMap(); } // 开启鼠标悬停翻译 #enableMouseHover() { if (this.#mouseHoverEnabled) return; this.#mouseHoverEnabled = true; this.#setting.mouseHoverSetting.useMouseHover = true; document.addEventListener("mousemove", this.#boundMouseMoveHandler); const { mouseHoverKey } = this.#setting.mouseHoverSetting; if (mouseHoverKey.length === 0) { // mouseHoverKey = DEFAULT_MOUSEHOVER_KEY; return; } this.#removeKeydownHandler = shortcutRegister( mouseHoverKey, this.#boundKeyDownHandler ); } // 禁用鼠标悬停翻译 #disableMouseHover() { if (!this.#mouseHoverEnabled) return; this.#mouseHoverEnabled = false; this.#setting.mouseHoverSetting.useMouseHover = false; document.removeEventListener("mousemove", this.#boundMouseMoveHandler); this.#removeKeydownHandler?.(); } // 注入JS/CSS #initInjector() { if (this.#isJsInjected) { return; } this.#isJsInjected = true; try { // const { injectJs, injectCss } = this.#rule; // if (isExt) { // injectJs && sendBgMsg(MSG_INJECT_JS, injectJs); // injectCss && sendBgMsg(MSG_INJECT_CSS, injectCss); // } else { // injectJs && // injectInlineJs(injectJs, "kiss-translator-userinit-injector"); // injectCss && injectInternalCss(injectCss); // } const { injectJs, injectCss, toLang } = this.#rule; if (isExt) { injectCss && sendBgMsg(MSG_INJECT_CSS, injectCss); } else { injectCss && injectInternalCss(injectCss); } if (injectJs?.trim()) { const apiSetting = { ...this.#apiSetting }; const glossary = { ...this.#glossary }; const apisMap = this.#apisMap; const apiDectect = tryDetectLang; interpreter.import({ KT: { apiTranslate, apiDectect, apiSetting, apisMap, toLang, glossary, }, }); interpreter.run(injectJs); } } catch (err) { kissLog("inject js", err); } } // 移除JS/CSS #removeInjector() { document .querySelectorAll(`[data-source^="kiss-inject"]`) ?.forEach((el) => el.remove()); } // 切换鼠标悬停翻译 toggleMouseHover() { this.#mouseHoverEnabled ? this.#disableMouseHover() : this.#enableMouseHover(); } // 开启翻译 enable() { if (this.#enabled) return; this.#enabled = true; this.#rule.transOpen = "true"; this.#runId++; if (this.#isInitialized) { if (this.#setting.transAllnow) { this.rescan(); } else { this.#reIOViewNodes(); } } else { this.#init(); } if (this.#rule.transTitle === "true") { this.#translateTitle(); } isExt && sendBgMsg(MSG_UPDATE_ICON, true); } // 翻译页面标题 async #translateTitle() { const docInfo = getDocInfo(); if (!docInfo?.title) return; try { const deLang = await tryDetectLang(docInfo.title); const { trText } = await this.#translateFetch(docInfo.title, deLang); this.#docInfo.title = document.title; // 缓存原标题 document.title = trText || docInfo.title; } catch (err) { kissLog("tanslate title", err); } } // 关闭翻译 disable() { if (!this.#enabled) return; this.#enabled = false; this.#rule.transOpen = "false"; this.#runId++; this.#cleanupAllNodes(); clearFetchPool(); clearAllBatchQueue(); // 恢复页面标题 if (this.#rule.transTitle === "true" && this.#docInfo.title) { document.title = this.#docInfo.title; } isExt && sendBgMsg(MSG_UPDATE_ICON, false); } // 重新扫描页面 rescan() { if (!this.#isInitialized) return; this.#runId++; this.#cleanupAllNodes(); this.#resetOptions(); clearFetchPool(); clearAllBatchQueue(); // 重新初始化 this.#init(); } // 切换是否翻译 toggle() { this.#enabled ? this.disable() : this.enable(); } // 快速切换模糊样式 toggleStyle() { const textStyle = this.#rule.textStyle === OPT_STYLE_FUZZY ? OPT_STYLE_NONE : OPT_STYLE_FUZZY; this.updateRule({ textStyle }); } // 切换划词翻译 toggleTransbox() { this.#setting.tranboxSetting.transOpen = !this.#setting.tranboxSetting.transOpen; } // 切换输入框翻译 toggleInputTranslate() { this.#setting.inputRule.transOpen = !this.#setting.inputRule.transOpen; } // 停止运行 stop() { this.disable(); this.#resetOptions(); this.#disableMouseHover(); this.#removeInjector(); this.#isInitialized = false; } // 更新规则 updateRule(newRule) { let hasChanged = false; let needsRescan = false; for (const key in newRule) { if ( Object.prototype.hasOwnProperty.call(this.#rule, key) && this.#rule[key] !== newRule[key] ) { this.#rule[key] = newRule[key]; if ( key === "autoScan" || key === "hasShadowroot" || key === "scanAll" || key === "isPlainText" ) { needsRescan = true; } else { hasChanged = true; } } } // 配置变更时清空正则缓存 this.#placeholderCache = null; if (needsRescan || (this.#enabled && this.#setting.transAllnow)) { this.rescan(); return; } if (hasChanged) { this.#reIOViewNodes(); } } get setting() { return { ...this.#setting }; } get rule() { return { ...this.#rule }; } get eventName() { return this.#eventName; } } ================================================ FILE: src/libs/translatorManager.js ================================================ import { browser } from "./browser"; import { Translator } from "./translator"; import { InputTranslator } from "./inputTranslate"; import { TransboxManager } from "./tranbox"; import { shortcutRegister } from "./shortcut"; import { sendIframeMsg } from "./iframe"; import { EVENT_KISS_INNER, EVENT_KISS_TRANSLATOR, MSG_HOVERNODE_TOGGLE, MSG_INPUT_TRANSLATE, newI18n, } from "../config"; import { touchTapListener } from "./touch"; import { PopupManager } from "./popupManager"; import { FabManager } from "./fabManager"; import { OPT_SHORTCUT_TRANSLATE, OPT_SHORTCUT_STYLE, OPT_SHORTCUT_POPUP, OPT_SHORTCUT_SETTING, MSG_TRANS_TOGGLE, MSG_TRANS_TOGGLE_STYLE, MSG_TRANS_GETRULE, MSG_TRANS_PUTRULE, MSG_OPEN_TRANBOX, MSG_TRANSBOX_TOGGLE, MSG_POPUP_TOGGLE, MSG_MOUSEHOVER_TOGGLE, MSG_TRANSINPUT_TOGGLE, } from "../config"; import { logger } from "./log"; export default class TranslatorManager { #clearShortcuts = []; #menuCommandIds = []; #clearTouchListeners = []; #isActive = false; #isUserscript; #isIframe; #innerMessageHandler = null; #browserMessageHandler = null; #windowMessageHandler = null; _translator; _transboxManager; _inputTranslator; _popupManager; _fabManager; constructor({ setting, rule, fabConfig, favWords, isIframe, isUserscript }) { this.#isIframe = isIframe; this.#isUserscript = isUserscript; this._translator = new Translator({ rule, setting, favWords, isUserscript, isIframe, }); this._transboxManager = new TransboxManager(setting); if (!isIframe) { this._inputTranslator = new InputTranslator(setting); this._popupManager = new PopupManager({ translator: this._translator, processActions: this.#processActions.bind(this), }); this._fabManager = new FabManager({ processActions: this.#processActions.bind(this), fabConfig, }); } this.#innerMessageHandler = this.#handleInnerMessage.bind(this); this.#browserMessageHandler = this.#handleBrowserMessage.bind(this); this.#windowMessageHandler = this.#handleWindowMessage.bind(this); } start() { if (this.#isActive) { logger.info("TranslatorManager is already started."); return; } this.#setupMessageListeners(); this.#setupTouchOperations(); if (!this.#isIframe && this.#isUserscript) { this.#registerShortcuts(); this.#registerMenus(); } this.#isActive = true; logger.info("TranslatorManager started."); } stop() { if (!this.#isActive) { logger.info("TranslatorManager is not running."); return; } // 移除消息监听器 window.removeEventListener( EVENT_KISS_TRANSLATOR, this.#innerMessageHandler ); if (this.#isUserscript) { window.removeEventListener("message", this.#innerMessageHandler); } else { browser.runtime.onMessage.removeListener(this.#browserMessageHandler); if (this.#isIframe) { window.removeEventListener("message", this.#innerMessageHandler); } } // 已注册的快捷键 this.#clearShortcuts.forEach((clear) => clear()); this.#clearShortcuts = []; // 触屏 this.#clearTouchListeners.forEach((clear) => clear()); this.#clearTouchListeners = []; // 油猴菜单 if (globalThis.GM && this.#menuCommandIds.length > 0) { this.#menuCommandIds.forEach((id) => GM.unregisterMenuCommand?.(id)); this.#menuCommandIds = []; } // 子模块 this._popupManager?.destroy(); this._fabManager?.destroy(); this._transboxManager?.disable(); this._inputTranslator?.disable(); this._translator.stop(); this.#isActive = false; logger.info("TranslatorManager stopped."); } #setupMessageListeners() { if (this.#isUserscript) { window.addEventListener("message", this.#innerMessageHandler); } else { browser.runtime.onMessage.addListener(this.#browserMessageHandler); if (this.#isIframe) { window.addEventListener("message", this.#innerMessageHandler); } } // 监听外部调用消息 window.addEventListener(EVENT_KISS_TRANSLATOR, this.#windowMessageHandler); } #setupTouchOperations() { if (this.#isIframe) return; const { touchModes = [2] } = this._translator.setting; if (touchModes.length === 0) { return; } const handleTap = () => { this.#processActions({ action: MSG_TRANS_TOGGLE }); }; const handleListener = (mode) => { let options = null; switch (mode) { case 2: case 3: case 4: options = { taps: 1, fingers: mode }; break; case 5: options = { taps: 2, fingers: 1 }; break; case 6: options = { taps: 3, fingers: 1 }; break; case 7: options = { taps: 2, fingers: 2 }; break; default: } if (options) { this.#clearTouchListeners.push(touchTapListener(handleTap, options)); } }; touchModes.forEach((mode) => handleListener(mode)); } // 处理外部调用 #handleWindowMessage(event) { logger.debug("handle window message:", event); this.#processActions(event.detail); } #handleInnerMessage(event) { this.#processActions(event.data); } #handleBrowserMessage(message, sender, sendResponse) { const result = this.#processActions(message, true); const response = result || { rule: this._translator.rule, setting: this._translator.setting, }; sendResponse(response); return true; } #registerShortcuts() { const { shortcuts } = this._translator.setting; this.#clearShortcuts = [ shortcutRegister(shortcuts[OPT_SHORTCUT_TRANSLATE], () => this.#processActions({ action: MSG_TRANS_TOGGLE }) ), shortcutRegister(shortcuts[OPT_SHORTCUT_STYLE], () => this.#processActions({ action: MSG_TRANS_TOGGLE_STYLE }) ), shortcutRegister(shortcuts[OPT_SHORTCUT_POPUP], () => this.#processActions({ action: MSG_POPUP_TOGGLE }) ), shortcutRegister(shortcuts[OPT_SHORTCUT_SETTING], () => window.open(process.env.REACT_APP_OPTIONSPAGE, "_blank") ), ]; } #registerMenus() { if (!globalThis.GM) return; const { contextMenuType, uiLang } = this._translator.setting; if (contextMenuType === 0) return; const i18n = newI18n(uiLang || "zh"); this.#menuCommandIds = [ GM.registerMenuCommand?.( i18n("translate_switch"), () => this.#processActions({ action: MSG_TRANS_TOGGLE }), "Q" ), GM.registerMenuCommand?.( i18n("toggle_style"), () => this.#processActions({ action: MSG_TRANS_TOGGLE_STYLE }), "C" ), GM.registerMenuCommand?.( i18n("open_menu"), () => this.#processActions({ action: MSG_POPUP_TOGGLE }), "K" ), GM.registerMenuCommand?.( i18n("open_setting"), () => window.open(process.env.REACT_APP_OPTIONSPAGE, "_blank"), "O" ), ]; } #processActions({ action, args } = {}, fromExt = false) { if (!action) return; if (!fromExt) { sendIframeMsg(action, args); } logger.debug("process action:", action, args); switch (action) { case MSG_TRANS_TOGGLE: this._translator.toggle(); break; case MSG_TRANS_TOGGLE_STYLE: this._translator.toggleStyle(); break; case MSG_TRANS_GETRULE: break; case MSG_TRANS_PUTRULE: this._translator.updateRule(args); break; case MSG_OPEN_TRANBOX: document.dispatchEvent( new CustomEvent(EVENT_KISS_INNER, { detail: { action: MSG_OPEN_TRANBOX }, }) ); break; case MSG_POPUP_TOGGLE: this._popupManager?.toggle(); break; case MSG_TRANSBOX_TOGGLE: this._transboxManager?.toggle(); this._translator.toggleTransbox(); break; case MSG_MOUSEHOVER_TOGGLE: this._translator.toggleMouseHover(); break; case MSG_TRANSINPUT_TOGGLE: this._inputTranslator?.toggle(); this._translator.toggleInputTranslate(); break; case MSG_HOVERNODE_TOGGLE: this._translator.toggleHoverNode(); break; case MSG_INPUT_TRANSLATE: this._inputTranslator.handleTranslate(); break; default: logger.info(`Message action is unavailable: ${action}`); return { error: `Message action is unavailable: ${action}` }; } } } ================================================ FILE: src/libs/trustedTypes.js ================================================ import { logger } from "./log"; export const trustedTypesHelper = (() => { const POLICY_NAME = "kiss-translator-policy"; let policy = null; if (globalThis.trustedTypes && globalThis.trustedTypes.createPolicy) { try { policy = globalThis.trustedTypes.createPolicy(POLICY_NAME, { createHTML: (string) => string, createScript: (string) => string, createScriptURL: (string) => string, }); } catch (err) { if (err.message.includes("already exists")) { policy = globalThis.trustedTypes.policies.get(POLICY_NAME); } else { logger.info("cont create Trusted Types", err); } } } return { createHTML: (htmlString) => { return policy ? policy.createHTML(htmlString) : htmlString; }, createScript: (scriptString) => { return policy ? policy.createScript(scriptString) : scriptString; }, createScriptURL: (urlString) => { return policy ? policy.createScriptURL(urlString) : urlString; }, isEnabled: () => policy !== null, }; })(); ================================================ FILE: src/libs/url.js ================================================ /** * URL 處理工具函數 */ /** * 檢查是否為 IP 位址 (v4 或 v6) * @param {string} hostname - 主機名稱 * @returns {boolean} * @example * isIPAddress("192.168.1.1") -> true * isIPAddress("::1") -> true * isIPAddress("example.com") -> false */ export const isIPAddress = (hostname) => { const isIPv4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(hostname); const isIPv6 = hostname.includes(":"); return isIPv4 || isIPv6; }; /** * 中間截斷字串,保留頭尾 * @param {string} str - 原始字串 * @param {number} [maxLen=45] - 最大長度 * @returns {string} 截斷後的字串 * @example * truncateMiddle("short") -> "short" * truncateMiddle("/C:/Users/JohnDoe/Documents/Projects/article.txt", 30) -> "/C:/Users/Joh...cle/article.txt" */ export const truncateMiddle = (str, maxLen = 45) => { if (str.length <= maxLen) return str; const ellipsis = "..."; const charsToShow = maxLen - ellipsis.length; const frontChars = Math.ceil(charsToShow / 2); const backChars = Math.floor(charsToShow / 2); return `${str.slice(0, frontChars)}${ellipsis}${str.slice(-backChars)}`; }; /** * 從 file:// URL 提取檔案匹配選項 * @param {string} href - file:// URL * @returns {string[]} 匹配選項陣列,由精確到寬鬆排序 * @example * "file:///C:/docs/article.txt" -> ["/C:/docs/article.txt", "/C:/docs/*", "*.txt", "article.txt"] * "file:///home/user/test.md" -> ["/home/user/test.md", "/home/user/*", "*.md", "test.md"] */ const getFileOptions = (href) => { const path = href.replace(/^file:\/\//, ""); const filename = path.substring(path.lastIndexOf("/") + 1); const dir = path.substring(0, path.lastIndexOf("/")); const ext = filename.includes(".") ? filename.substring(filename.lastIndexOf(".")) : ""; const options = []; // 完整路徑(最精確) if (path) { try { options.push(decodeURIComponent(path)); } catch { options.push(path); } } // 目錄萬用 if (dir) { options.push(`${dir}/*`); } // 副檔名萬用 if (ext) { options.push(`*${ext}`); } // 檔名(最寬鬆) if (filename && filename !== path) { try { options.push(decodeURIComponent(filename)); } catch { options.push(filename); } } return options; }; /** * 從 hostname 生成通配符選項 * @param {string} hostname - 主機名稱 * @returns {string[]} 匹配選項陣列,由精確到寬鬆排序 * @example * "localhost" -> ["localhost"] * "example.com" -> ["example.com", "*.example.com"] * "foo.example.com" -> ["foo.example.com", "*.example.com"] * "foo.bar.example.com" -> ["foo.bar.example.com", "*.bar.example.com", "*.*.example.com"] * "a.b.c.example.com" -> ["a.b.c.example.com", "*.b.c.example.com", "*.*.c.example.com", "*.*.*.example.com"] * "192.168.1.1" -> ["192.168.1.1"] */ const getWildcardOptions = (hostname) => { if (isIPAddress(hostname)) { return [hostname]; } const parts = hostname.split("."); if (parts.length <= 1) { return [hostname]; } if (parts.length === 2) { return [hostname, `*.${hostname}`]; } // 逐層往上替換成 * // foo.bar.example.com -> [foo.bar.example.com, *.bar.example.com, *.*.example.com] const options = [hostname]; const mainDomain = parts.slice(-2).join("."); for (let i = 1; i <= parts.length - 2; i++) { const wildcards = "*.".repeat(i); const remainingParts = parts.slice(i).join("."); // 避免重複:*.example.com 與 *.*.example.com 不同 if (remainingParts !== mainDomain || i === 1) { options.push(`${wildcards}${remainingParts}`); } } return options; }; /** * 生成網域匹配選項 * @param {string} href - 完整 URL * @returns {string[]} 可選的匹配模式陣列,由精確到寬鬆排序 * @example * "https://example.com/page" -> ["example.com", "*.example.com"] * "http://localhost:3000/" -> ["localhost:3000", "localhost:*", "localhost"] * "http://192.168.1.1:8080/" -> ["192.168.1.1:8080", "192.168.1.1:*", "192.168.1.1"] * "https://foo.bar.example.com/" -> ["foo.bar.example.com", "*.bar.example.com", "*.*.example.com"] * "http://dev.example.com:8080/" -> ["dev.example.com:8080", "dev.example.com:*", "dev.example.com", "*.example.com"] * "file:///C:/docs/article.txt" -> ["/C:/docs/article.txt", "/C:/docs/*", "*.txt", "article.txt"] * "chrome-extension://xxx/" -> [] */ export const getDomainOptions = (href) => { if (!href || typeof href !== "string") { return []; } try { if (href.startsWith("file")) { return getFileOptions(href); } if (!href.startsWith("http")) { return []; } const url = new URL(href); const { hostname, port, protocol } = url; const defaultPort = protocol === "https:" ? "443" : "80"; const wildcardOptions = getWildcardOptions(hostname); // 非預設 port 時,加入 port 相關選項 if (port && port !== defaultPort) { const host = wildcardOptions[0]; return [ `${host}:${port}`, `${host}:*`, host, ...wildcardOptions.slice(1), ]; } return wildcardOptions; } catch { return []; } }; ================================================ FILE: src/libs/utils.js ================================================ /** * 移除 Markdown 代码块标记 * @param {string} text 原始文本 * @param {boolean} startOnly 是否只处理开头 * @returns {string} 移除代码块标记后的文本 */ export function stripMarkdownCodeBlock(text, startOnly = false) { if (!text) return ""; let result = text.replace(/^```[a-z]*\s*\n?/i, ""); if (!startOnly) { result = result.replace(/\n?```$/i, ""); } return result; } /** * 限制数字大小 * @param {*} num * @param {*} min * @param {*} max * @returns */ export const limitNumber = (num, min = 0, max = 100) => { const number = parseInt(num); if (Number.isNaN(number) || number < min) { return min; } else if (number > max) { return max; } return number; }; export const limitFloat = (num, min = 0.0, max = 100.0) => { const number = parseFloat(num); if (Number.isNaN(number) || number < min) { return min; } else if (number > max) { return max; } return number; }; /** * 匹配是否为数组中的值 * @param {*} arr * @param {*} val * @returns */ export const matchValue = (arr, val) => { if (arr.length === 0 || arr.includes(val)) { return val; } return arr[0]; }; /** * 等待 * @param {*} delay * @returns */ export const sleep = (delay) => new Promise((resolve) => { const timer = setTimeout(() => { clearTimeout(timer); resolve(); }, delay); }); /** * 防抖函数 * @param {*} func * @param {*} delay * @returns */ export const debounce = (func, delay = 200) => { let timer = null; const debouncedFunc = (...args) => { timer && clearTimeout(timer); timer = setTimeout(() => { func(...args); timer = null; }, delay); }; debouncedFunc.cancel = () => { clearTimeout(timer); timer = null; }; return debouncedFunc; }; /** * 节流函数 * @param {Function} func 要执行的函数 * @param {number} delay 延迟时间 * @param {object} options 选项 { leading: boolean, trailing: boolean } * @returns {Function} */ export const throttle = ( func, delay, options = { leading: true, trailing: true } ) => { let timeoutId = null; let lastArgs = null; let lastThis = null; let result; let previous = 0; function later() { previous = options.leading === false ? 0 : Date.now(); timeoutId = null; result = func.apply(lastThis, lastArgs); if (!timeoutId) { lastThis = lastArgs = null; } } const throttled = function (...args) { const now = Date.now(); if (!previous && options.leading === false) { previous = now; } const remaining = delay - (now - previous); lastArgs = args; lastThis = this; if (remaining <= 0 || remaining > delay) { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } previous = now; result = func.apply(lastThis, lastArgs); if (!timeoutId) { lastThis = lastArgs = null; } } else if (!timeoutId && options.trailing !== false) { timeoutId = setTimeout(later, remaining); } return result; }; throttled.cancel = () => { clearTimeout(timeoutId); previous = 0; timeoutId = null; lastThis = lastArgs = null; }; return throttled; }; /** * 判断字符串全是某个字符 * @param {*} s * @param {*} c * @param {*} i * @returns */ export const isAllchar = (s, c, i = 0) => { while (i < s.length) { if (s[i] !== c) { return false; } i++; } return true; }; /** * 字符串通配符(*)匹配 * @param {*} s * @param {*} p * @returns */ export const isMatch = (s, p) => { if (s.length === 0 || p.length === 0) { return false; } p = "*" + p + "*"; let [sIndex, pIndex] = [0, 0]; let [sRecord, pRecord] = [-1, -1]; while (sIndex < s.length && pRecord < p.length) { if (p[pIndex] === "*") { pIndex++; [sRecord, pRecord] = [sIndex, pIndex]; } else if (s[sIndex] === p[pIndex]) { sIndex++; pIndex++; } else if (sRecord + 1 < s.length) { sRecord++; [sIndex, pIndex] = [sRecord, pRecord]; } else { return false; } } if (p.length === pIndex) { return true; } return isAllchar(p, "*", pIndex); }; /** * 类型检查 * @param {*} o * @returns */ export const type = (o) => { const s = Object.prototype.toString.call(o); return s.match(/\[object (.*?)\]/)[1].toLowerCase(); }; /** * sha256 * @param {*} text * @returns */ export const sha256 = async (text, salt) => { const data = new TextEncoder().encode(text + salt); const digest = await crypto.subtle.digest({ name: "SHA-256" }, data); return [...new Uint8Array(digest)] .map((b) => b.toString(16).padStart(2, "0")) .join(""); }; /** * 生成随机事件名称 * @returns */ export const genEventName = () => `kiss-${btoa(Math.random()).slice(3, 11)}`; /** * 判断两个 Set 是否相同 * @param {*} a * @param {*} b * @returns */ export const isSameSet = (a, b) => { const s = new Set([...a, ...b]); return s.size === a.size && s.size === b.size; }; /** * 去掉字符串末尾某个字符 * @param {*} s * @param {*} c * @param {*} count * @returns */ export const removeEndchar = (s, c, count = 1) => { if (!s) return ""; let i = s.length; while (i > s.length - count && s[i - 1] === c) { i--; } return s.slice(0, i); }; /** * 匹配字符串及语言标识 * @param {*} str * @param {*} sign * @returns */ export const matchInputStr = (str, sign) => { switch (sign) { case "//": return str.match(/\/\/([\w-]+)\s+([^]+)/); case "\\": return str.match(/\\([\w-]+)\s+([^]+)/); case "\\\\": return str.match(/\\\\([\w-]+)\s+([^]+)/); case ">": return str.match(/>([\w-]+)\s+([^]+)/); case ">>": return str.match(/>>([\w-]+)\s+([^]+)/); default: } return str.match(/\/([\w-]+)\s+([^]+)/); }; /** * 判断是否英文单词 * @param {*} str * @returns */ export const isValidWord = (str) => { const regex = /^[a-zA-Z-]+$/; return regex.test(str); }; /** * blob转为base64 * @param {*} blob * @returns */ export const blobToBase64 = (blob) => { return new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.readAsDataURL(blob); }); }; /** * 获取html内的文本 * @param {*} htmlStr * @param {*} skipTag * @returns */ export const getHtmlText = (htmlStr, skipTag = "") => { const parser = new DOMParser(); const doc = parser.parseFromString(htmlStr, "text/html"); if (skipTag) { doc.querySelectorAll(skipTag).forEach((el) => el.remove()); } return doc.body.innerText.trim(); }; /** * 解析JSON字符串对象 * @param {*} str * @returns */ export const parseJsonObj = (str) => { if (!str || type(str) !== "string") { return {}; } try { if (str.trim()[0] !== "{") { str = `{${str}}`; } return JSON.parse(str); } catch (err) { // } return {}; }; /** * 提取json内容 * @param {*} s * @returns */ export const extractJson = (raw) => { const jsonRegex = /({.*}|\[.*\])/s; const match = raw.match(jsonRegex); return match ? match[0] : null; }; /** * 空闲执行 * @param {*} cb * @param {*} timeout * @returns */ export const scheduleIdle = (cb, timeout = 200) => { if (window.requestIdleCallback) { return requestIdleCallback(cb, { timeout }); } return setTimeout(cb, timeout); }; /** * 截取url部分 * @param {*} href * @returns */ export const parseUrlPattern = (href) => { if (href.startsWith("file")) { const filename = href.substring(href.lastIndexOf("/") + 1); return filename; } else if (href.startsWith("http")) { const url = new URL(href); return url.host; } return href; }; /** * 带超时的任务 * @param {Promise|Function} task - 任务 * @param {number} timeout - 超时时间 (毫秒) * @param {string} [timeoutMsg] - 超时错误提示 * @returns {Promise} */ export const withTimeout = (task, timeout, timeoutMsg = "Task timed out") => { const promise = typeof task === "function" ? task() : task; return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error(timeoutMsg)), timeout) ), ]); }; /** * 截短字符串 * @param {*} str * @param {*} maxLength * @returns */ export const truncateWords = (str, maxLength = 300) => { if (typeof str !== "string") return ""; if (str.length <= maxLength) return str; const truncated = str.slice(0, maxLength); return truncated.slice(0, truncated.lastIndexOf(" ")) + " …"; }; /** * 生成随机数 * @param {*} min * @param {*} max * @param {*} integer * @returns */ export const randomBetween = (min, max, integer = true) => { const value = Math.random() * (max - min) + min; return integer ? Math.floor(value) : value; }; /** * 根据文件名自动获取 MIME 类型 * @param {*} filename * @returns */ function getMimeTypeFromFilename(filename) { const defaultType = "application/octet-stream"; if (!filename || filename.indexOf(".") === -1) { return defaultType; } const extension = filename.split(".").pop().toLowerCase(); const mimeMap = { // 文本 txt: "text/plain;charset=utf-8", html: "text/html;charset=utf-8", css: "text/css;charset=utf-8", js: "text/javascript;charset=utf-8", json: "application/json;charset=utf-8", xml: "application/xml;charset=utf-8", md: "text/markdown;charset=utf-8", vtt: "text/vtt;charset=utf-8", // 图像 png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", svg: "image/svg+xml", webp: "image/webp", ico: "image/x-icon", // 音频/视频 mp3: "audio/mpeg", mp4: "video/mp4", webm: "video/webm", wav: "audio/wav", // 应用程序/文档 pdf: "application/pdf", zip: "application/zip", doc: "application/msword", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }; // 默认值 return mimeMap[extension] || defaultType; } /** * 下载文件 * @param {*} str * @param {*} filename */ export function downloadBlobFile(str, filename = "kiss-file.txt") { const mimeType = getMimeTypeFromFilename(filename); const blob = new Blob([str], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.style.display = "none"; a.href = url; a.download = filename || `kiss-file.txt`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // HTML转义 export function escapeHTML(str) { const div = document.createElement("div"); div.textContent = str; return div.innerHTML; } ================================================ FILE: src/options.js ================================================ import React from "react"; import ReactDOM from "react-dom/client"; import Options from "./views/Options"; globalThis.__KISS_CONTEXT__ = "options"; const root = ReactDOM.createRoot(document.getElementById("root")); root.render( ); ================================================ FILE: src/popup.js ================================================ import React from "react"; import ReactDOM from "react-dom/client"; import { SettingProvider } from "./hooks/Setting"; import ThemeProvider from "./hooks/Theme"; import Popup from "./views/Popup"; globalThis.__KISS_CONTEXT__ = "popup"; const root = ReactDOM.createRoot(document.getElementById("root")); root.render( ); ================================================ FILE: src/rules.js ================================================ import fs from "fs"; import path from "path"; import { BUILTIN_RULES } from "./config/rules"; (() => { // rules try { const data = JSON.stringify(BUILTIN_RULES, null, 2); const file = path.resolve( __dirname, "../build/web/kiss-translator-rules.json" ); fs.writeFileSync(file, data); console.info(`Built-in rules generated: ${file}`); } catch (err) { console.error(err); } // version try { var pjson = require("../package.json"); const file = path.resolve(__dirname, "../build/web/version.txt"); fs.writeFileSync(file, pjson.version); console.info(`Version file generated: ${file}`); } catch (err) { console.error(err); } })(); ================================================ FILE: src/scripts/archive.mjs ================================================ #!/usr/bin/env zx console.log(chalk.cyan("\nStarting compression tasks...\n")); // 1. 进入 build 目录 cd("build"); // 2. 清理旧的 zip 文件 await $`npx shx rm -f *.zip`; /** * 定义打包任务配置 * * @property {string} output - 输出文件名 * @property {string} source - 要打包的源(文件或目录名) * @property {string} [cwd] - (可选) 执行打包命令时所在的目录。 */ const tasks = [ { output: "chrome.zip", source: "chrome" }, { output: "edge.zip", source: "edge" }, { output: "userscript.zip", source: "userscript" }, { output: "../firefox.zip", source: "*", cwd: "firefox", }, { output: "../thunderbird.zip", source: "*", cwd: "thunderbird", }, ]; try { for (const task of tasks) { if (task.cwd) { // === 特殊打包:进入目录内部打包 (Firefox/Thunderbird) === const originalCwd = process.cwd(); // 记录当前位置 (build/) // 1. 进入子目录 cd(task.cwd); console.log(`Zipping contents of ${task.cwd} (flat structure)...`); // 2. 执行打包: 将当前目录所有文件 (*) 打包到父级目录的 zip 中 await $`npx bestzip ${task.output} *`; // 3. 回到原目录 cd(originalCwd); } else { // === 普通打包:打包文件夹本身 (Chrome/Edge) === console.log(`Zipping folder ${task.source}...`); await $`npx bestzip ${task.output} ${task.source}`; } } console.log(chalk.green("\n✅ All zip files created successfully.")); } catch (err) { console.error(chalk.red("❌ Error during zipping:"), err); process.exit(1); } ================================================ FILE: src/scripts/build-ios.mjs ================================================ #!/usr/bin/env zx console.log(chalk.cyan("\nBuilding iOS Userscript...\n")); const srcFile = "build/web/kiss-translator.user.js"; const destFile = "build/web/kiss-translator-ios-safari.user.js"; const userscriptDir = "build/userscript"; // 目标汇总目录 try { // 1. 检查源文件 if (!fs.existsSync(srcFile)) { throw new Error( `Source file not found: ${srcFile}. Run 'pnpm build:web' first.` ); } // 2. 复制并重命名 await fs.copy(srcFile, destFile); // 3. 读取并替换内容 let content = await fs.readFile(destFile, "utf-8"); const oldStr = "// @grant unsafeWindow"; const newStr = "// @inject-into content"; if (!content.includes(oldStr)) { console.warn(chalk.yellow(`Warning: Pattern "${oldStr}" not found.`)); } content = content.replace(new RegExp(oldStr, "g"), newStr); // 4. 写入原 Web 目录 await fs.writeFile(destFile, content, "utf-8"); // 5. 同时复制一份到 userscript 目录 await fs.ensureDir(userscriptDir); const iosDestInUserscript = path.join(userscriptDir, path.basename(destFile)); await fs.copy(destFile, iosDestInUserscript); console.log(chalk.green(`✅ iOS Userscript generated at: ${destFile}`)); console.log(chalk.green(`✅ Copied to: ${iosDestInUserscript}`)); } catch (err) { console.error(chalk.red("❌ Error building iOS userscript:"), err); process.exit(1); } ================================================ FILE: src/scripts/build-safari.js ================================================ import { $, globby } from "zx"; import path from "node:path"; import fs from "node:fs/promises"; import dotenv from "dotenv"; import { findUp } from "find-up"; async function main() { const rootPath = path.dirname(await findUp("package.json")); dotenv.config({ path: path.resolve(rootPath, ".env.local") }); // https://github.com/vitejs/vite/issues/5885 process.env.NODE_ENV = "production"; const ProjectName = "Kiss Translator"; const AppCategory = "public.app-category.productivity"; const Identifier = "com.fishjar.kiss-translator"; const DevelopmentTeam = process.env.DEVELOPMENT_TEAM; const DistPath = "build"; await $`pnpm build:safari-output`; await $`xcrun safari-web-extension-converter --bundle-identifier ${Identifier} --force --project-location ${DistPath} build/safari`; async function updateProjectConfig() { const projectConfigPath = path.resolve( rootPath, `${DistPath}/${ProjectName}/${ProjectName}.xcodeproj/project.pbxproj` ); const packageJson = JSON.parse( await fs.readFile(path.resolve(rootPath, "package.json")) ); const content = await fs.readFile(projectConfigPath, "utf-8"); const newContent = content .replaceAll( "MARKETING_VERSION = 1.0;", `MARKETING_VERSION = ${packageJson.version};` ) .replace( new RegExp( `INFOPLIST_KEY_CFBundleDisplayName = ("?${ProjectName}"?);`, "g" ), `INFOPLIST_KEY_CFBundleDisplayName = $1;\n INFOPLIST_KEY_LSApplicationCategoryType = "${AppCategory}";` ) .replace( new RegExp( `INFOPLIST_KEY_CFBundleDisplayName = ("?${ProjectName}"?);`, "g" ), `INFOPLIST_KEY_CFBundleDisplayName = $1;\n INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;` ) .replaceAll( `COPY_PHASE_STRIP = NO;`, DevelopmentTeam ? `COPY_PHASE_STRIP = NO;\n DEVELOPMENT_TEAM = ${DevelopmentTeam};` : "COPY_PHASE_STRIP = NO;" ) .replace( /CURRENT_PROJECT_VERSION = \d+;/g, `CURRENT_PROJECT_VERSION = ${parseProjectVersion(packageJson.version)};` ); await fs.writeFile(projectConfigPath, newContent); } async function updateInfoPlist() { const projectPath = path.resolve(rootPath, DistPath, ProjectName); const files = await globby("**/*.plist", { cwd: projectPath, }); for (const file of files) { const content = await fs.readFile( path.resolve(projectPath, file), "utf-8" ); await fs.writeFile( path.resolve(projectPath, file), content.replaceAll( "\n", " CFBundleVersion\n $(CURRENT_PROJECT_VERSION)\n\n" ) ); } } function parseProjectVersion(version) { const [major, minor, patch] = version.split(".").map(Number); return major * 10000 + minor * 100 + patch; } await updateProjectConfig(); await updateInfoPlist(); } main(); ================================================ FILE: src/scripts/build-safari.mjs ================================================ #!/usr/bin/env zx import { $, globby } from "zx"; import path from "node:path"; import fs from "node:fs/promises"; import dotenv from "dotenv"; import { findUp } from "find-up"; // 打开详细日志,方便调试 $.verbose = true; async function main() { // 1. 初始化路径与配置 const packageJsonPath = await findUp("package.json"); if (!packageJsonPath) throw new Error("Could not find package.json"); const rootPath = path.dirname(packageJsonPath); // 加载环境变量 dotenv.config({ path: path.join(rootPath, ".env.local") }); // 从 package.json 读取版本 const pkg = JSON.parse(await fs.readFile(packageJsonPath, "utf-8")); // 2. 集中配置项 const CONFIG = { projectName: "Kiss Translator", identifier: "com.fishjar.kiss-translator", appCategory: "public.app-category.productivity", developmentTeam: process.env.DEVELOPMENT_TEAM, // 如果没有设置,后续逻辑会处理 distPath: "build", sourcePath: "build/safari", // Web Extension 产物位置 version: pkg.version, }; // 设置环境变量 process.env.NODE_ENV = "production"; console.log(`🚀 开始构建: ${CONFIG.projectName} v${CONFIG.version}`); // 3. 执行构建命令 // 确保构建目录存在 await $`pnpm build:safari-output`; // 转换项目 (注意:--force 会覆盖已存在的项目) await $`xcrun safari-web-extension-converter --bundle-identifier ${CONFIG.identifier} --force --project-location ${CONFIG.distPath} ${CONFIG.sourcePath}`; /** * 核心逻辑:修改 Xcode 工程配置 (project.pbxproj) */ async function updateProjectConfig() { const projectPbxPath = path.join( rootPath, CONFIG.distPath, CONFIG.projectName, `${CONFIG.projectName}.xcodeproj`, "project.pbxproj" ); let content = await fs.readFile(projectPbxPath, "utf-8"); // 预先计算 Project Version (例如: 1.2.3 -> 10203) const projectVersionInt = parseProjectVersion(CONFIG.version); // 准备要注入的 Info.plist 键值对 const additionalInfoKeys = [ `INFOPLIST_KEY_LSApplicationCategoryType = "${CONFIG.appCategory}";`, `INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;`, ].join("\n\t\t"); // 使用 Xcode 风格的缩进 // --- 开始替换 --- // 1. 替换 Marketing Version content = content.replace( /MARKETING_VERSION = .*?;/g, `MARKETING_VERSION = ${CONFIG.version};` ); // 2. 替换 Project Version content = content.replace( /CURRENT_PROJECT_VERSION = \d+;/g, `CURRENT_PROJECT_VERSION = ${projectVersionInt};` ); // 3. 注入 Development Team (如果有) if (CONFIG.developmentTeam) { // 查找 COPY_PHASE_STRIP,在其后插入 TEAM ID // 使用更宽松的正则来匹配可能的空白字符 content = content.replace( /(COPY_PHASE_STRIP = NO;)/g, `$1\n\t\t\t\tDEVELOPMENT_TEAM = ${CONFIG.developmentTeam};` ); } // 4. 注入 InfoPlist 额外配置 // 原逻辑是在 DisplayName 后追加。这里合并操作,只替换一次,避免重复查找。 // 匹配: INFOPLIST_KEY_CFBundleDisplayName = "Name"; const displayNameRegex = new RegExp( `(INFOPLIST_KEY_CFBundleDisplayName = "${CONFIG.projectName}";)`, "g" ); content = content.replace( displayNameRegex, `$1\n\t\t${additionalInfoKeys}` ); await fs.writeFile(projectPbxPath, content); console.log("✅ Xcode 项目配置已更新"); } /** * 核心逻辑:修改 Info.plist */ async function updateInfoPlist() { const projectDir = path.join(rootPath, CONFIG.distPath, CONFIG.projectName); const files = await globby("**/*.plist", { cwd: projectDir, absolute: true, }); // 构造要插入的 XML 片段 const versionXml = ` CFBundleVersion $(CURRENT_PROJECT_VERSION)`; for (const file of files) { let content = await fs.readFile(file, "utf-8"); // 使用正则精准匹配文件末尾的 closing tags,忽略空白符差异 // 替换 \n 为 新内容 + 闭合标签 if (!content.includes("CFBundleVersion")) { content = content.replace( /\s*<\/dict>\s*<\/plist>\s*$/, `${versionXml}\n\n` ); await fs.writeFile(file, content); } } console.log(`✅ 已更新 ${files.length} 个 Info.plist 文件`); } await updateProjectConfig(); await updateInfoPlist(); console.log("🎉 构建完成!"); } function parseProjectVersion(version) { const [major, minor, patch] = version.split(".").map(Number); // 处理 NaN 情况,防止版本号格式错误导致 NaN return (major || 0) * 10000 + (minor || 0) * 100 + (patch || 0); } main().catch((err) => { console.error("❌ 构建失败:", err); process.exit(1); }); ================================================ FILE: src/scripts/build-task.mjs ================================================ #!/usr/bin/env zx import { argv, quote, $ } from "zx"; // 在 Windows 上使用 cmd.exe,避免 zx 默认使用 WSL bash 导致 node not found if (process.platform === "win32") { $.shell = "cmd.exe"; $.prefix = ""; $.quote = quote } // 用法: zx src/scripts/build-task.mjs --target=chrome const target = argv.target; if (!target) { console.error( chalk.red("Error: Please specify a target, e.g., --target=chrome") ); process.exit(1); } const buildRoot = "build"; const targetDir = path.join(buildRoot, target); // 辅助:获取构建目录下的文件路径 const inDest = (file) => path.join(targetDir, file); console.log(chalk.blue(`\n🚀 Starting build task for: ${chalk.bold(target)}`)); try { // 1. 【清理】 清空当前目标的构建目录 await fs.remove(targetDir); // 2. 【构建】 区分普通构建和特殊构建(如 Edge) if (target === "edge") { // Edge 特殊逻辑:直接复制 Chrome 构建结果 const chromeDir = path.join(buildRoot, "chrome"); if (!(await fs.pathExists(chromeDir))) { throw new Error( 'Chrome build not found! Please run "pnpm build:chrome" first.' ); } await fs.copy(chromeDir, targetDir); console.log(chalk.green("✅ Copied Chrome build to Edge.")); } else { // 标准 React 构建流程 const clientEnv = target === "web" ? "userscript" : target; process.env.BUILD_PATH = `./${targetDir}`; process.env.REACT_APP_CLIENT = clientEnv; process.env.FORCE_COLOR = "1"; console.log(chalk.gray(`Running react-app-rewired build...`)); await $`react-app-rewired build`; } // 3. 【后处理】 文件清理与移动 console.log(chalk.gray(`Running post-build cleanups...`)); // ----------------------------------------------------------------------- // 场景 A: Chrome, Edge, Safari (标准扩展) // ----------------------------------------------------------------------- if (["chrome", "edge", "safari"].includes(target)) { // 1. 清理 HTML await fs.remove(inDest("content.html")); // 2. 清理多余的 Firefox/Thunderbird manifest await fs.remove(inDest("manifest.firefox.json")); await fs.remove(inDest("manifest.thunderbird.json")); } // ----------------------------------------------------------------------- // 场景 B: Firefox, Thunderbird (需要替换 Manifest) // ----------------------------------------------------------------------- if (["firefox", "thunderbird"].includes(target)) { await fs.remove(inDest("content.html")); const specificManifest = inDest(`manifest.${target}.json`); const finalManifest = inDest("manifest.json"); if (await fs.pathExists(specificManifest)) { await fs.move(specificManifest, finalManifest, { overwrite: true }); } // 清理所有残留的 manifest.*.json const files = await fs.readdir(targetDir); for (const f of files) { if (f.startsWith("manifest.") && f !== "manifest.json") { await fs.remove(inDest(f)); } } } // ----------------------------------------------------------------------- // 场景 C: Web (Userscript) // ----------------------------------------------------------------------- if (target === "web") { // 1. Web 版不需要任何 manifest 文件 const filesInDir = await fs.readdir(targetDir); for (const f of filesInDir) { if (f.startsWith("manifest") && f.endsWith(".json")) { await fs.remove(inDest(f)); } } // 2. 将生成的普通 userscript 复制到 userscript 汇总目录 const userscriptDir = path.join(buildRoot, "userscript"); await fs.ensureDir(userscriptDir); for (const f of filesInDir) { // 重新遍历,因为上面可能删除了文件 if (f.endsWith(".user.js")) { await fs.copy(inDest(f), path.join(userscriptDir, f)); } } } console.log( chalk.green(`✅ Build task for [${target}] completed successfully!`) ); } catch (err) { console.error(chalk.red(`\n❌ Build failed for ${target}:`)); console.error(err); process.exit(1); } ================================================ FILE: src/scripts/sync-version.mjs ================================================ #!/usr/bin/env zx import { $ } from "zx"; /** * 版本号同步脚本 * 从 package.json 读取版本号,自动同步到其他配置文件 */ const rootDir = path.resolve(__dirname, "../.."); // 读取 package.json 中的版本号 const pkgPath = path.join(rootDir, "package.json"); const pkg = await fs.readJSON(pkgPath); const version = pkg.version; console.log(chalk.blue(`📦 从 package.json 读取版本号: ${chalk.bold(version)}`)); // 需要同步的文件列表 const filesToSync = [ { path: path.join(rootDir, ".env"), type: "env", pattern: /^REACT_APP_VERSION=.+$/m, replacement: `REACT_APP_VERSION=${version}`, }, { path: path.join(rootDir, "public/manifest.json"), type: "json", key: "version", }, { path: path.join(rootDir, "public/manifest.firefox.json"), type: "json", key: "version", }, { path: path.join(rootDir, "public/manifest.thunderbird.json"), type: "json", key: "version", }, ]; let syncCount = 0; // 遍历并更新每个文件 for (const file of filesToSync) { try { if (file.type === "env") { // 处理 .env 文件 let content = await fs.readFile(file.path, "utf-8"); const newContent = content.replace(file.pattern, file.replacement); if (content !== newContent) { await fs.writeFile(file.path, newContent, "utf-8"); console.log(chalk.green(`✅ 已更新: ${path.relative(rootDir, file.path)}`)); syncCount++; } else { console.log(chalk.gray(`⏭️ 无需更新: ${path.relative(rootDir, file.path)}`)); } } else if (file.type === "json") { // 处理 JSON 文件 const jsonData = await fs.readJSON(file.path); if (jsonData[file.key] !== version) { jsonData[file.key] = version; await fs.writeJSON(file.path, jsonData, { spaces: 2 }); console.log(chalk.green(`✅ 已更新: ${path.relative(rootDir, file.path)}`)); syncCount++; } else { console.log(chalk.gray(`⏭️ 无需更新: ${path.relative(rootDir, file.path)}`)); } } } catch (err) { console.error(chalk.red(`❌ 更新失败: ${path.relative(rootDir, file.path)}`)); console.error(err.message); } } console.log(chalk.blue(`\n🎉 版本号同步完成!共更新 ${syncCount} 个文件到版本 ${chalk.bold(version)}`)); ================================================ FILE: src/scripts/update-version.mjs ================================================ #!/usr/bin/env zx import { $, argv } from "zx"; /** * 版本号更新脚本 * 使用 npm version 命令更新 package.json 中的版本号,然后自动同步到其他文件 * * 用法: * pnpm version:patch // 2.0.19 -> 2.0.20 * pnpm version:minor // 2.0.19 -> 2.1.0 * pnpm version:major // 2.0.19 -> 3.0.0 * pnpm version:set -- 2.1.0 // 手动指定版本号 */ const rootDir = path.resolve(__dirname, "../.."); const versionType = argv._[0] || argv.type || "patch"; console.log(chalk.blue(`\n🚀 开始更新版本号...\n`)); try { // 读取当前版本 const pkgPath = path.join(rootDir, "package.json"); const pkg = await fs.readJSON(pkgPath); const oldVersion = pkg.version; console.log(chalk.gray(`当前版本: ${oldVersion}`)); // 使用 npm version 更新 package.json // --no-git-tag-version 参数防止自动创建 git tag if (versionType === "set") { const newVersion = argv._[1]; if (!newVersion) { console.error(chalk.red("❌ 错误: 请指定版本号,例如: pnpm version:set -- 2.1.0")); process.exit(1); } await $`npm version ${newVersion} --no-git-tag-version`; } else { await $`npm version ${versionType} --no-git-tag-version`; } // 重新读取更新后的版本 const updatedPkg = await fs.readJSON(pkgPath); const newVersion = updatedPkg.version; console.log(chalk.green(`✅ package.json 版本已更新: ${oldVersion} -> ${newVersion}\n`)); // 同步到其他文件 console.log(chalk.blue(`📦 开始同步版本号到其他文件...\n`)); await $`zx src/scripts/sync-version.mjs`; console.log(chalk.green.bold(`\n✨ 版本更新完成!新版本: ${newVersion}\n`)); console.log(chalk.gray(`提示: 别忘了更新 CHANGELOG.md 并提交更改\n`)); } catch (err) { console.error(chalk.red("\n❌ 版本更新失败:")); console.error(err.message); process.exit(1); } ================================================ FILE: src/subtitle/BilingualSubtitleManager.js ================================================ import { logger } from "../libs/log.js"; import { truncateWords, throttle } from "../libs/utils.js"; import { apiTranslate } from "../apis/index.js"; import { apiMicrosoftDict } from "../apis/index.js"; import { trustedTypesHelper } from "../libs/trustedTypes.js"; import { isMobile } from "../libs/mobile.js"; // 添加CSS样式用于高亮显示悬停的单词 const addWordHoverStyles = () => { if (document.getElementById("kiss-word-hover-styles")) return; const style = document.createElement("style"); style.id = "kiss-word-hover-styles"; style.textContent = ` .kiss-word-hover { cursor: pointer; text-decoration: underline; text-decoration-color: #4fc3f7; text-decoration-thickness: 2px; } .kiss-word-tooltip { position: fixed; background: rgba(0, 0, 0, 0.9); color: white; border-radius: 6px; padding: 12px; font-size: 14px; z-index: 2147483647; max-width: 300px; word-wrap: break-word; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); backdrop-filter: blur(4px); border: 1px solid rgba(255, 255, 255, 0.1); font-family: Arial, sans-serif; } .kiss-word-tooltip-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-weight: bold; font-size: 16px; color: #4fc3f7; } .kiss-word-tooltip-close { background: none; border: none; color: #aaa; cursor: pointer; font-size: 18px; padding: 0; margin-left: 10px; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; } .kiss-word-tooltip-close:hover { color: white; background: rgba(255, 255, 255, 0.1); border-radius: 50%; } .kiss-word-loading { color: #bbb; font-style: italic; } .kiss-word-definition { margin: 4px 0; } .kiss-word-pos { color: #4fc3f7; font-weight: bold; } .kiss-word-phonetic { color: #bbb; font-style: italic; margin-right: 10px; } .kiss-word-example { margin-top: 10px; padding-top: 8px; border-top: 1px solid #444; } .kiss-word-example-title { font-weight: bold; margin-bottom: 5px; } .kiss-word-example-sentence { margin-bottom: 3px; } .kiss-word-example-translation { color: #bbb; font-style: italic; } `; document.head.appendChild(style); }; /** * @class BilingualSubtitleManager * @description 负责在视频上显示和翻译字幕的核心逻辑 */ export class BilingualSubtitleManager { #videoEl; #formattedSubtitles = []; #captionWindowEl = null; #paperEl = null; #currentSubtitleIndex = -1; // #preTranslateSeconds = 90; // #throttleSeconds = 30; #setting = {}; #isAdPlaying = false; #throttledTriggerTranslations; #tooltipEl = null; #hoverTimeout = null; // 用于延迟显示/隐藏tooltip #wasPlayingBeforeHover = false; //记录hover单词前视频是否处于播放状态 #hoverTarget = null; /** * @param {object} options * @param {HTMLVideoElement} options.videoEl - 页面上的 video 元素。 * @param {Array} options.formattedSubtitles - 已格式化好的字幕数组。 * @param {object} options.setting - 配置对象,如目标翻译语言。 */ constructor({ videoEl, formattedSubtitles, setting }) { this.#setting = setting; this.#videoEl = videoEl; this.#formattedSubtitles = formattedSubtitles; this.onTimeUpdate = this.onTimeUpdate.bind(this); this.onSeek = this.onSeek.bind(this); this.#throttledTriggerTranslations = throttle( this.#triggerTranslations.bind(this), (setting.throttleTrans ?? 30) * 1000 ); // todo: 使用 @emotion/css const enhanceMode = this.#setting.enhanceMode ?? "mobile_off"; const isEnhance = enhanceMode === "on" || (enhanceMode === "mobile_off" && !isMobile); if (isEnhance) { addWordHoverStyles(); } } /** * 启动字幕显示和翻译。 */ start() { if (this.#formattedSubtitles.length === 0) { logger.warn("Bilingual Subtitles: No subtitles to display."); return; } logger.info("Bilingual Subtitle Manager: Starting..."); this.#createCaptionWindow(); this.#attachEventListeners(); this.onTimeUpdate(); } /** * 销毁实例,清理资源。 */ destroy() { logger.info("Bilingual Subtitle Manager: Destroying..."); this.#removeEventListeners(); this.#throttledTriggerTranslations?.cancel(); this.#captionWindowEl?.parentElement?.parentElement?.remove(); this.#formattedSubtitles = []; // 清理tooltip元素 if (this.#tooltipEl) { this.#tooltipEl.remove(); this.#tooltipEl = null; } // 清理定时器 if (this.#hoverTimeout) { clearTimeout(this.#hoverTimeout); this.#hoverTimeout = null; } } /** * 更新广告播放状态。 */ setIsAdPlaying(isPlaying) { this.#isAdPlaying = isPlaying; this.onTimeUpdate(); } /** * 创建并配置用于显示字幕的 DOM 元素。 */ #createCaptionWindow() { const container = document.createElement("div"); container.className = `kiss-caption-container notranslate`; Object.assign(container.style, { position: "absolute", width: "100%", height: "100%", left: "0", top: "0", pointerEvents: "none", }); const paper = document.createElement("div"); paper.className = `kiss-caption-paper`; Object.assign(paper.style, { position: "absolute", width: "80%", left: "50%", bottom: "10%", transform: "translateX(-50%)", textAlign: "center", containerType: "inline-size", zIndex: "2147483647", pointerEvents: "auto", display: "none", }); this.#paperEl = paper; this.#captionWindowEl = document.createElement("div"); this.#captionWindowEl.className = `kiss-caption-window`; this.#captionWindowEl.style.cssText = this.#setting.windowStyle; this.#captionWindowEl.style.pointerEvents = "auto"; this.#captionWindowEl.style.cursor = "grab"; this.#captionWindowEl.style.opacity = "1"; this.#paperEl.appendChild(this.#captionWindowEl); container.appendChild(this.#paperEl); const videoContainer = this.#videoEl.parentElement?.parentElement; if (!videoContainer) { logger.warn("could not find videoContainer"); return; } videoContainer.style.position = "relative"; videoContainer.appendChild(container); const enhanceMode = this.#setting.enhanceMode ?? "mobile_off"; const isEnhance = enhanceMode === "on" || (enhanceMode === "mobile_off" && !isMobile); this.#enableDragging(this.#paperEl, container, this.#captionWindowEl); if (isEnhance) { this.#captionWindowEl.addEventListener("pointerenter", (e) => { if (e.target === this.#captionWindowEl) { this.#wasPlayingBeforeHover = this.#videoEl && !this.#videoEl.paused; if (this.#videoEl && !this.#videoEl.paused) { this.#videoEl.pause(); } } }); this.#captionWindowEl.addEventListener("pointerleave", (e) => { if (e.target === this.#captionWindowEl) { if ( this.#wasPlayingBeforeHover && this.#videoEl && this.#videoEl.paused ) { this.#videoEl.play(); } this.#wasPlayingBeforeHover = false; this.#hoverTarget = null; } }); } } // 处理单词悬停事件 #handleWordHover(event) { const target = event.target; if (target.classList.contains("kiss-subtitle-word")) { // 清除之前的定时器 if (this.#hoverTimeout) { clearTimeout(this.#hoverTimeout); this.#hoverTimeout = null; } target.classList.add("kiss-word-hover"); // 延迟显示tooltip,避免误触 this.#hoverTimeout = setTimeout(() => { this.#showWordTooltip( target.dataset.word, event.clientX, event.clientY ); }, 300); } } // 处理鼠标移出事件 #handleWordHoverOut(event) { const target = event.target; if (target.classList.contains("kiss-subtitle-word")) { target.classList.remove("kiss-word-hover"); // 清除显示定时器 if (this.#hoverTimeout) { clearTimeout(this.#hoverTimeout); this.#hoverTimeout = null; } // 延迟隐藏tooltip this.#hoverTimeout = setTimeout(() => { this.#hideWordTooltip(); }, 100); } } // 处理鼠标移动事件 #handleWordMouseMove(event) { // 不再跟随鼠标移动,保持tooltip在固定位置 // 移除之前的逻辑 } #attachSpanListeners() { if (!this.#captionWindowEl) return; const spans = this.#captionWindowEl.querySelectorAll(".kiss-subtitle-word"); spans.forEach((span) => { if (span.dataset.kissListenerAttached) return; const enterHandler = (e) => this.#handleWordHover(e); const leaveHandler = (e) => this.#handleWordHoverOut(e); span.addEventListener("pointerenter", enterHandler); span.addEventListener("pointerleave", leaveHandler); span.dataset.kissListenerAttached = "1"; }); } // 显示单词提示框 async #showWordTooltip(word, x, y) { // 如果已经存在提示框,则先移除 if (this.#tooltipEl) { this.#tooltipEl.remove(); } // 创建提示框 this.#tooltipEl = document.createElement("div"); this.#tooltipEl.className = "kiss-word-tooltip"; this.#tooltipEl.innerHTML = trustedTypesHelper.createHTML( '
Looking up...
' ); // 将提示框定位在播放器右上角 const videoContainer = this.#videoEl.parentElement?.parentElement; if (videoContainer) { const containerRect = videoContainer.getBoundingClientRect(); const tooltipWidth = 300; const tooltipHeight = 400; // 定位在播放器右上角,距离右边缘45px,上下边缘各20px const left = containerRect.right - tooltipWidth - 45; const top = containerRect.top + 20; // 确保提示框不会超出浏览器窗口右边界 const maxLeft = window.innerWidth - tooltipWidth - 10; this.#tooltipEl.style.left = Math.min(maxLeft, Math.max(10, left)) + "px"; this.#tooltipEl.style.top = Math.max(10, top) + "px"; this.#tooltipEl.style.maxWidth = tooltipWidth + "px"; this.#tooltipEl.style.maxHeight = tooltipHeight + "px"; this.#tooltipEl.style.overflow = "auto"; } document.body.appendChild(this.#tooltipEl); try { // 获取单词翻译 const dictResult = await apiMicrosoftDict(word); // 构造美式音标字符串 let phonetic = ""; if (dictResult && dictResult.aus) { // 只使用美式音标,去除"美"标签和方括号 const usPhonetic = dictResult.aus.find((au) => au.key === "美"); if (usPhonetic && usPhonetic.phonetic) { phonetic = usPhonetic.phonetic; } else if (dictResult.aus.length > 0 && dictResult.aus[0].phonetic) { // 如果没有明确标记为"美"的音标,使用第一个音标 phonetic = dictResult.aus[0].phonetic; } } // 构造释义字符串 let definition = ""; if (dictResult && dictResult.trs) { definition = dictResult.trs .slice(0, 3) .map((tr) => `${tr.pos ? tr.pos + " " : ""}${tr.def}`) .join("; "); } // 构造例句数组 let examples = []; if (dictResult && dictResult.sentences) { examples = dictResult.sentences.slice(0, 2).map((sentence) => ({ eng: sentence.eng, chs: sentence.chs, })); } // 获取当前字幕的时间戳(使用重新分段后的时间) const currentTimeMs = this.#getCurrentSubtitleStartTime(); // 添加单词和完整信息到生词本 const event = new CustomEvent("kiss-add-word", { detail: { word, phonetic, // 现在只包含音标本身,如 ɪnˈkredəb(ə)l definition, examples, timestamp: currentTimeMs, // 添加时间戳 }, }); document.dispatchEvent(event); if ( dictResult && (dictResult.trs || dictResult.aus || dictResult.sentences) ) { let content = `
${word}
`; // 显示音标 if (dictResult.aus && dictResult.aus.length > 0) { content += "
"; dictResult.aus.forEach((au) => { if (au.phonetic) { content += `${au.phonetic}`; } }); content += "
"; } // 显示释义 if (dictResult.trs) { dictResult.trs.slice(0, 3).forEach((tr) => { content += `
${tr.pos ? '' + tr.pos + " " : ""}${tr.def}
`; }); } // 显示例句 if (dictResult.sentences && dictResult.sentences.length > 0) { content += `
例句
`; dictResult.sentences.slice(0, 2).forEach((sentence) => { content += `
${sentence.eng}
${sentence.chs}
`; }); content += "
"; } if (this.#tooltipEl) { this.#tooltipEl.innerHTML = trustedTypesHelper.createHTML(content); } } else { if (this.#tooltipEl) { this.#tooltipEl.innerHTML = trustedTypesHelper.createHTML(`
${word}
No definition found
`); } } } catch (error) { logger.info("Dictionary lookup failed for word:", word, error); // 获取当前字幕的时间戳 const currentTimeMs = this.#getCurrentSubtitleStartTime(); // 即使查询失败,也将单词添加到生词本(无完整信息) const event = new CustomEvent("kiss-add-word", { detail: { word, phonetic: "", definition: "", examples: [], timestamp: currentTimeMs, // 添加时间戳 }, }); document.dispatchEvent(event); if (this.#tooltipEl) { this.#tooltipEl.innerHTML = trustedTypesHelper.createHTML(`
${word}
Failed to load definition
`); } } } // 隐藏单词提示框 #hideWordTooltip() { if (this.#tooltipEl) { this.#tooltipEl.remove(); this.#tooltipEl = null; } } /** * 为指定的元素启用垂直拖动功能。 */ #enableDragging(dragElement, boundaryContainer, handleElement) { let isDragging = false; let startY; let initialBottom; let dragElementHeight; const onDragStart = (e) => { if (e.type === "mousedown" && e.button !== 0) return; e.preventDefault(); isDragging = true; handleElement.style.cursor = "grabbing"; startY = e.type === "touchstart" ? e.touches[0].clientY : e.clientY; initialBottom = boundaryContainer.getBoundingClientRect().bottom - dragElement.getBoundingClientRect().bottom; dragElementHeight = dragElement.offsetHeight; document.addEventListener("mousemove", onDragMove, { capture: true }); document.addEventListener("touchmove", onDragMove, { capture: true, passive: false, }); document.addEventListener("mouseup", onDragEnd, { capture: true }); document.addEventListener("touchend", onDragEnd, { capture: true }); }; const onDragMove = (e) => { if (!isDragging) return; e.preventDefault(); const currentY = e.type === "touchmove" ? e.touches[0].clientY : e.clientY; const deltaY = currentY - startY; let newBottom = initialBottom - deltaY; const containerHeight = boundaryContainer.clientHeight; newBottom = Math.max(0, newBottom); newBottom = Math.min(containerHeight - dragElementHeight, newBottom); if (dragElementHeight > containerHeight) { newBottom = Math.max(0, newBottom); } dragElement.style.bottom = `${newBottom}px`; }; const onDragEnd = (e) => { if (!isDragging) return; e.preventDefault(); isDragging = false; handleElement.style.cursor = "grab"; document.removeEventListener("mousemove", onDragMove, { capture: true }); document.removeEventListener("touchmove", onDragMove, { capture: true }); document.removeEventListener("mouseup", onDragEnd, { capture: true }); document.removeEventListener("touchend", onDragEnd, { capture: true }); const finalBottomPx = dragElement.style.bottom; setTimeout(() => { dragElement.style.bottom = finalBottomPx; }, 50); }; handleElement.addEventListener("mousedown", onDragStart); handleElement.addEventListener("touchstart", onDragStart, { passive: false, }); } /** * 绑定视频元素的 timeupdate 和 seeked 事件监听器。 */ #attachEventListeners() { this.#videoEl.addEventListener("timeupdate", this.onTimeUpdate); this.#videoEl.addEventListener("seeked", this.onSeek); } /** * 移除事件监听器。 */ #removeEventListeners() { this.#videoEl.removeEventListener("timeupdate", this.onTimeUpdate); this.#videoEl.removeEventListener("seeked", this.onSeek); } /** * 视频播放时间更新时的回调,负责更新字幕和触发预翻译。 */ onTimeUpdate() { const currentTimeMs = this.#videoEl.currentTime * 1000; const subtitleIndex = this.#findSubtitleIndexForTime(currentTimeMs); if (subtitleIndex !== this.#currentSubtitleIndex) { this.#currentSubtitleIndex = subtitleIndex; const subtitle = subtitleIndex !== -1 ? this.#formattedSubtitles[subtitleIndex] : null; this.#updateCaptionDisplay(subtitle); } this.#throttledTriggerTranslations(currentTimeMs); } /** * 用户拖动进度条后的回调。 */ onSeek() { this.#currentSubtitleIndex = -1; this.#throttledTriggerTranslations.cancel(); this.onTimeUpdate(); } /** * 根据时间(毫秒)查找对应的字幕索引。 * @param {number} currentTimeMs * @returns {number} 找到的字幕索引,-1 表示没找到。 */ #findSubtitleIndexForTime(currentTimeMs) { return this.#formattedSubtitles.findIndex( (sub) => currentTimeMs >= sub.start && currentTimeMs <= sub.end ); } /** * 更新字幕窗口的显示内容。 * @param {object | null} subtitle - 字幕对象,或 null 用于清空。 */ #updateCaptionDisplay(subtitle) { if (!this.#paperEl || !this.#captionWindowEl) return; if (this.#isAdPlaying) { this.#paperEl.style.display = "none"; return; } if (subtitle) { // 创建带有单词标记的字幕内容 const p1 = document.createElement("p"); p1.style.cssText = this.#setting.originStyle; const enhanceMode = this.#setting.enhanceMode ?? "mobile_off"; const isEnhance = enhanceMode === "on" || (enhanceMode === "mobile_off" && !isMobile); if (isEnhance) { p1.innerHTML = trustedTypesHelper.createHTML( this.#wrapWordsWithSpans(subtitle.text) ); } else { p1.textContent = truncateWords(subtitle.text); } const p2 = document.createElement("p"); p2.style.cssText = this.#setting.translationStyle; if (isEnhance) { p2.innerHTML = trustedTypesHelper.createHTML( this.#wrapWordsWithSpans(subtitle.translation || "...") ); } else { p2.textContent = truncateWords(subtitle.translation) || "..."; } if (this.#setting.isBilingual) { this.#captionWindowEl.replaceChildren(p1, p2); } else { this.#captionWindowEl.replaceChildren(p2); } if (isEnhance) { this.#attachSpanListeners(); } this.#paperEl.style.display = "block"; } else { this.#paperEl.style.display = "none"; } } // 将句子中的每个单词包装在span标签中 #wrapWordsWithSpans(text) { // 使用正则表达式分割单词,保留空格和标点符号 // 这个正则表达式匹配英文单词(包括带撇号的) return text.replace( /\b([a-zA-Z]+(?:'[a-zA-Z]+)?)\b/g, '$1' ); } /** * 提前翻译指定时间范围内的字幕。 * @param {number} currentTimeMs */ #triggerTranslations(currentTimeMs) { const { preTrans = 90 } = this.#setting; const lookAheadMs = preTrans * 1000; for (const sub of this.#formattedSubtitles) { const isCurrent = sub.start <= currentTimeMs && sub.end >= currentTimeMs; const isUpcoming = sub.start > currentTimeMs && sub.start <= currentTimeMs + lookAheadMs; const needsTranslation = !sub.translation && !sub.isTranslating; if ((isCurrent || isUpcoming) && needsTranslation) { this.#translateAndStore(sub); } } } /** * 执行单个字幕的翻译并更新其状态。 * @param {object} subtitle - 需要翻译的字幕对象。 */ async #translateAndStore(subtitle) { subtitle.isTranslating = true; try { const { fromLang, toLang, apiSetting } = this.#setting; const { trText } = await apiTranslate({ text: subtitle.text, fromLang, toLang, apiSetting, }); subtitle.translation = trText; } catch (error) { logger.info("Translation failed for:", subtitle.text, error); subtitle.translation = "[Translation failed]"; } finally { subtitle.isTranslating = false; const currentSubtitleIndexNow = this.#findSubtitleIndexForTime( this.#videoEl.currentTime * 1000 ); if (this.#formattedSubtitles[currentSubtitleIndexNow] === subtitle) { this.#updateCaptionDisplay(subtitle); } // 通知外部组件字幕已更新 if (this.onSubtitleUpdate) { this.onSubtitleUpdate(this.#formattedSubtitles); } } } /** * 追加新的字幕 * @param {Array} newSubtitlesChunk - 新的、要追加的字幕数据块。 */ appendSubtitles(newSubtitlesChunk) { if (!newSubtitlesChunk || newSubtitlesChunk.length === 0) { return; } logger.info( `Bilingual Subtitle Manager: Appending ${newSubtitlesChunk.length} new subtitles...` ); // 同一个数组引用,此处无需重复添加和排序 // this.#formattedSubtitles.push(...newSubtitlesChunk); // this.#formattedSubtitles.sort((a, b) => a.start - b.start); this.#currentSubtitleIndex = -1; this.onTimeUpdate(); // 通知外部组件字幕已更新 if (this.onSubtitleUpdate) { this.onSubtitleUpdate(this.#formattedSubtitles); } } updateSetting(obj) { this.#setting = { ...this.#setting, ...obj }; } // 获取当前字幕的开始时间(使用重新分段后的时间) #getCurrentSubtitleStartTime() { const currentTimeMs = this.#videoEl.currentTime * 1000; // 查找当前时间对应的字幕 const currentSubtitle = this.#formattedSubtitles.find( (sub) => currentTimeMs >= sub.start && currentTimeMs <= sub.end ); // 返回重新分段后的字幕开始时间,如果没有找到则返回当前时间 return currentSubtitle ? currentSubtitle.start : currentTimeMs; } } ================================================ FILE: src/subtitle/Menus.js ================================================ import { useCallback, useMemo, useState } from "react"; import { API_SPE_TYPES } from "../config"; function Label({ children }) { return (
{children}
); } function MenuItem({ children, onClick, disabled = false }) { const [hover, setHover] = useState(false); return (
setHover(true)} onMouseLeave={() => setHover(false)} onClick={onClick} > {children}
); } function Switch({ label, name, value, onChange, disabled }) { const handleClick = useCallback(() => { if (disabled) return; onChange({ name, value: !value }); }, [disabled, onChange, name, value]); return (
); } function Select({ label, name, value, options, onChange, disabled }) { const [isOpen, setIsOpen] = useState(false); const selectedOption = useMemo( () => options.find((opt) => opt.value === value) || options[0], [options, value] ); const handleToggle = useCallback(() => { if (disabled) return; setIsOpen((prev) => !prev); }, [disabled]); const handleSelect = useCallback( (optionValue) => { onChange({ name, value: optionValue }); setIsOpen(false); }, [onChange, name] ); return (
{selectedOption?.label || ""}
{isOpen && (
{options.map((option) => (
handleSelect(option.value)} style={{ padding: "8px 12px", cursor: "pointer", background: option.value === value ? "rgba(32,156,238,.3)" : "transparent", opacity: option.value === value ? 1 : 0.8, transition: "all 0.2s", }} onMouseEnter={(e) => { e.currentTarget.style.background = "rgba(255,255,255,.1)"; }} onMouseLeave={(e) => { e.currentTarget.style.background = option.value === value ? "rgba(32,156,238,.3)" : "transparent"; }} > {option.label}
))}
)}
); } function Button({ label, onClick, disabled }) { const handleClick = useCallback(() => { if (disabled) return; onClick(); }, [disabled, onClick]); return ( ); } export function Menus({ i18n, formData, progressed = 0, updateSetting, downloadSubtitle, transApis, }) { const handleChange = useCallback( ({ name, value }) => { updateSetting({ name, value }); }, [updateSetting] ); // 过滤启用的API const enabledApis = useMemo( () => (transApis || []).filter((api) => !api.isDisabled), [transApis] ); // 过滤AI启用的API const aiEnabledApis = useMemo( () => enabledApis.filter((api) => API_SPE_TYPES.ai.has(api.apiType)), [enabledApis] ); // 构建断句服务选项 const segOptions = useMemo(() => { const options = [{ value: "-", label: i18n("disable") || "禁用" }]; aiEnabledApis.forEach((api) => { options.push({ value: api.apiSlug, label: api.apiName }); }); return options; }, [aiEnabledApis, i18n]); const status = useMemo(() => { if (progressed === 0) return i18n("waiting_subtitles"); if (progressed === 100) return i18n("download_subtitles"); return i18n("processing_subtitles"); }, [progressed, i18n]); const { segSlug, skipAd, isBilingual, showOrigin } = formData; return (
updateCss("color", e.target.value)} style={{ width: 40, height: 30, border: "none", cursor: "pointer", }} /> updateCss("color", e.target.value)} placeholder="white / #ffffff" sx={{ flex: 1 }} /> ); } // 背景样式 - padding, background-color, color, line-height, text-shadow if (type === "window") { const paddingStr = cssObj["padding"] || "0.5em 1em"; const padding = parsePadding(paddingStr); const bgColorStr = cssObj["background-color"] || "rgba(0, 0, 0, 0.5)"; const bgRgba = parseRgba(bgColorStr) || { r: 0, g: 0, b: 0, a: 0.5 }; const bgHex = rgbToHex(bgRgba.r, bgRgba.g, bgRgba.b); const lineHeight = parseFloat(cssObj["line-height"]) || 1.3; const hasTextShadow = !!cssObj["text-shadow"]; return ( {label} - {i18n("visual_editor") || "可视化编辑"} {/* 背景颜色 */} {i18n("background_color") || "背景颜色"} { const rgb = hexToRgb(e.target.value); updateCss( "background-color", `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${bgRgba.a})` ); }} style={{ width: 40, height: 30, border: "none", cursor: "pointer", }} /> {i18n("opacity") || "透明度"} { updateCss( "background-color", `rgba(${bgRgba.r}, ${bgRgba.g}, ${bgRgba.b}, ${val})` ); }} sx={{ flex: 1 }} /> {Math.round(bgRgba.a * 100)}% {/* 内边距 */} {i18n("padding") || "内边距"} {i18n("vertical") || "上下"} { updateCss( "padding", `${val}${padding.unit} ${padding.horizontal}${padding.unit}` ); }} sx={{ width: 100 }} /> {i18n("horizontal") || "左右"} { updateCss( "padding", `${padding.vertical}${padding.unit} ${val}${padding.unit}` ); }} sx={{ width: 100 }} /> {/* 行高 */} {i18n("line_height") || "行高"} updateCss("line-height", String(val))} sx={{ flex: 1 }} /> {lineHeight} {/* 文字阴影 */} {i18n("text_shadow") || "文字阴影"} { if (e.target.checked) { updateCss("text-shadow", "1px 1px 2px black"); } else { const newObj = { ...cssObj }; delete newObj["text-shadow"]; setCssObj(newObj); onChange(objectToCss(newObj)); } }} /> } label={ hasTextShadow ? i18n("enabled") || "已启用" : i18n("disabled") || "已禁用" } /> ); } return null; } export default function SubtitleSetting() { const i18n = useI18n(); const { subtitleSetting, updateSubtitle } = useSubtitle(); const { enabledApis, aiEnabledApis } = useApiList(); const handleChange = (e) => { e.preventDefault(); let { name, value } = e.target; updateSubtitle({ [name]: value, }); }; const handleStyleChange = (name) => (value) => { updateSubtitle({ [name]: value }); }; const { enabled, apiSlug, segSlug, chunkLength, preTrans = 90, throttleTrans = 30, toLang, isBilingual, enhanceMode = OPT_ENHANCE_MOBILE_OFF, showList = true, skipAd = false, windowStyle, originStyle, translationStyle, } = subtitleSetting; return ( {i18n("subtitle_helper_1")}
{i18n("subtitle_helper_2")}
{i18n("subtitle_helper_3")}
{ updateSubtitle({ enabled: !enabled }); }} /> } label={i18n("toggle_subtitle_translate")} sx={{ width: "fit-content" }} /> {enabledApis.map((api) => ( {api.apiName} ))} {i18n("disable")} {aiEnabledApis.map((api) => ( {api.apiName} ))} {OPT_LANGS_TO.map(([lang, name]) => ( {name} ))} {i18n("enable")} {i18n("disable")} {i18n("enable")} {i18n("disable")} {i18n("enable")} {i18n("disable")} {i18n("disable_on_mobile")} {i18n("enable")} {i18n("disable")} {/* 原文样式 - 可视化编辑器 */} }> {i18n("advanced_css") || "高级 CSS 编辑"} {/* 译文样式 - 可视化编辑器 */} }> {i18n("advanced_css") || "高级 CSS 编辑"} {/* 背景样式 - 可视化编辑器 */} }> {i18n("advanced_css") || "高级 CSS 编辑"}
); } ================================================ FILE: src/views/Options/SyncSetting.js ================================================ import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import { useI18n } from "../../hooks/I18n"; import { useSync } from "../../hooks/Sync"; import Alert from "@mui/material/Alert"; import Link from "@mui/material/Link"; import MenuItem from "@mui/material/MenuItem"; import LoadingButton from "@mui/lab/LoadingButton"; import Button from "@mui/material/Button"; import { URL_KISS_WORKER, OPT_SYNCTYPE_ALL, OPT_SYNCTYPE_WORKER, OPT_SYNCTYPE_WEBDAV, OPT_SYNCTOKEN_PERFIX, } from "../../config"; import { useState } from "react"; import { syncSettingAndRules } from "../../libs/sync"; import { useAlert } from "../../hooks/Alert"; import { useSetting } from "../../hooks/Setting"; import { kissLog } from "../../libs/log"; import SyncIcon from "@mui/icons-material/Sync"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import ContentPasteIcon from "@mui/icons-material/ContentPaste"; export default function SyncSetting() { const i18n = useI18n(); const { sync, updateSync } = useSync(); const alert = useAlert(); const [loading, setLoading] = useState(false); const { reloadSetting } = useSetting(); const handleChange = async (e) => { e.preventDefault(); const { name, value } = e.target; await updateSync({ [name]: value, }); }; const handleSyncTest = async (e) => { e.preventDefault(); try { setLoading(true); await syncSettingAndRules(); reloadSetting(); alert.success(i18n("sync_success")); } catch (err) { kissLog("sync all", err); alert.error(i18n("sync_failed")); } finally { setLoading(false); } }; const handleGenerateShareString = async () => { try { const base64Config = btoa( JSON.stringify({ syncType: syncType, syncUrl: syncUrl, syncUser: syncUser, syncKey: syncKey, }) ); const shareString = `${OPT_SYNCTOKEN_PERFIX}${base64Config}`; await navigator.clipboard.writeText(shareString); kissLog("Share string copied to clipboard", shareString); } catch (error) { kissLog("Failed to copy share string to clipboard", error); } }; const handleImportFromClipboard = async () => { try { const text = await navigator.clipboard.readText(); kissLog("read_clipboard", text); if (text.startsWith(OPT_SYNCTOKEN_PERFIX)) { const base64Config = text.slice(OPT_SYNCTOKEN_PERFIX.length); const jsonString = atob(base64Config); const updatedConfig = JSON.parse(jsonString); if (!OPT_SYNCTYPE_ALL.includes(updatedConfig.syncType)) { kissLog("error syncType", updatedConfig.syncType); return; } if (updatedConfig.syncUrl) { updateSync({ syncType: updatedConfig.syncType, syncUrl: updatedConfig.syncUrl, syncUser: updatedConfig.syncUser, syncKey: updatedConfig.syncKey, }); } else { kissLog("Invalid config structure"); } } else { kissLog("Invalid share string", text); } } catch (error) { kissLog("Failed to read from clipboard or parse JSON", error); } }; if (!sync) { return; } const { syncType = OPT_SYNCTYPE_WORKER, syncUrl = "", syncUser = "", syncKey = "", } = sync; return ( {i18n("sync_warn")} {i18n("sync_warn_2")} {OPT_SYNCTYPE_ALL.map((item) => ( {item} ))} {i18n("about_sync_api")} ) } /> {syncType === OPT_SYNCTYPE_WEBDAV && ( )} } loading={loading} > {i18n("sync_now")} ); } ================================================ FILE: src/views/Options/Tranbox.js ================================================ import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import MenuItem from "@mui/material/MenuItem"; import Grid from "@mui/material/Grid"; import { useI18n } from "../../hooks/I18n"; import { OPT_LANGS_FROM, OPT_LANGS_TO, OPT_TRANBOX_TRIGGER_CLICK, OPT_TRANBOX_TRIGGER_ALL, OPT_DICT_BING, OPT_DICT_ALL, OPT_SUG_ALL, OPT_SUG_YOUDAO, } from "../../config"; import ShortcutInput from "./ShortcutInput"; import FormControlLabel from "@mui/material/FormControlLabel"; import Switch from "@mui/material/Switch"; import { useCallback } from "react"; import { limitNumber } from "../../libs/utils"; import { useTranbox } from "../../hooks/Tranbox"; import { isExt } from "../../libs/client"; import { useApiList } from "../../hooks/Api"; import ValidationInput from "../../hooks/ValidationInput"; export default function Tranbox() { const i18n = useI18n(); const { tranboxSetting, updateTranbox } = useTranbox(); const { enabledApis } = useApiList(); const handleChange = (e) => { e.preventDefault(); let { name, value } = e.target; switch (name) { case "btnOffsetX": case "btnOffsetY": case "boxOffsetX": case "boxOffsetY": value = limitNumber(value, -200, 200); break; default: } updateTranbox({ [name]: value, }); }; const handleShortcutInput = useCallback( (val) => { updateTranbox({ tranboxShortcut: val }); }, [updateTranbox] ); const { transOpen, apiSlugs, fromLang, toLang, toLang2 = "en", tranboxShortcut, btnOffsetX, btnOffsetY, boxOffsetX = 0, boxOffsetY = 10, hideTranBtn = false, hideClickAway = false, simpleStyle = false, followSelection = false, autoHeight = false, triggerMode = OPT_TRANBOX_TRIGGER_CLICK, // extStyles = "", enDict = OPT_DICT_BING, enSug = OPT_SUG_YOUDAO, } = tranboxSetting; return ( { updateTranbox({ transOpen: !transOpen }); }} /> } label={i18n("toggle_selection_translate")} sx={{ width: "fit-content" }} /> {enabledApis.map((api) => ( {api.apiName} ))} {OPT_LANGS_FROM.map(([lang, name]) => ( {name} ))} {OPT_LANGS_TO.map(([lang, name]) => ( {name} ))} {i18n("disable")} {OPT_LANGS_TO.map(([lang, name]) => ( {name} ))} {i18n("disable")} {OPT_DICT_ALL.map((item) => ( {item} ))} {i18n("disable")} {OPT_SUG_ALL.map((item) => ( {item} ))} {OPT_TRANBOX_TRIGGER_ALL.map((item) => ( {i18n(`trigger_${item}`)} ))} {i18n("show")} {i18n("hide")} {i18n("disable")} {i18n("enable")} {i18n("disable")} {i18n("enable")} {i18n("disable")} {i18n("enable")} {i18n("disable")} {i18n("enable")} {!isExt && ( )} {/* */} ); } ================================================ FILE: src/views/Options/UploadButton.js ================================================ import { useRef } from "react"; import FileUploadIcon from "@mui/icons-material/FileUpload"; import { useI18n } from "../../hooks/I18n"; import Button from "@mui/material/Button"; export default function UploadButton({ handleImport, text, fileType = "json", fileExts = [".json"], }) { const i18n = useI18n(); const inputRef = useRef(null); const handleClick = () => { if (inputRef.current) { inputRef.current.click(); inputRef.current.value = null; } }; const onChange = (e) => { const file = e.target.files[0]; if (!file) { return; } if (!file.type.includes(fileType)) { alert(i18n("error_wrong_file_type")); return; } const reader = new FileReader(); reader.onload = async (e) => { handleImport(e.target.result); }; reader.readAsText(file); }; return ( ); } ================================================ FILE: src/views/Options/index.js ================================================ import { Routes, Route, HashRouter } from "react-router-dom"; import About from "./About"; import Rules from "./Rules"; import Setting from "./Setting"; import Layout from "./Layout"; import SyncSetting from "./SyncSetting"; import { SettingProvider } from "../../hooks/Setting"; import ThemeProvider from "../../hooks/Theme"; import { useEffect, useState } from "react"; import { isGm } from "../../libs/client"; import { sleep } from "../../libs/utils"; import { trySyncSettingAndRules } from "../../libs/sync"; import { AlertProvider } from "../../hooks/Alert"; import { ConfirmProvider } from "../../hooks/Confirm"; import Link from "@mui/material/Link"; import Divider from "@mui/material/Divider"; import Stack from "@mui/material/Stack"; import { adaptScript } from "../../libs/gm"; import Alert from "@mui/material/Alert"; import Apis from "./Apis"; import InputSetting from "./InputSetting"; import Tranbox from "./Tranbox"; import FavWords from "./FavWords"; import Playgound from "./Playground"; import MouseHoverSetting from "./MouseHover"; import SubtitleSetting from "./Subtitle"; import Loading from "../../hooks/Loading"; import StylesSetting from "./StylesSetting"; export default function Options() { const [error, setError] = useState(""); const [ready, setReady] = useState(false); useEffect(() => { const isValidVersion = (v1Str, v2Str) => { if (!v1Str || !v2Str) { return false; } const v1 = v1Str.split("."); const v2 = v2Str.split("."); return v1[0] === v2[0] && v1[1] === v2[1]; }; (async () => { if (isGm) { // 等待GM注入 let i = 0; for (;;) { if (window?.APP_INFO?.name === process.env.REACT_APP_NAME) { const { version, eventName } = window.APP_INFO; // 检查版本是否一致(只检查前两位) if (!isValidVersion(version, process.env.REACT_APP_VERSION)) { setError( `The version of the local script(v${version}) is not the latest version(v${process.env.REACT_APP_VERSION}). 本地脚本之版本(v${version})非最新版(v${process.env.REACT_APP_VERSION})。` ); return; } if (eventName) { // 注入GM接口 adaptScript(eventName); } break; } if (++i > 8) { setError( "Time out. Please confirm whether to install or enable KISS Translator GreaseMonkey script? 连接超时,请检查是否安装或启用简约翻译油猴脚本。" ); return; } await sleep(1000); } } // 同步数据 await trySyncSettingAndRules(); setReady(true); })(); }, []); if (error) { return (
{`KISS Translator v${process.env.REACT_APP_VERSION}`} {error} Install/Update Userscript for Tampermonkey/Violentmonkey Install/Update Userscript for iOS Safari
); } if (!ready) { return ; } return ( }> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> ); } ================================================ FILE: src/views/Popup/Header.js ================================================ import IconButton from "@mui/material/IconButton"; import CloseIcon from "@mui/icons-material/Close"; import HomeIcon from "@mui/icons-material/Home"; import Stack from "@mui/material/Stack"; // import DarkModeButton from "../Options/DarkModeButton"; import Typography from "@mui/material/Typography"; import SyncAltIcon from "@mui/icons-material/SyncAlt"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import { useI18n } from "../../hooks/I18n"; export default function Header({ onClose, toggleTab, openSeparateWindow }) { const i18n = useI18n(); const handleHomepage = () => { window.open(process.env.REACT_APP_HOMEPAGE, "_blank"); }; return ( {`${process.env.REACT_APP_NAME} v${process.env.REACT_APP_VERSION}`} {onClose ? ( { onClose(); }} > ) : ( {/* */} )} ); } ================================================ FILE: src/views/Popup/PopupCont.js ================================================ import { useState, useEffect, useMemo } from "react"; import Stack from "@mui/material/Stack"; import MenuItem from "@mui/material/MenuItem"; import FormControlLabel from "@mui/material/FormControlLabel"; import Switch from "@mui/material/Switch"; import Button from "@mui/material/Button"; import Grid from "@mui/material/Grid"; import Snackbar from "@mui/material/Snackbar"; import MuiAlert from "@mui/material/Alert"; import { sendBgMsg, sendTabMsg, getCurTab } from "../../libs/msg"; import { isExt } from "../../libs/client"; import { useI18n } from "../../hooks/I18n"; import TextField from "@mui/material/TextField"; import { MSG_TRANS_TOGGLE, MSG_TRANS_PUTRULE, MSG_SAVE_RULE, MSG_COMMAND_SHORTCUTS, MSG_TRANSBOX_TOGGLE, MSG_MOUSEHOVER_TOGGLE, MSG_TRANSINPUT_TOGGLE, OPT_LANGS_FROM, OPT_LANGS_TO, } from "../../config"; import { saveRule } from "../../libs/rules"; import { tryClearCaches } from "../../libs/cache"; import { kissLog } from "../../libs/log"; import { getDomainOptions, truncateMiddle } from "../../libs/url"; import { useAllTextStyles } from "../../hooks/CustomStyles"; export default function PopupCont({ rule, setting, setRule, setSetting, handleOpenSetting, processActions, isContent = false, }) { const i18n = useI18n(); const [commands, setCommands] = useState({}); const [domainOptions, setDomainOptions] = useState([]); const [selectedDomain, setSelectedDomain] = useState(""); const [snackbar, setSnackbar] = useState({ open: false, message: "" }); const { allTextStyles } = useAllTextStyles(); const handleTransToggle = async (e) => { try { setRule({ ...rule, transOpen: e.target.checked ? "true" : "false" }); if (!processActions) { await sendTabMsg(MSG_TRANS_TOGGLE); } else { processActions({ action: MSG_TRANS_TOGGLE }); } } catch (err) { kissLog("toggle trans", err); } }; const handleTransboxToggle = async (e) => { try { setSetting((pre) => ({ ...pre, tranboxSetting: { ...pre.tranboxSetting, transOpen: e.target.checked }, })); if (!processActions) { await sendTabMsg(MSG_TRANSBOX_TOGGLE); } else { processActions({ action: MSG_TRANSBOX_TOGGLE }); } } catch (err) { kissLog("toggle transbox", err); } }; const handleMousehoverToggle = async (e) => { try { setSetting((pre) => ({ ...pre, mouseHoverSetting: { ...pre.mouseHoverSetting, useMouseHover: e.target.checked, }, })); if (!processActions) { await sendTabMsg(MSG_MOUSEHOVER_TOGGLE); } else { processActions({ action: MSG_MOUSEHOVER_TOGGLE }); } } catch (err) { kissLog("toggle mousehover", err); } }; const handleInputTransToggle = async (e) => { try { setSetting((pre) => ({ ...pre, inputRule: { ...pre.inputRule, transOpen: e.target.checked, }, })); if (!processActions) { await sendTabMsg(MSG_TRANSINPUT_TOGGLE); } else { processActions({ action: MSG_TRANSINPUT_TOGGLE }); } } catch (err) { kissLog("toggle inputtrans", err); } }; const handleChange = async (e) => { try { let { name, value, checked } = e.target; if (name === "isPlainText") { value = checked; } setRule((pre) => ({ ...pre, [name]: value })); if (!processActions) { await sendTabMsg(MSG_TRANS_PUTRULE, { [name]: value }); } else { processActions({ action: MSG_TRANS_PUTRULE, args: { [name]: value } }); } } catch (err) { kissLog("update rule", err); } }; const handleClearCache = () => { tryClearCaches(); }; const handleSaveRule = async () => { try { if (!selectedDomain) { return; } const curRule = { ...rule, pattern: selectedDomain }; if (isExt && isContent) { sendBgMsg(MSG_SAVE_RULE, curRule); } else { saveRule(curRule); } setSnackbar({ open: true, message: `${i18n("save_rule")}: ${selectedDomain}`, }); } catch (err) { kissLog("save rule", err); } }; useEffect(() => { (async () => { try { let href = ""; if (!isContent) { const tab = await getCurTab(); href = tab.url; } else { href = window.location?.href; } if (href && typeof href === "string") { const options = getDomainOptions(href); setDomainOptions(options); if (options.length > 0) { setSelectedDomain(options[0]); } } } catch (err) { kissLog("get domain options", err); } })(); }, [isContent]); useEffect(() => { (async () => { try { const commands = {}; if (isExt) { const res = await sendBgMsg(MSG_COMMAND_SHORTCUTS); res.forEach(({ name, shortcut }) => { commands[name] = shortcut; }); } else { const shortcuts = setting.shortcuts; if (shortcuts) { Object.entries(shortcuts).forEach(([key, val]) => { commands[key] = val.join("+"); }); } } setCommands(commands); } catch (err) { kissLog("query cmds", err); } })(); }, [setting.shortcuts]); const optApis = useMemo( () => setting.transApis .filter((api) => !api.isDisabled) .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) .map((api) => ({ key: api.apiSlug, name: api.apiName || api.apiSlug, })), [setting.transApis] ); const tranboxEnabled = setting.tranboxSetting.transOpen; const mouseHoverEnabled = setting.mouseHoverSetting.useMouseHover; const inputTransEnabled = setting.inputRule.transOpen; const { transOpen, apiSlug, fromLang, toLang, textStyle, autoScan, transOnly, hasRichText, scanAll, isPlainText = false, } = rule; return ( } label={ commands["toggleTranslate"] ? `${i18n("translate_alt")}(${commands["toggleTranslate"]})` : i18n("translate_alt") } /> } label={i18n("autoscan_alt")} /> } label={i18n("scan_all_nodes")} /> } label={i18n("richtext_alt")} /> } label={i18n("transonly_alt")} /> } label={i18n("selection_translate")} /> } label={i18n("mousehover_translate")} /> } label={i18n("input_translate")} /> } label={i18n("plain_text_translate")} /> {OPT_LANGS_FROM.map(([lang, name]) => ( {name} ))} {OPT_LANGS_TO.map(([lang, name]) => ( {name} ))} {optApis.map(({ key, name }) => ( {name} ))} {allTextStyles.map((item) => ( {item.styleName} ))} setSelectedDomain(e.target.value)} fullWidth sx={{ mb: 1 }} > {domainOptions.map((domain) => ( {truncateMiddle(domain)} ))} setSnackbar({ open: false, message: "" })} anchorOrigin={{ vertical: "top", horizontal: "center" }} > setSnackbar({ open: false, message: "" })} severity="success" variant="filled" sx={{ width: "100%" }} > {snackbar.message} ); } ================================================ FILE: src/views/Popup/index.js ================================================ import { useState, useEffect, useCallback } from "react"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import Button from "@mui/material/Button"; import { sendBgMsg, sendTabMsg } from "../../libs/msg"; import { browser } from "../../libs/browser"; import { useI18n } from "../../hooks/I18n"; import Divider from "@mui/material/Divider"; import Header from "./Header"; import { MSG_OPEN_SEPARATE_WINDOW, MSG_TRANS_GETRULE } from "../../config"; import { kissLog } from "../../libs/log"; import PopupCont from "./PopupCont"; import TranForm from "../Selection/TranForm"; import { useSetting } from "../../hooks/Setting"; function Trantab() { const [text, setText] = useState(""); const { setting } = useSetting(); const { tranboxSetting: { enDict, enSug, apiSlugs, fromLang, toLang, toLang2 }, transApis, langDetector, } = setting; return ( ); } export default function Popup() { const i18n = useI18n(); const [rule, setRule] = useState(null); const [setting, setSetting] = useState(null); const [showTrantab, setShowTrantab] = useState(false); const [isSeparate, setIsSeparate] = useState(false); const handleOpenSetting = useCallback(() => { browser?.runtime.openOptionsPage(); }, []); useEffect(() => { (async () => { try { const cleanHash = window.location.hash.slice(1); if (cleanHash === "tranbox") { setIsSeparate(true); return; } const res = await sendTabMsg(MSG_TRANS_GETRULE); if (!res.error) { setRule(res.rule); setSetting(res.setting); } } catch (err) { kissLog("query rule", err); } })(); }, []); const toggleTab = useCallback(() => { setShowTrantab((pre) => !pre); }, []); const openSeparateWindow = useCallback(() => { sendBgMsg(MSG_OPEN_SEPARATE_WINDOW); window.close(); }, []); if (isSeparate) { return ( ); } return (
{showTrantab ? ( ) : rule ? ( ) : ( )} ); } ================================================ FILE: src/views/Selection/AudioBtn.js ================================================ import IconButton from "@mui/material/IconButton"; import VolumeUpIcon from "@mui/icons-material/VolumeUp"; import { useAudio } from "../../hooks/Audio"; import queryString from "query-string"; export function AudioBtn({ src }) { const { error, ready, playing, onPlay } = useAudio(src); if (error || !ready) { return ( ); } if (playing) { return ( ); } return ( ); } export function BaiduAudioBtn({ text, lan = "uk", spd = 3 }) { if (!text) return null; const src = `https://fanyi.baidu.com/gettts?${queryString.stringify({ lan, text, spd })}`; return ; } ================================================ FILE: src/views/Selection/CopyBtn.js ================================================ import IconButton from "@mui/material/IconButton"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import LibraryAddCheckIcon from "@mui/icons-material/LibraryAddCheck"; import { useState } from "react"; export default function CopyBtn({ text, title = "copy" }) { const [copied, setCopied] = useState(false); const handleClick = async (e) => { e.stopPropagation(); await navigator.clipboard.writeText(text); setCopied(true); const timer = setTimeout(() => { clearTimeout(timer); setCopied(false); }, 500); }; return ( {copied ? ( ) : ( )} ); } ================================================ FILE: src/views/Selection/DictCont.js ================================================ import { useState, useEffect, useMemo } from "react"; import Stack from "@mui/material/Stack"; import FavBtn from "./FavBtn"; import Typography from "@mui/material/Typography"; import CircularProgress from "@mui/material/CircularProgress"; import Divider from "@mui/material/Divider"; import Alert from "@mui/material/Alert"; import CopyBtn from "./CopyBtn"; import { useAsyncNow } from "../../hooks/Fetch"; import { dictHandlers } from "./DictHandler"; import { useI18n } from "../../hooks/I18n"; function DictBody({ text, setCopyText, setRealWord, dict }) { const { loading, error, data } = useAsyncNow(dict.apiFn, text); useEffect(() => { if (!data) { return; } const realWord = dict.reWord(data) || text; const copyText = [realWord, dict.toText(data).join("\n")].join("\n"); setRealWord(realWord); setCopyText(copyText); }, [data, text, dict, setCopyText, setRealWord]); const uiAudio = useMemo(() => dict.uiAudio(data), [data, dict]); const uiTrans = useMemo(() => dict.uiTrans(data), [data, dict]); if (loading) { return ; } if (error) { return {error}; } if (!data) { return Not found!; } return ( {uiAudio} {uiTrans} ); } export default function DictCont({ text, enDict }) { const i18n = useI18n(); const [copyText, setCopyText] = useState(text); const [realWord, setRealWord] = useState(text); const dict = dictHandlers[enDict]; return ( {text && ( {realWord} )} {dict && ( )} ); } ================================================ FILE: src/views/Selection/DictHandler.js ================================================ import Typography from "@mui/material/Typography"; import { AudioBtn, BaiduAudioBtn } from "./AudioBtn"; import { OPT_DICT_BING, OPT_DICT_YOUDAO } from "../../config"; import { apiMicrosoftDict, apiYoudaoDict } from "../../apis"; export const dictHandlers = { [OPT_DICT_BING]: { apiFn: apiMicrosoftDict, reWord: (data) => data?.word, toText: (data) => data?.trs?.map(({ pos, def }) => `${pos ? `[${pos}] ` : ""}${def}`) || [], uiAudio: (data) => ( {data?.aus?.map(({ key, audio, phonetic }) => ( {`${key} [${phonetic || ""}]`} ))} ), uiTrans: (data) => ( {data?.trs?.map(({ pos, def }, idx) => ( {pos && `[${pos}] `} {def} ))} {/* 时态 */} {data?.presents?.length > 0 && ( {data.presents.join(", ")} )} {/* 英汉双解 */} {data?.ecs?.length > 0 && ( 英汉双解 {data.ecs.map(({ pos, lis }) => ( {pos} {lis.map((item, idx) => ( {item} ))} ))} )} {/* 显示例句 */} {data?.sentences?.length > 0 && ( 例句 {data.sentences.slice(0, 2).map((sentence, idx) => ( {sentence.eng?.split(data.word)?.map((part, i, arr) => ( {i > 0 && ( {data.word} )} {part} ))} {sentence.chs} ))} )} ), }, [OPT_DICT_YOUDAO]: { apiFn: apiYoudaoDict, reWord: (data) => data?.ec?.word?.["return-phrase"], toText: (data) => data?.ec?.word?.trs?.map( ({ pos, tran }) => `${pos ? `[${pos}] ` : ""}${tran}` ) || [], uiAudio: (data) => ( {`英 ${data?.ec?.word?.ukphone ? `[${data?.ec?.word?.ukphone}]` : ""}`} {`美 ${data?.ec?.word?.usphone ? `[${data?.ec?.word?.usphone}]` : ""}`} ), uiTrans: (data) => ( {data?.ec?.word?.trs?.map(({ pos, tran }, idx) => ( {pos && `[${pos}] `} {tran} ))} {/* 显示例句 */} {data?.blng_sents_part?.["sentence-pair"]?.length > 0 && ( 例句 {data.blng_sents_part["sentence-pair"] .slice(0, 2) .map((sentence, idx) => ( {sentence.sentence ?.split(data.ec?.word?.["return-phrase"]) ?.map((part, i, arr) => ( {i > 0 && data.ec?.word?.["return-phrase"] && ( {data.ec.word["return-phrase"]} )} {part} ))} {sentence["sentence-translation"]} ))} )} ), }, }; ================================================ FILE: src/views/Selection/DraggableResizable.js ================================================ import { useState } from "react"; import Paper from "@mui/material/Paper"; import Box from "@mui/material/Box"; import { isMobile } from "../../libs/mobile"; import { useTheme, alpha } from "@mui/material/styles"; import { limitNumber } from "../../libs/utils"; function Pointer({ direction, size, setSize, position, setPosition, children, minSize, maxSize, ...props }) { const [origin, setOrigin] = useState(null); function handlePointerDown(e) { !isMobile && e.target.setPointerCapture(e.pointerId); const { clientX, clientY } = isMobile ? e.targetTouches[0] : e; setOrigin({ x: position.x, y: position.y, w: size.w, h: size.h, clientX, clientY, }); } function handlePointerMove(e) { const { clientX, clientY } = isMobile ? e.targetTouches[0] : e; if (origin) { const dx = clientX - origin.clientX; const dy = clientY - origin.clientY; let x = position.x; let y = position.y; let w = size.w; let h = size.h; switch (direction) { case "Header": x = origin.x + dx; y = origin.y + dy; break; case "TopLeft": x = origin.x + dx; y = origin.y + dy; w = origin.w - dx; h = origin.h - dy; break; case "Top": y = origin.y + dy; h = origin.h - dy; break; case "TopRight": y = origin.y + dy; w = origin.w + dx; h = origin.h - dy; break; case "Left": x = origin.x + dx; w = origin.w - dx; break; case "Right": w = origin.w + dx; break; case "BottomLeft": x = origin.x + dx; w = origin.w - dx; h = origin.h + dy; break; case "Bottom": h = origin.h + dy; break; case "BottomRight": w = origin.w + dx; h = origin.h + dy; break; default: } if (w < minSize.w) { w = minSize.w; x = position.x; } if (w > maxSize.w) { w = maxSize.w; x = position.x; } if (h < minSize.h) { h = minSize.h; y = position.y; } if (h > maxSize.h) { h = maxSize.h; y = position.y; } setPosition({ x: limitNumber(x, 0, window.innerWidth - w), y: limitNumber(y, 0, window.innerHeight - 50), }); setSize({ w: limitNumber(w, minSize.w, window.innerWidth), h: limitNumber(h, minSize.h, window.innerHeight), }); } } function handlePointerUp(e) { e.stopPropagation(); setOrigin(null); } const touchProps = isMobile ? { onTouchStart: handlePointerDown, onTouchMove: handlePointerMove, onTouchEnd: handlePointerUp, } : { onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, }; return (
{children}
); } export default function DraggableResizable({ header, children, position = { x: 0, y: 0, }, size = { w: 600, h: 400, }, minSize = { w: 300, h: 200, }, maxSize = { w: 1200, h: 1200, }, setSize, setPosition, onChangeSize, onChangePosition, autoHeight, ...props }) { const lineWidth = 4; const theme = useTheme(); const isDark = theme.palette.mode === "dark"; //dark模式日食效果,突出显示翻译小窗口 const glowShadow = isDark ? ` 0 0 0 1px rgba(255,255,255,0.18), 0 0 10px 2px rgba(255,255,255,0.18), 0 8px 32px rgba(0,0,0,0.35) ` : ` 0 4px 18px rgba(0, 0, 0, 0.15) `; const opts = { size, setSize, position, setPosition, minSize, maxSize, }; return ( {header} { const containerStyle = autoHeight ? { maxWidth: size.w, maxHeight: size.h, overflow: "hidden auto", } : { maxWidth: size.w, height: size.h, overflow: "hidden auto", }; const scrollbarTrackColor = theme.palette.mode === "dark" ? "#1f1f23" : theme.palette.background.paper; const scrollbarThumbColor = theme.palette.mode === "dark" ? alpha(theme.palette.text.primary, 0.28) : alpha(theme.palette.text.primary, 0.24); return { ...containerStyle, backgroundColor: theme.palette.background.paper, "&::-webkit-scrollbar": { width: 10, height: 10, }, "&::-webkit-scrollbar-track": { background: scrollbarTrackColor, }, "&::-webkit-scrollbar-thumb": { backgroundColor: scrollbarThumbColor, borderRadius: 8, border: `2px solid ${theme.palette.background.paper}`, }, "&::-webkit-scrollbar-thumb:hover": { backgroundColor: alpha(theme.palette.text.primary, 0.36), }, // firefox scrollbarWidth: "thin", scrollbarColor: `${scrollbarThumbColor} ${scrollbarTrackColor}`, }; }} > {children} ); } ================================================ FILE: src/views/Selection/FavBtn.js ================================================ import IconButton from "@mui/material/IconButton"; import FavoriteIcon from "@mui/icons-material/Favorite"; import FavoriteBorderIcon from "@mui/icons-material/FavoriteBorder"; import { useState } from "react"; import { useFavWords } from "../../hooks/FavWords"; import { kissLog } from "../../libs/log"; export default function FavBtn({ word, title }) { const { favWords, toggleFav } = useFavWords(); const [loading, setLoading] = useState(false); const handleClick = () => { try { setLoading(true); toggleFav(word); } catch (err) { kissLog("set fav", err); } finally { setLoading(false); } }; return ( {favWords[word] ? ( ) : ( )} ); } ================================================ FILE: src/views/Selection/SugCont.js ================================================ import Typography from "@mui/material/Typography"; import CircularProgress from "@mui/material/CircularProgress"; import Divider from "@mui/material/Divider"; import Alert from "@mui/material/Alert"; import { apiBaiduSuggest, apiYoudaoSuggest } from "../../apis"; import Stack from "@mui/material/Stack"; import { OPT_SUG_BAIDU, OPT_SUG_YOUDAO } from "../../config"; import { useAsyncNow } from "../../hooks/Fetch"; function SugBaidu({ text }) { const { loading, error, data } = useAsyncNow(apiBaiduSuggest, text); if (loading) { return ; } if (error) { return {error}; } if (!data) { return null; } return ( <> {data.map(({ k, v }) => ( {k} {v} ))} ); } function SugYoudao({ text }) { const { loading, error, data } = useAsyncNow(apiYoudaoSuggest, text); if (loading) { return ; } if (error) { return {error}; } if (!data) { return null; } return ( <> {data.map(({ entry, explain }) => ( {entry} {explain} ))} ); } export default function SugCont({ text, enSug }) { const sugMap = { [OPT_SUG_BAIDU]: , [OPT_SUG_YOUDAO]: , }; return ( {sugMap[enSug] || Sug not support} ); } ================================================ FILE: src/views/Selection/TranBox.js ================================================ import { SettingProvider } from "../../hooks/Setting"; import ThemeProvider from "../../hooks/Theme"; import DraggableResizable from "./DraggableResizable"; import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; import IconButton from "@mui/material/IconButton"; import UnfoldLessIcon from "@mui/icons-material/UnfoldLess"; import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import PushPinIcon from "@mui/icons-material/PushPin"; import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined"; import LockIcon from "@mui/icons-material/Lock"; import LockOpenIcon from "@mui/icons-material/LockOpen"; import CloseIcon from "@mui/icons-material/Close"; import Typography from "@mui/material/Typography"; import { useI18n } from "../../hooks/I18n"; import { useCallback, useState } from "react"; import TranForm from "./TranForm.js"; import { MSG_OPEN_SEPARATE_WINDOW } from "../../config/msg.js"; import { sendBgMsg } from "../../libs/msg.js"; import { isExt } from "../../libs/client.js"; import { useTheme, alpha } from "@mui/material/styles"; import Logo from "../../components/Logo"; function TranBoxHeader({ setShowBox, simpleStyle, setSimpleStyle, hideClickAway, setHideClickAway, followSelection, setFollowSelection, }) { const theme = useTheme(); const i18n = useI18n(); const iconColor = theme.palette.text.secondary; const openSeparateWindow = useCallback(() => { sendBgMsg(MSG_OPEN_SEPARATE_WINDOW); }, []); const blurOnLeave = (e) => e.currentTarget.blur(); const baseBtnStyle = { borderRadius: "6px", padding: "5px", minWidth: "30px", minHeight: "30px", transition: "all 0.2s ease", backgroundColor: "transparent", "& svg": { color: iconColor, }, }; return ( e.stopPropagation()} onTouchEnd={(e) => e.stopPropagation()} sx={{ backgroundColor: theme.palette.background.default, padding: "4px 8px 4px 12px", height: "36px", display: "flex", alignItems: "center", minHeight: "auto", }} > {!simpleStyle && ( {`${process.env.REACT_APP_NAME} v${process.env.REACT_APP_VERSION}`} )} {isExt && ( )} setHideClickAway((pre) => !pre)} sx={{ ...baseBtnStyle, "&:hover": { backgroundColor: theme.palette.success.light + "20", transform: "scale(1.05)", boxShadow: theme.shadows[2], "& svg": { color: theme.palette.success.main }, }, "&:active": { transform: "scale(0.95)", backgroundColor: theme.palette.success.light + "40", }, }} > {hideClickAway ? ( ) : ( )} setFollowSelection((pre) => !pre)} sx={{ ...baseBtnStyle, "&:hover": { backgroundColor: theme.palette.warning.light + "20", transform: "scale(1.05)", boxShadow: theme.shadows[2], "& svg": { color: theme.palette.warning.main }, }, "&:active": { transform: "scale(0.95)", backgroundColor: theme.palette.warning.light + "40", }, }} > {followSelection ? ( ) : ( )} setSimpleStyle((pre) => !pre)} sx={{ ...baseBtnStyle, "&:hover": { backgroundColor: theme.palette.info.light + "20", transform: "scale(1.05)", boxShadow: theme.shadows[2], "& svg": { color: theme.palette.info.main }, }, "&:active": { transform: "scale(0.95)", backgroundColor: theme.palette.info.light + "40", }, }} > {simpleStyle ? ( ) : ( )} setShowBox(false)} sx={{ ...baseBtnStyle, "&:hover": { backgroundColor: theme.palette.error.light + "20", transform: "scale(1.05)", boxShadow: theme.shadows[2], "& svg": { color: theme.palette.error.main }, }, "&:active": { transform: "scale(0.95)", backgroundColor: theme.palette.error.light + "40", }, }} > ); } function TranBoxContent({ simpleStyle, text, setText, apiSlugs, fromLang, toLang, toLang2, transApis, langDetector, enDict, enSug, }) { const theme = useTheme(); const isDark = theme.palette.mode === "dark"; const scrollbarTrackColor = theme.palette.mode === "dark" ? "#1f1f23" : theme.palette.background.paper; const scrollbarThumbColor = theme.palette.mode === "dark" ? alpha(theme.palette.text.primary, 0.28) : alpha(theme.palette.text.primary, 0.24); return ( ); } export default function TranBox(props) { const [mouseHover, setMouseHover] = useState(false); const simpleStyle = props.simpleStyle; const setSimpleStyle = props.setSimpleStyle; const hideClickAway = props.hideClickAway; const setHideClickAway = props.setHideClickAway; const followSelection = props.followSelection; const setFollowSelection = props.setFollowSelection; return ( {props.showBox && ( } onClick={(e) => e.stopPropagation()} onMouseEnter={() => setMouseHover(true)} onMouseLeave={() => setMouseHover(false)} > )} ); } ================================================ FILE: src/views/Selection/TranBtn.js ================================================ import { isMobile } from "../../libs/mobile"; import { limitNumber } from "../../libs/utils"; export default function TranBtn({ onTrigger, btnEvent, position, btnOffsetX, btnOffsetY, }) { const left = limitNumber(position.x + btnOffsetX, 0, window.innerWidth - 32); const top = limitNumber(position.y + btnOffsetY, 0, window.innerHeight - 32); return (
); } ================================================ FILE: src/views/Selection/TranCont.js ================================================ import TextField from "@mui/material/TextField"; import Box from "@mui/material/Box"; import CircularProgress from "@mui/material/CircularProgress"; import Stack from "@mui/material/Stack"; import { useI18n } from "../../hooks/I18n"; import { useEffect, useState, useMemo } from "react"; import { apiTranslate } from "../../apis"; import CopyBtn from "./CopyBtn"; import Typography from "@mui/material/Typography"; import Alert from "@mui/material/Alert"; export default function TranCont({ text, fromLang, toLang, apiSlug, transApis, simpleStyle = false, }) { const i18n = useI18n(); const [trText, setTrText] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const apiSetting = useMemo( () => transApis.find((api) => api.apiSlug === apiSlug), [transApis, apiSlug] ); useEffect(() => { if (!text?.trim() || !apiSetting) { return; } (async () => { try { setLoading(true); setTrText(""); setError(""); const { trText } = await apiTranslate({ text, fromLang, toLang, apiSetting, }); setTrText(trText); } catch (err) { setError(err.message); } finally { setLoading(false); } })(); }, [text, fromLang, toLang, apiSetting]); if (!apiSetting) { return null; } if (simpleStyle) { return ( {error ? ( {error} ) : loading ? ( ) : ( {trText} )} ); } return ( : null, endAdornment: ( ), }} /> ); } ================================================ FILE: src/views/Selection/TranForm.js ================================================ import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import MenuItem from "@mui/material/MenuItem"; import Grid from "@mui/material/Grid"; import Box from "@mui/material/Box"; import IconButton from "@mui/material/IconButton"; import DoneIcon from "@mui/icons-material/Done"; import CircularProgress from "@mui/material/CircularProgress"; import ContentPasteIcon from "@mui/icons-material/ContentPaste"; import { useI18n } from "../../hooks/I18n"; import { OPT_LANGS_FROM, OPT_LANGS_TO, OPT_LANGDETECTOR_ALL, OPT_DICT_ALL, OPT_SUG_ALL, OPT_LANGS_MAP, OPT_DICT_MAP, OPT_SUG_MAP, } from "../../config"; import { useState, useMemo, useEffect, useRef } from "react"; import TranCont from "./TranCont"; import DictCont from "./DictCont"; import SugCont from "./SugCont"; import CopyBtn from "./CopyBtn"; import { isValidWord } from "../../libs/utils"; import { kissLog } from "../../libs/log"; import { tryDetectLang } from "../../libs/detect"; export default function TranForm({ text, setText, apiSlugs: initApiSlugs, fromLang: initFromLang, toLang: initToLang, toLang2: initToLang2, transApis, simpleStyle = false, langDetector: initLangDetector = "-", enDict: initEnDict = "-", enSug: initEnSug = "-", isPlaygound = false, }) { const i18n = useI18n(); const [editMode, setEditMode] = useState(false); const [editText, setEditText] = useState(text); const [apiSlugs, setApiSlugs] = useState(initApiSlugs); const [fromLang, setFromLang] = useState(initFromLang); const [toLang, setToLang] = useState(initToLang); const [toLang2, setToLang2] = useState(initToLang2); const [langDetector, setLangDetector] = useState(initLangDetector); const [enDict, setEnDict] = useState(initEnDict); const [enSug, setEnSug] = useState(initEnSug); const [deLang, setDeLang] = useState(""); const [deLoading, setDeLoading] = useState(false); const inputRef = useRef(null); useEffect(() => { const input = inputRef.current; if (!input) return; input.focus(); const len = input.value.length; input.setSelectionRange(len, len); }, []); useEffect(() => { if (isValidWord(text)) { const event = new CustomEvent("kiss-add-word", { detail: { word: text }, }); document.dispatchEvent(event); } }, [text]); useEffect(() => { if (!editMode) { setEditText(text); } }, [text, editMode]); useEffect(() => { if (!text.trim()) { setDeLang(""); return; } (async () => { try { setDeLoading(true); const deLang = await tryDetectLang(text, langDetector); if (deLang) { setDeLang(deLang); } } catch (err) { kissLog("tranbox: detect lang", err); } finally { setDeLoading(false); } })(); }, [text, langDetector, setDeLang, setDeLoading]); const handlePaste = async () => { try { const text = await navigator.clipboard.readText(); setText(text.trim()); } catch (err) { // } }; // todo: 语言变化后,realToLang引发二次翻译请求 const realToLang = useMemo(() => { if ( fromLang === "auto" && toLang !== toLang2 && toLang2 !== "-" && deLang === toLang ) { return toLang2; } return toLang; }, [fromLang, toLang, toLang2, deLang]); const optApis = useMemo( () => transApis .filter((api) => !api.isDisabled) .map((api) => ({ key: api.apiSlug, name: api.apiName || api.apiSlug, })), [transApis] ); const isWord = useMemo(() => isValidWord(text), [text]); const xs = useMemo(() => (isPlaygound ? 6 : 4), [isPlaygound]); const md = useMemo(() => (isPlaygound ? 3 : 4), [isPlaygound]); const activeApiSlugs = useMemo(() => { const validSlugs = new Set(optApis.map((api) => api.key)); return apiSlugs.filter((slug) => validSlugs.has(slug)); }, [apiSlugs, optApis]); return ( {!simpleStyle && ( <> { setApiSlugs(e.target.value); }} > {optApis.map(({ key, name }) => ( {name} ))} { setFromLang(e.target.value); }} > {OPT_LANGS_FROM.map(([lang, name]) => ( {name} ))} { setToLang(e.target.value); }} > {OPT_LANGS_TO.map(([lang, name]) => ( {name} ))} {isPlaygound && ( <> { setToLang2(e.target.value); }} > {OPT_LANGS_TO.map(([lang, name]) => ( {name} ))} { setEnDict(e.target.value); }} > {i18n("disable")} {OPT_DICT_ALL.map((item) => ( {item} ))} { setEnSug(e.target.value); }} > {i18n("disable")} {OPT_SUG_ALL.map((item) => ( {item} ))} { setLangDetector(e.target.value); }} > {i18n("disable")} {OPT_LANGDETECTOR_ALL.map((item) => ( {item} ))} ) : null, }} /> )} { setEditText(e.target.value); }} onFocus={() => { setEditMode(true); }} onBlur={() => { setEditMode(false); setText(editText.trim()); }} InputProps={{ endAdornment: ( {editMode ? ( { e.stopPropagation(); setEditMode(false); setText(editText.trim()); }} title={i18n("submit")} > ) : text ? ( ) : ( )} ), }} /> )} {activeApiSlugs.map((slug) => ( ))} {isWord && OPT_DICT_MAP.has(enDict) && ( )} {isWord && OPT_SUG_MAP.has(enSug) && ( )} ); } ================================================ FILE: src/views/Selection/index.js ================================================ import TranBtn from "./TranBtn"; import TranBox from "./TranBox"; import useTranBoxState from "../../hooks/useTranBoxState"; import useSelectionController from "../../hooks/useSelectionController"; import useTranboxShortcuts from "../../hooks/useTranboxShortcuts"; export default function Selection({ contextMenuType, tranboxSetting, transApis, uiLang, langDetector, }) { const { boxSize, setBoxSize, boxPosition, setBoxPosition, simpleStyle, setSimpleStyle, hideClickAway, setHideClickAway, followSelection, setFollowSelection, boxOffsetX, boxOffsetY, } = useTranBoxState(tranboxSetting); const { showBox, setShowBox, showBtn, text, setText, position, handleOpenTranbox, handleToggleTranbox, btnEvent, } = useSelectionController({ tranboxSetting, followSelection, boxOffsetX, boxOffsetY, boxSize, setBoxPosition, hideClickAway, }); useTranboxShortcuts({ tranboxSetting, showBox, setShowBox, handleToggleTranbox, contextMenuType, uiLang, }); return ( <> { } {showBtn && ( { e.stopPropagation(); handleOpenTranbox(); }} /> )} ); }