51 Commits

Author SHA1 Message Date
a720a4cfb8 Complete the custom editor theme 2025-07-11 23:03:28 +08:00
b5510d605c Add multi-window document functionality 2025-07-10 18:45:51 +08:00
4d62da912a ⬆️ Upgrade wails v3 from Alpha 9 to Alpha 10 2025-07-10 10:01:52 +08:00
b52e067d50 🍎 Fix build issues 2025-07-08 17:36:45 +08:00
8dce06c30e 🐛 Fixed the reboot issue on different platforms 2025-07-08 12:41:30 +08:00
b404434b5b 🎨 Updated 2025-07-07 16:09:09 +08:00
685897e828 🎨 Updated 2025-07-07 15:55:48 +08:00
4f1d70135e 📝 README.md 2025-07-07 15:41:46 +08:00
4dc424781b 📄 Change the license 2025-07-07 13:39:54 +08:00
7fcfc5e992 🎨 Refactoring the extension service and the keybinding service 2025-07-07 13:19:59 +08:00
7c2318a13f Add translation features 2025-07-06 23:41:15 +08:00
a2a332e735 Added context menu 2025-07-04 14:37:35 +08:00
ebee33ea7c Add self-updating service 2025-07-03 15:21:01 +08:00
81eb2c94ac 🐛 Fixed some issues 2025-07-02 12:10:46 +08:00
25e1a98932 🐛 Fixed block formatting issue 2025-07-01 23:55:15 +08:00
1ccee779ae 🐛 Fixed extension management issues 2025-07-01 23:25:24 +08:00
3e45e6aa9b Performance optimization 2025-07-01 20:11:27 +08:00
1604564e63 Complete multi-document mode 2025-07-01 18:16:05 +08:00
70d88dabba Use SQLite instead of JSON storage 2025-06-29 23:42:06 +08:00
6f8775472d Add selection box extension 2025-06-25 23:50:57 +08:00
a9b967aba4 🐛 Fixed the issue of text highlighting expansion 2025-06-25 22:53:00 +08:00
69957a16cf 🎨 binding keymap and extension 2025-06-25 21:09:21 +08:00
650884cb85 Improve extension management 2025-06-25 17:50:50 +08:00
8e91e3cf7c Add extension management page 2025-06-24 19:18:30 +08:00
f3bcb87828 Add extension management service 2025-06-24 14:16:53 +08:00
ea025e3f5d Add update service 2025-06-23 19:42:48 +08:00
4f8272e290 Add configuration merge service 2025-06-23 12:03:56 +08:00
d6dd34db87 ♻️ Refactor and clean up the code 2025-06-22 21:30:45 +08:00
eb9b037f8e 🎨 Optimize code 2025-06-22 15:08:38 +08:00
35c89e086e 🎨 Updated 2025-06-22 12:08:50 +08:00
77287bccfa 🎨 Updated 2025-06-21 19:05:08 +08:00
a92e5486b2 🐛 Fixed theme and packaging issues 2025-06-21 15:33:50 +08:00
1153c0a652 🐛 Fixed bug 2025-06-21 15:04:04 +08:00
145b868a44 🐛 Fixed the issue of deleting blocks 2025-06-21 00:34:22 +08:00
6acab678d0 Unified management of keymap 2025-06-21 00:28:35 +08:00
cb3d369aef 🎨 Updated 2025-06-20 13:46:13 +08:00
85544ba1e4 Added key binding service 2025-06-20 13:37:48 +08:00
13072a00a1 Add formatting method 2025-06-19 20:23:20 +08:00
25858cb42b Use guesslang to detect the language 2025-06-19 16:55:02 +08:00
f2894b2a89 🐛 Fixed the issue of block creation 2025-06-19 13:40:25 +08:00
a62ea251cd Cancel use HTTP services and WebSockets 2025-06-19 10:44:27 +08:00
3c880199ae ♻️ Refactor code 2025-06-19 01:05:08 +08:00
9204315c7b Improve code block function 2025-06-18 21:19:19 +08:00
cce9cf7e92 Add code blocks and rainbow bracket extensions 2025-06-18 18:16:56 +08:00
87fe9d48b1 🎨 Update 2025-06-17 19:15:59 +08:00
1d6cf7cf68 Added text highlight and minimap 2025-06-17 17:31:01 +08:00
0927b921c3 🎨 Update 2025-06-12 00:58:35 +08:00
4844ccdf58 Added hyperlink extension 2025-06-10 21:28:50 +08:00
c79cba48c2 Added search and replace 2025-06-10 19:17:29 +08:00
ceb177114d 🐛 Fixed the bug of obtaining the operating system 2025-06-09 14:18:25 +08:00
5203784b63 Added window title bars for different operating systems 2025-06-09 03:02:07 +08:00
212 changed files with 28650 additions and 8439 deletions

83
LICENSE
View File

@@ -1,73 +1,18 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
MIT License
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Copyright (c) 2025 landaiqing
1. Definitions.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright 2025 landaiqing
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

174
README.md
View File

@@ -1,59 +1,163 @@
# Welcome to Your New Wails3 Project!
# <img src="./frontend/public/appicon.png" alt="VoidRaft Logo" width="32" height="32" style="vertical-align: middle;"> VoidRaft
Congratulations on generating your Wails3 application! This README will guide you through the next steps to get your project up and running.
[中文](README_ZH.md) | **English**
## Getting Started
> *An elegant text snippet recording tool designed for developers.*
1. Navigate to your project directory in the terminal.
VoidRaft is a modern developer-focused text editor that allows you to record, organize, and manage various text snippets anytime, anywhere. Whether it's temporary code snippets, API responses, meeting notes, or daily to-do lists, VoidRaft provides a smooth and elegant editing experience.
2. To run your application in development mode, use the following command:
## Core Features
```
wails3 dev
```
### Developer-Friendly
This will start your application and enable hot-reloading for both frontend and backend changes.
- Multi-language code block support - Syntax highlighting for 30+ programming languages
- Smart language detection - Automatically recognizes code block language types
- Code formatting - Built-in Prettier support for one-click code beautification
- Block editing mode - Split content into independent code blocks, each with different language settings
- Multi-window support - edit multiple documents at the same time
- Support for custom themes - Custom editor themes
3. To build your application for production, use:
### Modern Interface
```
wails3 build
```
- Clean and elegant design - Simple yet sophisticated user interface
- Dark/Light themes - Adapts to different usage scenarios
- Multi-language support - Built-in internationalization with multiple language switching
This will create a production-ready executable in the `build` directory.
### Extension System
## Exploring Wails3 Features
- Modular architecture - Extensible editor based on CodeMirror 6
- Rich extensions - Includes various practical editor extensions
- Rainbow bracket matching
- VSCode-style search and replace
- Color picker
- Built-in translation tool
- Text highlighting
- Code folding
- Hyperlink support
- Checkbox support
- Minimap
Now that you have your project set up, it's time to explore the features that Wails3 offers:
## Quick Start
1. **Check out the examples**: The best way to learn is by example. Visit the `examples` directory in the `v3/examples` directory to see various sample applications.
### Download and Use
2. **Run an example**: To run any of the examples, navigate to the example's directory and use:
1. Visit the releases page: https://github.com/landaiqing/voidraft/releases
2. Select and download the appropriate version
3. Run the installer and start using
```
go run .
```
### Development Environment
Note: Some examples may be under development during the alpha phase.
```bash
# Clone the project
git clone https://github.com/landaiqing/voidraft
cd voidraft
3. **Explore the documentation**: Visit the [Wails3 documentation](https://v3alpha.wails.io/) for in-depth guides and API references.
# Install frontend dependencies
cd frontend
npm install
npm run build
cd ..
4. **Join the community**: Have questions or want to share your progress? Join the [Wails Discord](https://discord.gg/JDdSxwjhGf) or visit the [Wails discussions on GitHub](https://github.com/wailsapp/wails/discussions).
# Start development server
wails3 dev
```
### Production Build
```bash
# Build application
wails3 package
```
After building, the executable will be generated in the `bin` directory.
## Technical Architecture
### Core Technologies
| Layer | Technology Stack |
|-------|------------------|
| Desktop Framework | Wails3 |
| Backend Language | Go 1.21+ |
| Frontend Framework | Vue 3 + TypeScript |
| Editor Core | CodeMirror 6 |
| Build Tool | Vite |
| Data Storage | SQLite |
## Project Structure
Take a moment to familiarize yourself with your project structure:
```
Voidraft/
├── frontend/ # Vue 3 frontend application
│ ├── src/
│ │ ├── views/editor/ # Editor core views
│ │ │ ├── extensions/ # Editor extension system
│ │ │ │ ├── codeblock/ # Code block support
│ │ │ │ ├── translator/ # Translation tool
│ │ │ │ ├── minimap/ # Code minimap
│ │ │ │ ├── vscodeSearch/ # VSCode-style search
│ │ │ │ └── ... # More extensions
│ │ │ └── ...
│ │ ├── components/ # Reusable components
│ │ ├── stores/ # Pinia state management
│ │ └── utils/ # Utility functions
│ └── public/ # Static assets
├── internal/ # Go backend core
│ ├── services/ # Core business services
│ ├── models/ # Data model definitions
│ └── events/ # Event handling mechanisms
└── main.go # Application entry point
```
- `frontend/`: Contains your frontend code (HTML, CSS, JavaScript/TypeScript)
- `main.go`: The entry point of your Go backend
- `app.go`: Define your application structure and methods here
- `wails.json`: Configuration file for your Wails project
## Development Roadmap
## Next Steps
### Platform Extension Plans
1. Modify the frontend in the `frontend/` directory to create your desired UI.
2. Add backend functionality in `main.go`.
3. Use `wails3 dev` to see your changes in real-time.
4. When ready, build your application with `wails3 build`.
| Platform | Status | Expected Time |
|----------|--------|---------------|
| macOS | Planned | Future versions |
| Linux | Planned | Future versions |
Happy coding with Wails3! If you encounter any issues or have questions, don't hesitate to consult the documentation or reach out to the Wails community.
### Planned Features
- ✅ Custom themes - Customize editor themes
- ✅ Multi-window support - Support editing multiple documents simultaneously
- [ ] Enhanced clipboard - Monitor and manage clipboard history
- [ ] Data synchronization - Cloud backup for configurations and documents
- [ ] Extension system - Support for custom plugins
## Acknowledgments
> Standing on the shoulders of giants, paying tribute to the open source spirit
The birth of VoidRaft is inseparable from the following excellent open source projects:
### Special Thanks
- **[Heynote](https://github.com/heyman/heynote/)** - VoidRaft is a feature-enhanced version based on Heynote's concept
- Inherits Heynote's elegant block editing philosophy
- Adds more practical features on the original foundation
- Rebuilt with modern technology stack
- Provides richer extension system and customization options
- Thanks to the Heynote team for bringing innovative ideas to the developer community
### Core Technology Stack
| Technology | Purpose | Link |
|------------|---------|------|
| Wails3 | Cross-platform desktop application framework | [wails.io](https://v3alpha.wails.io/) |
| Vue.js | Progressive frontend framework | [vuejs.org](https://vuejs.org/) |
| CodeMirror 6 | Modern code editor | [codemirror.net](https://codemirror.net/) |
| Prettier | Code formatting tool | [prettier.io](https://prettier.io/) |
| TypeScript | Type-safe JavaScript | [typescriptlang.org](https://www.typescriptlang.org/) |
## License
This project is open source under the [MIT License](LICENSE).
Welcome to Fork, Star, and contribute code.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![GitHub stars](https://img.shields.io/github/stars/landaiqing/Voidraft.svg?style=social&label=Star)](https://github.com/yourusername/Voidraft)
[![GitHub forks](https://img.shields.io/github/forks/landaiqing/Voidraft.svg?style=social&label=Fork)](https://github.com/yourusername/Voidraft)
*Made with ❤️ by landaiqing*

165
README_ZH.md Normal file
View File

@@ -0,0 +1,165 @@
# <img src="./frontend/public/appicon.png" alt="Voidraft Logo" width="32" height="32" style="vertical-align: middle;"> Voidraft
**中文** | [English](README.md)
> *一个专为开发者打造的优雅文本片段记录工具。*
Voidraft 是一个现代化的开发者专用文本编辑器让你能够随时随地记录、整理和管理各种文本片段。无论是临时的代码片段、API 响应、会议笔记还是日常的待办事项Voidraft 都能为你提供流畅而优雅的编辑体验。
## 核心特性
### 开发者友好
- 多语言代码块支持 - 支持 30+ 种编程语言的语法高亮
- 智能语言检测 - 自动识别代码块语言类型
- 代码格式化 - 内置 Prettier 支持,一键美化代码
- 块状编辑模式 - 将内容分割为独立的代码块,每个块可设置不同语言
- 支持多窗口 - 同时编辑多个文档
- 支持自定义主题 - 自定义编辑器主题
### 现代化界面
- 清新脱俗的设计 - 简洁而不失优雅的用户界面
- 深色/浅色主题 - 适应不同使用场景
- 多语言支持 - 内置国际化功能,支持多种语言切换
### 扩展系统
- 模块化架构 - 基于 CodeMirror 6 的可扩展编辑器
- 丰富的扩展 - 包含多种实用编辑器扩展
- 彩虹括号匹配
- VSCode 风格搜索替换
- 颜色选择器
- 内置翻译工具
- 文本高亮
- 代码折叠
- 超链接支持
- 复选框支持
- 小地图
## 快速开始
### 下载使用
1. 访问发布页面https://github.com/landaiqing/voidraft/releases
2. 选择适合的版本下载
3. 运行安装程序,开始使用
### 开发环境
```bash
# 克隆项目
git clone https://github.com/landaiqing/voidraft
cd voidraft
# 安装前端依赖
cd frontend
npm install
npm run build
cd ..
# 启动开发服务器
wails3 dev
```
### 生产构建
```bash
# 构建应用
wails3 package
```
构建完成后,可执行文件将生成在 `bin` 目录中。
## 技术架构
### 核心技术
| 层级 | 技术栈 |
|------|--------|
| 桌面框架 | Wails3 |
| 后端语言 | Go 1.21+ |
| 前端框架 | Vue 3 + TypeScript |
| 编辑器核心 | CodeMirror 6 |
| 构建工具 | Vite |
| 数据存储 | SQLite |
## 项目结构
```
Voidraft/
├── frontend/ # Vue 3 前端应用
│ ├── src/
│ │ ├── views/editor/ # 编辑器核心视图
│ │ │ ├── extensions/ # 编辑器扩展系统
│ │ │ │ ├── codeblock/ # 代码块支持
│ │ │ │ ├── translator/ # 翻译工具
│ │ │ │ ├── minimap/ # 代码小地图
│ │ │ │ ├── vscodeSearch/ # VSCode风格搜索
│ │ │ │ └── ... # 更多扩展
│ │ │ └── ...
│ │ ├── components/ # 可复用组件
│ │ ├── stores/ # Pinia 状态管理
│ │ └── utils/ # 工具函数库
│ └── public/ # 静态资源文件
├── internal/ # Go 后端核心
│ ├── services/ # 核心业务服务
│ ├── models/ # 数据模型定义
│ └── events/ # 事件处理机制
└── main.go # 应用程序入口
```
## 开发路线图
### 平台扩展计划
| 平台 | 状态 | 预期时间 |
|------|------|----------|
| macOS | 计划中 | 后续版本 |
| Linux | 计划中 | 后续版本 |
### 计划添加的功能
- ✅ 自定义主题 - 自定义编辑器主题
- ✅ 多窗口支持 - 支持同时编辑多个文档
- [ ] 剪切板增强 - 监听和管理剪切板历史
- [ ] 数据同步 - 配置和文档云端备份
- [ ] 扩展系统 - 支持自定义插件
## 致谢
> 站在巨人的肩膀上,致敬开源精神
Voidraft 的诞生离不开以下优秀的开源项目:
### 特别感谢
- **[Heynote](https://github.com/heyman/heynote/)** - Voidraft 是基于 Heynote 概念的功能增强版本
- 继承了 Heynote 优雅的块状编辑理念
- 在原有基础上增加了更多实用功能
- 采用现代化技术栈重新构建
- 提供更丰富的扩展系统和自定义选项
- 感谢 Heynote 团队为开发者社区带来的创新思路
### 核心技术栈
| 技术 | 用途 | 链接 |
|------|------|------------------------------------------------------|
| Wails3 | 跨平台桌面应用框架 | [wails.io](https://v3alpha.wails.io/) |
| Vue.js | 渐进式前端框架 | [vuejs.org](https://vuejs.org/) |
| CodeMirror 6 | 现代化代码编辑器 | [codemirror.net](https://codemirror.net/) |
| Prettier | 代码格式化工具 | [prettier.io](https://prettier.io/) |
| TypeScript | 类型安全的 JavaScript | [typescriptlang.org](https://www.typescriptlang.org/) |
## 许可证
本项目采用 [MIT 许可证](LICENSE) 开源。
欢迎 Fork、Star 和贡献代码。
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![GitHub stars](https://img.shields.io/github/stars/landaiqing/Voidraft.svg?style=social&label=Star)](https://github.com/yourusername/Voidraft)
[![GitHub forks](https://img.shields.io/github/forks/landaiqing/Voidraft.svg?style=social&label=Fork)](https://github.com/yourusername/Voidraft)
*Made with ❤️ by landaiqing*

View File

@@ -8,9 +8,9 @@ info:
companyName: "Voidraft" # The name of the company
productName: "Voidraft" # The name of the application
productIdentifier: "landaiqing" # The unique product identifier
description: "Your Inspiration Catcher - Instant thought-capturing tool with minimalist design" # The application description
description: "Voidraft" # The application description
copyright: "© 2025 Voidraft. All rights reserved." # Copyright text
comments: "Effortlessly capture and organize fleeting ideas with minimal design" # Comments
comments: "Voidraft" # Comments
version: "0.0.1.0" # The application version
# Dev mode configuration

View File

@@ -6,13 +6,13 @@
<key>CFBundleName</key>
<string>Voidraft</string>
<key>CFBundleExecutable</key>
<string>voidraft</string>
<string>Voidraft</string>
<key>CFBundleIdentifier</key>
<string>landaiqing</string>
<key>CFBundleVersion</key>
<string>0.0.1.0</string>
<key>CFBundleGetInfoString</key>
<string>Effortlessly capture and organize fleeting ideas with minimal design</string>
<string>Voidraft</string>
<key>CFBundleShortVersionString</key>
<string>0.0.1.0</string>
<key>CFBundleIconFile</key>

View File

@@ -6,13 +6,13 @@
<key>CFBundleName</key>
<string>Voidraft</string>
<key>CFBundleExecutable</key>
<string>voidraft</string>
<string>Voidraft</string>
<key>CFBundleIdentifier</key>
<string>landaiqing</string>
<key>CFBundleVersion</key>
<string>0.0.1.0</string>
<key>CFBundleGetInfoString</key>
<string>Effortlessly capture and organize fleeting ideas with minimal design</string>
<string>Voidraft</string>
<key>CFBundleShortVersionString</key>
<string>0.0.1.0</string>
<key>CFBundleIconFile</key>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -3,26 +3,26 @@
#
# The lines below are called `modelines`. See `:help modeline`
name: "voidraft"
name: "Voidraft"
arch: ${GOARCH}
platform: "linux"
version: "0.0.1.0"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
description: "Your Inspiration Catcher - Instant thought-capturing tool with minimalist design"
description: "Voidraft"
vendor: "Voidraft"
homepage: "https://wails.io"
license: "MIT"
release: "1"
contents:
- src: "./bin/voidraft"
dst: "/usr/local/bin/voidraft"
- src: "./bin/Voidraft"
dst: "/usr/local/bin/Voidraft"
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/voidraft.png"
- src: "./build/linux/voidraft.desktop"
dst: "/usr/share/applications/voidraft.desktop"
dst: "/usr/share/icons/hicolor/128x128/apps/Voidraft.png"
- src: "./build/linux/Voidraft.desktop"
dst: "/usr/share/applications/Voidraft.desktop"
depends:
- gtk3

View File

@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=voidraft
Exec=voidraft
Icon=appicon
Categories=Development;
Terminal=false
Keywords=wails
Version=1.0
StartupNotify=false

View File

@@ -6,10 +6,10 @@
"0000": {
"ProductVersion": "0.0.1.0",
"CompanyName": "Voidraft",
"FileDescription": "Your Inspiration Catcher - Instant thought-capturing tool with minimalist design",
"FileDescription": "Voidraft",
"LegalCopyright": "© 2025 Voidraft. All rights reserved.",
"ProductName": "Voidraft",
"Comments": "Effortlessly capture and organize fleeting ideas with minimal design"
"Comments": "Voidraft"
}
}
}

View File

@@ -5,7 +5,7 @@
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "voidraft"
!define INFO_PROJECTNAME "Voidraft"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "Voidraft"

View File

@@ -11,15 +11,55 @@ import * as slog$0 from "../../../../../../log/slog/models.js";
export class App {
/**
* The main application menu
* Manager pattern for organized API
*/
"ApplicationMenu": Menu | null;
"Window": WindowManager | null;
"ContextMenu": ContextMenuManager | null;
"KeyBinding": KeyBindingManager | null;
"Browser": BrowserManager | null;
"Env": EnvironmentManager | null;
"Dialog": DialogManager | null;
"Event": EventManager | null;
"Menu": MenuManager | null;
"Screen": ScreenManager | null;
"Clipboard": ClipboardManager | null;
"SystemTray": SystemTrayManager | null;
"Logger": slog$0.Logger | null;
/** Creates a new App instance. */
constructor($$source: Partial<App> = {}) {
if (!("ApplicationMenu" in $$source)) {
this["ApplicationMenu"] = null;
if (!("Window" in $$source)) {
this["Window"] = null;
}
if (!("ContextMenu" in $$source)) {
this["ContextMenu"] = null;
}
if (!("KeyBinding" in $$source)) {
this["KeyBinding"] = null;
}
if (!("Browser" in $$source)) {
this["Browser"] = null;
}
if (!("Env" in $$source)) {
this["Env"] = null;
}
if (!("Dialog" in $$source)) {
this["Dialog"] = null;
}
if (!("Event" in $$source)) {
this["Event"] = null;
}
if (!("Menu" in $$source)) {
this["Menu"] = null;
}
if (!("Screen" in $$source)) {
this["Screen"] = null;
}
if (!("Clipboard" in $$source)) {
this["Clipboard"] = null;
}
if (!("SystemTray" in $$source)) {
this["SystemTray"] = null;
}
if (!("Logger" in $$source)) {
this["Logger"] = null;
@@ -34,31 +74,308 @@ export class App {
static createFrom($$source: any = {}): App {
const $$createField0_0 = $$createType1;
const $$createField1_0 = $$createType3;
const $$createField2_0 = $$createType5;
const $$createField3_0 = $$createType7;
const $$createField4_0 = $$createType9;
const $$createField5_0 = $$createType11;
const $$createField6_0 = $$createType13;
const $$createField7_0 = $$createType15;
const $$createField8_0 = $$createType17;
const $$createField9_0 = $$createType19;
const $$createField10_0 = $$createType21;
const $$createField11_0 = $$createType23;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("ApplicationMenu" in $$parsedSource) {
$$parsedSource["ApplicationMenu"] = $$createField0_0($$parsedSource["ApplicationMenu"]);
if ("Window" in $$parsedSource) {
$$parsedSource["Window"] = $$createField0_0($$parsedSource["Window"]);
}
if ("ContextMenu" in $$parsedSource) {
$$parsedSource["ContextMenu"] = $$createField1_0($$parsedSource["ContextMenu"]);
}
if ("KeyBinding" in $$parsedSource) {
$$parsedSource["KeyBinding"] = $$createField2_0($$parsedSource["KeyBinding"]);
}
if ("Browser" in $$parsedSource) {
$$parsedSource["Browser"] = $$createField3_0($$parsedSource["Browser"]);
}
if ("Env" in $$parsedSource) {
$$parsedSource["Env"] = $$createField4_0($$parsedSource["Env"]);
}
if ("Dialog" in $$parsedSource) {
$$parsedSource["Dialog"] = $$createField5_0($$parsedSource["Dialog"]);
}
if ("Event" in $$parsedSource) {
$$parsedSource["Event"] = $$createField6_0($$parsedSource["Event"]);
}
if ("Menu" in $$parsedSource) {
$$parsedSource["Menu"] = $$createField7_0($$parsedSource["Menu"]);
}
if ("Screen" in $$parsedSource) {
$$parsedSource["Screen"] = $$createField8_0($$parsedSource["Screen"]);
}
if ("Clipboard" in $$parsedSource) {
$$parsedSource["Clipboard"] = $$createField9_0($$parsedSource["Clipboard"]);
}
if ("SystemTray" in $$parsedSource) {
$$parsedSource["SystemTray"] = $$createField10_0($$parsedSource["SystemTray"]);
}
if ("Logger" in $$parsedSource) {
$$parsedSource["Logger"] = $$createField1_0($$parsedSource["Logger"]);
$$parsedSource["Logger"] = $$createField11_0($$parsedSource["Logger"]);
}
return new App($$parsedSource as Partial<App>);
}
}
export class Menu {
/**
* BrowserManager manages browser-related operations
*/
export class BrowserManager {
/** Creates a new Menu instance. */
constructor($$source: Partial<Menu> = {}) {
/** Creates a new BrowserManager instance. */
constructor($$source: Partial<BrowserManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new Menu instance from a string or object.
* Creates a new BrowserManager instance from a string or object.
*/
static createFrom($$source: any = {}): Menu {
static createFrom($$source: any = {}): BrowserManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new Menu($$parsedSource as Partial<Menu>);
return new BrowserManager($$parsedSource as Partial<BrowserManager>);
}
}
/**
* ClipboardManager manages clipboard operations
*/
export class ClipboardManager {
/** Creates a new ClipboardManager instance. */
constructor($$source: Partial<ClipboardManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new ClipboardManager instance from a string or object.
*/
static createFrom($$source: any = {}): ClipboardManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new ClipboardManager($$parsedSource as Partial<ClipboardManager>);
}
}
/**
* ContextMenuManager manages all context menu operations
*/
export class ContextMenuManager {
/** Creates a new ContextMenuManager instance. */
constructor($$source: Partial<ContextMenuManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new ContextMenuManager instance from a string or object.
*/
static createFrom($$source: any = {}): ContextMenuManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new ContextMenuManager($$parsedSource as Partial<ContextMenuManager>);
}
}
/**
* DialogManager manages dialog-related operations
*/
export class DialogManager {
/** Creates a new DialogManager instance. */
constructor($$source: Partial<DialogManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new DialogManager instance from a string or object.
*/
static createFrom($$source: any = {}): DialogManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new DialogManager($$parsedSource as Partial<DialogManager>);
}
}
/**
* EnvironmentManager manages environment-related operations
*/
export class EnvironmentManager {
/** Creates a new EnvironmentManager instance. */
constructor($$source: Partial<EnvironmentManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new EnvironmentManager instance from a string or object.
*/
static createFrom($$source: any = {}): EnvironmentManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new EnvironmentManager($$parsedSource as Partial<EnvironmentManager>);
}
}
/**
* EventManager manages event-related operations
*/
export class EventManager {
/** Creates a new EventManager instance. */
constructor($$source: Partial<EventManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new EventManager instance from a string or object.
*/
static createFrom($$source: any = {}): EventManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new EventManager($$parsedSource as Partial<EventManager>);
}
}
/**
* KeyBindingManager manages all key binding operations
*/
export class KeyBindingManager {
/** Creates a new KeyBindingManager instance. */
constructor($$source: Partial<KeyBindingManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new KeyBindingManager instance from a string or object.
*/
static createFrom($$source: any = {}): KeyBindingManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new KeyBindingManager($$parsedSource as Partial<KeyBindingManager>);
}
}
/**
* MenuManager manages menu-related operations
*/
export class MenuManager {
/** Creates a new MenuManager instance. */
constructor($$source: Partial<MenuManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new MenuManager instance from a string or object.
*/
static createFrom($$source: any = {}): MenuManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new MenuManager($$parsedSource as Partial<MenuManager>);
}
}
export class ScreenManager {
/** Creates a new ScreenManager instance. */
constructor($$source: Partial<ScreenManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new ScreenManager instance from a string or object.
*/
static createFrom($$source: any = {}): ScreenManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new ScreenManager($$parsedSource as Partial<ScreenManager>);
}
}
/**
* ServiceOptions provides optional parameters for calls to [NewService].
*/
export class ServiceOptions {
/**
* Name can be set to override the name of the service
* for logging and debugging purposes.
*
* If empty, it will default
* either to the value obtained through the [ServiceName] interface,
* or to the type name.
*/
"Name": string;
/**
* If the service instance implements [http.Handler],
* it will be mounted on the internal asset server
* at the prefix specified by Route.
*/
"Route": string;
/**
* MarshalError will be called if non-nil
* to marshal to JSON the error values returned by this service's methods.
*
* MarshalError is not allowed to fail,
* but it may return a nil slice to fall back
* to the globally configured error handler.
*
* If the returned slice is not nil, it must contain valid JSON.
*/
"MarshalError": any;
/** Creates a new ServiceOptions instance. */
constructor($$source: Partial<ServiceOptions> = {}) {
if (!("Name" in $$source)) {
this["Name"] = "";
}
if (!("Route" in $$source)) {
this["Route"] = "";
}
if (!("MarshalError" in $$source)) {
this["MarshalError"] = null;
}
Object.assign(this, $$source);
}
/**
* Creates a new ServiceOptions instance from a string or object.
*/
static createFrom($$source: any = {}): ServiceOptions {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new ServiceOptions($$parsedSource as Partial<ServiceOptions>);
}
}
/**
* SystemTrayManager manages system tray-related operations
*/
export class SystemTrayManager {
/** Creates a new SystemTrayManager instance. */
constructor($$source: Partial<SystemTrayManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new SystemTrayManager instance from a string or object.
*/
static createFrom($$source: any = {}): SystemTrayManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new SystemTrayManager($$parsedSource as Partial<SystemTrayManager>);
}
}
@@ -79,8 +396,48 @@ export class WebviewWindow {
}
}
/**
* WindowManager manages all window-related operations
*/
export class WindowManager {
/** Creates a new WindowManager instance. */
constructor($$source: Partial<WindowManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new WindowManager instance from a string or object.
*/
static createFrom($$source: any = {}): WindowManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new WindowManager($$parsedSource as Partial<WindowManager>);
}
}
// Private type creation functions
const $$createType0 = Menu.createFrom;
const $$createType0 = WindowManager.createFrom;
const $$createType1 = $Create.Nullable($$createType0);
const $$createType2 = slog$0.Logger.createFrom;
const $$createType2 = ContextMenuManager.createFrom;
const $$createType3 = $Create.Nullable($$createType2);
const $$createType4 = KeyBindingManager.createFrom;
const $$createType5 = $Create.Nullable($$createType4);
const $$createType6 = BrowserManager.createFrom;
const $$createType7 = $Create.Nullable($$createType6);
const $$createType8 = EnvironmentManager.createFrom;
const $$createType9 = $Create.Nullable($$createType8);
const $$createType10 = DialogManager.createFrom;
const $$createType11 = $Create.Nullable($$createType10);
const $$createType12 = EventManager.createFrom;
const $$createType13 = $Create.Nullable($$createType12);
const $$createType14 = MenuManager.createFrom;
const $$createType15 = $Create.Nullable($$createType14);
const $$createType16 = ScreenManager.createFrom;
const $$createType17 = $Create.Nullable($$createType16);
const $$createType18 = ClipboardManager.createFrom;
const $$createType19 = $Create.Nullable($$createType18);
const $$createType20 = SystemTrayManager.createFrom;
const $$createType21 = $Create.Nullable($$createType20);
const $$createType22 = slog$0.Logger.createFrom;
const $$createType23 = $Create.Nullable($$createType22);

View File

@@ -0,0 +1,9 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as Service from "./service.js";
export {
Service
};
export * from "./models.js";

View File

@@ -0,0 +1,51 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Create as $Create} from "@wailsio/runtime";
export class Config {
/**
* DBSource is the database URI to use.
* The string ":memory:" can be used to create an in-memory database.
* The sqlite driver can be configured through query parameters.
* For more details see https://pkg.go.dev/modernc.org/sqlite#Driver.Open
*/
"DBSource": string;
/** Creates a new Config instance. */
constructor($$source: Partial<Config> = {}) {
if (!("DBSource" in $$source)) {
this["DBSource"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new Config instance from a string or object.
*/
static createFrom($$source: any = {}): Config {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new Config($$parsedSource as Partial<Config>);
}
}
/**
* Row holds a single row in the result of a query.
* It is a key-value map where keys are column names.
*/
export type Row = { [_: string]: any };
/**
* Rows holds the result of a query
* as an array of key-value maps where keys are column names.
*/
export type Rows = Row[];
/**
* Stmt wraps a prepared sql statement pointer.
* It provides the same methods as the [sql.Stmt] type.
*/
export type Stmt = string;

View File

@@ -0,0 +1,223 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as application$0 from "../../application/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
export {
ExecContext as Execute,
QueryContext as Query
};
import { Stmt } from "./stmt.js";
/**
* Prepare creates a prepared statement for later queries or executions.
* Multiple queries or executions may be run concurrently from the returned statement.
*
* The caller must call the statement's Close method when it is no longer needed.
* Statements are closed automatically
* when the connection they are associated with is closed.
*
* Prepare supports early cancellation.
*/
export function Prepare(query: string): Promise<Stmt | null> & { cancel(): void } {
const promise = PrepareContext(query);
const wrapper: any = (promise.then(function (id) {
return id == null ? null : new Stmt(
ClosePrepared.bind(null, id),
ExecPrepared.bind(null, id),
QueryPrepared.bind(null, id));
}));
wrapper.cancel = promise.cancel;
return wrapper;
}
/**
* Close closes the current database connection if one is open, otherwise has no effect.
* Additionally, Close closes all open prepared statements associated to the connection.
*
* Even when a non-nil error is returned,
* the database service is left in a consistent state,
* ready for a call to [Service.Open].
*/
export function Close(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1888105376) as any;
return $resultPromise;
}
/**
* ClosePrepared closes a prepared statement
* obtained with [Service.Prepare] or [Service.PrepareContext].
* ClosePrepared is idempotent:
* it has no effect on prepared statements that are already closed.
*/
function ClosePrepared(stmt: $models.Stmt | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2526200629, stmt) as any;
return $resultPromise;
}
/**
* Configure changes the database service configuration.
* The connection state at call time is preserved.
* Consumers will need to call [Service.Open] manually after Configure
* in order to reconnect with the new configuration.
*
* See [NewWithConfig] for details on configuration.
*/
export function Configure(config: $models.Config | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1939578712, config) as any;
return $resultPromise;
}
/**
* ExecContext executes a query without returning any rows.
* It supports early cancellation.
*/
function ExecContext(query: string, ...args: any[]): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(674944556, query, args) as any;
return $resultPromise;
}
/**
* ExecPrepared executes a prepared statement
* obtained with [Service.Prepare] or [Service.PrepareContext]
* without returning any rows.
* It supports early cancellation.
*/
function ExecPrepared(stmt: $models.Stmt | null, ...args: any[]): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2086877656, stmt, args) as any;
return $resultPromise;
}
/**
* Execute executes a query without returning any rows.
*/
export function Execute(query: string, ...args: any[]): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3811930203, query, args) as any;
return $resultPromise;
}
/**
* Open validates the current configuration,
* closes the current connection if one is present,
* then opens and validates a new connection.
*
* Even when a non-nil error is returned,
* the database service is left in a consistent state,
* ready for a new call to Open.
*/
export function Open(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2012175612) as any;
return $resultPromise;
}
/**
* Prepare creates a prepared statement for later queries or executions.
* Multiple queries or executions may be run concurrently from the returned statement.
*
* The caller should call the statement's Close method when it is no longer needed.
* Statements are closed automatically
* when the connection they are associated with is closed.
*/
export function Prepare(query: string): Promise<$models.Stmt | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(1801965143, query) as any;
return $resultPromise;
}
/**
* PrepareContext creates a prepared statement for later queries or executions.
* Multiple queries or executions may be run concurrently from the returned statement.
*
* The caller must call the statement's Close method when it is no longer needed.
* Statements are closed automatically
* when the connection they are associated with is closed.
*
* PrepareContext supports early cancellation.
*/
function PrepareContext(query: string): Promise<$models.Stmt | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(570941694, query) as any;
return $resultPromise;
}
/**
* Query executes a query and returns a slice of key-value records,
* one per row, with column names as keys.
*/
export function Query(query: string, ...args: any[]): Promise<$models.Rows> & { cancel(): void } {
let $resultPromise = $Call.ByID(860757720, query, args) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* QueryContext executes a query and returns a slice of key-value records,
* one per row, with column names as keys.
* It supports early cancellation, returning the slice of results fetched so far.
*/
function QueryContext(query: string, ...args: any[]): Promise<$models.Rows> & { cancel(): void } {
let $resultPromise = $Call.ByID(4115542347, query, args) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* QueryPrepared executes a prepared statement
* obtained with [Service.Prepare] or [Service.PrepareContext]
* and returns a slice of key-value records, one per row, with column names as keys.
* It supports early cancellation, returning the slice of results fetched so far.
*/
function QueryPrepared(stmt: $models.Stmt | null, ...args: any[]): Promise<$models.Rows> & { cancel(): void } {
let $resultPromise = $Call.ByID(3885083725, stmt, args) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* ServiceName returns the name of the plugin.
* You should use the go module format e.g. github.com/myuser/myplugin
*/
export function ServiceName(): Promise<string> & { cancel(): void } {
let $resultPromise = $Call.ByID(1637123084) as any;
return $resultPromise;
}
/**
* ServiceShutdown closes the database connection.
* It returns a non-nil error in case of failures.
*/
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3650435925) as any;
return $resultPromise;
}
/**
* ServiceStartup opens the database connection.
* It returns a non-nil error in case of failures.
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1113159936, options) as any;
return $resultPromise;
}
// Private type creation functions
const $$createType0 = $Create.Map($Create.Any, $Create.Any);
const $$createType1 = $Create.Array($$createType0);

View File

@@ -0,0 +1,79 @@
//@ts-check
//@ts-ignore: Unused imports
import * as $models from "./models.js";
const execSymbol = Symbol("exec"),
querySymbol = Symbol("query"),
closeSymbol = Symbol("close");
/**
* Stmt represents a prepared statement for later queries or executions.
* Multiple queries or executions may be run concurrently on the same statement.
*
* The caller must call the statement's Close method when it is no longer needed.
* Statements are closed automatically
* when the connection they are associated with is closed.
*/
export class Stmt {
/**
* Constructs a new prepared statement instance.
* @param {(...args: any[]) => Promise<void>} close
* @param {(...args: any[]) => Promise<void> & { cancel(): void }} exec
* @param {(...args: any[]) => Promise<$models.Rows> & { cancel(): void }} query
*/
constructor(close, exec, query) {
/**
* @member
* @private
* @type {typeof close}
*/
this[closeSymbol] = close;
/**
* @member
* @private
* @type {typeof exec}
*/
this[execSymbol] = exec;
/**
* @member
* @private
* @type {typeof query}
*/
this[querySymbol] = query;
}
/**
* Closes the prepared statement.
* It has no effect when the statement is already closed.
* @returns {Promise<void>}
*/
Close() {
return this[closeSymbol]();
}
/**
* Executes the prepared statement without returning any rows.
* It supports early cancellation.
*
* @param {any[]} args
* @returns {Promise<void> & { cancel(): void }}
*/
Exec(...args) {
return this[execSymbol](...args);
}
/**
* Executes the prepared statement
* and returns a slice of key-value records, one per row, with column names as keys.
* It supports early cancellation, returning the array of results fetched so far.
*
* @param {any[]} args
* @returns {Promise<$models.Rows> & { cancel(): void }}
*/
Query(...args) {
return this[querySymbol](...args);
}
}

View File

@@ -0,0 +1,4 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export * from "./models.js";

View File

@@ -0,0 +1,71 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Create as $Create} from "@wailsio/runtime";
/**
* LanguageInfo 语言信息结构体
*/
export class LanguageInfo {
/**
* 语言代码
*/
"Code": string;
/**
* 语言名称
*/
"Name": string;
/** Creates a new LanguageInfo instance. */
constructor($$source: Partial<LanguageInfo> = {}) {
if (!("Code" in $$source)) {
this["Code"] = "";
}
if (!("Name" in $$source)) {
this["Name"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new LanguageInfo instance from a string or object.
*/
static createFrom($$source: any = {}): LanguageInfo {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new LanguageInfo($$parsedSource as Partial<LanguageInfo>);
}
}
/**
* TranslatorType 翻译器类型
*/
export enum TranslatorType {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* GoogleTranslatorType 谷歌翻译器
*/
GoogleTranslatorType = "google",
/**
* BingTranslatorType 必应翻译器
*/
BingTranslatorType = "bing",
/**
* YoudaoTranslatorType 有道翻译器
*/
YoudaoTranslatorType = "youdao",
/**
* DeeplTranslatorType DeepL翻译器
*/
DeeplTranslatorType = "deepl",
};

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
// This file is automatically generated. DO NOT EDIT
/**
* ConfigService 提供基于 Viper 的配置管理功能
* ConfigService 应用配置服务
* @module
*/
@@ -42,6 +42,14 @@ export function ResetConfig(): Promise<void> & { cancel(): void } {
return $resultPromise;
}
/**
* ServiceShutdown 关闭服务
*/
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3963562361) as any;
return $resultPromise;
}
/**
* Set 设置配置项
*/

View File

@@ -0,0 +1,39 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* DatabaseService provides shared database functionality
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
/**
* OnDataPathChanged handles data path changes
*/
export function OnDataPathChanged(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3652863491) as any;
return $resultPromise;
}
/**
* ServiceShutdown shuts down the service when the application closes
*/
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3907893632) as any;
return $resultPromise;
}
/**
* ServiceStartup initializes the service when the application starts
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2067840771, options) as any;
return $resultPromise;
}

View File

@@ -2,7 +2,7 @@
// This file is automatically generated. DO NOT EDIT
/**
* DocumentService 提供文档管理功能
* DocumentService provides document management functionality
* @module
*/
@@ -10,23 +10,18 @@
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as models$0 from "../models/models.js";
/**
* ForceSave 强制保存
* CreateDocument creates a new document and returns the created document with ID
*/
export function ForceSave(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2767091023) as any;
return $resultPromise;
}
/**
* GetActiveDocument 获取活动文档
*/
export function GetActiveDocument(): Promise<models$0.Document | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(1785823398) as any;
export function CreateDocument(title: string): Promise<models$0.Document | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(3360680842, title) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
@@ -35,45 +30,90 @@ export function GetActiveDocument(): Promise<models$0.Document | null> & { cance
}
/**
* Initialize 初始化服务
* DeleteDocument marks a document as deleted (default document with ID=1 cannot be deleted)
*/
export function Initialize(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3418008221) as any;
export function DeleteDocument(id: number): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(412287269, id) as any;
return $resultPromise;
}
/**
* OnDataPathChanged 处理数据路径变更
* GetDocumentByID gets a document by ID
*/
export function OnDataPathChanged(oldPath: string, newPath: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(269349439, oldPath, newPath) as any;
export function GetDocumentByID(id: number): Promise<models$0.Document | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(3468193232, id) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* GetFirstDocumentID gets the first active document's ID for frontend initialization
*/
export function GetFirstDocumentID(): Promise<number> & { cancel(): void } {
let $resultPromise = $Call.ByID(2970773833) as any;
return $resultPromise;
}
/**
* ReloadDocument 重新加载文档
* ListAllDocumentsMeta lists all active (non-deleted) document metadata
*/
export function ReloadDocument(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3093415283) as any;
export function ListAllDocumentsMeta(): Promise<(models$0.Document | null)[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(3073950297) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType2($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* ListDeletedDocumentsMeta lists all deleted document metadata
*/
export function ListDeletedDocumentsMeta(): Promise<(models$0.Document | null)[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(490143787) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType2($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* RestoreDocument restores a deleted document
*/
export function RestoreDocument(id: number): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(784200778, id) as any;
return $resultPromise;
}
/**
* ServiceShutdown 关闭服务
* ServiceStartup initializes the service when the application starts
*/
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(638578044) as any;
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1474135487, options) as any;
return $resultPromise;
}
/**
* UpdateActiveDocumentContent 更新文档内容
* UpdateDocumentContent updates the content of a document
*/
export function UpdateActiveDocumentContent(content: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1486276638, content) as any;
export function UpdateDocumentContent(id: number, content: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3251897116, id, content) as any;
return $resultPromise;
}
/**
* UpdateDocumentTitle updates the title of a document
*/
export function UpdateDocumentTitle(id: number, title: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2045530459, id, title) as any;
return $resultPromise;
}
// Private type creation functions
const $$createType0 = models$0.Document.createFrom;
const $$createType1 = $Create.Nullable($$createType0);
const $$createType2 = $Create.Array($$createType1);

View File

@@ -0,0 +1,74 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* ExtensionService 扩展管理服务
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as models$0 from "../models/models.js";
/**
* GetAllExtensions 获取所有扩展配置
*/
export function GetAllExtensions(): Promise<models$0.Extension[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(3094292124) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* ResetAllExtensionsToDefault 重置所有扩展到默认状态
*/
export function ResetAllExtensionsToDefault(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(270611949) as any;
return $resultPromise;
}
/**
* ResetExtensionToDefault 重置扩展到默认状态
*/
export function ResetExtensionToDefault(id: models$0.ExtensionID): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(868308101, id) as any;
return $resultPromise;
}
/**
* ServiceStartup 启动时调用
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(40324057, options) as any;
return $resultPromise;
}
/**
* UpdateExtensionEnabled 更新扩展启用状态
*/
export function UpdateExtensionEnabled(id: models$0.ExtensionID, enabled: boolean): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1067300094, id, enabled) as any;
return $resultPromise;
}
/**
* UpdateExtensionState 更新扩展状态
*/
export function UpdateExtensionState(id: models$0.ExtensionID, enabled: boolean, config: models$0.ExtensionConfig): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2917946454, id, enabled, config) as any;
return $resultPromise;
}
// Private type creation functions
const $$createType0 = models$0.Extension.createFrom;
const $$createType1 = $Create.Array($$createType0);

View File

@@ -2,7 +2,7 @@
// This file is automatically generated. DO NOT EDIT
/**
* HotkeyService 全局热键服务
* HotkeyService Windows全局热键服务
* @module
*/
@@ -18,7 +18,7 @@ import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/applic
import * as models$0 from "../models/models.js";
/**
* GetCurrentHotkey 获取当前注册的热键
* GetCurrentHotkey 获取当前热键
*/
export function GetCurrentHotkey(): Promise<models$0.HotkeyCombo | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(2572811187) as any;
@@ -38,7 +38,7 @@ export function Initialize(app: application$0.App | null): Promise<void> & { can
}
/**
* IsRegistered 检查是否已注册热键
* IsRegistered 检查是否已注册
*/
export function IsRegistered(): Promise<boolean> & { cancel(): void } {
let $resultPromise = $Call.ByID(106954156) as any;
@@ -54,21 +54,13 @@ export function RegisterHotkey(hotkey: models$0.HotkeyCombo | null): Promise<voi
}
/**
* ServiceShutdown 关闭热键服务
* ServiceShutdown 关闭服务
*/
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(157291181) as any;
return $resultPromise;
}
/**
* ToggleWindow 切换窗口显示/隐藏 - 通过事件通知前端处理
*/
export function ToggleWindow(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1318185132) as any;
return $resultPromise;
}
/**
* UnregisterHotkey 取消注册全局热键
*/

View File

@@ -2,20 +2,34 @@
// This file is automatically generated. DO NOT EDIT
import * as ConfigService from "./configservice.js";
import * as DatabaseService from "./databaseservice.js";
import * as DialogService from "./dialogservice.js";
import * as DocumentService from "./documentservice.js";
import * as ExtensionService from "./extensionservice.js";
import * as HotkeyService from "./hotkeyservice.js";
import * as KeyBindingService from "./keybindingservice.js";
import * as MigrationService from "./migrationservice.js";
import * as SelfUpdateService from "./selfupdateservice.js";
import * as StartupService from "./startupservice.js";
import * as SystemService from "./systemservice.js";
import * as TranslationService from "./translationservice.js";
import * as TrayService from "./trayservice.js";
import * as WindowService from "./windowservice.js";
export {
ConfigService,
DatabaseService,
DialogService,
DocumentService,
ExtensionService,
HotkeyService,
KeyBindingService,
MigrationService,
SelfUpdateService,
StartupService,
SystemService,
TrayService
TranslationService,
TrayService,
WindowService
};
export * from "./models.js";

View File

@@ -0,0 +1,42 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* KeyBindingService 快捷键管理服务
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as models$0 from "../models/models.js";
/**
* GetAllKeyBindings 获取所有快捷键配置
*/
export function GetAllKeyBindings(): Promise<models$0.KeyBinding[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(1633502882) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* ServiceStartup 启动时调用
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2057121990, options) as any;
return $resultPromise;
}
// Private type creation functions
const $$createType0 = models$0.KeyBinding.createFrom;
const $$createType1 = $Create.Array($$createType0);

View File

@@ -50,13 +50,5 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
return $resultPromise;
}
/**
* SetProgressBroadcaster 设置进度广播函数
*/
export function SetProgressBroadcaster(broadcaster: any): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3244071921, broadcaster) as any;
return $resultPromise;
}
// Private type creation functions
const $$createType0 = $models.MigrationProgress.createFrom;

View File

@@ -5,6 +5,10 @@
// @ts-ignore: Unused imports
import {Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
/**
* MemoryStats 内存统计信息
*/
@@ -76,19 +80,8 @@ export class MemoryStats {
* MigrationProgress 迁移进度信息
*/
export class MigrationProgress {
/**
* 迁移状态
*/
"status": MigrationStatus;
/**
* 进度百分比 (0-100)
*/
"progress": number;
/**
* 错误信息
*/
"error"?: string;
/** Creates a new MigrationProgress instance. */
@@ -121,18 +114,130 @@ export enum MigrationStatus {
*/
$zero = "",
/**
* 迁移中
*/
MigrationStatusMigrating = "migrating",
/**
* 完成
*/
MigrationStatusCompleted = "completed",
/**
* 失败
*/
MigrationStatusFailed = "failed",
};
/**
* SelfUpdateResult 自我更新结果
*/
export class SelfUpdateResult {
/**
* 是否有更新
*/
"hasUpdate": boolean;
/**
* 当前版本
*/
"currentVersion": string;
/**
* 最新版本
*/
"latestVersion": string;
/**
* 是否已应用更新
*/
"updateApplied": boolean;
/**
* 下载链接
*/
"assetURL": string;
/**
* 发布说明
*/
"releaseNotes": string;
/**
* 错误信息
*/
"error": string;
/**
* 更新源github/gitea
*/
"source": string;
/** Creates a new SelfUpdateResult instance. */
constructor($$source: Partial<SelfUpdateResult> = {}) {
if (!("hasUpdate" in $$source)) {
this["hasUpdate"] = false;
}
if (!("currentVersion" in $$source)) {
this["currentVersion"] = "";
}
if (!("latestVersion" in $$source)) {
this["latestVersion"] = "";
}
if (!("updateApplied" in $$source)) {
this["updateApplied"] = false;
}
if (!("assetURL" in $$source)) {
this["assetURL"] = "";
}
if (!("releaseNotes" in $$source)) {
this["releaseNotes"] = "";
}
if (!("error" in $$source)) {
this["error"] = "";
}
if (!("source" in $$source)) {
this["source"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new SelfUpdateResult instance from a string or object.
*/
static createFrom($$source: any = {}): SelfUpdateResult {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new SelfUpdateResult($$parsedSource as Partial<SelfUpdateResult>);
}
}
/**
* WindowInfo 窗口信息
*/
export class WindowInfo {
"Window": application$0.WebviewWindow | null;
"DocumentID": number;
"Title": string;
/** Creates a new WindowInfo instance. */
constructor($$source: Partial<WindowInfo> = {}) {
if (!("Window" in $$source)) {
this["Window"] = null;
}
if (!("DocumentID" in $$source)) {
this["DocumentID"] = 0;
}
if (!("Title" in $$source)) {
this["Title"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new WindowInfo instance from a string or object.
*/
static createFrom($$source: any = {}): WindowInfo {
const $$createField0_0 = $$createType1;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("Window" in $$parsedSource) {
$$parsedSource["Window"] = $$createField0_0($$parsedSource["Window"]);
}
return new WindowInfo($$parsedSource as Partial<WindowInfo>);
}
}
// Private type creation functions
const $$createType0 = application$0.WebviewWindow.createFrom;
const $$createType1 = $Create.Nullable($$createType0);

View File

@@ -0,0 +1,51 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* SelfUpdateService 自我更新服务
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
/**
* ApplyUpdate 应用更新
*/
export function ApplyUpdate(): Promise<$models.SelfUpdateResult | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(2009328394) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* CheckForUpdates 检查更新
*/
export function CheckForUpdates(): Promise<$models.SelfUpdateResult | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(438757208) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* RestartApplication 重启应用程序
*/
export function RestartApplication(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3341481538) as any;
return $resultPromise;
}
// Private type creation functions
const $$createType0 = $models.SelfUpdateResult.createFrom;
const $$createType1 = $Create.Nullable($$createType0);

View File

@@ -0,0 +1,19 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* StartupService 开机启动服务
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
/**
* SetEnabled 设置开机启动状态
*/
export function SetEnabled(enabled: boolean): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2911601468, enabled) as any;
return $resultPromise;
}

View File

@@ -0,0 +1,78 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* TranslationService 翻译服务
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as translator$0 from "../common/translator/models.js";
/**
* GetAvailableTranslators 获取所有可用翻译器类型
* @returns {[]string} 翻译器类型列表
*/
export function GetAvailableTranslators(): Promise<string[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(1186597995) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType0($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* GetStandardLanguageCode 获取标准化的语言代码
*/
export function GetStandardLanguageCode(translatorType: translator$0.TranslatorType, languageCode: string): Promise<string> & { cancel(): void } {
let $resultPromise = $Call.ByID(1158131995, translatorType, languageCode) as any;
return $resultPromise;
}
/**
* GetTranslatorLanguages 获取翻译器的语言列表
* @param {string} translatorType - 翻译器类型 ("google", "bing", "youdao", "deepl")
* @returns {map[string]string} 语言代码到名称的映射
* @returns {error} 可能的错误
*/
export function GetTranslatorLanguages(translatorType: translator$0.TranslatorType): Promise<{ [_: string]: translator$0.LanguageInfo }> & { cancel(): void } {
let $resultPromise = $Call.ByID(3976114458, translatorType) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType2($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* IsLanguageSupported 检查指定的语言代码是否受支持
*/
export function IsLanguageSupported(translatorType: translator$0.TranslatorType, languageCode: string): Promise<boolean> & { cancel(): void } {
let $resultPromise = $Call.ByID(2819945417, translatorType, languageCode) as any;
return $resultPromise;
}
/**
* TranslateWith 使用指定翻译器进行翻译
* @param {string} text - 待翻译文本
* @param {string} from - 源语言代码 (如 "en", "zh", "auto")
* @param {string} to - 目标语言代码 (如 "en", "zh")
* @param {string} translatorType - 翻译器类型 ("google", "bing", "youdao", "deepl")
* @returns {string} 翻译后的文本
* @returns {error} 可能的错误
*/
export function TranslateWith(text: string, $from: string, to: string, translatorType: string): Promise<string> & { cancel(): void } {
let $resultPromise = $Call.ByID(3577923623, text, $from, to, translatorType) as any;
return $resultPromise;
}
// Private type creation functions
const $$createType0 = $Create.Array($Create.Any);
const $$createType1 = translator$0.LanguageInfo.createFrom;
const $$createType2 = $Create.Map($Create.Any, $$createType1);

View File

@@ -0,0 +1,59 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* WindowService 窗口管理服务
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
/**
* GetOpenWindows 获取所有打开的窗口信息
*/
export function GetOpenWindows(): Promise<$models.WindowInfo[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(1464997251) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* IsDocumentWindowOpen 检查指定文档的窗口是否已打开
*/
export function IsDocumentWindowOpen(documentID: number): Promise<boolean> & { cancel(): void } {
let $resultPromise = $Call.ByID(1735611839, documentID) as any;
return $resultPromise;
}
/**
* OpenDocumentWindow 为指定文档ID打开新窗口
*/
export function OpenDocumentWindow(documentID: number): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(494716471, documentID) as any;
return $resultPromise;
}
/**
* SetAppReferences 设置应用和主窗口引用
*/
export function SetAppReferences(app: application$0.App | null, mainWindow: application$0.WebviewWindow | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1120840759, app, mainWindow) as any;
return $resultPromise;
}
// Private type creation functions
const $$createType0 = $models.WindowInfo.createFrom;
const $$createType1 = $Create.Array($$createType0);

View File

@@ -8,10 +8,16 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
BlockLanguageSelector: typeof import('./src/components/toolbar/BlockLanguageSelector.vue')['default']
DocumentSelector: typeof import('./src/components/toolbar/DocumentSelector.vue')['default']
LinuxTitleBar: typeof import('./src/components/titlebar/LinuxTitleBar.vue')['default']
LoadingScreen: typeof import('./src/components/loading/LoadingScreen.vue')['default']
MacOSTitleBar: typeof import('./src/components/titlebar/MacOSTitleBar.vue')['default']
MemoryMonitor: typeof import('./src/components/monitor/MemoryMonitor.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Toolbar: typeof import('./src/components/toolbar/Toolbar.vue')['default']
WindowsTitleBar: typeof import('./src/components/titlebar/WindowsTitleBar.vue')['default']
WindowTitleBar: typeof import('./src/components/titlebar/WindowTitleBar.vue')['default']
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,57 +15,68 @@
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-angular": "^0.1.4",
"@codemirror/lang-cpp": "^6.0.2",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-java": "^6.0.1",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-less": "^6.0.2",
"@codemirror/lang-lezer": "^6.0.2",
"@codemirror/lang-liquid": "^6.2.3",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/lang-php": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.3",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sass": "^6.0.2",
"@codemirror/lang-sql": "^6.9.0",
"@codemirror/lang-vue": "^0.1.3",
"@codemirror/lang-wast": "^6.0.2",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.1",
"@codemirror/language": "^6.11.2",
"@codemirror/language-data": "^6.5.1",
"@codemirror/legacy-modes": "^6.5.1",
"@codemirror/lint": "^6.8.5",
"@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.37.1",
"@codemirror/view": "^6.38.0",
"@lezer/highlight": "^1.2.1",
"@types/uuid": "^10.0.0",
"@vueuse/core": "^13.3.0",
"codemirror": "^6.0.1",
"pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.3.0",
"sass": "^1.89.1",
"uuid": "^11.1.0",
"vue": "^3.5.16",
"vue-i18n": "^11.1.5",
"@lezer/lr": "^1.4.2",
"codemirror": "^6.0.2",
"codemirror-lang-elixir": "^4.0.0",
"colors-named": "^1.0.2",
"colors-named-hex": "^1.0.2",
"franc-min": "^6.2.0",
"hsl-matcher": "^1.2.4",
"lezer": "^0.13.5",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.4.1",
"prettier": "^3.6.2",
"remarkable": "^2.0.1",
"sass": "^1.89.2",
"vue": "^3.5.17",
"vue-i18n": "^11.1.9",
"vue-pick-colors": "^1.8.0",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@eslint/js": "^9.28.0",
"@types/node": "^22.15.29",
"@vitejs/plugin-vue": "^5.2.4",
"@eslint/js": "^9.30.1",
"@lezer/generator": "^1.8.0",
"@types/lodash": "^4.17.20",
"@types/node": "^24.0.12",
"@types/remarkable": "^2.0.8",
"@vitejs/plugin-vue": "^6.0.0",
"@wailsio/runtime": "latest",
"eslint": "^9.28.0",
"eslint-plugin-vue": "^10.1.0",
"globals": "^16.2.0",
"eslint": "^9.30.1",
"eslint-plugin-vue": "^10.3.0",
"globals": "^16.3.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.33.1",
"unplugin-vue-components": "^28.7.0",
"vite": "^6.3.5",
"vue-eslint-parser": "^10.1.3",
"vue-tsc": "^2.2.10"
"typescript-eslint": "^8.36.0",
"unplugin-vue-components": "^28.8.0",
"vite": "^7.0.3",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.1"
}
}

28
frontend/public/guesslang.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,48 @@
importScripts("guesslang.min.js")
const LANGUAGES = ["json", "py", "html", "sql", "md", "java", "php", "css", "xml", "cpp", "rs", "cs", "rb", "sh", "yaml", "toml", "go", "clj", "ex", "erl", "js", "ts", "swift", "kt", "groovy", "ps1", "dart", "scala"]
const guessLang = new self.GuessLang()
function sendResult(language, confidence, idx) {
postMessage({language, confidence, idx})
}
onmessage = (event) => {
const {content, idx} = event.data
// JSON 快速检测
const trimmed = content.trim()
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
try {
if (typeof JSON.parse(trimmed) === "object") {
sendResult("json", 1.0, idx)
return
}
} catch (e) {
}
}
guessLang.runModel(content).then((result) => {
if (result.length > 0) {
const lang = result[0]
if (LANGUAGES.includes(lang.languageId) && lang.confidence > 0.15) {
sendResult(lang.languageId, lang.confidence, idx)
return
}
}
for (let lang of result) {
if (LANGUAGES.includes(lang.languageId) && lang.confidence > 0.5) {
sendResult(lang.languageId, lang.confidence, idx)
return
}
}
sendResult("text", 0.0, idx)
}).catch(() => {
sendResult("text", 0.0, idx)
})
}

View File

@@ -1,17 +1,33 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { useConfigStore } from '@/stores/configStore';
import { useSystemTheme } from '@/composables/useSystemTheme';
import { useSystemStore } from '@/stores/systemStore';
import { useKeybindingStore } from '@/stores/keybindingStore';
import { useThemeStore } from '@/stores/themeStore';
import { useUpdateStore } from '@/stores/updateStore';
import WindowTitleBar from '@/components/titlebar/WindowTitleBar.vue';
const configStore = useConfigStore();
const { setTheme } = useSystemTheme();
const systemStore = useSystemStore();
const keybindingStore = useKeybindingStore();
const themeStore = useThemeStore();
const updateStore = useUpdateStore();
// 应用启动时加载配置
// 应用启动时加载配置和初始化系统信息
onMounted(async () => {
await configStore.initConfig();
// 并行初始化配置、系统信息和快捷键配置
await Promise.all([
configStore.initConfig(),
systemStore.initializeSystemInfo(),
keybindingStore.loadKeyBindings(),
]);
// 初始化语言和主题
await configStore.initializeLanguage();
setTheme(configStore.config.appearance.systemTheme);
themeStore.initializeTheme();
// 启动时检查更新
await updateStore.checkOnStartup();
});
</script>
@@ -36,7 +52,6 @@ onMounted(async () => {
.app-content {
flex: 1;
margin-top: 32px;
overflow: hidden;
position: relative;
}

View File

@@ -23,6 +23,16 @@
--dark-scrollbar-track: #2a2a2a;
--dark-scrollbar-thumb: #555555;
--dark-scrollbar-thumb-hover: #666666;
--dark-selection-bg: rgba(181, 206, 168, 0.1);
--dark-selection-text: #b5cea8;
--dark-danger-color: #ff6b6b;
--dark-bg-primary: #1a1a1a;
--dark-bg-hover: #2a2a2a;
--dark-loading-bg-gradient: radial-gradient(#222922, #000500);
--dark-loading-color: #fff;
--dark-loading-glow: 0 0 10px rgba(50, 255, 50, 0.5), 0 0 5px rgba(100, 255, 100, 0.5);
--dark-loading-done-color: #6f6;
--dark-loading-overlay: linear-gradient(transparent 0%, rgba(10, 16, 10, 0.5) 50%);
/* 浅色主题颜色变量 */
--light-toolbar-bg: #f8f9fa;
@@ -45,6 +55,16 @@
--light-scrollbar-track: #f1f3f4;
--light-scrollbar-thumb: #c1c1c1;
--light-scrollbar-thumb-hover: #a8a8a8;
--light-selection-bg: rgba(59, 130, 246, 0.15);
--light-selection-text: #2563eb;
--light-danger-color: #dc3545;
--light-bg-primary: #ffffff;
--light-bg-hover: #f1f3f4;
--light-loading-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
--light-loading-color: #1a3c1a;
--light-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
--light-loading-done-color: #008800;
--light-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
/* 默认使用深色主题 */
--toolbar-bg: var(--dark-toolbar-bg);
@@ -68,6 +88,17 @@
--scrollbar-track: var(--dark-scrollbar-track);
--scrollbar-thumb: var(--dark-scrollbar-thumb);
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
--selection-bg: var(--dark-selection-bg);
--selection-text: var(--dark-selection-text);
--text-danger: var(--dark-danger-color);
--bg-primary: var(--dark-bg-primary);
--bg-hover: var(--dark-bg-hover);
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
--voidraft-loading-color: var(--dark-loading-color);
--voidraft-loading-glow: var(--dark-loading-glow);
--voidraft-loading-done-color: var(--dark-loading-done-color);
--voidraft-loading-overlay: var(--dark-loading-overlay);
--voidraft-mono-font: "HarmonyOS Sans Mono", monospace;
color-scheme: light dark;
}
@@ -96,6 +127,16 @@
--scrollbar-track: var(--dark-scrollbar-track);
--scrollbar-thumb: var(--dark-scrollbar-thumb);
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
--selection-bg: var(--dark-selection-bg);
--selection-text: var(--dark-selection-text);
--text-danger: var(--dark-danger-color);
--bg-primary: var(--dark-bg-primary);
--bg-hover: var(--dark-bg-hover);
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
--voidraft-loading-color: var(--dark-loading-color);
--voidraft-loading-glow: var(--dark-loading-glow);
--voidraft-loading-done-color: var(--dark-loading-done-color);
--voidraft-loading-overlay: var(--dark-loading-overlay);
}
}
@@ -123,6 +164,16 @@
--scrollbar-track: var(--light-scrollbar-track);
--scrollbar-thumb: var(--light-scrollbar-thumb);
--scrollbar-thumb-hover: var(--light-scrollbar-thumb-hover);
--selection-bg: var(--light-selection-bg);
--selection-text: var(--light-selection-text);
--text-danger: var(--light-danger-color);
--bg-primary: var(--light-bg-primary);
--bg-hover: var(--light-bg-hover);
--voidraft-bg-gradient: var(--light-loading-bg-gradient);
--voidraft-loading-color: var(--light-loading-color);
--voidraft-loading-glow: var(--light-loading-glow);
--voidraft-loading-done-color: var(--light-loading-done-color);
--voidraft-loading-overlay: var(--light-loading-overlay);
}
}
@@ -149,6 +200,16 @@
--scrollbar-track: var(--light-scrollbar-track);
--scrollbar-thumb: var(--light-scrollbar-thumb);
--scrollbar-thumb-hover: var(--light-scrollbar-thumb-hover);
--selection-bg: var(--light-selection-bg);
--selection-text: var(--light-selection-text);
--text-danger: var(--light-danger-color);
--bg-primary: var(--light-bg-primary);
--bg-hover: var(--light-bg-hover);
--voidraft-bg-gradient: var(--light-loading-bg-gradient);
--voidraft-loading-color: var(--light-loading-color);
--voidraft-loading-glow: var(--light-loading-glow);
--voidraft-loading-done-color: var(--light-loading-done-color);
--voidraft-loading-overlay: var(--light-loading-overlay);
}
/* 手动选择深色主题 */
@@ -174,4 +235,14 @@
--scrollbar-track: var(--dark-scrollbar-track);
--scrollbar-thumb: var(--dark-scrollbar-thumb);
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
--selection-bg: var(--dark-selection-bg);
--selection-text: var(--dark-selection-text);
--text-danger: var(--dark-danger-color);
--bg-primary: var(--dark-bg-primary);
--bg-hover: var(--dark-bg-hover);
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
--voidraft-loading-color: var(--dark-loading-color);
--voidraft-loading-glow: var(--dark-loading-glow);
--voidraft-loading-done-color: var(--dark-loading-done-color);
--voidraft-loading-overlay: var(--dark-loading-overlay);
}

View File

@@ -0,0 +1,177 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps({
text: {
type: String,
default: 'LOADING'
}
});
const characters = ref<HTMLSpanElement[]>([]);
const isDone = ref(false);
const cycleCount = 5;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*()-_=+{}|[]\\;\':"<>?,./`~'.split('');
let animationFrameId: number | null = null;
let resetTimeoutId: number | null = null;
// 将字符串拆分为单个字符的span
function letterize() {
const container = document.querySelector('.loading-word');
if (!container) return;
// 清除现有内容
container.innerHTML = '';
// 为每个字符创建span
for (let i = 0; i < props.text.length; i++) {
const span = document.createElement('span');
span.setAttribute('data-orig', props.text[i]);
span.textContent = '-';
span.className = `char${i+1}`;
container.appendChild(span);
}
// 获取所有span元素
characters.value = Array.from(container.querySelectorAll('span'));
}
// 获取随机字符
function getRandomChar() {
return chars[Math.floor(Math.random() * chars.length)];
}
// 动画循环
function animationLoop() {
let currentCycle = 0;
let currentLetterIndex = 0;
let isAnimationDone = false;
function loop() {
// 为未完成的字符设置随机字符和不透明度
for (let i = currentLetterIndex; i < characters.value.length; i++) {
const char = characters.value[i];
if (!char.classList.contains('done')) {
char.textContent = getRandomChar();
char.style.opacity = Math.random().toString();
}
}
if (currentCycle < cycleCount) {
// 继续当前周期
currentCycle++;
} else if (currentLetterIndex < characters.value.length) {
// 当前周期结束,显示下一个字符的原始值
const currentChar = characters.value[currentLetterIndex];
currentChar.textContent = currentChar.getAttribute('data-orig') || '';
currentChar.style.opacity = '1';
currentChar.classList.add('done');
currentLetterIndex++;
currentCycle = 0;
} else {
// 所有字符都已显示
isAnimationDone = true;
isDone.value = true;
}
if (!isAnimationDone) {
animationFrameId = requestAnimationFrame(loop);
} else {
// 等待一段时间后重置动画
resetTimeoutId = window.setTimeout(() => {
reset();
}, 750);
}
}
loop();
}
// 重置动画
function reset() {
isDone.value = false;
for (const char of characters.value) {
char.textContent = char.getAttribute('data-orig') || '';
char.classList.remove('done');
}
animationLoop();
}
// 清理所有定时器
function cleanup() {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
if (resetTimeoutId !== null) {
clearTimeout(resetTimeoutId);
resetTimeoutId = null;
}
}
onMounted(() => {
letterize();
animationLoop();
});
onBeforeUnmount(() => {
cleanup();
});
</script>
<template>
<div class="loading-screen">
<div class="loading-word"></div>
<div class="overlay"></div>
</div>
</template>
<style scoped lang="scss">
.loading-screen {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--voidraft-bg-gradient, radial-gradient(#222922, #000500));
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--voidraft-mono-font, monospace),serif;
}
.loading-word {
color: var(--voidraft-loading-color, #fff);
font-size: 2.5em;
height: 2.5em;
line-height: 2.5em;
text-align: center;
text-shadow: var(--voidraft-loading-glow, 0 0 10px rgba(50, 255, 50, 0.5), 0 0 5px rgba(100, 255, 100, 0.5));
}
.loading-word span {
display: inline-block;
transform: translateX(100%) scale(0.9);
transition: transform 500ms;
}
.loading-word .done {
color: var(--voidraft-loading-done-color, #6f6);
transform: translateX(0) scale(1);
}
.overlay {
background-image: var(--voidraft-loading-overlay, linear-gradient(transparent 0%, rgba(10, 16, 10, 0.5) 50%));
background-size: 1000px 2px;
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
pointer-events: none;
}
</style>

View File

@@ -1,18 +1,40 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import { ref, onMounted, onUnmounted, nextTick, computed, watch } from 'vue';
import { SystemService } from '@/../bindings/voidraft/internal/services';
import type { MemoryStats } from '@/../bindings/voidraft/internal/services';
import { useI18n } from 'vue-i18n';
import { useThemeStore } from '@/stores/themeStore';
import { SystemThemeType } from '@/../bindings/voidraft/internal/models/models';
const { t } = useI18n();
const themeStore = useThemeStore();
const memoryStats = ref<MemoryStats | null>(null);
const formattedMemory = ref('');
const isLoading = ref(true);
const canvasRef = ref<HTMLCanvasElement | null>(null);
let intervalId: ReturnType<typeof setInterval> | null = null;
// 存储历史数据点 (最近60个数据点每3秒一个点总共3分钟历史)
// 存储历史数据点 (最近60个数据点)
const historyData = ref<number[]>([]);
const maxDataPoints = 60;
// 动态最大内存值MB初始为200MB会根据实际使用动态调整
const maxMemoryMB = ref(200);
// 使用themeStore获取当前主题
const isDarkTheme = computed(() => {
const theme = themeStore.currentTheme;
if (theme === SystemThemeType.SystemThemeAuto) {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return theme === SystemThemeType.SystemThemeDark;
});
// 监听主题变化,重新绘制图表
watch(() => themeStore.currentTheme, () => {
nextTick(() => drawChart());
});
// 静默错误处理包装器
const withSilentErrorHandling = async <T>(
operation: () => Promise<T>,
@@ -47,8 +69,14 @@ const fetchMemoryStats = async () => {
formattedMemory.value = `${heapMB.toFixed(0)}M`;
}
// 添加新数据点到历史记录
const memoryUsagePercent = Math.min((stats.heapInUse / (100 * 1024 * 1024)) * 100, 100);
// 自动调整最大内存值,确保图表能够显示更大范围
if (heapMB > maxMemoryMB.value * 0.8) {
// 如果内存使用超过当前最大值的80%则将最大值调整为当前使用值的2倍
maxMemoryMB.value = Math.ceil(heapMB * 2);
}
// 添加新数据点到历史记录 - 使用动态最大值计算百分比
const memoryUsagePercent = Math.min((heapMB / maxMemoryMB.value) * 100, 100);
historyData.value.push(memoryUsagePercent);
// 保持最大数据点数量
@@ -62,7 +90,7 @@ const fetchMemoryStats = async () => {
isLoading.value = false;
};
// 绘制实时曲线图
// 绘制实时曲线图 - 简化版
const drawChart = () => {
if (!canvasRef.value || historyData.value.length === 0) return;
@@ -82,11 +110,16 @@ const drawChart = () => {
// 清除画布
ctx.clearRect(0, 0, width, height);
// 绘制背景网格 - 朦胧的网格,从上到下逐渐清晰
for (let i = 0; i <= 6; i++) {
const y = (height / 6) * i;
const opacity = 0.01 + (i / 6) * 0.03; // 从上到下逐渐清晰
ctx.strokeStyle = `rgba(255, 255, 255, ${opacity})`;
// 根据主题选择合适的颜色 - 更柔和的颜色
const gridColor = isDarkTheme.value ? 'rgba(255, 255, 255, 0.03)' : 'rgba(0, 0, 0, 0.07)';
const lineColor = isDarkTheme.value ? 'rgba(74, 158, 255, 0.6)' : 'rgba(37, 99, 235, 0.6)';
const fillColor = isDarkTheme.value ? 'rgba(74, 158, 255, 0.05)' : 'rgba(37, 99, 235, 0.05)';
const pointColor = isDarkTheme.value ? 'rgba(74, 158, 255, 0.8)' : 'rgba(37, 99, 235, 0.8)';
// 绘制背景网格 - 更加柔和
for (let i = 0; i <= 4; i++) {
const y = (height / 4) * i;
ctx.strokeStyle = gridColor;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(0, y);
@@ -95,9 +128,9 @@ const drawChart = () => {
}
// 垂直网格线
for (let i = 0; i <= 8; i++) {
const x = (width / 8) * i;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.02)';
for (let i = 0; i <= 6; i++) {
const x = (width / 6) * i;
ctx.strokeStyle = gridColor;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(x, 0);
@@ -112,13 +145,7 @@ const drawChart = () => {
const stepX = width / (maxDataPoints - 1);
const startX = width - (dataLength - 1) * stepX;
// 绘制填充区域 - 从上朦胧到下清晰的渐变
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, 'rgba(74, 158, 255, 0.1)'); // 顶部很淡
gradient.addColorStop(0.3, 'rgba(74, 158, 255, 0.15)');
gradient.addColorStop(0.7, 'rgba(74, 158, 255, 0.25)');
gradient.addColorStop(1, 'rgba(74, 158, 255, 0.4)'); // 底部较浓
// 绘制填充区域 - 更柔和的填充
ctx.beginPath();
ctx.moveTo(startX, height);
@@ -126,17 +153,23 @@ const drawChart = () => {
const firstY = height - (historyData.value[0] / 100) * height;
ctx.lineTo(startX, firstY);
// 使用二次贝塞尔曲线平滑曲线
// 绘制数据点路径 - 使用曲线连接点,确保连续性
for (let i = 1; i < dataLength; i++) {
const x = startX + i * stepX;
const y = height - (historyData.value[i] / 100) * height;
// 使用贝塞尔曲线平滑连接
if (i < dataLength - 1) {
const nextX = startX + (i + 1) * stepX;
const nextY = height - (historyData.value[i + 1] / 100) * height;
const controlX = x + stepX / 2;
const controlY = y;
ctx.quadraticCurveTo(controlX, controlY, (x + nextX) / 2, (y + nextY) / 2);
const cpX1 = x - stepX / 4;
const cpY1 = y;
const cpX2 = x + stepX / 4;
const cpY2 = nextY;
// 使用三次贝塞尔曲线平滑连接点
ctx.bezierCurveTo(cpX1, cpY1, cpX2, cpY2, nextX, nextY);
i++; // 跳过下一个点,因为已经在曲线中处理了
} else {
ctx.lineTo(x, y);
}
@@ -146,68 +179,55 @@ const drawChart = () => {
const lastX = startX + (dataLength - 1) * stepX;
ctx.lineTo(lastX, height);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fillStyle = fillColor;
ctx.fill();
// 绘制主曲线 - 从上到下逐渐清晰
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 绘制主曲线 - 平滑连续的曲线
ctx.beginPath();
ctx.moveTo(startX, firstY);
// 分段绘制曲线,每段有不同的透明度
const segments = 10;
for (let seg = 0; seg < segments; seg++) {
const segmentStart = seg / segments;
const segmentEnd = (seg + 1) / segments;
const opacity = 0.3 + (seg / segments) * 0.7; // 从上0.3到下1.0
// 重新绘制曲线路径,但这次只绘制线条
for (let i = 1; i < dataLength; i++) {
const x = startX + i * stepX;
const y = height - (historyData.value[i] / 100) * height;
ctx.strokeStyle = `rgba(74, 158, 255, ${opacity})`;
ctx.lineWidth = 1.5 + (seg / segments) * 0.8; // 线条也从细到粗
ctx.beginPath();
let segmentStarted = false;
for (let i = 0; i < dataLength; i++) {
const x = startX + i * stepX;
const y = height - (historyData.value[i] / 100) * height;
const yPercent = 1 - (y / height);
// 使用贝塞尔曲线平滑连接
if (i < dataLength - 1) {
const nextX = startX + (i + 1) * stepX;
const nextY = height - (historyData.value[i + 1] / 100) * height;
const cpX1 = x - stepX / 4;
const cpY1 = y;
const cpX2 = x + stepX / 4;
const cpY2 = nextY;
if (yPercent >= segmentStart && yPercent <= segmentEnd) {
if (!segmentStarted) {
ctx.moveTo(x, y);
segmentStarted = true;
} else {
if (i < dataLength - 1) {
const nextX = startX + (i + 1) * stepX;
const nextY = height - (historyData.value[i + 1] / 100) * height;
const controlX = x + stepX / 2;
const controlY = y;
ctx.quadraticCurveTo(controlX, controlY, (x + nextX) / 2, (y + nextY) / 2);
} else {
ctx.lineTo(x, y);
}
}
}
}
if (segmentStarted) {
ctx.stroke();
// 使用三次贝塞尔曲线平滑连接点
ctx.bezierCurveTo(cpX1, cpY1, cpX2, cpY2, nextX, nextY);
i++; // 跳过下一个点,因为已经在曲线中处理了
} else {
ctx.lineTo(x, y);
}
}
// 绘制当前值的高亮点 - 根据位置调整透明度
ctx.strokeStyle = lineColor;
ctx.lineWidth = 1.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
// 绘制当前值的高亮点
const lastY = height - (historyData.value[dataLength - 1] / 100) * height;
const pointOpacity = 0.4 + (1 - lastY / height) * 0.6;
// 外圈
ctx.fillStyle = `rgba(74, 158, 255, ${pointOpacity * 0.3})`;
ctx.fillStyle = pointColor;
ctx.globalAlpha = 0.4;
ctx.beginPath();
ctx.arc(lastX, lastY, 4, 0, Math.PI * 2);
ctx.arc(lastX, lastY, 3, 0, Math.PI * 2);
ctx.fill();
// 内圈
ctx.fillStyle = `rgba(74, 158, 255, ${pointOpacity})`;
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(lastX, lastY, 2, 0, Math.PI * 2);
ctx.arc(lastX, lastY, 1.5, 0, Math.PI * 2);
ctx.fill();
};
@@ -228,31 +248,59 @@ const handleResize = () => {
}
};
// 仅监听系统主题变化
const setupSystemThemeListener = () => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = () => {
// 仅当设置为auto时才响应系统主题变化
if (themeStore.currentTheme === SystemThemeType.SystemThemeAuto) {
nextTick(() => drawChart());
}
};
// 添加监听器
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleSystemThemeChange);
}
// 返回清理函数
return () => {
if (mediaQuery.removeEventListener) {
mediaQuery.removeEventListener('change', handleSystemThemeChange);
}
};
};
onMounted(() => {
fetchMemoryStats();
// 每3秒更新一次内存信息
// 每1秒更新一次内存信息
intervalId = setInterval(fetchMemoryStats, 3000);
// 监听窗口大小变化
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId);
}
window.removeEventListener('resize', handleResize);
// 设置系统主题监听器仅用于auto模式
const cleanupThemeListener = setupSystemThemeListener();
// 在卸载时清理
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId);
}
window.removeEventListener('resize', handleResize);
cleanupThemeListener();
});
});
</script>
<template>
<div class="memory-monitor" @click="triggerGC" :title="`内存: ${formattedMemory} | 点击清理内存`">
<div class="memory-monitor" @click="triggerGC" :title="`${t('monitor.memory')}: ${formattedMemory} | ${t('monitor.clickToClean')}`">
<div class="monitor-info">
<div class="memory-label">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
<span>内存</span>
<span>{{ t('monitor.memory') }}</span>
</div>
<div class="memory-value" v-if="!isLoading">{{ formattedMemory }}</div>
<div class="memory-loading" v-else>--</div>
@@ -279,11 +327,11 @@ onUnmounted(() => {
&:hover {
.monitor-info {
.memory-label {
color: #4a9eff;
color: var(--selection-text);
}
.memory-value {
color: #ffffff;
color: var(--toolbar-text);
}
}
@@ -301,7 +349,7 @@ onUnmounted(() => {
display: flex;
align-items: center;
gap: 4px;
color: #a0a0a0;
color: var(--text-secondary);
font-size: 10px;
font-weight: 500;
transition: color 0.2s ease;
@@ -318,7 +366,7 @@ onUnmounted(() => {
}
.memory-value, .memory-loading {
color: #e0e0e0;
color: var(--toolbar-text-secondary);
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 9px;
font-weight: 600;

View File

@@ -0,0 +1,297 @@
<template>
<div class="linux-titlebar" style="--wails-draggable:drag" @contextmenu.prevent>
<div class="titlebar-content" @dblclick="toggleMaximize" @contextmenu.prevent>
<div class="titlebar-icon">
<img src="/appicon.png" alt="voidraft" />
</div>
<div class="titlebar-title">{{ titleText }}</div>
</div>
<div class="titlebar-controls" style="--wails-draggable:no-drag" @contextmenu.prevent>
<button
class="titlebar-button minimize-button"
@click="minimizeWindow"
:title="t('titlebar.minimize')"
>
<svg width="16" height="16" viewBox="0 0 16 16">
<path d="M4 8h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<button
class="titlebar-button maximize-button"
@click="toggleMaximize"
:title="isMaximized ? t('titlebar.restore') : t('titlebar.maximize')"
>
<svg width="16" height="16" viewBox="0 0 16 16" v-if="!isMaximized">
<rect x="4" y="4" width="8" height="8" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
<svg width="16" height="16" viewBox="0 0 16 16" v-else>
<rect x="3" y="5" width="6" height="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
<rect x="7" y="3" width="6" height="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
</svg>
</button>
<button
class="titlebar-button close-button"
@click="closeWindow"
:title="t('titlebar.close')"
>
<svg width="16" height="16" viewBox="0 0 16 16">
<path d="M4 4l8 8m0-8L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import * as runtime from '@wailsio/runtime';
import { useWindowStore } from '@/stores/windowStore';
import { useDocumentStore } from '@/stores/documentStore';
const { t } = useI18n();
const isMaximized = ref(false);
const documentStore = useDocumentStore();
const minimizeWindow = async () => {
try {
await runtime.Window.Minimise();
} catch (error) {
// Error handling
}
};
const toggleMaximize = async () => {
try {
const newState = !isMaximized.value;
isMaximized.value = newState;
if (newState) {
await runtime.Window.Maximise();
} else {
await runtime.Window.UnMaximise();
}
setTimeout(async () => {
await checkMaximizedState();
}, 100);
} catch (error) {
isMaximized.value = !isMaximized.value;
}
};
const closeWindow = async () => {
try {
await runtime.Window.Close();
} catch (error) {
// Error handling
}
};
const checkMaximizedState = async () => {
try {
isMaximized.value = await runtime.Window.IsMaximised();
} catch (error) {
// Error handling
}
};
// 计算标题文本
const titleText = computed(() => {
const currentDoc = documentStore.currentDocument;
return currentDoc ? `voidraft - ${currentDoc.title}` : 'voidraft';
});
onMounted(async () => {
await checkMaximizedState();
runtime.Events.On('window:maximised', () => {
isMaximized.value = true;
});
runtime.Events.On('window:unmaximised', () => {
isMaximized.value = false;
});
runtime.Events.On('window:focus', async () => {
await checkMaximizedState();
});
});
onUnmounted(() => {
runtime.Events.Off('window:maximised');
runtime.Events.Off('window:unmaximised');
runtime.Events.Off('window:focus');
});
</script>
<style scoped lang="scss">
.linux-titlebar {
display: flex;
height: 34px;
background: var(--toolbar-bg, linear-gradient(to bottom, #f6f6f6, #e8e8e8));
border-bottom: 1px solid var(--toolbar-border, #d0d0d0);
user-select: none;
-webkit-user-select: none;
width: 100%;
font-family: 'Ubuntu', 'Cantarell', 'DejaVu Sans', system-ui, sans-serif;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
-webkit-context-menu: none;
-moz-context-menu: none;
context-menu: none;
}
.titlebar-content {
display: flex;
align-items: center;
flex: 1;
padding-left: 12px;
gap: 8px;
color: var(--toolbar-text, #333);
font-size: 13px;
font-weight: 500;
cursor: default;
-webkit-context-menu: none;
-moz-context-menu: none;
context-menu: none;
}
.titlebar-content .titlebar-icon {
width: 16px;
height: 16px;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.titlebar-title {
font-size: 13px;
color: var(--toolbar-text, #333);
font-weight: 500;
}
.titlebar-controls {
display: flex;
height: 100%;
-webkit-context-menu: none;
-moz-context-menu: none;
context-menu: none;
}
.titlebar-button {
width: 36px;
height: 34px;
border: none;
background: transparent;
color: var(--toolbar-text, #555);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
padding: 0;
margin: 0;
border-radius: 0;
svg {
width: 16px;
height: 16px;
opacity: 0.8;
transition: opacity 0.15s ease;
}
&:hover {
background: var(--toolbar-button-hover, rgba(0, 0, 0, 0.1));
svg {
opacity: 1;
}
}
&:active {
background: var(--toolbar-button-active, rgba(0, 0, 0, 0.15));
}
}
.close-button {
&:hover {
background: #e74c3c;
color: #ffffff;
svg {
opacity: 1;
}
}
&:active {
background: #c0392b;
}
}
// Dark theme support
@media (prefers-color-scheme: dark) {
.linux-titlebar {
background: var(--toolbar-bg, linear-gradient(to bottom, #3c3c3c, #2e2e2e));
border-bottom-color: var(--toolbar-border, #1e1e1e);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.titlebar-content,
.titlebar-title {
color: var(--toolbar-text, #f0f0f0);
}
.titlebar-button {
color: var(--toolbar-text, #ccc);
&:hover {
background: var(--toolbar-button-hover, rgba(255, 255, 255, 0.1));
}
&:active {
background: var(--toolbar-button-active, rgba(255, 255, 255, 0.15));
}
}
}
// GNOME-like styling variant
.linux-titlebar.gnome-style {
height: 38px;
border-radius: 12px 12px 0 0;
.titlebar-button {
height: 38px;
width: 32px;
border-radius: 6px;
margin: 3px 2px;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
}
// KDE-like styling variant
.linux-titlebar.kde-style {
background: var(--toolbar-bg, #eff0f1);
border-bottom: 1px solid var(--toolbar-border, #bdc3c7);
.titlebar-button {
border-radius: 4px;
margin: 2px 1px;
&:hover {
background: rgba(61, 174, 233, 0.2);
}
}
}
</style>

View File

@@ -0,0 +1,271 @@
<template>
<div class="macos-titlebar" style="--wails-draggable:drag" @contextmenu.prevent>
<div class="titlebar-controls" style="--wails-draggable:no-drag" @contextmenu.prevent>
<button
class="titlebar-button close-button"
@click="closeWindow"
:title="t('titlebar.close')"
>
<div class="button-icon">
<svg width="6" height="6" viewBox="0 0 6 6" v-show="showControlIcons">
<path d="M1 1l4 4m0-4L1 5" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
</svg>
</div>
</button>
<button
class="titlebar-button minimize-button"
@click="minimizeWindow"
:title="t('titlebar.minimize')"
>
<div class="button-icon">
<svg width="8" height="1" viewBox="0 0 8 1" v-show="showControlIcons">
<path d="M0 0h8" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
</svg>
</div>
</button>
<button
class="titlebar-button maximize-button"
@click="toggleMaximize"
:title="isMaximized ? t('titlebar.restore') : t('titlebar.maximize')"
>
<div class="button-icon">
<svg width="6" height="6" viewBox="0 0 6 6" v-show="showControlIcons && !isMaximized">
<path d="M1 1l4 0 0 4-4 0z" fill="none" stroke="currentColor" stroke-width="1"/>
<path d="M2 2l2 0 0 2" fill="none" stroke="currentColor" stroke-width="1"/>
</svg>
<svg width="6" height="6" viewBox="0 0 6 6" v-show="showControlIcons && isMaximized">
<path d="M1 2l4 0 0 3-4 0z" fill="none" stroke="currentColor" stroke-width="1"/>
<path d="M2 1l3 0 0 3" fill="none" stroke="currentColor" stroke-width="1"/>
</svg>
</div>
</button>
</div>
<div class="titlebar-content" @dblclick="toggleMaximize" @contextmenu.prevent>
<div class="titlebar-title">{{ titleText }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import * as runtime from '@wailsio/runtime';
import { useWindowStore } from '@/stores/windowStore';
import { useDocumentStore } from '@/stores/documentStore';
const { t } = useI18n();
const isMaximized = ref(false);
const showControlIcons = ref(false);
const documentStore = useDocumentStore();
const minimizeWindow = async () => {
try {
await runtime.Window.Minimise();
} catch (error) {
// Error handling
}
};
const toggleMaximize = async () => {
try {
const newState = !isMaximized.value;
isMaximized.value = newState;
if (newState) {
await runtime.Window.Maximise();
} else {
await runtime.Window.UnMaximise();
}
setTimeout(async () => {
await checkMaximizedState();
}, 100);
} catch (error) {
isMaximized.value = !isMaximized.value;
}
};
const closeWindow = async () => {
try {
await runtime.Window.Close();
} catch (error) {
// Error handling
}
};
const checkMaximizedState = async () => {
try {
isMaximized.value = await runtime.Window.IsMaximised();
} catch (error) {
// Error handling
}
};
// 计算标题文本
const titleText = computed(() => {
const currentDoc = documentStore.currentDocument;
return currentDoc ? `voidraft - ${currentDoc.title}` : 'voidraft';
});
onMounted(async () => {
await checkMaximizedState();
runtime.Events.On('window:maximised', () => {
isMaximized.value = true;
});
runtime.Events.On('window:unmaximised', () => {
isMaximized.value = false;
});
runtime.Events.On('window:focus', async () => {
await checkMaximizedState();
});
});
onUnmounted(() => {
runtime.Events.Off('window:maximised');
runtime.Events.Off('window:unmaximised');
runtime.Events.Off('window:focus');
});
</script>
<style scoped lang="scss">
.macos-titlebar {
display: flex;
height: 28px;
background: var(--toolbar-bg, #ececec);
border-bottom: 1px solid var(--toolbar-border, rgba(0, 0, 0, 0.1));
user-select: none;
-webkit-user-select: none;
width: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
-webkit-context-menu: none;
-moz-context-menu: none;
context-menu: none;
&:hover {
.titlebar-button {
.button-icon {
opacity: 1;
}
}
}
}
.titlebar-controls {
display: flex;
height: 100%;
align-items: center;
padding-left: 8px;
gap: 8px;
-webkit-context-menu: none;
-moz-context-menu: none;
context-menu: none;
}
.titlebar-button {
width: 12px;
height: 12px;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
padding: 0;
margin: 0;
position: relative;
.button-icon {
opacity: 0;
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: rgba(0, 0, 0, 0.7);
}
&:hover .button-icon {
opacity: 1;
}
}
.close-button {
background: #ff5f57;
&:hover {
background: #ff453a;
}
&:active {
background: #d7463f;
}
}
.minimize-button {
background: #ffbd2e;
&:hover {
background: #ffb524;
}
&:active {
background: #e6a220;
}
}
.maximize-button {
background: #28ca42;
&:hover {
background: #1ebe36;
}
&:active {
background: #1ba932;
}
}
.titlebar-content {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
cursor: default;
-webkit-context-menu: none;
-moz-context-menu: none;
context-menu: none;
}
.titlebar-title {
font-size: 13px;
font-weight: 500;
color: var(--toolbar-text, #333);
text-align: center;
}
@media (prefers-color-scheme: dark) {
.macos-titlebar {
background: var(--toolbar-bg, #2d2d2d);
border-bottom-color: var(--toolbar-border, rgba(255, 255, 255, 0.1));
}
.titlebar-title {
color: var(--toolbar-text, #fff);
}
.titlebar-button .button-icon {
color: rgba(255, 255, 255, 0.8);
}
}
</style>

View File

@@ -1,250 +1,17 @@
<template>
<div class="windows-titlebar" style="--wails-draggable:drag" @contextmenu.prevent @mouseenter="checkMaximizedState" @mouseup="checkMaximizedState">
<div class="titlebar-content" @dblclick="toggleMaximize" @contextmenu.prevent>
<div class="titlebar-icon">
<img src="/appicon.png" alt="voidraft" />
</div>
<div class="titlebar-title">voidraft</div>
</div>
<div class="titlebar-controls" style="--wails-draggable:no-drag" @contextmenu.prevent>
<button
class="titlebar-button minimize-button"
@click="minimizeWindow"
:title="t('titlebar.minimize')"
>
<span class="titlebar-icon">&#xE921;</span>
</button>
<button
class="titlebar-button maximize-button"
@click="toggleMaximize"
:title="isMaximized ? t('titlebar.restore') : t('titlebar.maximize')"
>
<span class="titlebar-icon" v-html="maximizeIcon"></span>
</button>
<button
class="titlebar-button close-button"
@click="closeWindow"
:title="t('titlebar.close')"
>
<span class="titlebar-icon">&#xE8BB;</span>
</button>
</div>
</div>
<WindowsTitleBar v-if="systemStore.isWindows" />
<MacOSTitleBar v-else-if="systemStore.isMacOS" />
<LinuxTitleBar v-else />
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import * as runtime from '@wailsio/runtime';
import { useSystemStore } from '@/stores/systemStore';
import WindowsTitleBar from './WindowsTitleBar.vue';
import MacOSTitleBar from './MacOSTitleBar.vue';
import LinuxTitleBar from './LinuxTitleBar.vue';
const { t } = useI18n();
const isMaximized = ref(false);
// 计算属性用于图标,减少重复渲染
const maximizeIcon = computed(() => isMaximized.value ? '&#xE923;' : '&#xE922;');
const minimizeWindow = async () => {
try {
await runtime.Window.Minimise();
} catch (error) {
}
};
const toggleMaximize = async () => {
try {
// 立即更新UI状态提供即时反馈
const newState = !isMaximized.value;
isMaximized.value = newState;
// 然后执行实际操作
if (newState) {
await runtime.Window.Maximise();
} else {
await runtime.Window.UnMaximise();
}
// 操作完成后再次确认状态(防止操作失败时状态不一致)
setTimeout(async () => {
await checkMaximizedState();
}, 100);
} catch (error) {
// 如果操作失败,恢复原状态
isMaximized.value = !isMaximized.value;
}
};
const closeWindow = async () => {
try {
// 使用Window的Close方法会触发窗口关闭事件
await runtime.Window.Close();
} catch (error) {
}
};
const checkMaximizedState = async () => {
try {
isMaximized.value = await runtime.Window.IsMaximised();
} catch (error) {
}
};
onMounted(async () => {
// 检查初始最大化状态
await checkMaximizedState();
// 监听窗口状态变化事件
runtime.Events.On('window:maximised', () => {
isMaximized.value = true;
});
runtime.Events.On('window:unmaximised', () => {
isMaximized.value = false;
});
// 监听窗口焦点事件,确保状态同步
runtime.Events.On('window:focus', async () => {
await checkMaximizedState();
});
onUnmounted(() => {
// 清理事件监听器
runtime.Events.Off('window:maximised');
runtime.Events.Off('window:unmaximised');
runtime.Events.Off('window:focus');
});
});
const systemStore = useSystemStore();
</script>
<style scoped lang="scss">
.windows-titlebar {
display: flex;
height: 32px;
background: var(--toolbar-bg);
border-bottom: 1px solid var(--toolbar-border);
user-select: none;
-webkit-user-select: none;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
/* 禁用右键菜单 */
-webkit-context-menu: none;
-moz-context-menu: none;
context-menu: none;
}
.titlebar-content {
display: flex;
align-items: center;
flex: 1;
padding-left: 8px;
gap: 8px;
color: var(--toolbar-text);
font-size: 12px;
font-weight: 400;
cursor: default;
/* 禁用右键菜单 */
-webkit-context-menu: none;
-moz-context-menu: none;
context-menu: none;
}
.titlebar-content .titlebar-icon {
width: 16px;
height: 16px;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.titlebar-title {
font-size: 12px;
color: var(--toolbar-text);
}
.titlebar-controls {
display: flex;
height: 100%;
/* 禁用右键菜单 */
-webkit-context-menu: none;
-moz-context-menu: none;
context-menu: none;
}
.titlebar-button {
width: 46px;
height: 32px;
border: none;
background: transparent;
color: var(--toolbar-text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.1s ease;
padding: 0;
margin: 0;
&:hover {
background: var(--toolbar-button-hover);
}
&:active {
background: var(--toolbar-button-hover);
opacity: 0.8;
}
}
.titlebar-button .titlebar-icon {
font-family: 'Segoe MDL2 Assets', 'Segoe UI Symbol', 'Segoe UI', system-ui;
font-size: 9px;
line-height: 1;
display: inline-block;
opacity: 0.9;
transition: opacity 0.1s ease;
.titlebar-button:hover & {
opacity: 1;
}
}
.minimize-button:hover,
.maximize-button:hover {
background: var(--toolbar-button-hover);
}
.minimize-button:active,
.maximize-button:active {
background: var(--toolbar-button-hover);
opacity: 0.8;
}
.close-button:hover {
background: #c42b1c;
color: #ffffff;
.titlebar-icon {
opacity: 1;
}
}
.close-button:active {
background: #a93226;
.titlebar-icon {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,250 @@
<template>
<div class="windows-titlebar" style="--wails-draggable:drag" @contextmenu.prevent>
<div class="titlebar-content" @dblclick="toggleMaximize" @contextmenu.prevent>
<div class="titlebar-icon">
<img src="/appicon.png" alt="voidraft"/>
</div>
<div class="titlebar-title">{{ titleText }}</div>
</div>
<div class="titlebar-controls" style="--wails-draggable:no-drag" @contextmenu.prevent>
<button
class="titlebar-button minimize-button"
@click="minimizeWindow"
:title="t('titlebar.minimize')"
>
<span class="titlebar-icon">&#xE921;</span>
</button>
<button
class="titlebar-button maximize-button"
@click="toggleMaximize"
:title="isMaximized ? t('titlebar.restore') : t('titlebar.maximize')"
>
<span class="titlebar-icon" v-html="maximizeIcon"></span>
</button>
<button
class="titlebar-button close-button"
@click="closeWindow"
:title="t('titlebar.close')"
>
<span class="titlebar-icon">&#xE8BB;</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, onMounted, onUnmounted, ref} from 'vue';
import {useI18n} from 'vue-i18n';
import * as runtime from '@wailsio/runtime';
import { useWindowStore } from '@/stores/windowStore';
import { useDocumentStore } from '@/stores/documentStore';
const {t} = useI18n();
const isMaximized = ref(false);
const documentStore = useDocumentStore();
// 计算属性用于图标,减少重复渲染
const maximizeIcon = computed(() => isMaximized.value ? '&#xE923;' : '&#xE922;');
// 计算标题文本
const titleText = computed(() => {
const currentDoc = documentStore.currentDocument;
return currentDoc ? `voidraft - ${currentDoc.title}` : 'voidraft';
});
const minimizeWindow = async () => {
try {
await runtime.Window.Minimise();
} catch (error) {
// Error handling
}
};
const toggleMaximize = async () => {
try {
// 立即更新UI状态提供即时反馈
const newState = !isMaximized.value;
isMaximized.value = newState;
// 然后执行实际操作
if (newState) {
await runtime.Window.Maximise();
} else {
await runtime.Window.UnMaximise();
}
// 操作完成后再次确认状态(防止操作失败时状态不一致)
setTimeout(async () => {
await checkMaximizedState();
}, 100);
} catch (error) {
// 如果操作失败,恢复原状态
isMaximized.value = !isMaximized.value;
}
};
const closeWindow = async () => {
try {
await runtime.Window.Close();
} catch (error) {
// Error handling
}
};
const checkMaximizedState = async () => {
try {
isMaximized.value = await runtime.Window.IsMaximised();
} catch (error) {
// Error handling
}
};
onMounted(async () => {
await checkMaximizedState();
runtime.Events.On('window:maximised', () => {
isMaximized.value = true;
});
runtime.Events.On('window:unmaximised', () => {
isMaximized.value = false;
});
runtime.Events.On('window:focus', async () => {
await checkMaximizedState();
});
});
onUnmounted(() => {
runtime.Events.Off('window:maximised');
runtime.Events.Off('window:unmaximised');
runtime.Events.Off('window:focus');
});
</script>
<style scoped lang="scss">
.windows-titlebar {
display: flex;
height: 32px;
background: var(--toolbar-bg);
border-bottom: 1px solid var(--toolbar-border);
user-select: none;
-webkit-user-select: none;
width: 100%;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
-webkit-context-menu: none;
-moz-context-menu: none;
context-menu: none;
}
.titlebar-content {
display: flex;
align-items: center;
flex: 1;
padding-left: 8px;
gap: 8px;
color: var(--toolbar-text);
font-size: 12px;
font-weight: 400;
cursor: default;
-webkit-context-menu: none;
-moz-context-menu: none;
context-menu: none;
}
.titlebar-content .titlebar-icon {
width: 16px;
height: 16px;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.titlebar-title {
font-size: 12px;
color: var(--toolbar-text);
}
.titlebar-controls {
display: flex;
height: 100%;
-webkit-context-menu: none;
-moz-context-menu: none;
context-menu: none;
}
.titlebar-button {
width: 46px;
height: 32px;
border: none;
background: transparent;
color: var(--toolbar-text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.1s ease;
padding: 0;
margin: 0;
&:hover {
background: var(--toolbar-button-hover);
}
&:active {
background: var(--toolbar-button-hover);
opacity: 0.8;
}
}
.titlebar-button .titlebar-icon {
font-family: 'Segoe MDL2 Assets', 'Segoe UI Symbol', 'Segoe UI', system-ui;
font-size: 9px;
line-height: 1;
display: inline-block;
opacity: 0.9;
transition: opacity 0.1s ease;
.titlebar-button:hover & {
opacity: 1;
}
}
.minimize-button:hover,
.maximize-button:hover {
background: var(--toolbar-button-hover);
}
.minimize-button:active,
.maximize-button:active {
background: var(--toolbar-button-hover);
opacity: 0.8;
}
.close-button:hover {
background: #c42b1c;
color: #ffffff;
.titlebar-icon {
opacity: 1;
}
}
.close-button:active {
background: #a93226;
.titlebar-icon {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,555 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useEditorStore } from '@/stores/editorStore';
import { SUPPORTED_LANGUAGES, type SupportedLanguage } from '@/views/editor/extensions/codeblock/types';
import { getActiveNoteBlock } from '@/views/editor/extensions/codeblock/state';
import { changeCurrentBlockLanguage } from '@/views/editor/extensions/codeblock/commands';
const { t } = useI18n();
const editorStore = useEditorStore();
// 组件状态
const showLanguageMenu = ref(false);
const searchQuery = ref('');
const searchInputRef = ref<HTMLInputElement>();
// 语言别名映射
const LANGUAGE_ALIASES: Record<SupportedLanguage, string> = {
auto: 'auto',
text: 'txt',
json: 'JSON',
py: 'python',
html: 'HTML',
sql: 'SQL',
md: 'markdown',
java: 'Java',
php: 'PHP',
css: 'CSS',
xml: 'XML',
cpp: 'c++',
rs: 'rust',
cs: 'c#',
rb: 'ruby',
sh: 'shell',
yaml: 'yml',
toml: 'TOML',
go: 'Go',
clj: 'clojure',
ex: 'elixir',
erl: 'erlang',
js: 'javascript',
ts: 'typescript',
swift: 'Swift',
kt: 'kotlin',
groovy: 'Groovy',
ps1: 'powershell',
dart: 'Dart',
scala: 'Scala'
};
// 语言显示名称映射
const LANGUAGE_NAMES: Record<SupportedLanguage, string> = {
auto: 'Auto',
text: 'Plain Text',
json: 'JSON',
py: 'Python',
html: 'HTML',
sql: 'SQL',
md: 'Markdown',
java: 'Java',
php: 'PHP',
css: 'CSS',
xml: 'XML',
cpp: 'C++',
rs: 'Rust',
cs: 'C#',
rb: 'Ruby',
sh: 'Shell',
yaml: 'YAML',
toml: 'TOML',
go: 'Go',
clj: 'Clojure',
ex: 'Elixir',
erl: 'Erlang',
js: 'JavaScript',
ts: 'TypeScript',
swift: 'Swift',
kt: 'Kotlin',
groovy: 'Groovy',
ps1: 'PowerShell',
dart: 'Dart',
scala: 'Scala'
};
// 当前活动块的语言信息
const currentBlockLanguage = ref<{ name: SupportedLanguage; auto: boolean }>({
name: 'text',
auto: false
});
// 事件监听器引用
const eventListeners = ref<{
updateListener?: () => void;
selectionUpdateListener?: () => void;
}>({});
// 更新当前块语言信息
const updateCurrentBlockLanguage = () => {
if (!editorStore.editorView) {
currentBlockLanguage.value = { name: 'text', auto: false };
return;
}
try {
const state = editorStore.editorView.state;
const activeBlock = getActiveNoteBlock(state as any);
if (activeBlock) {
const newLanguage = {
name: activeBlock.language.name as SupportedLanguage,
auto: activeBlock.language.auto
};
// 只有当语言信息实际发生变化时才更新
if (currentBlockLanguage.value.name !== newLanguage.name ||
currentBlockLanguage.value.auto !== newLanguage.auto) {
currentBlockLanguage.value = newLanguage;
}
} else {
if (currentBlockLanguage.value.name !== 'text' || currentBlockLanguage.value.auto !== false) {
currentBlockLanguage.value = { name: 'text', auto: false };
}
}
} catch (error) {
console.warn('Failed to get active block language:', error);
currentBlockLanguage.value = { name: 'text', auto: false };
}
};
// 清理事件监听器
const cleanupEventListeners = () => {
if (editorStore.editorView?.dom && eventListeners.value.updateListener) {
const dom = editorStore.editorView.dom;
dom.removeEventListener('click', eventListeners.value.updateListener);
dom.removeEventListener('keyup', eventListeners.value.updateListener);
dom.removeEventListener('keydown', eventListeners.value.updateListener);
dom.removeEventListener('focus', eventListeners.value.updateListener);
dom.removeEventListener('mouseup', eventListeners.value.updateListener);
if (eventListeners.value.selectionUpdateListener) {
dom.removeEventListener('selectionchange', eventListeners.value.selectionUpdateListener);
}
}
eventListeners.value = {};
};
// 设置事件监听器
const setupEventListeners = (view: any) => {
cleanupEventListeners();
// 监听编辑器状态更新
const updateListener = () => {
// 使用 requestAnimationFrame 确保在下一帧更新,性能更好
requestAnimationFrame(() => {
updateCurrentBlockLanguage();
});
};
// 监听选择变化
const selectionUpdateListener = () => {
requestAnimationFrame(() => {
updateCurrentBlockLanguage();
});
};
// 保存监听器引用
eventListeners.value = { updateListener, selectionUpdateListener };
// 监听关键事件:光标位置变化、文档变化、焦点变化
view.dom.addEventListener('click', updateListener);
view.dom.addEventListener('keyup', updateListener);
view.dom.addEventListener('keydown', updateListener);
view.dom.addEventListener('focus', updateListener);
view.dom.addEventListener('mouseup', updateListener); // 鼠标选择结束
// 监听编辑器的选择变化事件
if (view.dom.addEventListener) {
view.dom.addEventListener('selectionchange', selectionUpdateListener);
}
// 立即更新一次当前状态
updateCurrentBlockLanguage();
};
// 监听编辑器状态变化
watch(
() => editorStore.editorView,
(newView) => {
if (newView) {
setupEventListeners(newView);
} else {
cleanupEventListeners();
}
},
{ immediate: true }
);
// 过滤后的语言列表
const filteredLanguages = computed(() => {
if (!searchQuery.value) {
return SUPPORTED_LANGUAGES;
}
const query = searchQuery.value.toLowerCase();
return SUPPORTED_LANGUAGES.filter(langId => {
const name = LANGUAGE_NAMES[langId];
const alias = LANGUAGE_ALIASES[langId];
return langId.toLowerCase().includes(query) ||
(name && name.toLowerCase().includes(query)) ||
(alias && alias.toLowerCase().includes(query));
});
});
// 切换语言选择器显示状态
const toggleLanguageMenu = () => {
showLanguageMenu.value = !showLanguageMenu.value;
// 如果菜单打开,滚动到当前语言
if (showLanguageMenu.value) {
scrollToCurrentLanguage();
}
};
// 关闭语言选择器
const closeLanguageMenu = () => {
showLanguageMenu.value = false;
searchQuery.value = '';
};
// 选择语言
const selectLanguage = (languageId: SupportedLanguage) => {
if (!editorStore.editorView) {
closeLanguageMenu();
return;
}
try {
const view = editorStore.editorView;
const state = view.state;
const dispatch = view.dispatch;
let targetLanguage: string;
let autoDetect: boolean;
if (languageId === 'auto') {
// 设置为自动检测
targetLanguage = 'text';
autoDetect = true;
} else {
// 设置为指定语言,关闭自动检测
targetLanguage = languageId;
autoDetect = false;
}
// 使用修复后的函数来更改语言
const success = changeCurrentBlockLanguage(state as any, dispatch, targetLanguage, autoDetect);
if (success) {
// 立即更新当前语言状态
updateCurrentBlockLanguage();
}
} catch (error) {
console.warn('Failed to change block language:', error);
}
closeLanguageMenu();
};
// 点击外部关闭
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('.block-language-selector')) {
closeLanguageMenu();
}
};
// 键盘事件处理
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeLanguageMenu();
}
};
onMounted(() => {
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleKeydown);
// 立即更新一次当前语言状态
updateCurrentBlockLanguage();
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleKeydown);
cleanupEventListeners();
});
// 获取当前语言的显示名称
const getCurrentLanguageName = computed(() => {
const lang = currentBlockLanguage.value;
if (lang.auto) {
return `${lang.name} (auto)`;
}
return lang.name;
});
// 获取当前显示的语言选项
const getCurrentDisplayLanguage = computed(() => {
const lang = currentBlockLanguage.value;
if (lang.auto) {
return 'auto';
}
return lang.name;
});
// 滚动到当前选择的语言
const scrollToCurrentLanguage = () => {
nextTick(() => {
const currentLang = getCurrentDisplayLanguage.value;
const selectorElement = document.querySelector('.block-language-selector');
if (!selectorElement) return;
const languageList = selectorElement.querySelector('.language-list') as HTMLElement;
const activeOption = selectorElement.querySelector(`.language-option[data-language="${currentLang}"]`) as HTMLElement;
if (languageList && activeOption) {
// 使用 scrollIntoView 进行平滑滚动
activeOption.scrollIntoView({
behavior: 'auto',
block: 'nearest',
inline: 'nearest'
});
}
});
};
</script>
<template>
<div class="block-language-selector">
<button
class="language-btn"
:title="t('toolbar.blockLanguage')"
@click="toggleLanguageMenu"
>
<span class="language-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
</svg>
</span>
<span class="language-name">{{ getCurrentLanguageName }}</span>
<span class="arrow" :class="{ 'open': showLanguageMenu }"></span>
</button>
<div class="language-menu" v-if="showLanguageMenu">
<!-- 搜索框 -->
<div class="search-container">
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
class="search-input"
:placeholder="t('toolbar.searchLanguage')"
@keydown.stop
/>
<svg class="search-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
</div>
<!-- 语言列表 -->
<div class="language-list">
<div
v-for="language in filteredLanguages"
:key="language"
class="language-option"
:class="{ 'active': getCurrentDisplayLanguage === language }"
:data-language="language"
@click="selectLanguage(language)"
>
<span class="language-name">{{ LANGUAGE_NAMES[language] || language }}</span>
<span class="language-alias">{{ language }}</span>
</div>
<!-- 无结果提示 -->
<div v-if="filteredLanguages.length === 0" class="no-results">
{{ t('toolbar.noLanguageFound') }}
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.block-language-selector {
position: relative;
.language-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 11px;
display: flex;
align-items: center;
gap: 3px;
padding: 2px 4px;
border-radius: 3px;
&:hover {
background-color: var(--border-color);
opacity: 0.8;
}
.language-icon {
display: flex;
align-items: center;
}
.language-name {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.arrow {
font-size: 8px;
margin-left: 2px;
transition: transform 0.2s ease;
&.open {
transform: rotate(180deg);
}
}
}
.language-menu {
position: absolute;
bottom: 100%;
right: 0;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 3px;
margin-bottom: 4px;
width: 220px;
max-height: 280px;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
overflow: hidden;
.search-container {
position: relative;
padding: 8px;
border-bottom: 1px solid var(--border-color);
.search-input {
width: 100%;
box-sizing: border-box;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 2px;
padding: 5px 8px 5px 26px;
font-size: 11px;
color: var(--text-primary);
outline: none;
line-height: 1.2;
&:focus {
border-color: var(--text-muted);
}
&::placeholder {
color: var(--text-muted);
}
}
.search-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
}
}
.language-list {
max-height: 200px;
overflow-y: auto;
.language-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
cursor: pointer;
font-size: 11px;
&:hover {
background-color: var(--border-color);
opacity: 0.8;
}
&.active {
background-color: var(--selection-bg);
color: var(--selection-text);
.language-alias {
color: var(--selection-text);
opacity: 0.7;
}
}
.language-name {
font-weight: normal;
}
.language-alias {
font-size: 10px;
color: var(--text-muted);
opacity: 0.6;
}
}
.no-results {
padding: 12px 8px;
text-align: center;
color: var(--text-muted);
font-size: 11px;
}
}
}
}
/* 自定义滚动条 */
.language-list::-webkit-scrollbar {
width: 4px;
}
.language-list::-webkit-scrollbar-track {
background: transparent;
}
.language-list::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 2px;
&:hover {
background-color: var(--text-muted);
}
}
</style>

View File

@@ -0,0 +1,753 @@
<script setup lang="ts">
import {computed, nextTick, onMounted, onUnmounted, ref} from 'vue';
import {useDocumentStore} from '@/stores/documentStore';
import {useI18n} from 'vue-i18n';
import type {Document} from '@/../bindings/voidraft/internal/models/models';
import {useWindowStore} from "@/stores/windowStore";
const documentStore = useDocumentStore();
const windowStore = useWindowStore();
const {t} = useI18n();
// 组件状态
const showMenu = ref(false);
const inputValue = ref('');
const inputRef = ref<HTMLInputElement>();
const editingId = ref<number | null>(null);
const editingTitle = ref('');
const editInputRef = ref<HTMLInputElement>();
const deleteConfirmId = ref<number | null>(null);
// 添加错误提示状态
const alreadyOpenDocId = ref<number | null>(null);
const errorMessageTimer = ref<number | null>(null);
// 过滤后的文档列表 + 创建选项
const filteredItems = computed(() => {
const docs = documentStore.documentList;
const query = inputValue.value.trim();
if (!query) {
return docs;
}
// 过滤匹配的文档
const filtered = docs.filter(doc =>
doc.title.toLowerCase().includes(query.toLowerCase())
);
// 如果输入的不是已存在文档的完整标题,添加创建选项
const exactMatch = docs.some(doc => doc.title.toLowerCase() === query.toLowerCase());
if (!exactMatch && query.length > 0) {
return [
{id: -1, title: t('toolbar.createDocument') + ` "${query}"`, isCreateOption: true} as any,
...filtered
];
}
return filtered;
});
// 当前文档显示名称
const currentDocName = computed(() => {
if (!documentStore.currentDocument) return t('toolbar.selectDocument');
const title = documentStore.currentDocument.title;
return title.length > 12 ? title.substring(0, 12) + '...' : title;
});
// 打开菜单
const openMenu = async () => {
showMenu.value = true;
await documentStore.updateDocuments();
nextTick(() => {
inputRef.value?.focus();
});
};
// 关闭菜单
const closeMenu = () => {
showMenu.value = false;
inputValue.value = '';
editingId.value = null;
editingTitle.value = '';
deleteConfirmId.value = null;
// 清除错误状态和定时器
clearErrorMessage();
};
// 清除错误提示和定时器
const clearErrorMessage = () => {
if (errorMessageTimer.value) {
clearTimeout(errorMessageTimer.value);
errorMessageTimer.value = null;
}
alreadyOpenDocId.value = null;
};
// 切换菜单
const toggleMenu = () => {
if (showMenu.value) {
closeMenu();
} else {
openMenu();
}
};
// 选择文档或创建文档
const selectItem = async (item: any) => {
if (item.isCreateOption) {
// 创建新文档
await createDoc(inputValue.value.trim());
} else {
// 选择现有文档
await selectDoc(item);
}
};
// 选择文档
const selectDoc = async (doc: Document) => {
try {
const hasOpen = await windowStore.isDocumentWindowOpen(doc.id);
if (hasOpen) {
// 设置错误状态并启动定时器
alreadyOpenDocId.value = doc.id;
// 清除之前的定时器(如果存在)
if (errorMessageTimer.value) {
clearTimeout(errorMessageTimer.value);
}
// 设置新的定时器3秒后清除错误信息
errorMessageTimer.value = window.setTimeout(() => {
alreadyOpenDocId.value = null;
errorMessageTimer.value = null;
}, 3000);
return;
}
const success = await documentStore.openDocument(doc.id);
if (success) {
closeMenu();
}
} catch (error) {
console.error('Failed to switch documents:', error);
}
};
// 文档名称长度限制
const MAX_TITLE_LENGTH = 50;
// 验证文档名称
const validateTitle = (title: string): string | null => {
if (!title.trim()) {
return t('toolbar.documentNameRequired');
}
if (title.trim().length > MAX_TITLE_LENGTH) {
return t('toolbar.documentNameTooLong', {max: MAX_TITLE_LENGTH});
}
return null;
};
// 创建文档
const createDoc = async (title: string) => {
const trimmedTitle = title.trim();
const error = validateTitle(trimmedTitle);
if (error) {
return;
}
try {
const newDoc = await documentStore.createNewDocument(trimmedTitle);
if (newDoc) {
await selectDoc(newDoc);
}
} catch (error) {
console.error('Failed to create document:', error);
}
};
// 开始重命名
const startRename = (doc: Document, event: Event) => {
event.stopPropagation();
editingId.value = doc.id;
editingTitle.value = doc.title;
deleteConfirmId.value = null; // 清除删除确认状态
nextTick(() => {
editInputRef.value?.focus();
editInputRef.value?.select();
});
};
// 保存编辑
const saveEdit = async () => {
if (editingId.value && editingTitle.value.trim()) {
const trimmedTitle = editingTitle.value.trim();
const error = validateTitle(trimmedTitle);
if (error) {
return;
}
try {
await documentStore.updateDocumentMetadata(editingId.value, trimmedTitle);
await documentStore.updateDocuments();
} catch (error) {
return;
}
}
editingId.value = null;
editingTitle.value = '';
};
// 在新窗口打开文档
const openInNewWindow = async (doc: Document, event: Event) => {
event.stopPropagation();
try {
await documentStore.openDocumentInNewWindow(doc.id);
} catch (error) {
console.error('Failed to open document in new window:', error);
}
};
// 处理删除 - 简化确认机制
const handleDelete = async (doc: Document, event: Event) => {
event.stopPropagation();
if (deleteConfirmId.value === doc.id) {
// 确认删除
try {
await documentStore.deleteDocument(doc.id);
await documentStore.updateDocuments();
// 如果删除的是当前文档,切换到第一个文档
if (documentStore.currentDocument?.id === doc.id && documentStore.documentList.length > 0) {
const firstDoc = documentStore.documentList[0];
if (firstDoc) {
await selectDoc(firstDoc);
}
}
} catch (error) {
console.error('deleted failed:', error);
}
deleteConfirmId.value = null;
} else {
// 进入确认状态
deleteConfirmId.value = doc.id;
editingId.value = null; // 清除编辑状态
// 3秒后自动取消确认状态
setTimeout(() => {
if (deleteConfirmId.value === doc.id) {
deleteConfirmId.value = null;
}
}, 3000);
}
};
// 格式化时间
const formatTime = (dateString: string | null) => {
if (!dateString) return t('toolbar.unknownTime');
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return t('toolbar.invalidDate');
// 根据当前语言显示时间格式
const locale = t('locale') === 'zh-CN' ? 'zh-CN' : 'en-US';
return date.toLocaleString(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
} catch (error) {
return t('toolbar.timeError');
}
};
// 键盘事件
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (editingId.value) {
editingId.value = null;
editingTitle.value = '';
} else if (deleteConfirmId.value) {
deleteConfirmId.value = null;
} else {
closeMenu();
}
}
};
// 输入框键盘事件
const handleInputKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault();
const query = inputValue.value.trim();
if (query) {
// 如果有匹配的项目,选择第一个
if (filteredItems.value.length > 0) {
selectItem(filteredItems.value[0]);
}
}
} else if (event.key === 'Escape') {
event.preventDefault();
closeMenu();
}
event.stopPropagation();
};
// 编辑键盘事件
const handleEditKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault();
saveEdit();
} else if (event.key === 'Escape') {
event.preventDefault();
editingId.value = null;
editingTitle.value = '';
}
event.stopPropagation();
};
// 点击外部关闭
const handleClickOutside = (event: Event) => {
const target = event.target as HTMLElement;
if (!target.closest('.document-selector')) {
closeMenu();
}
};
// 生命周期
onMounted(() => {
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleKeydown);
// 清理定时器
if (errorMessageTimer.value) {
clearTimeout(errorMessageTimer.value);
}
});
</script>
<template>
<div class="document-selector">
<!-- 选择器按钮 -->
<button class="doc-btn" @click="toggleMenu">
<span class="doc-name">{{ currentDocName }}</span>
<span class="arrow" :class="{ open: showMenu }"></span>
</button>
<!-- 菜单 -->
<div v-if="showMenu" class="doc-menu">
<!-- 输入框 -->
<div class="input-box">
<input
ref="inputRef"
v-model="inputValue"
type="text"
class="main-input"
:placeholder="t('toolbar.searchOrCreateDocument')"
:maxlength="MAX_TITLE_LENGTH"
@keydown="handleInputKeydown"
/>
<svg class="input-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
</div>
<!-- 项目列表 -->
<div class="item-list">
<div
v-for="item in filteredItems"
:key="item.id"
class="list-item"
:class="{
'active': !item.isCreateOption && documentStore.currentDocument?.id === item.id,
'create-item': item.isCreateOption
}"
@click="selectItem(item)"
>
<!-- 创建选项 -->
<div v-if="item.isCreateOption" class="create-option">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"></path>
<path d="M12 5v14"></path>
</svg>
<span>{{ item.title }}</span>
</div>
<!-- 文档项 -->
<div v-else class="doc-item-content">
<!-- 普通显示 -->
<div v-if="editingId !== item.id" class="doc-info">
<div class="doc-title">{{ item.title }}</div>
<!-- 根据状态显示错误信息或时间 -->
<div v-if="alreadyOpenDocId === item.id" class="doc-error">
{{ t('toolbar.alreadyOpenInNewWindow') }}
</div>
<div v-else class="doc-date">{{ formatTime(item.updatedAt) }}</div>
</div>
<!-- 编辑状态 -->
<div v-else class="doc-edit">
<input
:ref="el => editInputRef = el as HTMLInputElement"
v-model="editingTitle"
type="text"
class="edit-input"
:maxlength="MAX_TITLE_LENGTH"
@keydown="handleEditKeydown"
@blur="saveEdit"
@click.stop
/>
</div>
<!-- 操作按钮 -->
<div v-if="editingId !== item.id" class="doc-actions">
<!-- 只有非当前文档才显示在新窗口打开按钮 -->
<button
v-if="documentStore.currentDocument?.id !== item.id"
class="action-btn"
@click="openInNewWindow(item, $event)"
:title="t('toolbar.openInNewWindow')"
>
<svg width="12" height="12" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
fill="currentColor">
<path
d="M172.8 1017.6c-89.6 0-166.4-70.4-166.4-166.4V441.6c0-89.6 70.4-166.4 166.4-166.4h416c89.6 0 166.4 70.4 166.4 166.4v416c0 89.6-70.4 166.4-166.4 166.4l-416-6.4z m0-659.2c-51.2 0-89.6 38.4-89.6 89.6v416c0 51.2 38.4 89.6 89.6 89.6h416c51.2 0 89.6-38.4 89.6-89.6V441.6c0-51.2-38.4-89.6-89.6-89.6H172.8z"></path>
<path
d="M851.2 19.2H435.2C339.2 19.2 268.8 96 268.8 185.6v25.6h70.4v-25.6c0-51.2 38.4-89.6 89.6-89.6h409.6c51.2 0 89.6 38.4 89.6 89.6v409.6c0 51.2-38.4 89.6-89.6 89.6h-38.4V768h51.2c96 0 166.4-76.8 166.4-166.4V185.6c0-96-76.8-166.4-166.4-166.4z"></path>
</svg>
</button>
<button class="action-btn" @click="startRename(item, $event)" :title="t('toolbar.rename')">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path>
</svg>
</button>
<button
v-if="documentStore.documentList.length > 1 && item.id !== 1"
class="action-btn delete-btn"
:class="{ 'delete-confirm': deleteConfirmId === item.id }"
@click="handleDelete(item, $event)"
:title="deleteConfirmId === item.id ? t('toolbar.confirmDelete') : t('toolbar.delete')"
>
<svg v-if="deleteConfirmId !== item.id" xmlns="http://www.w3.org/2000/svg" width="12" height="12"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<polyline points="3,6 5,6 21,6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
<span v-else class="confirm-text">{{ t('toolbar.confirm') }}</span>
</button>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="filteredItems.length === 0" class="empty">
{{ t('toolbar.noDocumentFound') }}
</div>
<!-- 加载状态 -->
<div v-if="documentStore.isLoading" class="loading">
{{ t('toolbar.loading') }}
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.document-selector {
position: relative;
.doc-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 11px;
display: flex;
align-items: center;
gap: 3px;
padding: 2px 4px;
border-radius: 3px;
&:hover {
background-color: var(--border-color);
opacity: 0.8;
}
.doc-name {
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.arrow {
font-size: 8px;
margin-left: 2px;
transition: transform 0.2s ease;
&.open {
transform: rotate(180deg);
}
}
}
.doc-menu {
position: absolute;
bottom: 100%;
right: 0;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 3px;
margin-bottom: 4px;
width: 260px;
max-height: 320px;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
overflow: hidden;
.input-box {
position: relative;
padding: 8px;
border-bottom: 1px solid var(--border-color);
.main-input {
width: 100%;
box-sizing: border-box;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 2px;
padding: 5px 8px 5px 26px;
font-size: 11px;
color: var(--text-primary);
outline: none;
&:focus {
border-color: var(--text-muted);
}
&::placeholder {
color: var(--text-muted);
}
}
.input-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
}
}
.item-list {
max-height: 240px;
overflow-y: auto;
.list-item {
cursor: pointer;
border-bottom: 1px solid var(--border-color);
&:hover {
background-color: var(--bg-hover);
}
&.active {
background-color: var(--selection-bg);
.doc-item-content .doc-info {
.doc-title {
color: var(--selection-text);
}
.doc-date, .doc-error {
color: var(--selection-text);
opacity: 0.7;
}
}
}
&.create-item {
.create-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px;
font-size: 11px;
font-weight: normal;
svg {
flex-shrink: 0;
color: var(--text-muted);
}
}
}
.doc-item-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 8px;
.doc-info {
flex: 1;
min-width: 0;
.doc-title {
font-size: 12px;
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: normal;
}
.doc-date {
font-size: 10px;
color: var(--text-muted);
opacity: 0.6;
}
.doc-error {
font-size: 10px;
color: var(--text-danger);
font-weight: 500;
animation: fadeInOut 3s forwards;
}
}
.doc-edit {
flex: 1;
.edit-input {
width: 100%;
box-sizing: border-box;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 2px;
padding: 4px 6px;
font-size: 11px;
color: var(--text-primary);
outline: none;
&:focus {
border-color: var(--text-muted);
}
}
}
.doc-actions {
display: flex;
gap: 6px;
opacity: 0;
transition: opacity 0.2s ease;
.action-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
min-width: 20px;
min-height: 20px;
svg {
width: 12px;
height: 12px;
}
&:hover {
background-color: var(--border-color);
color: var(--text-primary);
}
&.delete-btn:hover {
color: var(--text-danger);
}
&.delete-confirm {
background-color: var(--text-danger);
color: white;
.confirm-text {
font-size: 10px;
padding: 0 4px;
font-weight: normal;
}
&:hover {
background-color: var(--text-danger);
color: white !important; // 确保确认状态下文字始终为白色
opacity: 0.8;
}
}
}
}
}
&:hover .doc-actions {
opacity: 1;
}
}
.empty, .loading {
padding: 12px 8px;
text-align: center;
font-size: 11px;
color: var(--text-muted);
}
}
}
// 自定义滚动条
.item-list {
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 2px;
&:hover {
background-color: var(--text-muted);
}
}
}
}
@keyframes fadeInOut {
0% {
opacity: 1;
}
70% {
opacity: 1;
}
100% {
opacity: 0;
}
}
</style>

View File

@@ -1,53 +1,38 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { ref, onMounted, watch } from 'vue';
import { useConfigStore } from '@/stores/configStore';
import { useEditorStore } from '@/stores/editorStore';
import { useErrorHandler } from '@/utils/errorHandler';
import {useI18n} from 'vue-i18n';
import {onMounted, onUnmounted, ref, watch, computed} from 'vue';
import {useConfigStore} from '@/stores/configStore';
import {useEditorStore} from '@/stores/editorStore';
import {useUpdateStore} from '@/stores/updateStore';
import {useWindowStore} from '@/stores/windowStore';
import * as runtime from '@wailsio/runtime';
import { useRouter } from 'vue-router';
import {useRouter} from 'vue-router';
import BlockLanguageSelector from './BlockLanguageSelector.vue';
import DocumentSelector from './DocumentSelector.vue';
import {getActiveNoteBlock} from '@/views/editor/extensions/codeblock/state';
import {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/languages';
import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode';
const editorStore = useEditorStore();
const configStore = useConfigStore();
const { safeCall } = useErrorHandler();
const { t, locale } = useI18n();
const updateStore = useUpdateStore();
const windowStore = useWindowStore();
const {t} = useI18n();
const router = useRouter();
// 语言下拉菜单
const showLanguageMenu = ref(false);
// 当前块是否支持格式化的响应式状态
const canFormatCurrentBlock = ref(false);
const supportedLanguages = [
{ code: 'zh-CN', name: '简体中文' },
{ code: 'en-US', name: 'English' }
];
// 窗口置顶状态管理(仅当前窗口,不同步到配置文件)
const isCurrentWindowOnTop = ref(false);
const toggleLanguageMenu = () => {
showLanguageMenu.value = !showLanguageMenu.value;
};
const closeLanguageMenu = () => {
showLanguageMenu.value = false;
};
const changeLanguage = async (langCode: string) => {
await safeCall(() => configStore.setLanguage(langCode as any), 'language.changeFailed');
closeLanguageMenu();
};
// 设置窗口置顶
const setWindowAlwaysOnTop = async (isTop: boolean) => {
await safeCall(async () => {
await runtime.Window.SetAlwaysOnTop(isTop);
}, 'window.setTopFailed');
await runtime.Window.SetAlwaysOnTop(isTop);
};
// 切换窗口置顶
const toggleAlwaysOnTop = async () => {
await safeCall(async () => {
await configStore.toggleAlwaysOnTop();
// 使用Window.SetAlwaysOnTop方法设置窗口置顶状态
await runtime.Window.SetAlwaysOnTop(configStore.config.general.alwaysOnTop);
}, 'config.alwaysOnTopFailed');
isCurrentWindowOnTop.value = !isCurrentWindowOnTop.value;
await runtime.Window.SetAlwaysOnTop(isCurrentWindowOnTop.value);
};
// 跳转到设置页面
@@ -55,93 +40,239 @@ const goToSettings = () => {
router.push('/settings');
};
// 执行格式化
const formatCurrentBlock = () => {
if (!canFormatCurrentBlock.value || !editorStore.editorView) return;
formatBlockContent(editorStore.editorView);
};
// 格式化按钮状态更新
const updateFormatButtonState = () => {
// 安全检查
const view = editorStore.editorView;
if (!view) {
canFormatCurrentBlock.value = false;
return;
}
try {
// 获取活动块和语言信息
const state = view.state;
const activeBlock = getActiveNoteBlock(state as any);
// 检查块和语言格式化支持
canFormatCurrentBlock.value = !!(
activeBlock &&
getLanguage(activeBlock.language.name as any)?.prettier
);
} catch (error) {
console.warn('Error checking format capability:', error);
canFormatCurrentBlock.value = false;
}
};
// 创建带300ms防抖的更新函数
const debouncedUpdateFormatButton = (() => {
let timeout: number | null = null;
return () => {
if (timeout) clearTimeout(timeout);
timeout = window.setTimeout(() => {
updateFormatButtonState();
timeout = null;
}, 300);
};
})();
// 编辑器事件管理
const setupEditorListeners = (view: any) => {
if (!view?.dom) return [];
const events = [
{ type: 'click', handler: updateFormatButtonState },
{ type: 'keyup', handler: debouncedUpdateFormatButton },
{ type: 'focus', handler: updateFormatButtonState }
];
// 注册所有事件
events.forEach(event => view.dom.addEventListener(event.type, event.handler));
// 返回清理函数数组
return events.map(event =>
() => view.dom.removeEventListener(event.type, event.handler)
);
};
// 监听编辑器视图变化
let cleanupListeners: (() => void)[] = [];
watch(
() => editorStore.editorView,
(newView) => {
// 清理旧监听器
cleanupListeners.forEach(cleanup => cleanup());
cleanupListeners = [];
if (newView) {
// 初始更新状态
updateFormatButtonState();
// 设置新监听器
cleanupListeners = setupEditorListeners(newView);
} else {
canFormatCurrentBlock.value = false;
}
},
{ immediate: true }
);
// 组件生命周期
const isLoaded = ref(false);
onMounted(() => {
isLoaded.value = true;
// 首次更新格式化状态
updateFormatButtonState();
});
// 监听置顶设置变化
watch(
() => configStore.config.general.alwaysOnTop,
async (newValue) => {
if (!isLoaded.value) return;
await runtime.Window.SetAlwaysOnTop(newValue);
}
);
onUnmounted(() => {
// 清理所有事件监听器
cleanupListeners.forEach(cleanup => cleanup());
cleanupListeners = [];
});
// 组件加载完成后应用置顶设置
watch(isLoaded, async (newLoaded) => {
if (newLoaded && configStore.config.general.alwaysOnTop) {
await setWindowAlwaysOnTop(true);
// 组件加载后初始化置顶状态
watch(isLoaded, async (loaded) => {
if (loaded) {
// 初始化时从配置文件读取置顶状态
isCurrentWindowOnTop.value = configStore.config.general.alwaysOnTop;
await setWindowAlwaysOnTop(isCurrentWindowOnTop.value);
}
});
const handleUpdateButtonClick = async () => {
if (updateStore.hasUpdate && !updateStore.isUpdating && !updateStore.updateSuccess) {
// 开始下载更新
await updateStore.applyUpdate();
} else if (updateStore.updateSuccess) {
// 更新成功后,点击重启
await updateStore.restartApplication();
}
};
// 更新按钮标题计算属性
const updateButtonTitle = computed(() => {
if (updateStore.isChecking) return t('settings.checking');
if (updateStore.isUpdating) return t('settings.updating');
if (updateStore.updateSuccess) return t('settings.updateSuccessRestartRequired');
if (updateStore.hasUpdate) return `${t('settings.newVersionAvailable')}: ${updateStore.updateResult?.latestVersion || ''}`;
return '';
});
</script>
<template>
<div class="toolbar-container">
<div class="statistics">
<span class="stat-item" :title="t('toolbar.editor.lines')">{{ t('toolbar.editor.lines') }}: <span class="stat-value">{{
<span class="stat-item" :title="t('toolbar.editor.lines')">{{ t('toolbar.editor.lines') }}: <span
class="stat-value">{{
editorStore.documentStats.lines
}}</span></span>
<span class="stat-item" :title="t('toolbar.editor.characters')">{{ t('toolbar.editor.characters') }}: <span class="stat-value">{{
<span class="stat-item" :title="t('toolbar.editor.characters')">{{ t('toolbar.editor.characters') }}: <span
class="stat-value">{{
editorStore.documentStats.characters
}}</span></span>
<span class="stat-item" :title="t('toolbar.editor.selected')" v-if="editorStore.documentStats.selectedCharacters > 0">
{{ t('toolbar.editor.selected') }}: <span class="stat-value">{{ editorStore.documentStats.selectedCharacters }}</span>
<span class="stat-item" :title="t('toolbar.editor.selected')"
v-if="editorStore.documentStats.selectedCharacters > 0">
{{ t('toolbar.editor.selected') }}: <span class="stat-value">{{
editorStore.documentStats.selectedCharacters
}}</span>
</span>
</div>
<div class="actions">
<span class="font-size" :title="t('toolbar.fontSizeTooltip')" @click="() => configStore.resetFontSize()">
{{ configStore.config.editing.fontSize }}px
</span>
<span class="tab-settings">
<label :title="t('toolbar.tabLabel')" class="tab-toggle">
<input type="checkbox" :checked="configStore.config.editing.enableTabIndent" @change="() => configStore.toggleTabIndent()"/>
<span>{{ t('toolbar.tabLabel') }}</span>
</label>
<span class="tab-type" :title="t('toolbar.tabType.' + (configStore.config.editing.tabType === 'spaces' ? 'spaces' : 'tab'))" @click="() => configStore.toggleTabType()">
{{ t('toolbar.tabType.' + (configStore.config.editing.tabType === 'spaces' ? 'spaces' : 'tab')) }}
</span>
<span class="tab-size" title="Tab大小" v-if="configStore.config.editing.tabType === 'spaces'">
<button class="tab-btn" @click="() => configStore.decreaseTabSize()" :disabled="configStore.config.editing.tabSize <= configStore.tabSize.min">-</button>
<span>{{ configStore.config.editing.tabSize }}</span>
<button class="tab-btn" @click="() => configStore.increaseTabSize()" :disabled="configStore.config.editing.tabSize >= configStore.tabSize.max">+</button>
</span>
</span>
<!-- 窗口置顶图标按钮 -->
<div
class="pin-button"
:class="{ 'active': configStore.config.general.alwaysOnTop }"
:title="t('toolbar.alwaysOnTop')"
@click="toggleAlwaysOnTop"
<!-- 文档选择器 -->
<DocumentSelector v-if="windowStore.isMainWindow"/>
<!-- 块语言选择器 -->
<BlockLanguageSelector/>
<!-- 格式化按钮 - 支持点击操作 -->
<div
v-if="canFormatCurrentBlock"
class="format-button"
:title="t('toolbar.formatHint')"
@click="formatCurrentBlock"
>
<svg class="pin-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path d="M557.44 104.96l361.6 361.6-60.16 64-26.88-33.92-181.12 181.12L617.6 832l-60.16 60.16-181.12-184.32-211.2 211.2-60.16-60.16 211.2-211.2-181.12-181.12 60.16-60.16 151.04-30.08 181.12-181.12-30.72-30.08 64-60.16zM587.52 256L387.84 455.04l-120.32 23.68 277.76 277.76 23.68-120.32L768 436.48z" />
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path
d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>
<path d="M5 3v4"/>
<path d="M19 17v4"/>
<path d="M3 5h4"/>
<path d="M17 19h4"/>
</svg>
</div>
<!-- 语言切换按钮 -->
<div class="selector-dropdown">
<button class="selector-btn" @click="toggleLanguageMenu">
{{ locale }}
<span class="arrow"></span>
</button>
<div class="selector-menu" v-if="showLanguageMenu">
<div
v-for="lang in supportedLanguages"
:key="lang.code"
class="selector-option"
:class="{ active: locale === lang.code }"
@click="changeLanguage(lang.code)"
>
{{ t(`languages.${lang.code}`) }}
</div>
</div>
<!-- 更新按钮 - 根据状态显示不同图标 -->
<div
v-if="updateStore.hasUpdate || updateStore.isChecking || updateStore.isUpdating || updateStore.updateSuccess"
class="update-button"
:class="{
'checking': updateStore.isChecking,
'updating': updateStore.isUpdating,
'success': updateStore.updateSuccess,
'available': updateStore.hasUpdate && !updateStore.isUpdating && !updateStore.updateSuccess
}"
:title="updateButtonTitle"
@click="handleUpdateButtonClick"
>
<!-- 检查更新中 -->
<svg v-if="updateStore.isChecking" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="rotating">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
<!-- 下载更新中 -->
<svg v-else-if="updateStore.isUpdating" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="rotating">
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
<path d="M12 2a10 10 0 1 0 10 10"></path>
</svg>
<!-- 更新成功等待重启 -->
<svg v-else-if="updateStore.updateSuccess" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="pulsing">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"></path>
<line x1="12" y1="2" x2="12" y2="12"></line>
</svg>
<!-- 有更新可用 -->
<svg v-else xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="7.5,10.5 12,15 16.5,10.5"/>
<polyline points="12,15 12,3"/>
</svg>
</div>
<button class="settings-btn" :title="t('toolbar.settings')" @click="goToSettings">
<!-- 窗口置顶图标按钮 -->
<div
class="pin-button"
:class="{ 'active': isCurrentWindowOnTop }"
:title="t('toolbar.alwaysOnTop')"
@click="toggleAlwaysOnTop"
>
<svg class="pin-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path
d="M557.44 104.96l361.6 361.6-60.16 64-26.88-33.92-181.12 181.12L617.6 832l-60.16 60.16-181.12-184.32-211.2 211.2-60.16-60.16 211.2-211.2-181.12-181.12 60.16-60.16 151.04-30.08 181.12-181.12-30.72-30.08 64-60.16zM587.52 256L387.84 455.04l-120.32 23.68 277.76 277.76 23.68-120.32L768 436.48z"/>
</svg>
</div>
<button v-if="windowStore.isMainWindow" class="settings-btn" :title="t('toolbar.settings')" @click="goToSettings">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
@@ -191,60 +322,100 @@ watch(isLoaded, async (newLoaded) => {
cursor: help;
}
.tab-settings {
/* 更新按钮样式 */
.update-button {
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
color: var(--text-muted);
font-size: 11px;
justify-content: center;
width: 20px;
height: 20px;
padding: 2px;
border-radius: 3px;
transition: all 0.2s ease;
.tab-toggle {
display: flex;
align-items: center;
gap: 3px;
cursor: pointer;
input {
cursor: pointer;
/* 有更新可用状态 */
&.available {
background-color: rgba(76, 175, 80, 0.1);
animation: pulse 2s infinite;
svg {
stroke: #4caf50;
}
&:hover {
background-color: rgba(76, 175, 80, 0.2);
transform: scale(1.05);
}
}
.tab-type {
cursor: pointer;
padding: 0 3px;
border-radius: 3px;
background-color: var(--border-color);
opacity: 0.6;
/* 检查更新中状态 */
&.checking {
background-color: rgba(255, 193, 7, 0.1);
&:hover {
opacity: 1;
background-color: var(--border-color);
svg {
stroke: #ffc107;
}
}
/* 更新下载中状态 */
&.updating {
background-color: rgba(33, 150, 243, 0.1);
svg {
stroke: #2196f3;
}
}
/* 更新成功状态 */
&.success {
background-color: rgba(156, 39, 176, 0.1);
svg {
stroke: #9c27b0;
}
}
.tab-size {
display: flex;
align-items: center;
gap: 2px;
/* 旋转动画 */
.rotating {
animation: rotate 1.5s linear infinite;
}
/* 脉冲动画 */
.pulsing {
animation: pulse-strong 1.2s ease-in-out infinite;
}
.tab-btn {
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
padding: 0 3px;
font-size: 12px;
line-height: 1;
&:disabled {
color: var(--text-muted);
opacity: 0.5;
cursor: not-allowed;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
@keyframes pulse-strong {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}
/* 窗口置顶图标按钮样式 */
.pin-button {
cursor: pointer;
@@ -256,20 +427,20 @@ watch(isLoaded, async (newLoaded) => {
padding: 2px;
border-radius: 3px;
transition: all 0.2s ease;
&:hover {
background-color: var(--border-color);
opacity: 0.8;
}
&.active {
background-color: rgba(181, 206, 168, 0.2);
.pin-icon {
fill: #b5cea8;
}
}
.pin-icon {
width: 14px;
height: 14px;
@@ -277,63 +448,33 @@ watch(isLoaded, async (newLoaded) => {
transition: fill 0.2s ease;
}
}
/* 通用下拉选择器样式 */
.selector-dropdown {
position: relative;
.selector-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 11px;
display: flex;
align-items: center;
gap: 2px;
padding: 2px 4px;
border-radius: 3px;
&:hover {
background-color: var(--border-color);
opacity: 0.8;
}
.arrow {
font-size: 8px;
margin-left: 2px;
}
.format-button {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 2px;
border-radius: 3px;
transition: all 0.2s ease;
&:hover {
background-color: var(--border-color);
opacity: 0.8;
}
.selector-menu {
position: absolute;
bottom: 100%;
right: 0;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 3px;
margin-bottom: 4px;
min-width: 120px;
max-height: 200px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
.selector-option {
padding: 4px 8px;
cursor: pointer;
font-size: 11px;
white-space: nowrap;
&:hover {
background-color: var(--border-color);
opacity: 0.8;
}
&.active {
color: #b5cea8;
}
}
svg {
width: 14px;
height: 14px;
stroke: var(--text-muted);
transition: stroke 0.2s ease;
}
&:hover svg {
stroke: var(--text-secondary);
}
}

View File

@@ -1,189 +0,0 @@
import { ref, computed, shallowRef } from 'vue';
import { Extension, Compartment } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import type { ThemeType } from '@/types';
// 主题加载器类型
type ThemeLoader = () => Promise<Extension>;
// 默认主题常量
const DEFAULT_THEME = 'default-dark' as ThemeType;
// 主题加载映射
const themeLoaderMap = new Map<string, ThemeLoader>();
// 初始化主题加载器
const initThemeLoaders = () => {
themeLoaderMap.set('default-dark', () => import('@/views/editor/theme/default-dark').then(m => m.defaultDark));
themeLoaderMap.set('dracula', () => import('@/views/editor/theme/dracula').then(m => m.dracula));
themeLoaderMap.set('aura', () => import('@/views/editor/theme/aura').then(m => m.aura));
themeLoaderMap.set('github-dark', () => import('@/views/editor/theme/github-dark').then(m => m.githubDark));
themeLoaderMap.set('github-light', () => import('@/views/editor/theme/github-light').then(m => m.githubLight));
themeLoaderMap.set('material-dark', () => import('@/views/editor/theme/material-dark').then(m => m.materialDark));
themeLoaderMap.set('material-light', () => import('@/views/editor/theme/material-light').then(m => m.materialLight));
themeLoaderMap.set('solarized-dark', () => import('@/views/editor/theme/solarized-dark').then(m => m.solarizedDark));
themeLoaderMap.set('solarized-light', () => import('@/views/editor/theme/solarized-light').then(m => m.solarizedLight));
themeLoaderMap.set('tokyo-night', () => import('@/views/editor/theme/tokyo-night').then(m => m.tokyoNight));
themeLoaderMap.set('tokyo-night-storm', () => import('@/views/editor/theme/tokyo-night-storm').then(m => m.tokyoNightStorm));
themeLoaderMap.set('tokyo-night-day', () => import('@/views/editor/theme/tokyo-night-day').then(m => m.tokyoNightDay));
};
// 延迟初始化
initThemeLoaders();
// 全局状态
const currentTheme = ref<ThemeType>(DEFAULT_THEME);
const themeCompartment = new Compartment();
const themeCache = new Map<ThemeType, Extension>();
const failedThemes = new Set<ThemeType>(); // 记录加载失败的主题
/**
* 编辑器主题管理
*/
export function useEditorTheme() {
/**
* 安全加载主题扩展
*/
const loadTheme = async (targetTheme: ThemeType): Promise<Extension> => {
// 1. 从缓存快速返回
const cached = themeCache.get(targetTheme);
if (cached) return cached;
// 2. 检查是否已知失败的主题,避免重复尝试
if (failedThemes.has(targetTheme) && targetTheme !== DEFAULT_THEME) {
console.info(`Theme ${targetTheme} is known to fail, attempting default theme directly`);
return attemptLoadTheme(DEFAULT_THEME).catch(() => [] as Extension);
}
// 3. 使用 try-catch 链和 nullish coalescing替代递归
const result = await attemptLoadTheme(targetTheme)
.catch(async (error) => {
// 仅当目标主题不是默认主题时,才尝试默认主题
if (targetTheme !== DEFAULT_THEME) {
console.warn(`Theme ${targetTheme} failed, fallback to ${DEFAULT_THEME}:`, error);
return attemptLoadTheme(DEFAULT_THEME).catch((fallbackError) => {
console.error(`Fallback theme ${DEFAULT_THEME} also failed:`, fallbackError);
return [] as Extension; // 最终回退到空扩展
});
}
// 如果默认主题也失败了,返回空扩展
console.error(`Default theme ${DEFAULT_THEME} failed:`, error);
return [] as Extension;
});
return result;
};
/**
* 单纯的主题加载尝试 - 不处理回退逻辑
*/
const attemptLoadTheme = async (themeType: ThemeType): Promise<Extension> => {
// 获取加载器,使用 optional chaining 和 nullish coalescing
const loader = themeLoaderMap.get(themeType);
if (!loader) {
const error = new Error(`Theme loader not found: ${themeType}`);
failedThemes.add(themeType);
throw error;
}
try {
const extension = await loader();
// 缓存成功加载的主题
themeCache.set(themeType, extension);
// 从失败列表中移除(如果存在)
failedThemes.delete(themeType);
return extension;
} catch (error) {
// 记录失败的主题
failedThemes.add(themeType);
console.error(`Failed to load theme: ${themeType}`, error);
throw error;
}
};
/**
* 创建可配置的主题扩展
*/
const createThemeExtension = async (themeType: ThemeType): Promise<Extension> => {
const extension = await loadTheme(themeType);
currentTheme.value = themeType;
return themeCompartment.of(extension);
};
/**
* 更新编辑器主题 - 使用防抖和错误处理
*/
const updateTheme = async (view: EditorView, themeType: ThemeType): Promise<void> => {
// 使用可选链操作符检查 view
if (!view?.dispatch || themeType === currentTheme.value) {
return;
}
const extension = await loadTheme(themeType);
// 使用 try-catch 包装 dispatch避免编辑器状态异常
try {
view.dispatch({
effects: themeCompartment.reconfigure(extension)
});
currentTheme.value = themeType;
} catch (error) {
console.error('Failed to dispatch theme update:', error);
throw error; // 重新抛出,让调用者处理
}
};
/**
* 批量预加载主题 - 使用 Promise.allSettled 确保部分失败不影响其他
*/
const preloadThemes = async (themes: ThemeType[]): Promise<PromiseSettledResult<Extension>[]> => {
const uniqueThemes = [...new Set(themes)]; // 去重
return Promise.allSettled(
uniqueThemes.map(theme => loadTheme(theme))
);
};
/**
* 重置主题系统状态
*/
const resetThemeSystem = (): void => {
themeCache.clear();
failedThemes.clear();
currentTheme.value = DEFAULT_THEME;
};
/**
* 获取主题系统状态信息
*/
const getThemeSystemInfo = () => ({
currentTheme: currentTheme.value,
cachedThemes: Array.from(themeCache.keys()),
failedThemes: Array.from(failedThemes),
availableThemes: Array.from(themeLoaderMap.keys()),
});
return {
// 状态
currentTheme: computed(() => currentTheme.value),
// 核心方法
createThemeExtension,
updateTheme,
loadTheme,
// 批量操作
preloadThemes,
// 工具方法
resetThemeSystem,
getThemeSystemInfo,
// 缓存管理
clearCache: () => themeCache.clear(),
clearFailedThemes: () => failedThemes.clear(),
};
}

View File

@@ -1,35 +0,0 @@
import { ref, watch, onMounted, onUnmounted } from 'vue';
import { useConfigStore } from '@/stores/configStore';
export function useSystemTheme() {
const configStore = useConfigStore();
const currentSystemTheme = ref<'dark' | 'light'>('dark');
// 设置主题 - 简化版本
const setTheme = (theme: string) => {
const root = document.documentElement;
root.setAttribute('data-theme', theme);
};
// 监听配置变化
watch(
() => configStore.config.appearance.systemTheme,
(newTheme) => {
// 直接根据配置设置主题,不需要检测系统主题
const root = document.documentElement;
root.setAttribute('data-theme', newTheme);
},
{ immediate: true }
);
onMounted(() => {
const root = document.documentElement;
const systemTheme = configStore.config.appearance.systemTheme;
root.setAttribute('data-theme', systemTheme);
});
return {
currentSystemTheme,
setTheme
};
}

View File

@@ -1,59 +0,0 @@
import { useConfigStore } from '@/stores/configStore';
import { useEditorTheme } from './useEditorTheme';
import type { ThemeType } from '@/types';
/**
* 主题管理 - 用于设置页面
*/
export function useTheme() {
const configStore = useConfigStore();
const { preloadThemes, getThemeSystemInfo } = useEditorTheme();
/**
* 设置主题 - 同时更新配置和预览
*/
const setTheme = async (themeType: ThemeType): Promise<void> => {
try {
// 更新配置存储(这会自动触发编辑器主题更新)
await configStore.setTheme(themeType);
console.info(`Theme switched to: ${themeType}`);
} catch (error) {
console.error('Failed to set theme:', error);
throw error;
}
};
/**
* 预加载常用主题
*/
const preloadCommonThemes = async (): Promise<void> => {
const commonThemes: ThemeType[] = [
'default-dark' as ThemeType,
'dracula' as ThemeType,
'github-dark' as ThemeType,
'material-dark' as ThemeType
];
try {
await preloadThemes(commonThemes);
console.info('Common themes preloaded successfully');
} catch (error) {
console.warn('Some themes failed to preload:', error);
}
};
/**
* 获取主题状态
*/
const getThemeStatus = () => ({
current: configStore.config.appearance.theme,
...getThemeSystemInfo()
});
return {
setTheme,
preloadCommonThemes,
getThemeStatus,
};
}

View File

@@ -1,423 +0,0 @@
import { ref, onUnmounted, reactive, computed, watch, nextTick } from 'vue';
// 基础WebSocket消息接口
interface WebSocketMessage<T = any> {
type: string;
data: T;
}
// 迁移进度接口(与后端保持一致)
interface MigrationProgress {
status: 'migrating' | 'completed' | 'failed';
progress: number; // 0-100
error?: string;
}
// 连接状态枚举
enum ConnectionState {
DISCONNECTED = 'disconnected',
CONNECTING = 'connecting',
CONNECTED = 'connected',
RECONNECTING = 'reconnecting',
ERROR = 'error'
}
// WebSocket配置选项
interface WebSocketOptions {
url?: string;
reconnectInterval?: number;
maxReconnectAttempts?: number;
debug?: boolean;
autoConnect?: boolean;
protocols?: string | string[];
heartbeat?: {
enabled: boolean;
interval: number;
message: string;
};
}
// 消息处理器类型
type MessageHandler<T = any> = (data: T) => void;
// 事件处理器映射
interface EventHandlers {
[messageType: string]: MessageHandler[];
}
// 连接事件类型
type ConnectionEventType = 'connect' | 'disconnect' | 'error' | 'reconnect';
type ConnectionEventHandler = (event?: Event | CloseEvent | ErrorEvent) => void;
export function useWebSocket(options: WebSocketOptions = {}) {
const {
url = 'ws://localhost:8899/ws/migration',
reconnectInterval = 3000,
maxReconnectAttempts = 10,
debug = false,
autoConnect = true,
protocols,
heartbeat = { enabled: false, interval: 30000, message: 'ping' }
} = options;
// === 状态管理 ===
const connectionState = ref<ConnectionState>(ConnectionState.DISCONNECTED);
const connectionError = ref<string | null>(null);
const reconnectAttempts = ref(0);
const lastMessage = ref<WebSocketMessage | null>(null);
const messageHistory = ref<WebSocketMessage[]>([]);
// 迁移进度状态(保持向后兼容)
const migrationProgress = reactive<MigrationProgress>({
status: 'completed',
progress: 0
});
// === 计算属性 ===
const isConnected = computed(() => connectionState.value === ConnectionState.CONNECTED);
const isConnecting = computed(() =>
connectionState.value === ConnectionState.CONNECTING ||
connectionState.value === ConnectionState.RECONNECTING
);
const canReconnect = computed(() => reconnectAttempts.value < maxReconnectAttempts);
// === 内部状态 ===
let ws: WebSocket | null = null;
let reconnectTimer: number | null = null;
let heartbeatTimer: number | null = null;
let isManualDisconnect = false;
// 事件处理器
const eventHandlers: EventHandlers = {};
const connectionEventHandlers: Map<ConnectionEventType, ConnectionEventHandler[]> = new Map();
// === 工具函数 ===
const log = (level: 'info' | 'warn' | 'error', message: string, ...args: any[]) => {
if (debug) {
console[level](`[WebSocket] ${message}`, ...args);
}
};
const clearTimers = () => {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
};
const updateConnectionState = (newState: ConnectionState, error?: string) => {
connectionState.value = newState;
connectionError.value = error || null;
log('info', `Connection state changed to: ${newState}`, error);
};
// === 事件系统 ===
const on = <T = any>(messageType: string, handler: MessageHandler<T>) => {
if (!eventHandlers[messageType]) {
eventHandlers[messageType] = [];
}
eventHandlers[messageType].push(handler as MessageHandler);
// 返回取消订阅函数
return () => off(messageType, handler);
};
const off = <T = any>(messageType: string, handler: MessageHandler<T>) => {
if (eventHandlers[messageType]) {
const index = eventHandlers[messageType].indexOf(handler as MessageHandler);
if (index > -1) {
eventHandlers[messageType].splice(index, 1);
}
}
};
const onConnection = (eventType: ConnectionEventType, handler: ConnectionEventHandler) => {
if (!connectionEventHandlers.has(eventType)) {
connectionEventHandlers.set(eventType, []);
}
connectionEventHandlers.get(eventType)!.push(handler);
return () => offConnection(eventType, handler);
};
const offConnection = (eventType: ConnectionEventType, handler: ConnectionEventHandler) => {
const handlers = connectionEventHandlers.get(eventType);
if (handlers) {
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
}
};
const emit = (eventType: ConnectionEventType, event?: Event | CloseEvent | ErrorEvent) => {
const handlers = connectionEventHandlers.get(eventType);
if (handlers) {
handlers.forEach(handler => {
try {
handler(event);
} catch (error) {
log('error', `Error in ${eventType} event handler:`, error);
}
});
}
};
// === 消息处理 ===
const handleMessage = (event: MessageEvent) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
log('info', 'Received message:', message);
// 更新消息历史
lastMessage.value = message;
messageHistory.value.push(message);
// 限制历史记录长度
if (messageHistory.value.length > 100) {
messageHistory.value.shift();
}
// 特殊处理迁移进度消息(保持向后兼容)
if (message.type === 'migration_progress') {
Object.assign(migrationProgress, message.data);
}
// 触发注册的处理器
const handlers = eventHandlers[message.type];
if (handlers) {
handlers.forEach(handler => {
try {
handler(message.data);
} catch (error) {
log('error', `Error in message handler for ${message.type}:`, error);
}
});
}
} catch (error) {
log('error', 'Failed to parse message:', error, event.data);
}
};
// === 心跳机制 ===
const startHeartbeat = () => {
if (!heartbeat.enabled || heartbeatTimer) return;
heartbeatTimer = window.setInterval(() => {
if (isConnected.value) {
send(heartbeat.message);
}
}, heartbeat.interval);
};
const stopHeartbeat = () => {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
};
// === 连接管理 ===
const connect = async (): Promise<void> => {
if (isConnecting.value || isConnected.value) {
log('warn', 'Already connecting or connected');
return;
}
updateConnectionState(ConnectionState.CONNECTING);
isManualDisconnect = false;
try {
log('info', 'Connecting to:', url);
ws = new WebSocket(url, protocols);
// 连接超时处理
const connectTimeout = setTimeout(() => {
if (ws && ws.readyState === WebSocket.CONNECTING) {
ws.close();
updateConnectionState(ConnectionState.ERROR, 'Connection timeout');
}
}, 10000);
ws.onopen = () => {
clearTimeout(connectTimeout);
log('info', 'Connected successfully');
updateConnectionState(ConnectionState.CONNECTED);
reconnectAttempts.value = 0;
clearTimers();
startHeartbeat();
emit('connect');
};
ws.onmessage = handleMessage;
ws.onclose = (event) => {
clearTimeout(connectTimeout);
stopHeartbeat();
log('info', 'Connection closed:', event.code, event.reason);
const wasConnected = connectionState.value === ConnectionState.CONNECTED;
updateConnectionState(ConnectionState.DISCONNECTED);
ws = null;
emit('disconnect', event);
// 自动重连逻辑
if (!isManualDisconnect && event.code !== 1000 && canReconnect.value) {
scheduleReconnect();
} else if (reconnectAttempts.value >= maxReconnectAttempts) {
updateConnectionState(ConnectionState.ERROR, 'Max reconnection attempts reached');
}
};
ws.onerror = (event) => {
clearTimeout(connectTimeout);
log('error', 'Connection error:', event);
updateConnectionState(ConnectionState.ERROR, 'WebSocket connection error');
emit('error', event);
};
} catch (error) {
log('error', 'Failed to create WebSocket:', error);
updateConnectionState(ConnectionState.ERROR, 'Failed to create WebSocket connection');
}
};
const disconnect = (code: number = 1000, reason: string = 'Manual disconnect') => {
isManualDisconnect = true;
clearTimers();
stopHeartbeat();
if (ws) {
log('info', 'Disconnecting manually');
ws.close(code, reason);
}
updateConnectionState(ConnectionState.DISCONNECTED);
};
const scheduleReconnect = () => {
if (!canReconnect.value || isManualDisconnect) return;
clearTimers();
reconnectAttempts.value++;
updateConnectionState(ConnectionState.RECONNECTING,
`Reconnecting... (${reconnectAttempts.value}/${maxReconnectAttempts})`);
log('info', `Scheduling reconnect attempt ${reconnectAttempts.value}/${maxReconnectAttempts} in ${reconnectInterval}ms`);
reconnectTimer = window.setTimeout(() => {
connect();
}, reconnectInterval);
emit('reconnect');
};
const reconnect = () => {
disconnect();
reconnectAttempts.value = 0;
nextTick(() => {
connect();
});
};
// === 消息发送 ===
const send = (message: any): boolean => {
if (!isConnected.value || !ws) {
log('warn', 'Cannot send message: not connected');
return false;
}
try {
const data = typeof message === 'string' ? message : JSON.stringify(message);
ws.send(data);
log('info', 'Sent message:', data);
return true;
} catch (error) {
log('error', 'Failed to send message:', error);
return false;
}
};
const sendMessage = <T = any>(type: string, data?: T): boolean => {
return send({ type, data });
};
// === 状态查询 ===
const getConnectionInfo = () => ({
state: connectionState.value,
error: connectionError.value,
reconnectAttempts: reconnectAttempts.value,
maxReconnectAttempts,
canReconnect: canReconnect.value,
url,
readyState: ws?.readyState,
protocol: ws?.protocol,
extensions: ws?.extensions
});
// === 初始化 ===
if (autoConnect) {
nextTick(() => {
connect();
});
}
// === 清理 ===
onUnmounted(() => {
disconnect();
});
// 监听连接状态变化,用于调试
if (debug) {
watch(connectionState, (newState, oldState) => {
log('info', `State transition: ${oldState} -> ${newState}`);
});
}
return {
// === 状态(只读) ===
connectionState: computed(() => connectionState.value),
isConnected,
isConnecting,
connectionError: computed(() => connectionError.value),
reconnectAttempts: computed(() => reconnectAttempts.value),
canReconnect,
lastMessage: computed(() => lastMessage.value),
messageHistory: computed(() => messageHistory.value),
// === 向后兼容的状态 ===
migrationProgress,
// === 连接控制 ===
connect,
disconnect,
reconnect,
// === 消息发送 ===
send,
sendMessage,
// === 事件系统 ===
on,
off,
onConnection,
offConnection,
// === 工具方法 ===
getConnectionInfo,
clearHistory: () => {
messageHistory.value = [];
lastMessage.value = null;
}
};
}
// 导出类型
export type { WebSocketMessage, MigrationProgress, WebSocketOptions, MessageHandler, ConnectionEventType };
export { ConnectionState };

View File

@@ -3,7 +3,8 @@ import messages from './locales';
// 创建i18n实例
const i18n = createI18n({
compositionOnly: false,
legacy: false,
compositionOnly: true,
globalInjection: true,
silentTranslationWarn: true,
locale: 'zh-CN',

View File

@@ -1,4 +1,5 @@
export default {
locale: 'en-US',
titlebar: {
minimize: 'Minimize',
maximize: 'Maximize',
@@ -11,38 +12,35 @@ export default {
characters: 'Ch',
selected: 'Sel'
},
fontSize: 'Font Size',
fontSizeTooltip: 'Font Size (Ctrl+wheel to adjust)',
tabLabel: 'Tab',
tabType: {
spaces: 'Spaces',
tab: 'Tab'
},
encoding: 'UTF-8',
settings: 'Settings',
alwaysOnTop: 'Always on Top'
},
config: {
loadSuccess: 'Configuration loaded successfully',
loadFailed: 'Failed to load configuration',
saveSuccess: 'Configuration saved',
saveFailed: 'Failed to save configuration',
resetSuccess: 'Configuration reset to defaults',
resetFailed: 'Failed to reset configuration',
fontSizeFixed: 'Font size ({value}) has been corrected to {fixed}',
tabSizeFixed: 'Tab size ({value}) has been corrected to {fixed}',
tabTypeFixed: 'Tab type ({value}) is invalid, corrected to spaces',
alwaysOnTopFailed: 'Failed to set window always on top',
alwaysOnTopSuccess: 'Window always on top status updated',
languageChanged: 'Language setting updated',
languageChangeFailed: 'Failed to update language setting',
themeChanged: 'Theme setting updated',
themeChangeFailed: 'Failed to update theme setting',
systemThemeChanged: 'System theme setting updated',
systemThemeChangeFailed: 'Failed to update system theme setting'
alwaysOnTop: 'Always on Top',
blockLanguage: 'Block Language',
searchLanguage: 'Search language...',
noLanguageFound: 'No language found',
formatHint: 'Click Format Block (Ctrl+Shift+F)',
// Document selector
selectDocument: 'Select Document',
searchOrCreateDocument: 'Search or enter new document name...',
createDocument: 'Create',
noDocumentFound: 'No document found',
loading: 'Loading...',
rename: 'Rename',
delete: 'Delete',
confirm: 'Confirm',
confirmDelete: 'Click again to confirm delete',
openInNewWindow: 'Open in New Window',
alreadyOpenInNewWindow: 'Already open in another window',
documentNameTooLong: 'Document name cannot exceed {max} characters',
documentNameRequired: 'Document name cannot be empty',
cannotDeleteLastDocument: 'Cannot delete the last document',
cannotDeleteDefaultDocument: 'Cannot delete the default document',
unknownTime: 'Unknown time',
invalidDate: 'Invalid date',
timeError: 'Time error',
},
languages: {
'zh-CN': '简体中文',
'zh-CN': 'Chinese',
'en-US': 'English'
},
systemTheme: {
@@ -50,23 +48,69 @@ export default {
light: 'Light',
auto: 'Follow System'
},
document: {
loadSuccess: 'Document loaded successfully',
loadFailed: 'Failed to load document',
saveSuccess: 'Document saved successfully',
saveFailed: 'Failed to save document',
manualSaveSuccess: 'Manually saved successfully',
settings: {
loadFailed: 'Failed to load save settings',
saveSuccess: 'Save settings updated',
saveFailed: 'Failed to update save settings'
keybindings: {
headers: {
shortcut: 'Shortcut',
category: 'Category',
description: 'Description'
},
},
migration: {
started: 'Starting data migration',
migrating: 'Migrating',
completed: 'Migration Completed',
failed: 'Migration Failed'
commands: {
showSearch: 'Show search panel',
hideSearch: 'Hide search panel',
searchToggleCase: 'Toggle case-sensitive matching',
searchToggleWord: 'Toggle whole word matching',
searchToggleRegex: 'Toggle regular expression matching',
searchShowReplace: 'Show replace functionality',
searchReplaceAll: 'Replace all matches',
blockSelectAll: 'Select all in block',
blockAddAfterCurrent: 'Add new block after current',
blockAddAfterLast: 'Add new block at end',
blockAddBeforeCurrent: 'Add new block before current',
blockGotoPrevious: 'Go to previous block',
blockGotoNext: 'Go to next block',
blockSelectPrevious: 'Select previous block',
blockSelectNext: 'Select next block',
blockDelete: 'Delete current block',
blockMoveUp: 'Move current block up',
blockMoveDown: 'Move current block down',
blockDeleteLine: 'Delete line',
blockMoveLineUp: 'Move line up',
blockMoveLineDown: 'Move line down',
blockTransposeChars: 'Transpose characters',
blockFormat: 'Format code block',
blockCopy: 'Copy',
blockCut: 'Cut',
blockPaste: 'Paste',
historyUndo: 'Undo',
historyRedo: 'Redo',
historyUndoSelection: 'Undo selection',
historyRedoSelection: 'Redo selection',
foldCode: 'Fold code',
unfoldCode: 'Unfold code',
foldAll: 'Fold all',
unfoldAll: 'Unfold all',
cursorSyntaxLeft: 'Cursor syntax left',
cursorSyntaxRight: 'Cursor syntax right',
selectSyntaxLeft: 'Select syntax left',
selectSyntaxRight: 'Select syntax right',
copyLineUp: 'Copy line up',
copyLineDown: 'Copy line down',
insertBlankLine: 'Insert blank line',
selectLine: 'Select line',
selectParentSyntax: 'Select parent syntax',
indentLess: 'Indent less',
indentMore: 'Indent more',
indentSelection: 'Indent selection',
cursorMatchingBracket: 'Cursor matching bracket',
toggleComment: 'Toggle comment',
toggleBlockComment: 'Toggle block comment',
insertNewlineAndIndent: 'Insert newline and indent',
deleteCharBackward: 'Delete character backward',
deleteCharForward: 'Delete character forward',
deleteGroupBackward: 'Delete group backward',
deleteGroupForward: 'Delete group forward',
textHighlightToggle: 'Toggle text highlight',
}
},
settings: {
title: 'Settings',
@@ -77,25 +121,25 @@ export default {
appearance: 'Appearance',
keyBindings: 'Key Bindings',
updates: 'Updates',
comingSoon: 'Coming Soon...',
save: 'Save',
reset: 'Reset',
apply: 'Apply',
cancel: 'Cancel',
dangerZone: 'Danger Zone',
resetAllSettings: 'Reset All Settings',
confirmReset: 'Click again to confirm reset',
confirmReset: 'Confirm the reset?',
globalHotkey: 'Global Keyboard Shortcuts',
enableGlobalHotkey: 'Enable Global Hotkeys',
window: 'Window/Application',
showInSystemTray: 'Show in System Tray',
enableSystemTray: 'Enable System Tray',
alwaysOnTop: 'Always on Top',
startup: 'Startup Settings',
startAtLogin: 'Start at Login',
dataStorage: 'Data Storage',
dataPath: 'Data Storage Path',
clickToSelectPath: 'Click to select path',
resetDefault: 'Reset Default',
resetToDefaultPath: 'Reset to default path',
restartRequiredForDataPath: 'Restart required',
fontSize: 'Font Size',
fontSizeDescription: 'Editor font size',
fontSettings: 'Font Settings',
@@ -103,6 +147,60 @@ export default {
fontFamilyDescription: 'Choose editor font family',
fontWeight: 'Font Weight',
fontWeightDescription: 'Set the thickness of the font',
fontWeights: {
'100': 'Thin (100)',
'200': 'Extra Light (200)',
'300': 'Light (300)',
'normal': 'Regular (400)',
'500': 'Medium (500)',
'600': 'Semi Bold (600)',
'bold': 'Bold (700)',
'800': 'Extra Bold (800)',
'900': 'Black (900)'
},
customThemeColors: 'Custom Theme Colors',
resetToDefault: 'Reset to Default',
colorValue: 'Color Value',
themeColors: {
basic: 'Basic Colors',
text: 'Text Colors',
syntax: 'Syntax Highlighting',
interface: 'Interface Elements',
border: 'Borders & Dividers',
search: 'Search & Matching',
background: 'Main Background',
backgroundSecondary: 'Secondary Background',
surface: 'Panel Background',
foreground: 'Primary Text',
foregroundSecondary: 'Secondary Text',
comment: 'Comments',
keyword: 'Keywords',
string: 'Strings',
function: 'Functions',
number: 'Numbers',
operator: 'Operators',
variable: 'Variables',
type: 'Types',
cursor: 'Cursor',
selection: 'Selection Background',
selectionBlur: 'Unfocused Selection',
activeLine: 'Active Line Highlight',
lineNumber: 'Line Numbers',
activeLineNumber: 'Active Line Number',
borderColor: 'Border Color',
borderLight: 'Light Border',
searchMatch: 'Search Match',
matchingBracket: 'Matching Bracket'
},
fontFamilies: {
harmonyOS: 'HarmonyOS Sans',
microsoftYahei: 'Microsoft YaHei',
pingfang: 'PingFang SC',
jetbrainsMono: 'JetBrains Mono',
firaCode: 'Fira Code',
sourceCodePro: 'Source Code Pro',
cascadiaCode: 'Cascadia Code'
},
lineHeight: 'Line Height',
lineHeightDescription: 'Set the spacing between text lines',
tabSettings: 'Tab Settings',
@@ -113,15 +211,84 @@ export default {
enableTabIndent: 'Enable Tab Indent',
language: 'Interface Language',
systemTheme: 'System Theme',
theme: 'Editor Theme',
themeDescription: 'Choose editor theme',
restartRequired: '(Restart required)',
saveOptions: 'Save Options',
autoSaveDelay: 'Auto Save Delay (ms)',
selectDirectoryFailed: 'Failed to select directory',
validation: {
customPathRequired: 'A valid directory must be selected when enabling custom path',
customPathAutoDisabled: 'Custom data path has been automatically disabled due to no valid directory selected'
updateSettings: 'Update Settings',
autoCheckUpdates: 'Automatically Check Updates',
autoCheckUpdatesDescription: 'Check for updates when application starts',
manualCheck: 'Manual Update',
currentVersion: 'Current Version',
checkForUpdates: 'Check for Updates',
checking: 'Checking...',
checkFailed: 'Check Failed',
newVersionAvailable: 'New Version Available',
upToDate: 'Up to Date',
viewUpdate: 'View Update',
releaseNotes: 'Release Notes',
networkError: 'Network connection error, please check your network settings',
extensions: 'Extensions',
extensionsPage: {
loading: 'Loading',
categoryEditing: 'Editing Enhancement',
categoryUI: 'UI Enhancement',
categoryTools: 'Tools',
configuration: 'Configuration',
resetToDefault: 'Reset to Default Configuration',
},
updateNow: 'Update Now',
updating: 'Updating...',
updateSuccess: 'Update Success',
updateSuccessRestartRequired: 'Update has been successfully applied. Please restart the application.',
restartNow: 'Restart Now',
hotkeyPreview: 'Preview:',
none: 'None',
},
extensions: {
rainbowBrackets: {
name: 'Rainbow Brackets',
description: 'Display nested brackets in different colors'
},
hyperlink: {
name: 'Hyperlink',
description: 'Recognize and make hyperlinks clickable'
},
colorSelector: {
name: 'Color Selector',
description: 'Visual color picker and color value display'
},
translator: {
name: 'Text Translator',
description: 'Translate selected text with multiple translation services'
},
minimap: {
name: 'Minimap',
description: 'Display minimap overview of the document'
},
search: {
name: 'Search',
description: 'Text search and replace functionality'
},
fold: {
name: 'Code Folding',
description: 'Collapse and expand code sections for better readability'
},
textHighlight: {
name: 'Text Highlight',
description: 'Highlight selected text content (Ctrl+Shift+H to toggle highlight)',
backgroundColor: 'Background Color',
opacity: 'Opacity'
},
checkbox: {
name: 'Checkbox',
description: 'Render [x] and [ ] as interactive checkboxes'
},
codeblock: {
name: 'Code Block',
description: 'Code block related functionality'
}
},
monitor: {
memory: 'Memory',
clickToClean: 'Click to clean memory'
}
};
};

View File

@@ -1,4 +1,5 @@
export default {
locale: 'zh-CN',
titlebar: {
minimize: '最小化',
maximize: '最大化',
@@ -11,35 +12,32 @@ export default {
characters: 'Ch',
selected: 'Sel'
},
fontSize: '字体大小',
fontSizeTooltip: '字体大小 (Ctrl+滚轮调整)',
tabLabel: 'Tab',
tabType: {
spaces: '空格',
tab: '制表符'
},
encoding: 'UTF-8',
settings: '设置',
alwaysOnTop: '窗口置顶'
},
config: {
loadSuccess: '配置加载成功',
loadFailed: '配置加载失败',
saveSuccess: '配置已保存',
saveFailed: '配置保存失败',
resetSuccess: '配置已重置为默认值',
resetFailed: '重置配置失败',
fontSizeFixed: '字体大小值({value})已被修正为{fixed}',
tabSizeFixed: 'Tab大小值({value})已被修正为{fixed}',
tabTypeFixed: 'Tab类型({value})不合法,已修正为空格',
alwaysOnTopFailed: '无法设置窗口置顶状态',
alwaysOnTopSuccess: '窗口置顶状态已更新',
languageChanged: '语言设置已更新',
languageChangeFailed: '语言设置更新失败',
themeChanged: '主题设置已更新',
themeChangeFailed: '主题设置更新失败',
systemThemeChanged: '系统主题设置已更新',
systemThemeChangeFailed: '系统主题设置更新失败'
alwaysOnTop: '窗口置顶',
blockLanguage: '块语言',
searchLanguage: '搜索语言...',
noLanguageFound: '未找到匹配的语言',
formatHint: '点击格式化区块Ctrl+Shift+F',
// 文档选择器
selectDocument: '选择文档',
searchOrCreateDocument: '搜索或输入新文档名...',
createDocument: '创建',
noDocumentFound: '没有找到文档',
loading: '加载中...',
rename: '重命名',
delete: '删除',
confirm: '确认',
confirmDelete: '再次点击确认删除',
openInNewWindow: '在新窗口中打开',
alreadyOpenInNewWindow: '已在新窗口中打开',
documentNameTooLong: '文档名称不能超过{max}个字符',
documentNameRequired: '文档名称不能为空',
cannotDeleteLastDocument: '无法删除最后一个文档',
cannotDeleteDefaultDocument: '无法删除默认文档',
unknownTime: '未知时间',
invalidDate: '无效日期',
timeError: '时间错误',
},
languages: {
'zh-CN': '简体中文',
@@ -50,23 +48,69 @@ export default {
light: '浅色',
auto: '跟随系统'
},
document: {
loadSuccess: '文档加载成功',
loadFailed: '文档加载失败',
saveSuccess: '文档保存成功',
saveFailed: '文档保存失败',
manualSaveSuccess: '手动保存成功',
settings: {
loadFailed: '加载保存设置失败',
saveSuccess: '保存设置已更新',
saveFailed: '保存设置更新失败'
keybindings: {
headers: {
shortcut: '快捷键',
category: '分类',
description: '描述'
},
},
migration: {
started: '开始迁移数据',
migrating: '迁移中',
completed: '迁移已完成',
failed: '迁移失败'
commands: {
showSearch: '显示搜索面板',
hideSearch: '隐藏搜索面板',
searchToggleCase: '切换大小写敏感匹配',
searchToggleWord: '切换整词匹配',
searchToggleRegex: '切换正则表达式匹配',
searchShowReplace: '显示替换功能',
searchReplaceAll: '替换全部匹配项',
blockSelectAll: '块内选择全部',
blockAddAfterCurrent: '在当前块后添加新块',
blockAddAfterLast: '在最后添加新块',
blockAddBeforeCurrent: '在当前块前添加新块',
blockGotoPrevious: '跳转到上一个块',
blockGotoNext: '跳转到下一个块',
blockSelectPrevious: '选择上一个块',
blockSelectNext: '选择下一个块',
blockDelete: '删除当前块',
blockMoveUp: '向上移动当前块',
blockMoveDown: '向下移动当前块',
blockDeleteLine: '删除行',
blockMoveLineUp: '向上移动行',
blockMoveLineDown: '向下移动行',
blockTransposeChars: '字符转置',
blockFormat: '格式化代码块',
blockCopy: '复制',
blockCut: '剪切',
blockPaste: '粘贴',
historyUndo: '撤销',
historyRedo: '重做',
historyUndoSelection: '撤销选择',
historyRedoSelection: '重做选择',
foldCode: '折叠代码',
unfoldCode: '展开代码',
foldAll: '折叠全部',
unfoldAll: '展开全部',
cursorSyntaxLeft: '光标按语法左移',
cursorSyntaxRight: '光标按语法右移',
selectSyntaxLeft: '按语法选择左侧',
selectSyntaxRight: '按语法选择右侧',
copyLineUp: '向上复制行',
copyLineDown: '向下复制行',
insertBlankLine: '插入空行',
selectLine: '选择行',
selectParentSyntax: '选择父级语法',
indentLess: '减少缩进',
indentMore: '增加缩进',
indentSelection: '缩进选择',
cursorMatchingBracket: '光标到匹配括号',
toggleComment: '切换注释',
toggleBlockComment: '切换块注释',
insertNewlineAndIndent: '插入新行并缩进',
deleteCharBackward: '向后删除字符',
deleteCharForward: '向前删除字符',
deleteGroupBackward: '向后删除组',
deleteGroupForward: '向前删除组',
textHighlightToggle: '切换文本高亮',
}
},
settings: {
title: '设置',
@@ -75,27 +119,28 @@ export default {
general: '常规',
editing: '编辑器',
appearance: '外观',
extensions: '扩展',
keyBindings: '快捷键',
updates: '更新',
comingSoon: '即将推出...',
save: '保存',
reset: '重置',
apply: '应用',
cancel: '取消',
dangerZone: '危险操作',
resetAllSettings: '重置所有设置',
confirmReset: '再次点击确认重置',
confirmReset: '确认重置?',
globalHotkey: '全局键盘快捷键',
enableGlobalHotkey: '启用全局热键',
window: '窗口/应用程序',
showInSystemTray: '在系统托盘中显示',
enableSystemTray: '启用系统托盘',
alwaysOnTop: '窗口始终置顶',
startup: '启动设置',
startAtLogin: '开机自启动',
dataStorage: '数据存储',
dataPath: '数据存储路径',
clickToSelectPath: '点击选择路径',
resetDefault: '恢复默认',
resetToDefaultPath: '恢复为默认路径',
restartRequiredForDataPath: '需要重启应用程序',
fontSize: '字体大小',
fontSizeDescription: '编辑器字体大小',
fontSettings: '字体设置',
@@ -113,15 +158,138 @@ export default {
enableTabIndent: '启用 Tab 缩进',
language: '界面语言',
systemTheme: '系统主题',
theme: '编辑器主题',
themeDescription: '选择编辑器主题',
restartRequired: '(需要重启)',
saveOptions: '保存选项',
autoSaveDelay: '自动保存延迟(毫秒)',
selectDirectoryFailed: '选择目录失败',
validation: {
customPathRequired: '启用自定义路径时必须选择一个有效的目录',
customPathAutoDisabled: '由于未选择有效目录,自定义数据路径已自动关闭'
updateSettings: '更新设置',
autoCheckUpdates: '自动检查更新',
autoCheckUpdatesDescription: '应用启动时自动检查更新',
manualCheck: '手动更新',
currentVersion: '当前版本',
checkForUpdates: '检查更新',
checking: '正在检查...',
checkFailed: '检查失败',
newVersionAvailable: '发现新版本',
upToDate: '已是最新版本',
viewUpdate: '查看更新',
releaseNotes: '更新日志',
networkError: '网络连接错误,请检查网络设置',
updateNow: '立即更新',
updating: '正在更新...',
updateSuccess: '更新成功',
updateSuccessRestartRequired: '更新已成功应用,请重启应用以生效',
updateSuccessNoRestart: '更新已完成,无需重启',
restartNow: '立即重启',
extensionsPage: {
loading: '加载中',
categoryEditing: '编辑增强',
categoryUI: '界面增强',
categoryTools: '工具扩展',
configuration: '配置',
resetToDefault: '重置为默认配置',
},
fontWeights: {
'100': '极细 (100)',
'200': '超细 (200)',
'300': '细 (300)',
'normal': '正常 (400)',
'500': '中等 (500)',
'600': '半粗 (600)',
'bold': '粗体 (700)',
'800': '超粗 (800)',
'900': '极粗 (900)'
},
customThemeColors: '自定义主题颜色',
resetToDefault: '重置为默认',
colorValue: '颜色值',
themeColors: {
basic: '基础色调',
text: '文本颜色',
syntax: '语法高亮',
interface: '界面元素',
border: '边框分割线',
search: '搜索匹配',
background: '主背景色',
backgroundSecondary: '次要背景色',
surface: '面板背景',
foreground: '主文本色',
foregroundSecondary: '次要文本色',
comment: '注释色',
keyword: '关键字',
string: '字符串',
function: '函数名',
number: '数字',
operator: '操作符',
variable: '变量',
type: '类型',
cursor: '光标',
selection: '选中背景',
selectionBlur: '失焦选中背景',
activeLine: '当前行高亮',
lineNumber: '行号',
activeLineNumber: '活动行号',
borderColor: '边框色',
borderLight: '浅色边框',
searchMatch: '搜索匹配',
matchingBracket: '匹配括号'
},
fontFamilies: {
harmonyOS: '鸿蒙字体',
microsoftYahei: '微软雅黑',
pingfang: '苹方字体',
jetbrainsMono: 'JetBrains Mono',
firaCode: 'Fira Code',
sourceCodePro: 'Source Code Pro',
cascadiaCode: 'Cascadia Code'
},
hotkeyPreview: '预览:',
none: '无',
},
extensions: {
rainbowBrackets: {
name: '彩虹括号',
description: '用不同颜色显示嵌套括号'
},
hyperlink: {
name: '超链接',
description: '识别并可点击超链接'
},
colorSelector: {
name: '颜色选择器',
description: '颜色值的可视化和选择'
},
translator: {
name: '划词翻译',
description: '选择文本后显示翻译按钮,支持多种翻译服务'
},
minimap: {
name: '小地图',
description: '显示小地图视图'
},
search: {
name: '搜索功能',
description: '文本搜索和替换功能'
},
fold: {
name: '代码折叠',
description: '折叠和展开代码段以提高代码可读性'
},
textHighlight: {
name: '文本高亮',
description: '高亮选中的文本内容 (Ctrl+Shift+H 切换高亮)',
backgroundColor: '背景颜色',
opacity: '透明度'
},
checkbox: {
name: '选择框',
description: '将 [x] 和 [ ] 渲染为可交互的选择框'
},
codeblock: {
name: '代码块',
description: '代码块相关功能'
}
},
monitor: {
memory: '内存',
clickToClean: '点击清理内存'
}
};
};

View File

@@ -6,6 +6,7 @@ import EditingPage from '@/views/settings/pages/EditingPage.vue';
import AppearancePage from '@/views/settings/pages/AppearancePage.vue';
import KeyBindingsPage from '@/views/settings/pages/KeyBindingsPage.vue';
import UpdatesPage from '@/views/settings/pages/UpdatesPage.vue';
import ExtensionsPage from '@/views/settings/pages/ExtensionsPage.vue';
const routes: RouteRecordRaw[] = [
{
@@ -34,6 +35,11 @@ const routes: RouteRecordRaw[] = [
name: 'SettingsAppearance',
component: AppearancePage
},
{
path: 'extensions',
name: 'SettingsExtensions',
component: ExtensionsPage
},
{
path: 'key-bindings',
name: 'SettingsKeyBindings',

View File

@@ -1,6 +1,6 @@
import {defineStore} from 'pinia';
import {computed, reactive} from 'vue';
import {ConfigService} from '@/../bindings/voidraft/internal/services';
import {ConfigService, StartupService} from '@/../bindings/voidraft/internal/services';
import {
AppConfig,
AppearanceConfig,
@@ -9,10 +9,10 @@ import {
LanguageType,
SystemThemeType,
TabType,
ThemeType
} from '@/../bindings/voidraft/internal/models';
UpdatesConfig,
UpdateSourceType,
} from '@/../bindings/voidraft/internal/models/models';
import {useI18n} from 'vue-i18n';
import {useErrorHandler} from '@/utils/errorHandler';
import {ConfigUtils} from '@/utils/configUtils';
import {WindowController} from '@/utils/windowController';
import * as runtime from '@wailsio/runtime';
@@ -44,32 +44,48 @@ type AppearanceConfigKeyMap = {
readonly [K in keyof AppearanceConfig]: string;
};
type UpdatesConfigKeyMap = {
readonly [K in keyof UpdatesConfig]: string;
};
type NumberConfigKey = 'fontSize' | 'tabSize' | 'lineHeight';
// 配置键映射
const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = {
alwaysOnTop: 'general.always_on_top',
dataPath: 'general.data_path',
enableSystemTray: 'general.enable_system_tray',
enableGlobalHotkey: 'general.enable_global_hotkey',
globalHotkey: 'general.global_hotkey'
alwaysOnTop: 'general.alwaysOnTop',
dataPath: 'general.dataPath',
enableSystemTray: 'general.enableSystemTray',
startAtLogin: 'general.startAtLogin',
enableGlobalHotkey: 'general.enableGlobalHotkey',
globalHotkey: 'general.globalHotkey'
} as const;
const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
fontSize: 'editing.font_size',
fontFamily: 'editing.font_family',
fontWeight: 'editing.font_weight',
lineHeight: 'editing.line_height',
enableTabIndent: 'editing.enable_tab_indent',
tabSize: 'editing.tab_size',
tabType: 'editing.tab_type',
autoSaveDelay: 'editing.auto_save_delay'
fontSize: 'editing.fontSize',
fontFamily: 'editing.fontFamily',
fontWeight: 'editing.fontWeight',
lineHeight: 'editing.lineHeight',
enableTabIndent: 'editing.enableTabIndent',
tabSize: 'editing.tabSize',
tabType: 'editing.tabType',
autoSaveDelay: 'editing.autoSaveDelay'
} as const;
const APPEARANCE_CONFIG_KEY_MAP: AppearanceConfigKeyMap = {
language: 'appearance.language',
theme: 'appearance.theme',
systemTheme: 'appearance.system_theme'
systemTheme: 'appearance.systemTheme',
customTheme: 'appearance.customTheme'
} as const;
const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = {
version: 'updates.version',
autoUpdate: 'updates.autoUpdate',
primarySource: 'updates.primarySource',
backupSource: 'updates.backupSource',
backupBeforeUpdate: 'updates.backupBeforeUpdate',
updateTimeout: 'updates.updateTimeout',
github: 'updates.github',
gitea: 'updates.gitea'
} as const;
// 配置限制
@@ -80,26 +96,40 @@ const CONFIG_LIMITS = {
tabType: {values: [TabType.TabTypeSpaces, TabType.TabTypeTab], default: TabType.TabTypeSpaces}
} as const;
// 常用字体选项
export const FONT_OPTIONS = [
// 创建获取翻译的函数
export const createFontOptions = (t: (key: string) => string) => [
{
label: '鸿蒙字体',
label: t('settings.fontFamilies.harmonyOS'),
value: '"HarmonyOS Sans SC", "HarmonyOS Sans", "Microsoft YaHei", "PingFang SC", "Helvetica Neue", Arial, sans-serif'
},
{label: '微软雅黑', value: '"Microsoft YaHei", "PingFang SC", "Helvetica Neue", Arial, sans-serif'},
{label: '苹方字体', value: '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif'},
{
label: 'JetBrains Mono',
label: t('settings.fontFamilies.microsoftYahei'),
value: '"Microsoft YaHei", "PingFang SC", "Helvetica Neue", Arial, sans-serif'
},
{
label: t('settings.fontFamilies.pingfang'),
value: '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif'
},
{
label: t('settings.fontFamilies.jetbrainsMono'),
value: '"JetBrains Mono", "Fira Code", "SF Mono", Monaco, Consolas, "Ubuntu Mono", monospace'
},
{label: 'Fira Code', value: '"Fira Code", "JetBrains Mono", "SF Mono", Monaco, Consolas, "Ubuntu Mono", monospace'},
{label: 'Source Code Pro', value: '"Source Code Pro", "SF Mono", Monaco, Consolas, "Ubuntu Mono", monospace'},
{label: 'Cascadia Code', value: '"Cascadia Code", "SF Mono", Monaco, Consolas, "Ubuntu Mono", monospace'},
{
label: '系统等宽字体',
value: '"SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace'
label: t('settings.fontFamilies.firaCode'),
value: '"Fira Code", "JetBrains Mono", "SF Mono", Monaco, Consolas, "Ubuntu Mono", monospace'
},
{
label: t('settings.fontFamilies.sourceCodePro'),
value: '"Source Code Pro", "SF Mono", Monaco, Consolas, "Ubuntu Mono", monospace'
},
{
label: t('settings.fontFamilies.cascadiaCode'),
value: '"Cascadia Code", "SF Mono", Monaco, Consolas, "Ubuntu Mono", monospace'
}
] as const;
];
// 常用字体选项
export const FONT_OPTIONS = createFontOptions((key) => key);
// 获取浏览器的默认语言
const getBrowserLanguage = (): SupportedLocaleType => {
@@ -120,6 +150,7 @@ const DEFAULT_CONFIG: AppConfig = {
alwaysOnTop: false,
dataPath: '',
enableSystemTray: true,
startAtLogin: false,
enableGlobalHotkey: false,
globalHotkey: {
ctrl: false,
@@ -141,21 +172,104 @@ const DEFAULT_CONFIG: AppConfig = {
},
appearance: {
language: LanguageType.LangZhCN,
theme: 'default-dark' as ThemeType,
systemTheme: 'dark' as SystemThemeType
systemTheme: SystemThemeType.SystemThemeAuto,
customTheme: {
darkTheme: {
// 基础色调
background: '#252B37',
backgroundSecondary: '#213644',
surface: '#474747',
foreground: '#9BB586',
foregroundSecondary: '#9c9c9c',
// 语法高亮
comment: '#6272a4',
keyword: '#ff79c6',
string: '#f1fa8c',
function: '#50fa7b',
number: '#bd93f9',
operator: '#ff79c6',
variable: '#8fbcbb',
type: '#8be9fd',
// 界面元素
cursor: '#fff',
selection: '#0865a9aa',
selectionBlur: '#225377aa',
activeLine: 'rgba(255,255,255,0.04)',
lineNumber: 'rgba(255,255,255, 0.15)',
activeLineNumber: 'rgba(255,255,255, 0.6)',
// 边框分割线
borderColor: '#1e222a',
borderLight: 'rgba(255,255,255, 0.1)',
// 搜索匹配
searchMatch: '#8fbcbb',
matchingBracket: 'rgba(255,255,255,0.1)'
},
lightTheme: {
// 基础色调
background: '#ffffff',
backgroundSecondary: '#f1faf1',
surface: '#f5f5f5',
foreground: '#444d56',
foregroundSecondary: '#6a737d',
// 语法高亮
comment: '#6a737d',
keyword: '#d73a49',
string: '#032f62',
function: '#005cc5',
number: '#005cc5',
operator: '#d73a49',
variable: '#24292e',
type: '#6f42c1',
// 界面元素
cursor: '#000',
selection: '#77baff8c',
selectionBlur: '#b2c2ca85',
activeLine: '#000000',
lineNumber: '#000000',
activeLineNumber: '#000000',
// 边框分割线
borderColor: '#dfdfdf',
borderLight: '#0000000C',
// 搜索匹配
searchMatch: '#005cc5',
matchingBracket: 'rgba(0,0,0,0.1)'
}
}
},
updates: {
version: "1.0.0",
autoUpdate: true,
primarySource: UpdateSourceType.UpdateSourceGithub,
backupSource: UpdateSourceType.UpdateSourceGitea,
backupBeforeUpdate: true,
updateTimeout: 30,
github: {
owner: "landaiqing",
repo: "voidraft",
},
gitea: {
baseURL: "https://git.landaiqing.cn",
owner: "landaiqing",
repo: "voidraft",
}
},
keyBindings: {},
updates: {},
metadata: {
version: '1.0.0',
lastUpdated: null
lastUpdated: new Date().toString(),
}
};
export const useConfigStore = defineStore('config', () => {
const {locale} = useI18n();
const {safeCall} = useErrorHandler();
const {locale, t} = useI18n();
// 响应式状态
const state = reactive({
@@ -163,6 +277,9 @@ export const useConfigStore = defineStore('config', () => {
isLoading: false,
configLoaded: false
});
// 初始化FONT_OPTIONS国际化版本
const localizedFontOptions = computed(() => createFontOptions(t));
// 计算属性 - 使用工厂函数简化
const createLimitComputed = (key: NumberConfigKey) => computed(() => CONFIG_LIMITS[key]);
@@ -216,6 +333,21 @@ export const useConfigStore = defineStore('config', () => {
state.config.appearance[key] = value;
};
const updateUpdatesConfig = async <K extends keyof UpdatesConfig>(key: K, value: UpdatesConfig[K]): Promise<void> => {
// 确保配置已加载
if (!state.configLoaded && !state.isLoading) {
await initConfig();
}
const backendKey = UPDATES_CONFIG_KEY_MAP[key];
if (!backendKey) {
throw new Error(`No backend key mapping found for updates.${key.toString()}`);
}
await ConfigService.Set(backendKey, value);
state.config.updates[key] = value;
};
// 加载配置
const initConfig = async (): Promise<void> => {
if (state.isLoading) return;
@@ -229,7 +361,6 @@ export const useConfigStore = defineStore('config', () => {
if (appConfig.general) Object.assign(state.config.general, appConfig.general);
if (appConfig.editing) Object.assign(state.config.editing, appConfig.editing);
if (appConfig.appearance) Object.assign(state.config.appearance, appConfig.appearance);
if (appConfig.keyBindings) Object.assign(state.config.keyBindings, appConfig.keyBindings);
if (appConfig.updates) Object.assign(state.config.updates, appConfig.updates);
if (appConfig.metadata) Object.assign(state.config.metadata, appConfig.metadata);
}
@@ -250,26 +381,26 @@ export const useConfigStore = defineStore('config', () => {
const clamp = (value: number) => ConfigUtils.clamp(value, limit.min, limit.max);
return {
increase: () => safeCall(() => updateEditingConfig(key, clamp(state.config.editing[key] + 1)), 'config.saveFailed', 'config.saveSuccess'),
decrease: () => safeCall(() => updateEditingConfig(key, clamp(state.config.editing[key] - 1)), 'config.saveFailed', 'config.saveSuccess'),
set: (value: number) => safeCall(() => updateEditingConfig(key, clamp(value)), 'config.saveFailed', 'config.saveSuccess'),
reset: () => safeCall(() => updateEditingConfig(key, limit.default), 'config.saveFailed', 'config.saveSuccess')
increase: async () => await updateEditingConfig(key, clamp(state.config.editing[key] + 1)),
decrease: async () => await updateEditingConfig(key, clamp(state.config.editing[key] - 1)),
set: async (value: number) => await updateEditingConfig(key, clamp(value)),
reset: async () => await updateEditingConfig(key, limit.default)
};
};
// 通用布尔值切换器
const createGeneralToggler = <T extends keyof GeneralConfig>(key: T) =>
() => safeCall(() => updateGeneralConfig(key, !state.config.general[key] as GeneralConfig[T]), 'config.saveFailed', 'config.saveSuccess');
async () => await updateGeneralConfig(key, !state.config.general[key] as GeneralConfig[T]);
const createEditingToggler = <T extends keyof EditingConfig>(key: T) =>
() => safeCall(() => updateEditingConfig(key, !state.config.editing[key] as EditingConfig[T]), 'config.saveFailed', 'config.saveSuccess');
async () => await updateEditingConfig(key, !state.config.editing[key] as EditingConfig[T]);
// 枚举值切换器
const createEnumToggler = <T extends TabType>(key: 'tabType', values: readonly T[]) =>
() => {
async () => {
const currentIndex = values.indexOf(state.config.editing[key] as T);
const nextIndex = (currentIndex + 1) % values.length;
return safeCall(() => updateEditingConfig(key, values[nextIndex]), 'config.saveFailed', 'config.saveSuccess');
return await updateEditingConfig(key, values[nextIndex]);
};
// 重置配置
@@ -278,16 +409,12 @@ export const useConfigStore = defineStore('config', () => {
state.isLoading = true;
try {
// 调用后端重置配置
await safeCall(() => ConfigService.ResetConfig(), 'config.resetFailed', 'config.resetSuccess');
// 立即重新加载后端配置以确保前端状态同步
await safeCall(async () => {
const appConfig = await ConfigService.GetConfig();
if (appConfig) {
state.config = JSON.parse(JSON.stringify(appConfig)) as AppConfig;
}
}, 'config.loadFailed', 'config.loadSuccess');
await ConfigService.ResetConfig()
const appConfig = await ConfigService.GetConfig();
if (appConfig) {
state.config = JSON.parse(JSON.stringify(appConfig)) as AppConfig;
}
} finally {
state.isLoading = false;
}
@@ -295,27 +422,61 @@ export const useConfigStore = defineStore('config', () => {
// 语言设置方法
const setLanguage = async (language: LanguageType): Promise<void> => {
await safeCall(async () => {
await updateAppearanceConfig('language', language);
await updateAppearanceConfig('language', language);
// 同步更新前端语言
const frontendLocale = ConfigUtils.backendLanguageToFrontend(language);
locale.value = frontendLocale as any;
}, 'config.languageChangeFailed', 'config.languageChanged');
};
// 主题设置方法
const setTheme = async (theme: ThemeType): Promise<void> => {
await safeCall(async () => {
await updateAppearanceConfig('theme', theme);
}, 'config.themeChangeFailed', 'config.themeChanged');
// 同步更新前端语言
const frontendLocale = ConfigUtils.backendLanguageToFrontend(language);
locale.value = frontendLocale as any;
};
// 系统主题设置方法
const setSystemTheme = async (systemTheme: SystemThemeType): Promise<void> => {
await safeCall(async () => {
await updateAppearanceConfig('systemTheme', systemTheme);
}, 'config.systemThemeChangeFailed', 'config.systemThemeChanged');
await updateAppearanceConfig('systemTheme', systemTheme);
};
// 更新自定义主题方法
const updateCustomTheme = async (themeType: 'darkTheme' | 'lightTheme', colorKey: string, colorValue: string): Promise<void> => {
// 确保配置已加载
if (!state.configLoaded && !state.isLoading) {
await initConfig();
}
try {
// 深拷贝当前配置
const customTheme = JSON.parse(JSON.stringify(state.config.appearance.customTheme));
// 更新对应主题的颜色值
customTheme[themeType][colorKey] = colorValue;
// 更新整个自定义主题配置到后端
await ConfigService.Set(APPEARANCE_CONFIG_KEY_MAP.customTheme, customTheme);
// 更新前端状态
state.config.appearance.customTheme = customTheme;
} catch (error) {
throw error;
}
};
// 设置整个自定义主题配置
const setCustomTheme = async (customTheme: any): Promise<void> => {
// 确保配置已加载
if (!state.configLoaded && !state.isLoading) {
await initConfig();
}
try {
// 更新整个自定义主题配置到后端
await ConfigService.Set(APPEARANCE_CONFIG_KEY_MAP.customTheme, customTheme);
// 更新前端状态
state.config.appearance.customTheme = customTheme;
// 确保Vue能检测到变化
state.config.appearance = { ...state.config.appearance };
} catch (error) {
throw error;
}
};
// 初始化语言设置
@@ -346,21 +507,19 @@ export const useConfigStore = defineStore('config', () => {
const togglers = {
tabIndent: createEditingToggler('enableTabIndent'),
alwaysOnTop: async () => {
await safeCall(async () => {
await updateGeneralConfig('alwaysOnTop', !state.config.general.alwaysOnTop);
// 立即应用窗口置顶状态
await runtime.Window.SetAlwaysOnTop(state.config.general.alwaysOnTop);
}, 'config.alwaysOnTopFailed', 'config.alwaysOnTopSuccess');
await updateGeneralConfig('alwaysOnTop', !state.config.general.alwaysOnTop);
// 立即应用窗口置顶状态
await runtime.Window.SetAlwaysOnTop(state.config.general.alwaysOnTop);
},
tabType: createEnumToggler('tabType', CONFIG_LIMITS.tabType.values)
};
// 字符串配置设置器
const setters = {
fontFamily: (value: string) => safeCall(() => updateEditingConfig('fontFamily', value), 'config.saveFailed', 'config.saveSuccess'),
fontWeight: (value: string) => safeCall(() => updateEditingConfig('fontWeight', value), 'config.saveFailed', 'config.saveSuccess'),
dataPath: (value: string) => safeCall(() => updateGeneralConfig('dataPath', value), 'config.saveFailed', 'config.saveSuccess'),
autoSaveDelay: (value: number) => safeCall(() => updateEditingConfig('autoSaveDelay', value), 'config.saveFailed', 'config.saveSuccess')
fontFamily: async (value: string) => await updateEditingConfig('fontFamily', value),
fontWeight: async (value: string) => await updateEditingConfig('fontWeight', value),
dataPath: async (value: string) => await updateGeneralConfig('dataPath', value),
autoSaveDelay: async (value: number) => await updateEditingConfig('autoSaveDelay', value)
};
return {
@@ -368,12 +527,13 @@ export const useConfigStore = defineStore('config', () => {
config: computed(() => state.config),
configLoaded: computed(() => state.configLoaded),
isLoading: computed(() => state.isLoading),
localizedFontOptions,
// 限制常量
...limits,
// 核心方法
initConfig: () => safeCall(() => initConfig(), 'config.loadFailed', 'config.loadSuccess'),
initConfig,
resetConfig,
// 语言相关方法
@@ -381,8 +541,9 @@ export const useConfigStore = defineStore('config', () => {
initializeLanguage,
// 主题相关方法
setTheme,
setSystemTheme,
updateCustomTheme,
setCustomTheme,
// 字体大小操作
...adjusters.fontSize,
@@ -393,7 +554,7 @@ export const useConfigStore = defineStore('config', () => {
// Tab操作
toggleTabIndent: togglers.tabIndent,
setEnableTabIndent: (value: boolean) => safeCall(() => updateEditingConfig('enableTabIndent', value), 'config.saveFailed', 'config.saveSuccess'),
setEnableTabIndent: (value: boolean) => updateEditingConfig('enableTabIndent', value),
...adjusters.tabSize,
increaseTabSize: adjusters.tabSize.increase,
decreaseTabSize: adjusters.tabSize.decrease,
@@ -405,7 +566,7 @@ export const useConfigStore = defineStore('config', () => {
// 窗口操作
toggleAlwaysOnTop: togglers.alwaysOnTop,
setAlwaysOnTop: (value: boolean) => safeCall(() => updateGeneralConfig('alwaysOnTop', value), 'config.saveFailed', 'config.saveSuccess'),
setAlwaysOnTop: (value: boolean) => updateGeneralConfig('alwaysOnTop', value),
// 字体操作
setFontFamily: setters.fontFamily,
@@ -418,10 +579,21 @@ export const useConfigStore = defineStore('config', () => {
setAutoSaveDelay: setters.autoSaveDelay,
// 热键配置相关方法
setEnableGlobalHotkey: (value: boolean) => safeCall(() => updateGeneralConfig('enableGlobalHotkey', value), 'config.saveFailed', 'config.saveSuccess'),
setGlobalHotkey: (hotkey: any) => safeCall(() => updateGeneralConfig('globalHotkey', hotkey), 'config.saveFailed', 'config.saveSuccess'),
setEnableGlobalHotkey: (value: boolean) => updateGeneralConfig('enableGlobalHotkey', value),
setGlobalHotkey: (hotkey: any) => updateGeneralConfig('globalHotkey', hotkey),
// 系统托盘配置相关方法
setEnableSystemTray: (value: boolean) => safeCall(() => updateGeneralConfig('enableSystemTray', value), 'config.saveFailed', 'config.saveSuccess')
setEnableSystemTray: (value: boolean) => updateGeneralConfig('enableSystemTray', value),
// 开机启动配置相关方法
setStartAtLogin: async (value: boolean) => {
// 先更新配置文件
await updateGeneralConfig('startAtLogin', value);
// 再调用系统设置API
await StartupService.SetEnabled(value);
},
// 更新配置相关方法
setAutoUpdate: async (value: boolean) => await updateUpdatesConfig('autoUpdate', value)
};
});

View File

@@ -1,124 +1,251 @@
import {defineStore} from 'pinia';
import {computed, ref} from 'vue';
import {DocumentService} from '@/../bindings/voidraft/internal/services';
import {OpenDocumentWindow} from '@/../bindings/voidraft/internal/services/windowservice';
import {Document} from '@/../bindings/voidraft/internal/models/models';
import {useErrorHandler} from '@/utils/errorHandler';
const SCRATCH_DOCUMENT_ID = 1; // 默认草稿文档ID
export const useDocumentStore = defineStore('document', () => {
const {safeCall} = useErrorHandler();
// === 核心状态 ===
const documents = ref<Record<number, Document>>({});
const recentDocumentIds = ref<number[]>([SCRATCH_DOCUMENT_ID]);
const currentDocumentId = ref<number | null>(null);
const currentDocument = ref<Document | null>(null);
// 状态
const activeDocument = ref<Document | null>(null);
const isLoading = ref(false);
const isSaving = ref(false);
const lastSaved = ref<Date | null>(null);
// === UI状态 ===
const showDocumentSelector = ref(false);
const isLoading = ref(false);
// 计算属性
const documentContent = computed(() => activeDocument.value?.content ?? '');
const documentTitle = computed(() => activeDocument.value?.meta?.title ?? '');
const hasActiveDocument = computed(() => !!activeDocument.value);
const isSaveInProgress = computed(() => isSaving.value);
const lastSavedTime = computed(() => lastSaved.value);
// === 计算属性 ===
const documentList = computed(() =>
Object.values(documents.value).sort((a, b) => {
const aIndex = recentDocumentIds.value.indexOf(a.id);
const bIndex = recentDocumentIds.value.indexOf(b.id);
// 状态管理包装器
const withStateGuard = async <T>(
operation: () => Promise<T>,
stateRef: typeof isLoading | typeof isSaving,
errorMessageKey: string,
successMessageKey?: string
): Promise<T | null> => {
if (stateRef.value) return null;
stateRef.value = true;
try {
return await safeCall(operation, errorMessageKey, successMessageKey);
} finally {
stateRef.value = false;
// 按最近使用排序
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex;
}
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
// 然后按更新时间排序
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
})
);
// === 私有方法 ===
const addRecentDocument = (docId: number) => {
const recent = recentDocumentIds.value.filter(id => id !== docId);
recent.unshift(docId);
recentDocumentIds.value = recent.slice(0, 100); // 保留最近100个
};
const setDocuments = (docs: Document[]) => {
documents.value = {};
docs.forEach(doc => {
documents.value[doc.id] = doc;
});
};
// === 公共API ===
// 在新窗口中打开文档
const openDocumentInNewWindow = async (docId: number): Promise<boolean> => {
try {
await OpenDocumentWindow(docId);
return true;
} catch (error) {
console.error('Failed to open document in new window:', error);
return false;
}
};
// 更新文档列表
const updateDocuments = async () => {
try {
const docs = await DocumentService.ListAllDocumentsMeta();
if (docs) {
setDocuments(docs.filter((doc): doc is Document => doc !== null));
}
} catch (error) {
console.error('Failed to update documents:', error);
}
};
// 打开文档
const openDocument = async (docId: number): Promise<boolean> => {
try {
closeDialog();
// 获取完整文档数据
const doc = await DocumentService.GetDocumentByID(docId);
if (!doc) {
throw new Error(`Document ${docId} not found`);
}
currentDocumentId.value = docId;
currentDocument.value = doc;
addRecentDocument(docId);
return true;
} catch (error) {
console.error('Failed to open document:', error);
return false;
}
};
// 创建新文档
const createNewDocument = async (title: string): Promise<Document | null> => {
try {
const newDoc = await DocumentService.CreateDocument(title);
if (!newDoc) {
throw new Error('Failed to create document');
}
// 更新文档列表
documents.value[newDoc.id] = newDoc;
return newDoc;
} catch (error) {
console.error('Failed to create document:', error);
return null;
}
};
// 保存新文档
const saveNewDocument = async (title: string, content: string): Promise<boolean> => {
try {
const newDoc = await createNewDocument(title);
if (!newDoc) return false;
// 更新内容
await DocumentService.UpdateDocumentContent(newDoc.id, content);
newDoc.content = content;
return true;
} catch (error) {
console.error('Failed to save new document:', error);
return false;
}
};
// 更新文档元数据
const updateDocumentMetadata = async (docId: number, title: string, newPath?: string): Promise<boolean> => {
try {
await DocumentService.UpdateDocumentTitle(docId, title);
// 更新本地状态
const doc = documents.value[docId];
if (doc) {
doc.title = title;
doc.updatedAt = new Date();
}
if (currentDocument.value?.id === docId) {
currentDocument.value.title = title;
currentDocument.value.updatedAt = new Date();
}
return true;
} catch (error) {
console.error('Failed to update document metadata:', error);
return false;
}
};
// 删除文档
const deleteDocument = async (docId: number): Promise<boolean> => {
try {
// 检查是否是默认文档使用ID判断
if (docId === SCRATCH_DOCUMENT_ID) {
return false;
}
await DocumentService.DeleteDocument(docId);
// 更新本地状态
delete documents.value[docId];
recentDocumentIds.value = recentDocumentIds.value.filter(id => id !== docId);
// 如果删除的是当前文档,切换到第一个可用文档
if (currentDocumentId.value === docId) {
const availableDocs = Object.values(documents.value);
if (availableDocs.length > 0) {
await openDocument(availableDocs[0].id);
} else {
currentDocumentId.value = null;
currentDocument.value = null;
}
}
return true;
} catch (error) {
console.error('Failed to delete document:', error);
return false;
}
};
// === UI控制 ===
const openDocumentSelector = () => {
closeDialog();
showDocumentSelector.value = true;
};
const closeDialog = () => {
showDocumentSelector.value = false;
};
// === 初始化 ===
const initialize = async (urlDocumentId?: number): Promise<void> => {
try {
await updateDocuments();
// 优先使用URL参数中的文档ID
if (urlDocumentId && documents.value[urlDocumentId]) {
await openDocument(urlDocumentId);
} else if (currentDocumentId.value && documents.value[currentDocumentId.value]) {
// 如果URL中没有指定文档ID则使用持久化的文档ID
await openDocument(currentDocumentId.value);
} else {
// 否则获取第一个文档ID并打开
const firstDocId = await DocumentService.GetFirstDocumentID();
if (firstDocId && documents.value[firstDocId]) {
await openDocument(firstDocId);
}
}
} catch (error) {
console.error('Failed to initialize document store:', error);
}
};
return {
// 状态
documents,
documentList,
recentDocumentIds,
currentDocumentId,
currentDocument,
showDocumentSelector,
isLoading,
// 方法
updateDocuments,
openDocument,
openDocumentInNewWindow,
createNewDocument,
saveNewDocument,
updateDocumentMetadata,
deleteDocument,
openDocumentSelector,
closeDialog,
initialize,
};
}, {
persist: {
key: 'voidraft-document',
storage: localStorage,
pick: ['currentDocumentId']
}
};
// 加载文档
const loadDocument = () => withStateGuard(
async () => {
const doc = await DocumentService.GetActiveDocument();
activeDocument.value = doc;
return doc;
},
isLoading,
'document.loadFailed',
'document.loadSuccess'
);
// 保存文档
const saveDocument = async (content: string): Promise<boolean> => {
const result = await withStateGuard(
async () => {
await DocumentService.UpdateActiveDocumentContent(content);
lastSaved.value = new Date();
// 使用可选链更新本地副本
if (activeDocument.value) {
activeDocument.value.content = content;
activeDocument.value.meta.lastUpdated = lastSaved.value;
}
return true;
},
isSaving,
'document.saveFailed',
'document.saveSuccess'
);
return result ?? false;
};
// 强制保存文档到磁盘
const forceSaveDocument = async (): Promise<boolean> => {
const result = await withStateGuard(
async () => {
// 直接调用强制保存API
await DocumentService.ForceSave();
lastSaved.value = new Date();
// 使用可选链更新时间戳
if (activeDocument.value) {
activeDocument.value.meta.lastUpdated = lastSaved.value;
}
return true;
},
isSaving,
'document.saveFailed',
'document.manualSaveSuccess'
);
return result ?? false;
};
// 初始化
const initialize = async () => {
await loadDocument();
};
return {
// 状态
activeDocument,
isLoading,
isSaving,
lastSaved,
// 计算属性
documentContent,
documentTitle,
hasActiveDocument,
isSaveInProgress,
lastSavedTime,
// 方法
loadDocument,
saveDocument,
forceSaveDocument,
initialize
};
});
});

View File

@@ -1,51 +1,717 @@
import {defineStore} from 'pinia';
import {ref} from 'vue';
import {DocumentStats} from '@/types/editor';
import {nextTick, ref, watch} from 'vue';
import {EditorView} from '@codemirror/view';
import {EditorState, Extension} from '@codemirror/state';
import {useConfigStore} from './configStore';
import {useDocumentStore} from './documentStore';
import {useThemeStore} from './themeStore';
import {ExtensionID, SystemThemeType} from '@/../bindings/voidraft/internal/models/models';
import {DocumentService, ExtensionService} from '@/../bindings/voidraft/internal/services';
import {ensureSyntaxTree} from "@codemirror/language";
import {createBasicSetup} from '@/views/editor/basic/basicSetup';
import {createThemeExtension, updateEditorTheme} from '@/views/editor/basic/themeExtension';
import {getTabExtensions, updateTabConfig} from '@/views/editor/basic/tabExtension';
import {createFontExtensionFromBackend, updateFontConfig} from '@/views/editor/basic/fontExtension';
import {createStatsUpdateExtension} from '@/views/editor/basic/statsExtension';
import {createContentChangePlugin} from '@/views/editor/basic/contentChangeExtension';
import {createDynamicKeymapExtension, updateKeymapExtension} from '@/views/editor/keymap';
import {createDynamicExtensions, getExtensionManager, setExtensionManagerView, removeExtensionManagerView} from '@/views/editor/manager';
import {useExtensionStore} from './extensionStore';
import createCodeBlockExtension from "@/views/editor/extensions/codeblock";
const NUM_EDITOR_INSTANCES = 5; // 最多缓存5个编辑器实例
export interface DocumentStats {
lines: number;
characters: number;
selectedCharacters: number;
}
interface EditorInstance {
view: EditorView;
documentId: number;
content: string;
isDirty: boolean;
lastModified: Date;
autoSaveTimer: number | null;
syntaxTreeCache: {
lastDocLength: number;
lastContentHash: string;
lastParsed: Date;
} | null;
}
export const useEditorStore = defineStore('editor', () => {
// 引用配置store
// === 依赖store ===
const configStore = useConfigStore();
const documentStore = useDocumentStore();
const themeStore = useThemeStore();
const extensionStore = useExtensionStore();
// 状态
// === 核心状态 ===
const editorCache = ref<{
lru: number[];
instances: Record<number, EditorInstance>;
containerElement: HTMLElement | null;
}>({
lru: [],
instances: {},
containerElement: null
});
const currentEditor = ref<EditorView | null>(null);
const documentStats = ref<DocumentStats>({
lines: 0,
characters: 0,
selectedCharacters: 0
});
// 编辑器视图
const editorView = ref<EditorView | null>(null);
// 编辑器加载状态
const isLoading = ref(false);
// 方法
function setEditorView(view: EditorView | null) {
editorView.value = view;
}
// 异步操作竞态条件控制
const operationSequence = ref(0);
const pendingOperations = ref(new Map<number, AbortController>());
const currentLoadingDocumentId = ref<number | null>(null);
// 更新文档统计信息
function updateDocumentStats(stats: DocumentStats) {
documentStats.value = stats;
}
// 自动保存设置 - 从配置动态获取
const getAutoSaveDelay = () => configStore.config.editing.autoSaveDelay;
// 应用字体大小
function applyFontSize() {
if (!editorView.value) return;
// 更新编辑器的字体大小
const editorDOM = editorView.value.dom;
if (editorDOM) {
editorDOM.style.fontSize = `${configStore.config.editing.fontSize}px`;
editorView.value?.requestMeasure();
// 生成新的操作序列号
const getNextOperationId = () => ++operationSequence.value;
// 取消之前的操作
const cancelPreviousOperations = (excludeId?: number) => {
pendingOperations.value.forEach((controller, id) => {
if (id !== excludeId) {
controller.abort();
pendingOperations.value.delete(id);
}
});
};
// 检查操作是否仍然有效
const isOperationValid = (operationId: number, documentId: number) => {
return (
pendingOperations.value.has(operationId) &&
!pendingOperations.value.get(operationId)?.signal.aborted &&
currentLoadingDocumentId.value === documentId
);
};
// === 私有方法 ===
// 生成内容哈希
const generateContentHash = (content: string): string => {
let hash = 0;
for (let i = 0; i < content.length; i++) {
const char = content.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
}
return hash.toString();
};
// 缓存化的语法树确保方法
const ensureSyntaxTreeCached = (view: EditorView, documentId: number): void => {
const instance = editorCache.value.instances[documentId];
if (!instance) return;
const docLength = view.state.doc.length;
const content = view.state.doc.toString();
const contentHash = generateContentHash(content);
const now = new Date();
// 检查是否需要重新构建语法树
const cache = instance.syntaxTreeCache;
const shouldRebuild = !cache ||
cache.lastDocLength !== docLength ||
cache.lastContentHash !== contentHash ||
(now.getTime() - cache.lastParsed.getTime()) > 30000; // 30秒过期
if (shouldRebuild) {
try {
ensureSyntaxTree(view.state, docLength, 5000);
// 更新缓存
instance.syntaxTreeCache = {
lastDocLength: docLength,
lastContentHash: contentHash,
lastParsed: now
};
} catch (error) {
console.warn('Failed to ensure syntax tree:', error);
}
}
};
// 创建编辑器实例
const createEditorInstance = async (
content: string,
operationId: number,
documentId: number
): Promise<EditorView> => {
if (!editorCache.value.containerElement) {
throw new Error('Editor container not set');
}
// 检查操作是否仍然有效
if (!isOperationValid(operationId, documentId)) {
throw new Error('Operation cancelled');
}
// 获取基本扩展
const basicExtensions = createBasicSetup();
// 获取主题扩展
const themeExtension = createThemeExtension(
configStore.config.appearance.systemTheme || SystemThemeType.SystemThemeAuto
);
// Tab相关扩展
const tabExtensions = getTabExtensions(
configStore.config.editing.tabSize,
configStore.config.editing.enableTabIndent,
configStore.config.editing.tabType
);
// 字体扩展
const fontExtension = createFontExtensionFromBackend({
fontFamily: configStore.config.editing.fontFamily,
fontSize: configStore.config.editing.fontSize,
lineHeight: configStore.config.editing.lineHeight,
fontWeight: configStore.config.editing.fontWeight
});
// 统计扩展
const statsExtension = createStatsUpdateExtension(updateDocumentStats);
// 内容变化扩展
const contentChangeExtension = createContentChangePlugin();
// 代码块扩展
const codeBlockExtension = createCodeBlockExtension({
showBackground: true,
enableAutoDetection: true
});
// 再次检查操作有效性
if (!isOperationValid(operationId, documentId)) {
throw new Error('Operation cancelled');
}
// 快捷键扩展
const keymapExtension = await createDynamicKeymapExtension();
// 检查操作有效性
if (!isOperationValid(operationId, documentId)) {
throw new Error('Operation cancelled');
}
// 动态扩展传递文档ID以便扩展管理器可以预初始化
const dynamicExtensions = await createDynamicExtensions(documentId);
// 最终检查操作有效性
if (!isOperationValid(operationId, documentId)) {
throw new Error('Operation cancelled');
}
// 组合所有扩展
const extensions: Extension[] = [
keymapExtension,
...basicExtensions,
themeExtension,
...tabExtensions,
fontExtension,
statsExtension,
contentChangeExtension,
codeBlockExtension,
...dynamicExtensions
];
// 创建编辑器状态
const state = EditorState.create({
doc: content,
extensions
});
// 创建编辑器视图
const view = new EditorView({
state
});
// 将光标定位到文档末尾并滚动到该位置
const docLength = view.state.doc.length;
view.dispatch({
selection: {anchor: docLength, head: docLength},
scrollIntoView: true
});
return view;
};
// 添加编辑器到缓存
const addEditorToCache = (documentId: number, view: EditorView, content: string) => {
// 如果缓存已满,移除最少使用的编辑器
if (editorCache.value.lru.length >= NUM_EDITOR_INSTANCES) {
const oldestId = editorCache.value.lru.shift();
if (oldestId && editorCache.value.instances[oldestId]) {
const oldInstance = editorCache.value.instances[oldestId];
// 清除自动保存定时器
if (oldInstance.autoSaveTimer) {
clearTimeout(oldInstance.autoSaveTimer);
}
// 移除DOM元素
if (oldInstance.view.dom.parentElement) {
oldInstance.view.dom.remove();
}
oldInstance.view.destroy();
delete editorCache.value.instances[oldestId];
}
}
// 添加新的编辑器实例
editorCache.value.instances[documentId] = {
view,
documentId,
content,
isDirty: false,
lastModified: new Date(),
autoSaveTimer: null,
syntaxTreeCache: null
};
// 添加到LRU列表
editorCache.value.lru.push(documentId);
// 初始化语法树缓存
ensureSyntaxTreeCached(view, documentId);
};
// 更新LRU
const updateLRU = (documentId: number) => {
const lru = editorCache.value.lru;
const index = lru.indexOf(documentId);
if (index > -1) {
lru.splice(index, 1);
}
lru.push(documentId);
};
// 获取或创建编辑器
const getOrCreateEditor = async (
documentId: number,
content: string,
operationId: number
): Promise<EditorView> => {
// 检查缓存
const cached = editorCache.value.instances[documentId];
if (cached) {
updateLRU(documentId);
return cached.view;
}
// 检查操作是否仍然有效
if (!isOperationValid(operationId, documentId)) {
throw new Error('Operation cancelled');
}
// 创建新的编辑器实例
const view = await createEditorInstance(content, operationId, documentId);
// 最终检查操作有效性
if (!isOperationValid(operationId, documentId)) {
// 如果操作已取消,清理创建的实例
view.destroy();
throw new Error('Operation cancelled');
}
addEditorToCache(documentId, view, content);
return view;
};
// 显示编辑器
const showEditor = (documentId: number) => {
const instance = editorCache.value.instances[documentId];
if (!instance || !editorCache.value.containerElement) return;
try {
// 移除当前编辑器DOM
if (currentEditor.value && currentEditor.value.dom && currentEditor.value.dom.parentElement) {
currentEditor.value.dom.remove();
}
// 确保容器为空
editorCache.value.containerElement.innerHTML = '';
// 将目标编辑器DOM添加到容器
editorCache.value.containerElement.appendChild(instance.view.dom);
currentEditor.value = instance.view;
// 设置扩展管理器视图
setExtensionManagerView(instance.view, documentId);
// 更新LRU
updateLRU(documentId);
// 重新测量和聚焦编辑器
nextTick(() => {
// 将光标定位到文档末尾并滚动到该位置
const docLength = instance.view.state.doc.length;
instance.view.dispatch({
selection: {anchor: docLength, head: docLength},
scrollIntoView: true
});
// 滚动到文档底部(将光标位置滚动到可见区域)
instance.view.focus();
// 使用缓存的语法树确保方法
ensureSyntaxTreeCached(instance.view, documentId);
});
} catch (error) {
console.error('Error showing editor:', error);
}
};
// 保存编辑器内容
const saveEditorContent = async (documentId: number): Promise<boolean> => {
const instance = editorCache.value.instances[documentId];
if (!instance || !instance.isDirty) return true;
try {
const content = instance.view.state.doc.toString();
const lastModified = instance.lastModified;
await DocumentService.UpdateDocumentContent(documentId, content);
// 检查在保存期间内容是否又被修改了
if (instance.lastModified === lastModified) {
instance.content = content;
instance.isDirty = false;
instance.lastModified = new Date();
}
// 如果内容在保存期间被修改了,保持 isDirty 状态
return true;
} catch (error) {
console.error('Failed to save editor content:', error);
return false;
}
};
// 内容变化处理
const onContentChange = (documentId: number) => {
const instance = editorCache.value.instances[documentId];
if (!instance) return;
instance.isDirty = true;
instance.lastModified = new Date();
// 清理语法树缓存,下次访问时重新构建
instance.syntaxTreeCache = null;
// 清除之前的定时器
if (instance.autoSaveTimer) {
clearTimeout(instance.autoSaveTimer);
}
// 设置新的自动保存定时器
instance.autoSaveTimer = window.setTimeout(() => {
saveEditorContent(documentId);
}, getAutoSaveDelay());
};
// === 公共API ===
// 设置编辑器容器
const setEditorContainer = (container: HTMLElement | null) => {
editorCache.value.containerElement = container;
// 如果设置容器时已有当前文档,立即加载编辑器
if (container && documentStore.currentDocument) {
loadEditor(documentStore.currentDocument.id, documentStore.currentDocument.content);
}
};
// 加载编辑器
const loadEditor = async (documentId: number, content: string) => {
// 设置加载状态
isLoading.value = true;
// 生成新的操作ID
const operationId = getNextOperationId();
const abortController = new AbortController();
try {
// 验证参数
if (!documentId) {
throw new Error('Invalid parameters for loadEditor');
}
// 取消之前的操作并设置当前操作
cancelPreviousOperations();
currentLoadingDocumentId.value = documentId;
pendingOperations.value.set(operationId, abortController);
// 保存当前编辑器内容
if (currentEditor.value) {
const currentDocId = documentStore.currentDocumentId;
if (currentDocId && currentDocId !== documentId) {
await saveEditorContent(currentDocId);
// 检查操作是否仍然有效
if (!isOperationValid(operationId, documentId)) {
return;
}
}
}
// 获取或创建编辑器
const view = await getOrCreateEditor(documentId, content, operationId);
// 检查操作是否仍然有效
if (!isOperationValid(operationId, documentId)) {
return;
}
// 更新内容(如果需要)
const instance = editorCache.value.instances[documentId];
if (instance && instance.content !== content) {
// 确保编辑器视图有效
if (view && view.state && view.dispatch) {
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: content
}
});
instance.content = content;
instance.isDirty = false;
// 清理语法树缓存,因为内容已更新
instance.syntaxTreeCache = null;
}
}
// 最终检查操作有效性
if (!isOperationValid(operationId, documentId)) {
return;
}
// 显示编辑器
showEditor(documentId);
} catch (error) {
if (error instanceof Error && error.message === 'Operation cancelled') {
console.log(`Editor loading cancelled for document ${documentId}`);
} else {
console.error('Failed to load editor:', error);
}
} finally {
// 清理操作记录
pendingOperations.value.delete(operationId);
if (currentLoadingDocumentId.value === documentId) {
currentLoadingDocumentId.value = null;
}
// 延迟一段时间后再取消加载状态
setTimeout(() => {
isLoading.value = false;
}, 800);
}
};
// 移除编辑器
const removeEditor = (documentId: number) => {
const instance = editorCache.value.instances[documentId];
if (instance) {
try {
// 如果正在加载这个文档,取消操作
if (currentLoadingDocumentId.value === documentId) {
cancelPreviousOperations();
currentLoadingDocumentId.value = null;
}
// 清除自动保存定时器
if (instance.autoSaveTimer) {
clearTimeout(instance.autoSaveTimer);
instance.autoSaveTimer = null;
}
// 从扩展管理器中移除视图
removeExtensionManagerView(documentId);
// 移除DOM元素
if (instance.view && instance.view.dom && instance.view.dom.parentElement) {
instance.view.dom.remove();
}
// 销毁编辑器
if (instance.view && instance.view.destroy) {
instance.view.destroy();
}
// 清理引用
if (currentEditor.value === instance.view) {
currentEditor.value = null;
}
delete editorCache.value.instances[documentId];
const lruIndex = editorCache.value.lru.indexOf(documentId);
if (lruIndex > -1) {
editorCache.value.lru.splice(lruIndex, 1);
}
} catch (error) {
console.error('Error removing editor:', error);
}
}
};
// 更新文档统计
const updateDocumentStats = (stats: DocumentStats) => {
documentStats.value = stats;
};
// 应用字体设置
const applyFontSettings = () => {
Object.values(editorCache.value.instances).forEach(instance => {
updateFontConfig(instance.view, {
fontFamily: configStore.config.editing.fontFamily,
fontSize: configStore.config.editing.fontSize,
lineHeight: configStore.config.editing.lineHeight,
fontWeight: configStore.config.editing.fontWeight
});
});
};
// 应用主题设置
const applyThemeSettings = () => {
Object.values(editorCache.value.instances).forEach(instance => {
updateEditorTheme(instance.view,
themeStore.currentTheme || SystemThemeType.SystemThemeAuto
);
});
};
// 应用Tab设置
const applyTabSettings = () => {
Object.values(editorCache.value.instances).forEach(instance => {
updateTabConfig(
instance.view,
configStore.config.editing.tabSize,
configStore.config.editing.enableTabIndent,
configStore.config.editing.tabType
);
});
};
// 应用快捷键设置
const applyKeymapSettings = async () => {
// 确保所有编辑器实例的快捷键都更新
await Promise.all(
Object.values(editorCache.value.instances).map(instance =>
updateKeymapExtension(instance.view)
)
);
};
// 清空所有编辑器
const clearAllEditors = () => {
// 取消所有挂起的操作
cancelPreviousOperations();
currentLoadingDocumentId.value = null;
Object.values(editorCache.value.instances).forEach(instance => {
// 清除自动保存定时器
if (instance.autoSaveTimer) {
clearTimeout(instance.autoSaveTimer);
}
// 从扩展管理器移除
removeExtensionManagerView(instance.documentId);
// 移除DOM元素
if (instance.view.dom.parentElement) {
instance.view.dom.remove();
}
// 销毁编辑器
instance.view.destroy();
});
editorCache.value.instances = {};
editorCache.value.lru = [];
currentEditor.value = null;
};
// 更新扩展
const updateExtension = async (id: ExtensionID, enabled: boolean, config?: any) => {
// 如果只是更新启用状态
if (config === undefined) {
await ExtensionService.UpdateExtensionEnabled(id, enabled);
} else {
// 如果需要更新配置
await ExtensionService.UpdateExtensionState(id, enabled, config);
}
// 更新前端编辑器扩展 - 应用于所有实例
const manager = getExtensionManager();
if (manager) {
// 使用立即更新模式,跳过防抖
manager.updateExtensionImmediate(id, enabled, config || {});
}
// 重新加载扩展配置
await extensionStore.loadExtensions();
// 不再需要单独更新当前编辑器的快捷键映射,因为扩展管理器会更新所有实例
// 但我们仍需要确保快捷键配置在所有编辑器上更新
await applyKeymapSettings();
};
// 监听文档切换
watch(() => documentStore.currentDocument, (newDoc) => {
if (newDoc && editorCache.value.containerElement) {
// 使用 nextTick 确保DOM更新完成后再加载编辑器
nextTick(() => {
loadEditor(newDoc.id, newDoc.content);
});
}
});
// 监听配置变化
watch(() => configStore.config.editing.fontSize, applyFontSettings);
watch(() => configStore.config.editing.fontFamily, applyFontSettings);
watch(() => configStore.config.editing.lineHeight, applyFontSettings);
watch(() => configStore.config.editing.fontWeight, applyFontSettings);
watch(() => configStore.config.editing.tabSize, applyTabSettings);
watch(() => configStore.config.editing.enableTabIndent, applyTabSettings);
watch(() => configStore.config.editing.tabType, applyTabSettings);
watch(() => themeStore.currentTheme, applyThemeSettings);
return {
// 状态
currentEditor,
documentStats,
editorView,
isLoading,
// 方法
setEditorView,
updateDocumentStats,
applyFontSize
setEditorContainer,
loadEditor,
removeEditor,
clearAllEditors,
onContentChange,
// 配置更新方法
applyFontSettings,
applyThemeSettings,
applyTabSettings,
applyKeymapSettings,
// 扩展管理方法
updateExtension,
editorView: currentEditor,
};
});

View File

@@ -0,0 +1,49 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { Extension, ExtensionID } from '@/../bindings/voidraft/internal/models/models'
import { ExtensionService } from '@/../bindings/voidraft/internal/services'
export const useExtensionStore = defineStore('extension', () => {
// 扩展配置数据
const extensions = ref<Extension[]>([])
// 获取启用的扩展
const enabledExtensions = computed(() =>
extensions.value.filter(ext => ext.enabled)
)
// 获取启用的扩展ID列表
const enabledExtensionIds = computed(() =>
enabledExtensions.value.map(ext => ext.id)
)
/**
* 从后端加载扩展配置
*/
const loadExtensions = async (): Promise<void> => {
try {
extensions.value = await ExtensionService.GetAllExtensions()
} catch (err) {
console.error('[ExtensionStore] Failed to load extensions:', err)
}
}
/**
* 获取扩展配置
*/
const getExtensionConfig = (id: ExtensionID): any => {
const extension = extensions.value.find(ext => ext.id === id)
return extension?.config ?? {}
}
return {
// 状态
extensions,
enabledExtensions,
enabledExtensionIds,
// 方法
loadExtensions,
getExtensionConfig,
}
})

View File

@@ -0,0 +1,86 @@
import {defineStore} from 'pinia'
import {computed, ref} from 'vue'
import {ExtensionID, KeyBinding, KeyBindingCommand} from '@/../bindings/voidraft/internal/models/models'
import {GetAllKeyBindings} from '@/../bindings/voidraft/internal/services/keybindingservice'
export const useKeybindingStore = defineStore('keybinding', () => {
// 快捷键配置数据
const keyBindings = ref<KeyBinding[]>([])
// 获取启用的快捷键
const enabledKeyBindings = computed(() =>
keyBindings.value.filter(kb => kb.enabled)
)
// 按扩展分组的快捷键
const keyBindingsByExtension = computed(() => {
const groups = new Map<ExtensionID, KeyBinding[]>()
for (const binding of keyBindings.value) {
if (!groups.has(binding.extension)) {
groups.set(binding.extension, [])
}
groups.get(binding.extension)!.push(binding)
}
return groups
})
// 获取指定扩展的快捷键
const getKeyBindingsByExtension = computed(() =>
(extension: ExtensionID) =>
keyBindings.value.filter(kb => kb.extension === extension)
)
// 按命令获取快捷键
const getKeyBindingByCommand = computed(() =>
(command: KeyBindingCommand) =>
keyBindings.value.find(kb => kb.command === command)
)
/**
* 从后端加载快捷键配置
*/
const loadKeyBindings = async (): Promise<void> => {
try {
keyBindings.value = await GetAllKeyBindings()
} catch (err) {
throw err
}
}
/**
* 检查是否存在指定命令的快捷键
*/
const hasCommand = (command: KeyBindingCommand): boolean => {
return keyBindings.value.some(kb => kb.command === command && kb.enabled)
}
/**
* 获取扩展相关的所有扩展ID
*/
const getAllExtensionIds = computed(() => {
const extensionIds = new Set<ExtensionID>()
for (const binding of keyBindings.value) {
extensionIds.add(binding.extension)
}
return Array.from(extensionIds)
})
return {
// 状态
keyBindings,
enabledKeyBindings,
keyBindingsByExtension,
getAllExtensionIds,
// 计算属性
getKeyBindingByCommand,
getKeyBindingsByExtension,
// 方法
loadKeyBindings,
hasCommand,
}
})

View File

@@ -1,166 +0,0 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
// 日志级别定义
export enum LogLevel {
INFO = 'info',
WARNING = 'warning',
ERROR = 'error'
}
// 日志项结构
export interface LogItem {
id: number;
level: LogLevel;
message: string;
timestamp: Date;
shown: boolean; // 是否已显示过
}
export const useLogStore = defineStore('log', () => {
// 日志列表
const logs = ref<LogItem[]>([]);
// 显示队列 - 存储待显示的日志
const displayQueue = ref<LogItem[]>([]);
// 当前显示的日志
const currentLog = ref<LogItem | null>(null);
// 最近一条日志,用于在工具栏显示
const latestLog = ref<LogItem | null>(null);
// 是否显示日志
const showLog = ref(false);
// 自动隐藏计时器
let hideTimer: number | null = null;
// 根据日志级别获取显示时间
function getDisplayTimeByLevel(level: LogLevel): number {
switch(level) {
case LogLevel.ERROR:
return 8000; // 错误显示更长时间
case LogLevel.WARNING:
return 6000; // 警告显示中等时间
case LogLevel.INFO:
default:
return 4000; // 信息显示较短时间
}
}
// 显示下一条日志
function showNextLog() {
if (hideTimer) {
window.clearTimeout(hideTimer);
hideTimer = null;
}
// 如果队列为空,则不显示
if (displayQueue.value.length === 0) {
showLog.value = false;
currentLog.value = null;
return;
}
// 取出队列中第一条日志显示
const nextLog = displayQueue.value.shift()!;
currentLog.value = nextLog;
showLog.value = true;
// 设置自动隐藏和切换到下一条
const displayTime = getDisplayTimeByLevel(nextLog.level);
hideTimer = window.setTimeout(() => {
showNextLog();
}, displayTime);
}
// 添加日志
function addLog(level: LogLevel, message: string, autoHideDelay = 0) {
const id = Date.now();
const logItem: LogItem = {
id,
level,
message,
timestamp: new Date(),
shown: false
};
// 添加到日志列表
logs.value.push(logItem);
// 保持日志列表在合理大小
if (logs.value.length > 100) {
logs.value = logs.value.slice(-100);
}
// 设置最新日志
latestLog.value = logItem;
// 添加到显示队列
displayQueue.value.push(logItem);
// 如果当前没有显示日志,则开始显示
if (!currentLog.value && !hideTimer) {
showNextLog();
}
return id;
}
// 添加不同级别的日志的便捷方法
function info(message: string) {
return addLog(LogLevel.INFO, message);
}
function warning(message: string) {
return addLog(LogLevel.WARNING, message);
}
function error(message: string) {
return addLog(LogLevel.ERROR, message);
}
// 清除日志
function clearLogs() {
logs.value = [];
displayQueue.value = [];
latestLog.value = null;
currentLog.value = null;
showLog.value = false;
if (hideTimer) {
window.clearTimeout(hideTimer);
hideTimer = null;
}
}
// 手动隐藏当前日志
function hideCurrentLog() {
if (hideTimer) {
window.clearTimeout(hideTimer);
hideTimer = null;
}
showLog.value = false;
currentLog.value = null;
displayQueue.value = []; // 清空队列
}
return {
// 状态
logs,
latestLog,
currentLog,
showLog,
displayQueue,
// 方法
addLog,
info,
warning,
error,
clearLogs,
hideCurrentLog,
showNextLog
};
});

View File

@@ -0,0 +1,66 @@
import {defineStore} from 'pinia';
import {computed, ref} from 'vue';
import * as runtime from '@wailsio/runtime';
export interface SystemEnvironment {
OS: string;
Arch: string;
Debug: boolean;
OSInfo: {
Name: string;
Branding: string;
Version: string;
ID: string;
};
PlatformInfo?: Record<string, string>;
}
export const useSystemStore = defineStore('system', () => {
// 状态
const environment = ref<SystemEnvironment | null>(null);
const isLoading = ref(false);
// 计算属性
const isWindows = computed(() => environment.value?.OS === 'windows');
const isMacOS = computed(() => environment.value?.OS === 'darwin');
const isLinux = computed(() => environment.value?.OS === 'linux');
// 获取标题栏高度
const titleBarHeight = computed(() => {
if (isWindows.value) return '32px';
if (isMacOS.value) return '28px';
return '34px'; // Linux 默认
});
// 初始化系统信息
const initializeSystemInfo = async (): Promise<void> => {
if (isLoading.value) return;
isLoading.value = true;
try {
environment.value = await runtime.System.Environment();
} catch (err) {
environment.value = null;
} finally {
isLoading.value = false;
}
};
return {
// 状态
environment,
isLoading,
// 计算属性
isWindows,
isMacOS,
isLinux,
titleBarHeight,
// 方法
initializeSystemInfo,
};
});

View File

@@ -0,0 +1,145 @@
import {defineStore} from 'pinia';
import {computed, reactive} from 'vue';
import {SystemThemeType} from '@/../bindings/voidraft/internal/models/models';
import {useConfigStore} from './configStore';
import {useEditorStore} from './editorStore';
import {defaultDarkColors} from '@/views/editor/theme/dark';
import {defaultLightColors} from '@/views/editor/theme/light';
/**
* 主题管理 Store
* 职责:管理主题状态和颜色配置
*/
export const useThemeStore = defineStore('theme', () => {
const configStore = useConfigStore();
// 响应式状态 - 存储当前使用的主题颜色
const themeColors = reactive({
darkTheme: { ...defaultDarkColors },
lightTheme: { ...defaultLightColors }
});
// 计算属性 - 当前选择的主题类型
const currentTheme = computed(() =>
configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto
);
// 初始化主题颜色 - 从配置加载
const initializeThemeColors = () => {
const customTheme = configStore.config?.appearance?.customTheme;
if (customTheme) {
if (customTheme.darkTheme) {
Object.assign(themeColors.darkTheme, customTheme.darkTheme);
}
if (customTheme.lightTheme) {
Object.assign(themeColors.lightTheme, customTheme.lightTheme);
}
}
};
// 应用主题到 DOM
const applyThemeToDOM = (theme: SystemThemeType) => {
document.documentElement.setAttribute('data-theme',
theme === SystemThemeType.SystemThemeAuto ? 'auto' :
theme === SystemThemeType.SystemThemeDark ? 'dark' : 'light'
);
};
// 初始化主题
const initializeTheme = () => {
const theme = configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto;
applyThemeToDOM(theme);
initializeThemeColors();
};
// 设置主题
const setTheme = async (theme: SystemThemeType) => {
await configStore.setSystemTheme(theme);
applyThemeToDOM(theme);
refreshEditorTheme();
};
// 更新主题颜色
const updateThemeColors = (darkColors: any = null, lightColors: any = null): boolean => {
let hasChanges = false;
if (darkColors) {
Object.entries(darkColors).forEach(([key, value]) => {
if (value !== undefined && themeColors.darkTheme[key] !== value) {
themeColors.darkTheme[key] = value;
hasChanges = true;
}
});
}
if (lightColors) {
Object.entries(lightColors).forEach(([key, value]) => {
if (value !== undefined && themeColors.lightTheme[key] !== value) {
themeColors.lightTheme[key] = value;
hasChanges = true;
}
});
}
return hasChanges;
};
// 保存主题颜色到配置
const saveThemeColors = async () => {
const customTheme = {
darkTheme: { ...themeColors.darkTheme },
lightTheme: { ...themeColors.lightTheme }
};
await configStore.setCustomTheme(customTheme);
};
// 重置主题颜色
const resetThemeColors = async (themeType: 'darkTheme' | 'lightTheme') => {
try {
// 1. 更新内存中的颜色状态
if (themeType === 'darkTheme') {
Object.assign(themeColors.darkTheme, defaultDarkColors);
}
if (themeType === 'lightTheme') {
Object.assign(themeColors.lightTheme, defaultLightColors);
}
// 2. 保存到配置
await saveThemeColors();
// 3. 刷新编辑器主题
refreshEditorTheme();
return true;
} catch (error) {
console.error('Failed to reset theme colors:', error);
return false;
}
};
// 刷新编辑器主题(在主题颜色更改后调用)
const refreshEditorTheme = () => {
// 使用当前主题重新应用DOM主题
const theme = currentTheme.value;
applyThemeToDOM(theme);
const editorStore = useEditorStore();
if (editorStore) {
editorStore.applyThemeSettings();
}
};
return {
currentTheme,
themeColors,
setTheme,
initializeTheme,
applyThemeToDOM,
updateThemeColors,
saveThemeColors,
resetThemeColors,
refreshEditorTheme
};
});

View File

@@ -0,0 +1,594 @@
import {defineStore} from 'pinia';
import {computed, ref, watch} from 'vue';
import {TranslationService} from '@/../bindings/voidraft/internal/services';
import {franc} from 'franc-min';
export interface TranslationResult {
sourceText: string;
translatedText: string;
sourceLang: string;
targetLang: string;
translatorType: string;
error?: string;
}
/**
* ISO 639-3 到 ISO 639-1/2 语言代码的映射
* franc-min 返回的是 ISO 639-3 代码需要转换为翻译API常用的 ISO 639-1/2 代码
*/
const ISO_LANGUAGE_MAP: Record<string, string> = {
// 常见语言
'cmn': 'zh', // 中文 (Mandarin Chinese)
'eng': 'en', // 英文 (English)
'jpn': 'ja', // 日语 (Japanese)
'kor': 'ko', // 韩语 (Korean)
'fra': 'fr', // 法语 (French)
'deu': 'de', // 德语 (German)
'spa': 'es', // 西班牙语 (Spanish)
'rus': 'ru', // 俄语 (Russian)
'ita': 'it', // 意大利语 (Italian)
'nld': 'nl', // 荷兰语 (Dutch)
'por': 'pt', // 葡萄牙语 (Portuguese)
'vie': 'vi', // 越南语 (Vietnamese)
'arb': 'ar', // 阿拉伯语 (Arabic)
'hin': 'hi', // 印地语 (Hindi)
'ben': 'bn', // 孟加拉语 (Bengali)
'tha': 'th', // 泰语 (Thai)
'tur': 'tr', // 土耳其语 (Turkish)
'heb': 'he', // 希伯来语 (Hebrew)
'pol': 'pl', // 波兰语 (Polish)
'swe': 'sv', // 瑞典语 (Swedish)
'fin': 'fi', // 芬兰语 (Finnish)
'dan': 'da', // 丹麦语 (Danish)
'ron': 'ro', // 罗马尼亚语 (Romanian)
'hun': 'hu', // 匈牙利语 (Hungarian)
'ces': 'cs', // 捷克语 (Czech)
'ell': 'el', // 希腊语 (Greek)
'bul': 'bg', // 保加利亚语 (Bulgarian)
'cat': 'ca', // 加泰罗尼亚语 (Catalan)
'ukr': 'uk', // 乌克兰语 (Ukrainian)
'hrv': 'hr', // 克罗地亚语 (Croatian)
'ind': 'id', // 印尼语 (Indonesian)
'mal': 'ms', // 马来语 (Malay)
'nob': 'no', // 挪威语 (Norwegian)
'lat': 'la', // 拉丁语 (Latin)
'lit': 'lt', // 立陶宛语 (Lithuanian)
'slk': 'sk', // 斯洛伐克语 (Slovak)
'slv': 'sl', // 斯洛文尼亚语 (Slovenian)
'srp': 'sr', // 塞尔维亚语 (Serbian)
'est': 'et', // 爱沙尼亚语 (Estonian)
'lav': 'lv', // 拉脱维亚语 (Latvian)
'fil': 'tl', // 菲律宾语/他加禄语 (Filipino/Tagalog)
// 未知/不确定
'und': 'auto' // 未知语言
};
// 语言代码的通用映射关系,适用于大部分翻译器
const COMMON_LANGUAGE_ALIASES: Record<string, string[]> = {
'zh': ['zh-CN', 'zh-TW', 'zh-Hans', 'zh-Hant', 'chinese', 'zhong'],
'en': ['en-US', 'en-GB', 'english', 'eng'],
'ja': ['jp', 'jpn', 'japanese'],
'ko': ['kr', 'kor', 'korean'],
'fr': ['fra', 'french'],
'de': ['deu', 'german', 'ger'],
'es': ['spa', 'spanish', 'esp'],
'ru': ['rus', 'russian'],
'pt': ['por', 'portuguese'],
'it': ['ita', 'italian'],
'nl': ['nld', 'dutch'],
'ar': ['ara', 'arabic'],
'hi': ['hin', 'hindi'],
'th': ['tha', 'thai'],
'tr': ['tur', 'turkish'],
'vi': ['vie', 'vietnamese'],
'id': ['ind', 'indonesian'],
'ms': ['mal', 'malay'],
'fi': ['fin', 'finnish'],
};
/**
* 翻译存储
*/
export const useTranslationStore = defineStore('translation', () => {
// 状态
const availableTranslators = ref<string[]>([]);
const isTranslating = ref(false);
const lastResult = ref<TranslationResult | null>(null);
const error = ref<string | null>(null);
// 语言列表 - 将类型设置为any以避免类型错误
const languageMaps = ref<Record<string, Record<string, any>>>({});
// 语言使用频率计数 - 使用pinia持久化
const languageUsageCount = ref<Record<string, number>>({});
// 最近使用的翻译语言 - 最多记录10个
const recentLanguages = ref<string[]>([]);
// 默认配置
// 注意:确保默认值在初始化和持久化后正确设置
const defaultTargetLang = ref('zh');
const defaultTranslator = ref('bing');
// 检测到的源语言,初始为空字符串表示尚未检测
const detectedSourceLang = ref('');
// 计算属性
const hasTranslators = computed(() => availableTranslators.value.length > 0);
const currentLanguageMap = computed(() => {
return languageMaps.value[defaultTranslator.value] || {};
});
// 监听默认语言变更,确保目标语言在当前翻译器支持的范围内
watch([defaultTranslator], () => {
// 当切换翻译器时,验证默认目标语言是否支持
if (Object.keys(languageMaps.value).length > 0) {
const validatedLang = validateLanguage(defaultTargetLang.value, defaultTranslator.value);
if (validatedLang !== defaultTargetLang.value) {
console.log(`目标语言 ${defaultTargetLang.value} 不受支持,已切换到 ${validatedLang}`);
defaultTargetLang.value = validatedLang;
}
}
});
/**
* 加载可用翻译器
*/
const loadAvailableTranslators = async (): Promise<void> => {
try {
const translators = await TranslationService.GetAvailableTranslators();
availableTranslators.value = translators;
// 如果默认翻译器不在可用列表中,则使用第一个可用的翻译器
if (translators.length > 0 && !translators.includes(defaultTranslator.value)) {
defaultTranslator.value = translators[0];
}
// 加载所有翻译器的语言列表
await Promise.all(translators.map(loadTranslatorLanguages));
// 在加载完所有语言列表后,确保默认目标语言有效
if (defaultTargetLang.value) {
const validatedLang = validateLanguage(defaultTargetLang.value, defaultTranslator.value);
if (validatedLang !== defaultTargetLang.value) {
console.log(`目标语言 ${defaultTargetLang.value} 不受支持,已切换到 ${validatedLang}`);
defaultTargetLang.value = validatedLang;
}
}
} catch (err) {
error.value = 'no available translators';
}
};
/**
* 加载指定翻译器的语言列表
* @param translatorType 翻译器类型
*/
const loadTranslatorLanguages = async (translatorType: string): Promise<void> => {
try {
const languages = await TranslationService.GetTranslatorLanguages(translatorType as any);
if (languages) {
languageMaps.value[translatorType] = languages;
}
} catch (err) {
console.error(`Failed to load languages for ${translatorType}:`, err);
}
};
/**
* 检测文本语言
* @param text 待检测的文本
* @returns 检测到的语言代码,如未检测到则返回空字符串
*/
const detectLanguage = (text: string): string => {
if (!text || text.trim().length < 10) {
return '';
}
try {
// franc返回ISO 639-3代码
const detectedIso639_3 = franc(text);
// 如果是未知语言,返回空字符串
if (detectedIso639_3 === 'und') {
return '';
}
// 转换为常用语言代码
return ISO_LANGUAGE_MAP[detectedIso639_3] || '';
} catch (err) {
console.error('语言检测失败:', err);
return '';
}
};
/**
* 在翻译器语言列表中查找相似的语言代码
* @param langCode 待查找的语言代码
* @param translatorType 翻译器类型
* @returns 找到的语言代码或空字符串
*/
const findSimilarLanguage = (langCode: string, translatorType: string): string => {
if (!langCode) return '';
const languageMap = languageMaps.value[translatorType] || {};
const langCodeLower = langCode.toLowerCase();
// 1. 尝试精确匹配
if (languageMap[langCode]) {
return langCode;
}
// 2. 检查通用别名映射
const possibleAliases = Object.entries(COMMON_LANGUAGE_ALIASES).find(
([code, aliases]) => code === langCodeLower || aliases.includes(langCodeLower)
);
if (possibleAliases) {
// 检查主代码是否可用
const [mainCode, aliases] = possibleAliases;
if (languageMap[mainCode]) {
return mainCode;
}
// 检查别名是否可用
for (const alias of aliases) {
if (languageMap[alias]) {
return alias;
}
}
}
// 3. 尝试正则表达式匹配
// 创建一个基于语言代码的正则表达式:例如 'en' 会匹配 'en-US', 'en_GB' 等
const codePattern = new RegExp(`^${langCodeLower}[-_]?`, 'i');
// 在语言列表中查找匹配的语言代码
const availableCodes = Object.keys(languageMap);
const matchedCode = availableCodes.find(code =>
codePattern.test(code.toLowerCase())
);
if (matchedCode) {
return matchedCode;
}
// 4. 反向匹配,例如 'zh-CN' 应该能匹配到 'zh'
if (langCodeLower.includes('-') || langCodeLower.includes('_')) {
const baseCode = langCodeLower.split(/[-_]/)[0];
if (languageMap[baseCode]) {
return baseCode;
}
// 通过基础代码查找别名
const baseCodeAliases = Object.entries(COMMON_LANGUAGE_ALIASES).find(
([code, aliases]) => code === baseCode || aliases.includes(baseCode)
);
if (baseCodeAliases) {
const [mainCode, aliases] = baseCodeAliases;
if (languageMap[mainCode]) {
return mainCode;
}
for (const alias of aliases) {
if (languageMap[alias]) {
return alias;
}
}
}
}
// 5. 最后尝试查找与部分代码匹配的任何语言
const partialMatch = availableCodes.find(code =>
code.toLowerCase().includes(langCodeLower) ||
langCodeLower.includes(code.toLowerCase())
);
if (partialMatch) {
return partialMatch;
}
// 如果所有匹配都失败,返回英语作为默认值
return 'en';
};
/**
* 验证语言代码是否受当前翻译器支持
* @param langCode 语言代码
* @param translatorType 翻译器类型(可选,默认使用当前翻译器)
* @returns 验证后的语言代码
*/
const validateLanguage = (langCode: string, translatorType?: string): string => {
// 如果语言代码为空返回auto作为API调用的默认值
if (!langCode) return 'auto';
const currentType = translatorType || defaultTranslator.value;
// 尝试在指定翻译器的语言列表中查找相似的语言代码
return findSimilarLanguage(langCode, currentType) || 'auto';
};
/**
* 增加语言使用次数并添加到最近使用列表
* @param langCode 语言代码
* @param weight 权重默认为1
*/
const incrementLanguageUsage = (langCode: string, weight: number = 1): void => {
if (!langCode || langCode === 'auto') return;
// 转换为小写,确保一致性
const normalizedCode = langCode.toLowerCase();
// 更新使用次数,乘以权重
const currentCount = languageUsageCount.value[normalizedCode] || 0;
languageUsageCount.value[normalizedCode] = currentCount + weight;
// 更新最近使用的语言列表
updateRecentLanguages(normalizedCode);
};
/**
* 更新最近使用的语言列表
* @param langCode 语言代码
*/
const updateRecentLanguages = (langCode: string): void => {
if (!langCode) return;
// 如果已经在列表中,先移除它
const index = recentLanguages.value.indexOf(langCode);
if (index !== -1) {
recentLanguages.value.splice(index, 1);
}
// 添加到列表开头
recentLanguages.value.unshift(langCode);
// 保持列表最多10个元素
if (recentLanguages.value.length > 10) {
recentLanguages.value = recentLanguages.value.slice(0, 10);
}
};
/**
* 获取按使用频率排序的语言列表
* @param translatorType 翻译器类型
* @param grouped 是否分组返回(常用/其他)
* @returns 排序后的语言列表或分组后的语言列表
*/
const getSortedLanguages = (translatorType: string, grouped: boolean = false): [string, any][] | {frequent: [string, any][], others: [string, any][]} => {
const languageMap = languageMaps.value[translatorType] || {};
// 获取语言列表
const languages = Object.entries(languageMap);
// 按使用频率排序
const sortedLanguages = languages.sort(([codeA, infoA], [codeB, infoB]) => {
// 获取使用次数默认为0
const countA = languageUsageCount.value[codeA.toLowerCase()] || 0;
const countB = languageUsageCount.value[codeB.toLowerCase()] || 0;
// 首先按使用频率降序排序
if (countB !== countA) {
return countB - countA;
}
// 其次按最近使用情况排序
const recentIndexA = recentLanguages.value.indexOf(codeA.toLowerCase());
const recentIndexB = recentLanguages.value.indexOf(codeB.toLowerCase());
if (recentIndexA !== -1 && recentIndexB !== -1) {
return recentIndexA - recentIndexB;
} else if (recentIndexA !== -1) {
return -1;
} else if (recentIndexB !== -1) {
return 1;
}
// 如果使用频率和最近使用情况都相同,按名称排序
const nameA = infoA.Name || infoA.name || codeA;
const nameB = infoB.Name || infoB.name || codeB;
return nameA.localeCompare(nameB);
});
// 如果不需要分组,直接返回排序后的列表
if (!grouped) {
return sortedLanguages;
}
// 分组:将有使用记录的语言归为常用组,其他归为其他组
const frequentLanguages: [string, any][] = [];
const otherLanguages: [string, any][] = [];
sortedLanguages.forEach(lang => {
const [code] = lang;
const usageCount = languageUsageCount.value[code.toLowerCase()] || 0;
const isInRecent = recentLanguages.value.includes(code.toLowerCase());
if (usageCount > 0 || isInRecent) {
frequentLanguages.push(lang);
} else {
otherLanguages.push(lang);
}
});
return {
frequent: frequentLanguages,
others: otherLanguages
};
};
/**
* 翻译文本
* @param text 待翻译文本
* @param to 目标语言代码
* @param translatorType 翻译器类型
* @returns 翻译结果
*/
const translateText = async (
text: string,
to?: string,
translatorType?: string
): Promise<TranslationResult> => {
// 使用提供的参数或默认值
const targetLang = to || defaultTargetLang.value;
const translator = translatorType || defaultTranslator.value;
// 处理空文本
if (!text) {
return {
sourceText: '',
translatedText: '',
sourceLang: '',
targetLang: targetLang,
translatorType: translator,
error: 'no text to translate'
};
}
// 检测源语言
const detected = detectLanguage(text);
if (detected) {
detectedSourceLang.value = detected;
}
// 使用检测到的语言或回退到auto
let actualSourceLang = detectedSourceLang.value || 'auto';
// 确认语言代码有效并针对当前翻译器进行匹配
actualSourceLang = validateLanguage(actualSourceLang, translator);
const actualTargetLang = validateLanguage(targetLang, translator);
// 如果源语言和目标语言相同,则直接返回原文
if (actualSourceLang !== 'auto' && actualSourceLang === actualTargetLang) {
return {
sourceText: text,
translatedText: text,
sourceLang: actualSourceLang,
targetLang: actualTargetLang,
translatorType: translator
};
}
isTranslating.value = true;
error.value = null;
try {
console.log(`翻译文本: 从 ${actualSourceLang}${actualTargetLang} 使用 ${translator} 翻译器`);
// 调用翻译服务
const translatedText = await TranslationService.TranslateWith(
text,
actualSourceLang,
actualTargetLang,
translator
);
// 增加目标语言的使用频率,使用较大的权重
incrementLanguageUsage(actualTargetLang, 3);
// 如果源语言不是auto也记录其使用情况但权重较小
if (actualSourceLang !== 'auto') {
incrementLanguageUsage(actualSourceLang, 1);
}
// 构建结果
const result: TranslationResult = {
sourceText: text,
translatedText,
sourceLang: actualSourceLang,
targetLang: actualTargetLang,
translatorType: translator
};
lastResult.value = result;
return result;
} catch (err) {
// 处理错误
const errorMessage = err instanceof Error ? err.message : 'translation failed';
error.value = errorMessage;
const result: TranslationResult = {
sourceText: text,
translatedText: '',
sourceLang: actualSourceLang,
targetLang: actualTargetLang,
translatorType: translator,
error: errorMessage
};
lastResult.value = result;
return result;
} finally {
isTranslating.value = false;
}
};
/**
* 设置默认翻译配置
* @param config 配置对象
*/
const setDefaultConfig = (config: {
targetLang?: string;
translatorType?: string;
}): void => {
let changed = false;
if (config.translatorType && config.translatorType !== defaultTranslator.value) {
defaultTranslator.value = config.translatorType;
// 切换翻译器时清空检测到的源语言,以便重新检测
detectedSourceLang.value = '';
changed = true;
}
if (config.targetLang) {
// 验证目标语言是否支持
const validLang = validateLanguage(config.targetLang, defaultTranslator.value);
defaultTargetLang.value = validLang;
changed = true;
}
if (changed) {
console.log(`已更新默认翻译配置:翻译器=${defaultTranslator.value},目标语言=${defaultTargetLang.value}`);
}
};
// 初始化时加载可用翻译器
loadAvailableTranslators();
return {
// 状态
availableTranslators,
isTranslating,
lastResult,
error,
detectedSourceLang,
defaultTargetLang,
defaultTranslator,
languageMaps,
languageUsageCount,
recentLanguages,
// 计算属性
hasTranslators,
currentLanguageMap,
// 方法
loadAvailableTranslators,
loadTranslatorLanguages,
translateText,
setDefaultConfig,
detectLanguage,
validateLanguage,
findSimilarLanguage,
getSortedLanguages,
incrementLanguageUsage
};
}, {
persist: {
key: 'voidraft-translation',
storage: localStorage,
pick: ['languageUsageCount', 'defaultTargetLang', 'defaultTranslator', 'recentLanguages']
}
});

View File

@@ -0,0 +1,135 @@
import {defineStore} from 'pinia'
import {computed, ref} from 'vue'
import {CheckForUpdates, ApplyUpdate, RestartApplication} from '@/../bindings/voidraft/internal/services/selfupdateservice'
import {SelfUpdateResult} from '@/../bindings/voidraft/internal/services/models'
import {useConfigStore} from './configStore'
import * as runtime from "@wailsio/runtime"
export const useUpdateStore = defineStore('update', () => {
// 状态
const isChecking = ref(false)
const isUpdating = ref(false)
const updateResult = ref<SelfUpdateResult | null>(null)
const hasCheckedOnStartup = ref(false)
const updateSuccess = ref(false)
const errorMessage = ref('')
// 计算属性
const hasUpdate = computed(() => updateResult.value?.hasUpdate || false)
// 检查更新
const checkForUpdates = async (): Promise<boolean> => {
if (isChecking.value) return false
// 重置错误信息
errorMessage.value = ''
isChecking.value = true
try {
const result = await CheckForUpdates()
if (result) {
updateResult.value = result
if (result.error) {
errorMessage.value = result.error
return false
}
return true
}
return false
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : 'Network error'
return false
} finally {
isChecking.value = false
}
}
// 应用更新
const applyUpdate = async (): Promise<boolean> => {
if (isUpdating.value) return false
// 重置错误信息
errorMessage.value = ''
isUpdating.value = true
try {
const result = await ApplyUpdate()
if (result) {
updateResult.value = result
if (result.error) {
errorMessage.value = result.error
return false
}
if (result.updateApplied) {
updateSuccess.value = true
return true
}
}
return false
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : 'Update failed'
return false
} finally {
isUpdating.value = false
}
}
// 重启应用
const restartApplication = async (): Promise<boolean> => {
try {
await RestartApplication()
return true
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : 'Restart failed'
return false
}
}
// 启动时检查更新
const checkOnStartup = async () => {
if (hasCheckedOnStartup.value) return
const configStore = useConfigStore()
if (configStore.config.updates.autoUpdate) {
await checkForUpdates()
}
hasCheckedOnStartup.value = true
}
// 打开发布页面
const openReleaseURL = async () => {
if (updateResult.value?.assetURL) {
await runtime.Browser.OpenURL(updateResult.value.assetURL)
}
}
// 重置状态
const reset = () => {
updateResult.value = null
isChecking.value = false
isUpdating.value = false
updateSuccess.value = false
errorMessage.value = ''
}
return {
// 状态
isChecking,
isUpdating,
updateResult,
hasCheckedOnStartup,
updateSuccess,
errorMessage,
// 计算属性
hasUpdate,
// 方法
checkForUpdates,
applyUpdate,
restartApplication,
checkOnStartup,
openReleaseURL,
reset
}
})

View File

@@ -0,0 +1,31 @@
import {computed} from 'vue';
import {defineStore} from 'pinia';
import {IsDocumentWindowOpen} from "@/../bindings/voidraft/internal/services/windowservice";
export const useWindowStore = defineStore('window', () => {
// 判断是否为主窗口
const isMainWindow = computed(() => {
const urlParams = new URLSearchParams(window.location.search);
return !urlParams.has('documentId');
});
// 获取当前窗口的documentId
const currentDocumentId = computed(() => {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('documentId');
});
/**
* 判断文档窗口是否打开
* @param documentId 文档ID
*/
async function isDocumentWindowOpen(documentId: number) {
return IsDocumentWindowOpen(documentId);
}
return {
isMainWindow,
currentDocumentId,
isDocumentWindowOpen
};
});

View File

@@ -1,5 +0,0 @@
export interface DocumentStats {
lines: number;
characters: number;
selectedCharacters: number;
}

View File

@@ -1,4 +0,0 @@
// 统一类型导出
export type { ThemeType, LanguageType, SystemThemeType } from '@/../bindings/voidraft/internal/models';
export * from './theme';
export * from './editor';

View File

@@ -1,204 +0,0 @@
import type { ThemeType } from '@/../bindings/voidraft/internal/models';
// 主题配置信息
export interface ThemeInfo {
id: ThemeType;
name: string;
displayName: string;
isDark: boolean;
previewColors: {
background: string;
foreground: string;
keyword: string;
string: string;
function: string;
comment: string;
};
}
// 可用主题列表
export const AVAILABLE_THEMES: ThemeInfo[] = [
{
id: 'default-dark' as ThemeType,
name: 'default-dark',
displayName: '深色默认',
isDark: true,
previewColors: {
background: '#252B37',
foreground: '#9BB586',
keyword: '#FF79C6',
string: '#F1FA8C',
function: '#50FA7B',
comment: '#6272A4'
}
},
{
id: 'dracula' as ThemeType,
name: 'dracula',
displayName: 'Dracula',
isDark: true,
previewColors: {
background: '#282A36',
foreground: '#F8F8F2',
keyword: '#FF79C6',
string: '#F1FA8C',
function: '#50FA7B',
comment: '#6272A4'
}
},
{
id: 'aura' as ThemeType,
name: 'aura',
displayName: 'Aura',
isDark: true,
previewColors: {
background: '#21202e',
foreground: '#edecee',
keyword: '#a277ff',
string: '#61ffca',
function: '#ffca85',
comment: '#6d6d6d'
}
},
{
id: 'github-dark' as ThemeType,
name: 'github-dark',
displayName: 'GitHub 深色',
isDark: true,
previewColors: {
background: '#24292e',
foreground: '#d1d5da',
keyword: '#f97583',
string: '#9ecbff',
function: '#79b8ff',
comment: '#6a737d'
}
},
{
id: 'github-light' as ThemeType,
name: 'github-light',
displayName: 'GitHub 浅色',
isDark: false,
previewColors: {
background: '#fff',
foreground: '#444d56',
keyword: '#d73a49',
string: '#032f62',
function: '#005cc5',
comment: '#6a737d'
}
},
{
id: 'material-dark' as ThemeType,
name: 'material-dark',
displayName: 'Material 深色',
isDark: true,
previewColors: {
background: '#263238',
foreground: '#EEFFFF',
keyword: '#C792EA',
string: '#C3E88D',
function: '#82AAFF',
comment: '#546E7A'
}
},
{
id: 'material-light' as ThemeType,
name: 'material-light',
displayName: 'Material 浅色',
isDark: false,
previewColors: {
background: '#FAFAFA',
foreground: '#90A4AE',
keyword: '#7C4DFF',
string: '#91B859',
function: '#6182B8',
comment: '#90A4AE'
}
},
{
id: 'solarized-dark' as ThemeType,
name: 'solarized-dark',
displayName: 'Solarized 深色',
isDark: true,
previewColors: {
background: '#002B36',
foreground: '#93A1A1',
keyword: '#859900',
string: '#2AA198',
function: '#268BD2',
comment: '#586E75'
}
},
{
id: 'solarized-light' as ThemeType,
name: 'solarized-light',
displayName: 'Solarized 浅色',
isDark: false,
previewColors: {
background: '#FDF6E3',
foreground: '#586E75',
keyword: '#859900',
string: '#2AA198',
function: '#268BD2',
comment: '#93A1A1'
}
},
{
id: 'tokyo-night' as ThemeType,
name: 'tokyo-night',
displayName: 'Tokyo Night',
isDark: true,
previewColors: {
background: '#1a1b26',
foreground: '#787c99',
keyword: '#bb9af7',
string: '#9ece6a',
function: '#7aa2f7',
comment: '#444b6a'
}
},
{
id: 'tokyo-night-storm' as ThemeType,
name: 'tokyo-night-storm',
displayName: 'Tokyo Night Storm',
isDark: true,
previewColors: {
background: '#24283b',
foreground: '#7982a9',
keyword: '#bb9af7',
string: '#9ece6a',
function: '#7aa2f7',
comment: '#565f89'
}
},
{
id: 'tokyo-night-day' as ThemeType,
name: 'tokyo-night-day',
displayName: 'Tokyo Night Day',
isDark: false,
previewColors: {
background: '#e1e2e7',
foreground: '#6a6f8e',
keyword: '#9854f1',
string: '#587539',
function: '#2e7de9',
comment: '#9da3c2'
}
}
];
// 根据主题ID获取主题信息
export function getThemeInfo(themeId: ThemeType): ThemeInfo | undefined {
return AVAILABLE_THEMES.find(theme => theme.id === themeId);
}
// 获取所有深色主题
export function getDarkThemes(): ThemeInfo[] {
return AVAILABLE_THEMES.filter(theme => theme.isDark);
}
// 获取所有浅色主题
export function getLightThemes(): ThemeInfo[] {
return AVAILABLE_THEMES.filter(theme => !theme.isDark);
}

View File

@@ -1,97 +0,0 @@
import { useLogStore } from '@/stores/logStore';
import { useI18n } from 'vue-i18n';
/**
* 创建组合式函数,用于在组件中使用错误处理
*/
export function useErrorHandler() {
const logStore = useLogStore();
const { t } = useI18n();
const handleError = (error: unknown, messageKey: string) => {
logStore.error(t(messageKey));
};
const safeCall = async <T>(
operation: () => Promise<T>,
errorMessageKey: string,
successMessageKey?: string
): Promise<T | null> => {
try {
const result = await operation();
if (successMessageKey) {
logStore.info(t(successMessageKey));
}
return result;
} catch (error) {
logStore.error(t(errorMessageKey));
return null;
}
};
/**
* 静默处理错误,不显示用户提示
*/
const silentCall = async <T>(
operation: () => Promise<T>
): Promise<T | null> => {
try {
return await operation();
} catch (error) {
// 静默忽略错误
return null;
}
};
/**
* 创建带错误处理的数值调整器
*/
const createSafeAdjuster = (
adjustFn: () => Promise<void>,
errorMessageKey: string
) => () => safeCall(adjustFn, errorMessageKey);
return {
handleError,
safeCall,
silentCall,
createSafeAdjuster
};
}
/**
* 错误处理工具函数集合不依赖Vue上下文
*/
export const ErrorUtils = {
/**
* 静默处理错误
*/
silent: async <T>(operation: () => Promise<T>): Promise<T | null> => {
try {
return await operation();
} catch (error) {
return null;
}
},
/**
* 带重试的错误处理
*/
withRetry: async <T>(
operation: () => Promise<T>,
maxRetries: number = 3,
delay: number = 1000
): Promise<T | null> => {
for (let i = 0; i < maxRetries; i++) {
try {
return await operation();
} catch (error) {
if (i === maxRetries - 1) {
return null;
}
await new Promise(resolve => setTimeout(resolve, delay));
}
}
return null;
}
};

View File

@@ -1,243 +1,55 @@
<script setup lang="ts">
import {onBeforeUnmount, onMounted, ref, watch} from 'vue';
import {EditorState, Extension} from '@codemirror/state';
import {EditorView} from '@codemirror/view';
import {onBeforeUnmount, onMounted, ref} from 'vue';
import {useEditorStore} from '@/stores/editorStore';
import {useConfigStore} from '@/stores/configStore';
import {useDocumentStore} from '@/stores/documentStore';
import {useLogStore} from '@/stores/logStore';
import {createBasicSetup} from './extensions/basicSetup';
import {
createStatsUpdateExtension,
createWheelZoomHandler,
getTabExtensions,
updateStats,
updateTabConfig,
createAutoSavePlugin,
createSaveShortcutPlugin,
createFontExtensionFromBackend,
updateFontConfig,
} from './extensions';
import { useEditorTheme } from '@/composables/useEditorTheme';
import { useI18n } from 'vue-i18n';
import type { ThemeType } from '@/types';
import { DocumentService } from '../../../bindings/voidraft/internal/services';
import {useConfigStore} from '@/stores/configStore';
import {createWheelZoomHandler} from './basic/wheelZoomExtension';
import Toolbar from '@/components/toolbar/Toolbar.vue';
import {useWindowStore} from "@/stores/windowStore";
import LoadingScreen from '@/components/loading/LoadingScreen.vue';
const editorStore = useEditorStore();
const configStore = useConfigStore();
const documentStore = useDocumentStore();
const logStore = useLogStore();
const { t } = useI18n();
const { createThemeExtension, updateTheme } = useEditorTheme();
const props = defineProps({
initialDoc: {
type: String,
default: ''
}
});
const configStore = useConfigStore();
const windowStore = useWindowStore();
const editorElement = ref<HTMLElement | null>(null);
const editorCreated = ref(false);
let isDestroying = false;
// 创建编辑
const createEditor = async () => {
if (!editorElement.value || editorCreated.value) return;
editorCreated.value = true;
// 加载文档内容
await documentStore.initialize();
const docContent = documentStore.documentContent || props.initialDoc;
// 获取基本扩展
const basicExtensions = createBasicSetup();
// 获取主题扩展
const themeExtension = await createThemeExtension(
configStore.config.appearance.theme || 'default-dark' as ThemeType
);
// 获取Tab相关扩展
const tabExtensions = getTabExtensions(
configStore.config.editing.tabSize,
configStore.config.editing.enableTabIndent,
configStore.config.editing.tabType
);
// 创建字体扩展
const fontExtension = createFontExtensionFromBackend({
fontFamily: configStore.config.editing.fontFamily,
fontSize: configStore.config.editing.fontSize,
lineHeight: configStore.config.editing.lineHeight,
fontWeight: configStore.config.editing.fontWeight
});
// 创建统计信息更新扩展
const statsExtension = createStatsUpdateExtension(
editorStore.updateDocumentStats
);
// 创建保存快捷键插件
const saveShortcutPlugin = createSaveShortcutPlugin(() => {
if (editorStore.editorView) {
handleManualSave();
}
});
// 创建自动保存插件
const autoSavePlugin = createAutoSavePlugin({
debounceDelay: 300, // 300毫秒的输入防抖
onSave: (success) => {
if (success) {
documentStore.lastSaved = new Date();
}
}
});
// 组合所有扩展
const extensions: Extension[] = [
themeExtension,
...basicExtensions,
...tabExtensions,
fontExtension,
statsExtension,
saveShortcutPlugin,
autoSavePlugin
];
// 创建编辑器状态
const state = EditorState.create({
doc: docContent,
extensions
});
// 创建编辑器视图
const view = new EditorView({
state,
parent: editorElement.value
});
// 将编辑器实例保存到store
editorStore.setEditorView(view);
// 应用初始字体大小
editorStore.applyFontSize();
// 立即更新统计信息
updateStats(view, editorStore.updateDocumentStats);
};
// 创建滚轮事件处理器
const handleWheel = createWheelZoomHandler(
// 创建滚轮缩放处理
const wheelHandler = createWheelZoomHandler(
configStore.increaseFontSize,
configStore.decreaseFontSize
);
// 手动保存文档
const handleManualSave = async () => {
if (!editorStore.editorView || isDestroying) return;
onMounted(async () => {
if (!editorElement.value) return;
// 从URL查询参数中获取documentId
const urlDocumentId = windowStore.currentDocumentId ? parseInt(windowStore.currentDocumentId) : undefined;
const view = editorStore.editorView as EditorView;
const content = view.state.doc.toString();
// 先更新内容
await DocumentService.UpdateActiveDocumentContent(content);
// 然后调用强制保存方法不再传递content参数
const success = await documentStore.forceSaveDocument();
if (success) {
logStore.info(t('document.manualSaveSuccess'));
}
};
// 重新配置编辑器(仅在必要时)
const reconfigureTabSettings = () => {
if (!editorStore.editorView) return;
updateTabConfig(
editorStore.editorView as EditorView,
configStore.config.editing.tabSize,
configStore.config.editing.enableTabIndent,
configStore.config.editing.tabType
);
};
// 重新配置字体设置
const reconfigureFontSettings = () => {
if (!editorStore.editorView) return;
updateFontConfig(editorStore.editorView as EditorView, {
fontFamily: configStore.config.editing.fontFamily,
fontSize: configStore.config.editing.fontSize,
lineHeight: configStore.config.editing.lineHeight,
fontWeight: configStore.config.editing.fontWeight
});
};
// 监听Tab设置变化
watch([
() => configStore.config.editing.tabSize,
() => configStore.config.editing.enableTabIndent,
() => configStore.config.editing.tabType,
], () => {
reconfigureTabSettings();
});
// 监听字体大小变化
watch([
() => configStore.config.editing.fontFamily,
() => configStore.config.editing.fontSize,
() => configStore.config.editing.lineHeight,
() => configStore.config.editing.fontWeight,
], () => {
reconfigureFontSettings();
editorStore.applyFontSize();
});
// 监听主题变化
watch(() => configStore.config.appearance.theme, async (newTheme) => {
if (newTheme && editorStore.editorView) {
await updateTheme(editorStore.editorView as EditorView, newTheme);
}
});
onMounted(() => {
// 创建编辑器
createEditor();
// 初始化文档存储优先使用URL参数中的文档ID
await documentStore.initialize(urlDocumentId);
// 设置编辑器容器
editorStore.setEditorContainer(editorElement.value);
// 添加滚轮事件监听
if (editorElement.value) {
editorElement.value.addEventListener('wheel', handleWheel, {passive: false});
}
// 确保统计信息已更新
if (editorStore.editorView) {
setTimeout(() => {
updateStats(editorStore.editorView as EditorView, editorStore.updateDocumentStats);
}, 100);
}
editorElement.value.addEventListener('wheel', wheelHandler, {passive: false});
});
onBeforeUnmount(() => {
isDestroying = true;
// 移除滚轮事件监听
if (editorElement.value) {
editorElement.value.removeEventListener('wheel', handleWheel);
}
// 直接销毁编辑器
if (editorStore.editorView) {
editorStore.editorView.destroy();
editorStore.setEditorView(null);
editorElement.value.removeEventListener('wheel', wheelHandler);
}
});
</script>
<template>
<div class="editor-container">
<LoadingScreen v-if="editorStore.isLoading" text="VOIDRAFT" />
<div ref="editorElement" class="editor"></div>
<Toolbar />
<Toolbar/>
</div>
</template>
@@ -248,6 +60,7 @@ onBeforeUnmount(() => {
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
.editor {
width: 100%;
@@ -264,4 +77,4 @@ onBeforeUnmount(() => {
:deep(.cm-scroller) {
overflow: auto;
}
</style>
</style>

View File

@@ -15,19 +15,17 @@ import {
bracketMatching,
defaultHighlightStyle,
foldGutter,
foldKeymap,
indentOnInput,
syntaxHighlighting,
} from '@codemirror/language';
import {defaultKeymap, history, historyKeymap,} from '@codemirror/commands';
import {highlightSelectionMatches, searchKeymap} from '@codemirror/search';
import {autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap} from '@codemirror/autocomplete';
import {lintKeymap} from '@codemirror/lint';
import {history} from '@codemirror/commands';
import {highlightSelectionMatches} from '@codemirror/search';
import {autocompletion, closeBrackets, closeBracketsKeymap} from '@codemirror/autocomplete';
import createEditorContextMenu from '../contextMenu';
// 基本编辑器设置,包含常用扩展
// 基本编辑器设置
export const createBasicSetup = (): Extension[] => {
return [
// 基础UI
lineNumbers(),
highlightActiveLineGutter(),
@@ -57,15 +55,12 @@ export const createBasicSetup = (): Extension[] => {
// 自动完成
autocompletion(),
// 上下文菜单
createEditorContextMenu(),
// 键盘映射
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
...lintKeymap
]),
];
};
};

View File

@@ -0,0 +1,39 @@
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
import { useDocumentStore } from '@/stores/documentStore';
import { useEditorStore } from '@/stores/editorStore';
/**
* 内容变化监听插件 - 集成文档和编辑器管理
*/
export function createContentChangePlugin() {
return ViewPlugin.fromClass(
class ContentChangePlugin {
private documentStore = useDocumentStore();
private editorStore = useEditorStore();
private lastContent = '';
constructor(private view: EditorView) {
this.lastContent = view.state.doc.toString();
}
update(update: ViewUpdate) {
if (!update.docChanged) return;
const newContent = this.view.state.doc.toString();
if (newContent === this.lastContent) return;
this.lastContent = newContent;
// 通知编辑器管理器内容已变化
const currentDocId = this.documentStore.currentDocumentId;
if (currentDocId) {
this.editorStore.onContentChange(currentDocId);
}
}
destroy() {
}
}
);
}

View File

@@ -81,14 +81,6 @@ export function createFontExtension(config: Partial<FontConfig> = {}): Extension
return EditorView.theme(styles);
}
// 创建响应式字体大小扩展
export function createResponsiveFontExtension(baseFontSize: number = 14): Extension {
return fontCompartment.of(createFontExtension({
fontSize: baseFontSize,
lineHeight: 1.5
}));
}
// 从后端配置创建字体扩展
export function createFontExtensionFromBackend(backendConfig: {
fontFamily?: string;

View File

@@ -0,0 +1,65 @@
import {Extension} from '@codemirror/state';
import {EditorView} from '@codemirror/view';
import {DocumentStats} from '@/stores/editorStore';
import {getActiveNoteBlock} from '@/views/editor/extensions/codeblock/state';
// 更新编辑器文档统计信息
export const updateStats = (
view: EditorView,
updateDocumentStats: (stats: DocumentStats) => void
) => {
if (!view) return;
const state = view.state;
// 获取当前光标所在的代码块
const activeBlock = getActiveNoteBlock(state as any);
if (!activeBlock) {
// 如果没有活动块,显示空统计
updateDocumentStats({
lines: 0,
characters: 0,
selectedCharacters: 0
});
return;
}
// 获取当前块的内容范围
const blockContent = state.doc.sliceString(activeBlock.content.from, activeBlock.content.to);
// 计算块内容的行数
const blockLines = blockContent.split('\n').length;
// 计算选中的字符数(只统计在当前块内的选中内容)
let selectedChars = 0;
const selections = state.selection;
if (selections) {
for (let i = 0; i < selections.ranges.length; i++) {
const range = selections.ranges[i];
// 计算选中范围与当前块内容范围的交集
const selectionStart = Math.max(range.from, activeBlock.content.from);
const selectionEnd = Math.min(range.to, activeBlock.content.to);
if (selectionStart < selectionEnd) {
selectedChars += selectionEnd - selectionStart;
}
}
}
updateDocumentStats({
lines: blockLines,
characters: blockContent.length,
selectedCharacters: selectedChars
});
};
// 创建统计信息更新监听器扩展
export const createStatsUpdateExtension = (
updateDocumentStats: (stats: DocumentStats) => void
): Extension => {
return EditorView.updateListener.of(update => {
if (update.docChanged || update.selectionSet) {
updateStats(update.view, updateDocumentStats);
}
});
};

View File

@@ -2,7 +2,7 @@ import {Compartment, Extension} from '@codemirror/state';
import {EditorView, keymap} from '@codemirror/view';
import {indentSelection} from '@codemirror/commands';
import {indentUnit} from '@codemirror/language';
import {TabType} from '../../../../bindings/voidraft/internal/models/models';
import {TabType} from '@/../bindings/voidraft/internal/models/models';
// Tab设置相关的compartment
export const tabSizeCompartment = new Compartment();

View File

@@ -0,0 +1,58 @@
import { Extension, Compartment } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { SystemThemeType } from '@/../bindings/voidraft/internal/models/models';
import { createDarkTheme } from '@/views/editor/theme/dark';
import { createLightTheme } from '@/views/editor/theme/light';
import { useThemeStore } from '@/stores/themeStore';
// 主题区间 - 用于动态切换主题
export const themeCompartment = new Compartment();
/**
* 根据主题类型获取主题扩展
*/
const getThemeExtension = (themeType: SystemThemeType): Extension => {
const themeStore = useThemeStore();
// 处理 auto 主题类型
let actualTheme: SystemThemeType = themeType;
if (themeType === SystemThemeType.SystemThemeAuto) {
actualTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? SystemThemeType.SystemThemeDark
: SystemThemeType.SystemThemeLight;
}
// 根据主题类型创建主题
if (actualTheme === SystemThemeType.SystemThemeLight) {
return createLightTheme(themeStore.themeColors.lightTheme);
} else {
return createDarkTheme(themeStore.themeColors.darkTheme);
}
};
/**
* 创建主题扩展(用于编辑器初始化)
*/
export const createThemeExtension = (themeType: SystemThemeType = SystemThemeType.SystemThemeDark): Extension => {
const extension = getThemeExtension(themeType);
return themeCompartment.of(extension);
};
/**
* 更新编辑器主题
*/
export const updateEditorTheme = (view: EditorView, themeType: SystemThemeType): void => {
if (!view?.dispatch) {
return;
}
try {
const extension = getThemeExtension(themeType);
view.dispatch({
effects: themeCompartment.reconfigure(extension)
});
} catch (error) {
console.error('Failed to update editor theme:', error);
}
};

View File

@@ -0,0 +1,156 @@
/**
* 编辑器上下文菜单样式
* 支持系统主题自动适配
*/
.cm-context-menu {
position: fixed;
background-color: var(--settings-card-bg);
color: var(--settings-text);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 4px 0;
/* 优化阴影效果,只在右下角显示自然的阴影 */
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.12);
min-width: 200px;
max-width: 320px;
z-index: 9999;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
opacity: 0;
transform: scale(0.95);
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
overflow: visible; /* 确保子菜单可以显示在外部 */
}
.cm-context-menu-item {
padding: 8px 12px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
transition: all 0.1s ease;
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cm-context-menu-item:hover {
background-color: var(--toolbar-button-hover);
color: var(--toolbar-text);
}
.cm-context-menu-item-label {
display: flex;
align-items: center;
gap: 8px;
}
.cm-context-menu-item-shortcut {
opacity: 0.7;
font-size: 12px;
padding: 2px 4px;
border-radius: 4px;
background-color: var(--settings-input-bg);
color: var(--settings-text-secondary);
margin-left: 16px;
}
.cm-context-menu-item-ripple {
position: absolute;
border-radius: 50%;
background-color: var(--selection-bg);
width: 100px;
height: 100px;
opacity: 0.5;
transform: scale(0);
transition: transform 0.3s ease-out, opacity 0.3s ease-out;
}
/* 菜单分组标题样式 */
.cm-context-menu-group-title {
padding: 6px 12px;
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
user-select: none;
}
/* 菜单分隔线样式 */
.cm-context-menu-divider {
height: 1px;
background-color: var(--border-color);
margin: 4px 0;
}
/* 子菜单样式 */
.cm-context-submenu-container {
position: relative;
}
.cm-context-menu-item-with-submenu {
position: relative;
}
.cm-context-menu-item-with-submenu::after {
content: "";
position: absolute;
right: 12px;
font-size: 16px;
opacity: 0.7;
}
.cm-context-submenu {
position: fixed; /* 改为fixed定位避免受父元素影响 */
min-width: 180px;
opacity: 0;
pointer-events: none;
transform: translateX(10px);
transition: opacity 0.2s ease, transform 0.2s ease;
z-index: 10000;
border-radius: 6px;
background-color: var(--settings-card-bg);
color: var(--settings-text);
border: 1px solid var(--border-color);
padding: 4px 0;
/* 子菜单也使用相同的阴影效果 */
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.12);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.cm-context-menu-item-with-submenu:hover .cm-context-submenu {
opacity: 1;
pointer-events: auto;
transform: translateX(0);
}
/* 深色主题下的特殊样式 */
:root[data-theme="dark"] .cm-context-menu {
/* 深色主题下阴影更深,但仍然只在右下角 */
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.25);
}
:root[data-theme="dark"] .cm-context-submenu {
/* 深色主题下子菜单阴影 */
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.25);
}
:root[data-theme="dark"] .cm-context-menu-divider {
background-color: var(--dark-border-color);
opacity: 0.6;
}
/* 动画相关类 */
.cm-context-menu.show {
opacity: 1;
transform: scale(1);
}
.cm-context-menu.hide {
opacity: 0;
}

View File

@@ -0,0 +1,426 @@
/**
* 上下文菜单视图实现
* 处理菜单的创建、定位和事件绑定
* 优化为单例模式避免频繁创建和销毁DOM元素
*/
import { EditorView } from "@codemirror/view";
import { MenuItem } from "../contextMenu";
import "./contextMenu.css";
// 为Window对象添加cmSubmenus属性
declare global {
interface Window {
cmSubmenus?: Map<string, HTMLElement>;
}
}
// 菜单DOM元素缓存
let menuElement: HTMLElement | null = null;
let clickOutsideHandler: ((e: MouseEvent) => void) | null = null;
// 子菜单缓存池
let submenuPool: Map<string, HTMLElement> = new Map();
/**
* 获取或创建菜单DOM元素
*/
function getOrCreateMenuElement(): HTMLElement {
if (!menuElement) {
menuElement = document.createElement("div");
menuElement.className = "cm-context-menu";
menuElement.style.display = "none";
document.body.appendChild(menuElement);
// 阻止菜单内右键点击冒泡
menuElement.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
return false;
});
}
return menuElement;
}
/**
* 创建或获取子菜单元素
* @param id 子菜单唯一标识
*/
function getOrCreateSubmenu(id: string): HTMLElement {
if (!submenuPool.has(id)) {
const submenu = document.createElement("div");
submenu.className = "cm-context-menu cm-context-submenu";
submenu.style.display = "none";
document.body.appendChild(submenu);
submenuPool.set(id, submenu);
// 阻止子菜单点击事件冒泡
submenu.addEventListener('click', (e) => {
e.stopPropagation();
});
}
return submenuPool.get(id)!;
}
/**
* 创建菜单项DOM元素
*/
function createMenuItemElement(item: MenuItem, view: EditorView): HTMLElement {
// 创建菜单项容器
const menuItem = document.createElement("div");
menuItem.className = "cm-context-menu-item";
// 如果有子菜单,添加相应类
if (item.submenu && item.submenu.length > 0) {
menuItem.classList.add("cm-context-menu-item-with-submenu");
}
// 创建内容容器
const contentContainer = document.createElement("div");
contentContainer.className = "cm-context-menu-item-label";
// 标签文本
const label = document.createElement("span");
label.textContent = item.label;
contentContainer.appendChild(label);
menuItem.appendChild(contentContainer);
// 快捷键提示(如果有)
if (item.shortcut) {
const shortcut = document.createElement("span");
shortcut.className = "cm-context-menu-item-shortcut";
shortcut.textContent = item.shortcut;
menuItem.appendChild(shortcut);
}
// 如果有子菜单,创建或获取子菜单
if (item.submenu && item.submenu.length > 0) {
// 使用菜单项标签作为子菜单ID
const submenuId = `submenu-${item.label.replace(/\s+/g, '-').toLowerCase()}`;
const submenu = getOrCreateSubmenu(submenuId);
// 清空现有子菜单内容
while (submenu.firstChild) {
submenu.removeChild(submenu.firstChild);
}
// 添加子菜单项
item.submenu.forEach(subItem => {
const subMenuItemElement = createMenuItemElement(subItem, view);
submenu.appendChild(subMenuItemElement);
});
// 初始状态设置为隐藏
submenu.style.opacity = '0';
submenu.style.pointerEvents = 'none';
submenu.style.visibility = 'hidden';
submenu.style.display = 'block';
// 当鼠标悬停在菜单项上时,显示子菜单
menuItem.addEventListener('mouseenter', () => {
const rect = menuItem.getBoundingClientRect();
// 计算子菜单位置
submenu.style.left = `${rect.right}px`;
submenu.style.top = `${rect.top}px`;
// 检查子菜单是否会超出屏幕右侧
setTimeout(() => {
const submenuRect = submenu.getBoundingClientRect();
if (submenuRect.right > window.innerWidth) {
// 如果会超出右侧,则显示在左侧
submenu.style.left = `${rect.left - submenuRect.width}px`;
}
// 检查子菜单是否会超出屏幕底部
if (submenuRect.bottom > window.innerHeight) {
// 如果会超出底部,则向上调整
const newTop = rect.top - (submenuRect.bottom - window.innerHeight);
submenu.style.top = `${Math.max(0, newTop)}px`;
}
}, 0);
// 显示子菜单
submenu.style.opacity = '1';
submenu.style.pointerEvents = 'auto';
submenu.style.visibility = 'visible';
submenu.style.transform = 'translateX(0)';
});
// 当鼠标离开菜单项时,隐藏子菜单
menuItem.addEventListener('mouseleave', (e) => {
// 检查是否移动到子菜单上
const toElement = e.relatedTarget as HTMLElement;
if (submenu.contains(toElement)) {
return; // 如果移动到子菜单上,不隐藏
}
// 隐藏子菜单
submenu.style.opacity = '0';
submenu.style.pointerEvents = 'none';
submenu.style.transform = 'translateX(10px)';
// 延迟设置visibility以便过渡动画能够完成
setTimeout(() => {
if (submenu.style.opacity === '0') {
submenu.style.visibility = 'hidden';
}
}, 200);
});
// 当鼠标离开子菜单时,隐藏它
submenu.addEventListener('mouseleave', (e) => {
// 检查是否移动回父菜单项
const toElement = e.relatedTarget as HTMLElement;
if (menuItem.contains(toElement)) {
return; // 如果移动回父菜单项,不隐藏
}
// 隐藏子菜单
submenu.style.opacity = '0';
submenu.style.pointerEvents = 'none';
submenu.style.transform = 'translateX(10px)';
// 延迟设置visibility以便过渡动画能够完成
setTimeout(() => {
if (submenu.style.opacity === '0') {
submenu.style.visibility = 'hidden';
}
}, 200);
});
// 记录子菜单
if (!window.cmSubmenus) {
window.cmSubmenus = new Map();
}
window.cmSubmenus.set(submenuId, submenu);
}
// 点击事件仅当有command时添加
if (item.command) {
menuItem.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
// 添加点击动画效果
const ripple = document.createElement("div");
ripple.className = "cm-context-menu-item-ripple";
// 计算相对位置
const rect = menuItem.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
ripple.style.left = (x - 50) + "px";
ripple.style.top = (y - 50) + "px";
menuItem.appendChild(ripple);
// 执行点击动画
setTimeout(() => {
ripple.style.transform = "scale(1)";
ripple.style.opacity = "0";
// 动画完成后移除ripple元素
setTimeout(() => {
if (ripple.parentNode === menuItem) {
menuItem.removeChild(ripple);
}
}, 300);
}, 10);
// 执行命令
item.command!(view);
// 隐藏菜单
hideContextMenu();
});
}
return menuItem;
}
/**
* 创建分隔线
*/
function createDivider(): HTMLElement {
const divider = document.createElement("div");
divider.className = "cm-context-menu-divider";
return divider;
}
/**
* 添加菜单组
* @param menuElement 菜单元素
* @param title 菜单组标题
* @param items 菜单项
* @param view 编辑器视图
*/
function addMenuGroup(menuElement: HTMLElement, title: string | null, items: MenuItem[], view: EditorView): void {
// 如果有标题,添加组标题
if (title) {
const groupTitle = document.createElement("div");
groupTitle.className = "cm-context-menu-group-title";
groupTitle.textContent = title;
menuElement.appendChild(groupTitle);
}
// 添加菜单项
items.forEach(item => {
const menuItemElement = createMenuItemElement(item, view);
menuElement.appendChild(menuItemElement);
});
}
/**
* 显示上下文菜单
*/
export function showContextMenu(view: EditorView, clientX: number, clientY: number, items: MenuItem[]): void {
// 获取或创建菜单元素
const menu = getOrCreateMenuElement();
// 如果已经有菜单显示,先隐藏所有子菜单
hideAllSubmenus();
// 清空现有菜单项
while (menu.firstChild) {
menu.removeChild(menu.firstChild);
}
// 添加主菜单项
items.forEach(item => {
const menuItemElement = createMenuItemElement(item, view);
menu.appendChild(menuItemElement);
});
// 显示菜单
menu.style.display = "block";
// 定位菜单
positionMenu(menu, clientX, clientY);
// 添加点击外部关闭事件
if (clickOutsideHandler) {
document.removeEventListener("click", clickOutsideHandler, true);
}
clickOutsideHandler = (e: MouseEvent) => {
// 检查点击是否在菜单外
if (menu && !menu.contains(e.target as Node)) {
let isInSubmenu = false;
// 检查是否点击在子菜单内
if (window.cmSubmenus) {
window.cmSubmenus.forEach((submenu) => {
if (submenu.contains(e.target as Node)) {
isInSubmenu = true;
}
});
}
if (!isInSubmenu) {
hideContextMenu();
}
}
};
// 使用捕获阶段确保事件被处理
document.addEventListener("click", clickOutsideHandler, true);
// ESC键关闭
document.addEventListener("keydown", handleKeyDown);
// 触发显示动画
setTimeout(() => {
if (menu) {
menu.classList.add("show");
}
}, 10);
}
/**
* 隐藏所有子菜单
*/
function hideAllSubmenus(): void {
if (window.cmSubmenus) {
window.cmSubmenus.forEach((submenu) => {
submenu.style.opacity = '0';
submenu.style.pointerEvents = 'none';
submenu.style.visibility = 'hidden';
submenu.style.transform = 'translateX(10px)';
});
}
}
/**
* 处理键盘事件
*/
function handleKeyDown(e: KeyboardEvent): void {
if (e.key === "Escape") {
hideContextMenu();
document.removeEventListener("keydown", handleKeyDown);
}
}
/**
* 隐藏上下文菜单
*/
export function hideContextMenu(): void {
// 隐藏所有子菜单
hideAllSubmenus();
if (menuElement) {
// 添加淡出动画
menuElement.classList.remove("show");
menuElement.classList.add("hide");
// 等待动画完成后隐藏不移除DOM元素
setTimeout(() => {
if (menuElement) {
menuElement.style.display = "none";
menuElement.classList.remove("hide");
}
}, 150);
}
if (clickOutsideHandler) {
document.removeEventListener("click", clickOutsideHandler, true);
clickOutsideHandler = null;
}
document.removeEventListener("keydown", handleKeyDown);
}
/**
* 定位菜单元素
*/
function positionMenu(menu: HTMLElement, clientX: number, clientY: number): void {
// 获取窗口尺寸
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
// 初始位置设置
let left = clientX;
let top = clientY;
// 确保菜单在视窗内
setTimeout(() => {
// 计算菜单尺寸
const menuWidth = menu.offsetWidth;
const menuHeight = menu.offsetHeight;
// 确保菜单不会超出右侧边界
if (left + menuWidth > windowWidth) {
left = windowWidth - menuWidth - 5;
}
// 确保菜单不会超出底部边界
if (top + menuHeight > windowHeight) {
top = windowHeight - menuHeight - 5;
}
// 应用位置
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
}, 0);
}

View File

@@ -0,0 +1,229 @@
/**
* 编辑器上下文菜单实现
* 提供基本的复制、剪切、粘贴等操作,支持动态快捷键显示
*/
import { EditorView } from "@codemirror/view";
import { Extension } from "@codemirror/state";
import { copyCommand, cutCommand, pasteCommand } from "../extensions/codeblock/copyPaste";
import { KeyBindingCommand } from "@/../bindings/voidraft/internal/models/models";
import { useKeybindingStore } from "@/stores/keybindingStore";
import {
undo, redo
} from "@codemirror/commands";
import {
deleteBlock, formatCurrentBlock,
addNewBlockAfterCurrent, addNewBlockAfterLast, addNewBlockBeforeCurrent
} from "../extensions/codeblock/commands";
import { commandRegistry } from "@/views/editor/keymap";
import i18n from "@/i18n";
import {useSystemStore} from "@/stores/systemStore";
/**
* 菜单项类型定义
*/
export interface MenuItem {
/** 菜单项显示文本 */
label: string;
/** 点击时执行的命令 (如果有子菜单可以为null) */
command?: (view: EditorView) => boolean;
/** 快捷键提示文本 (可选) */
shortcut?: string;
/** 子菜单项 (可选) */
submenu?: MenuItem[];
}
// 导入相关功能
import { showContextMenu } from "./contextMenuView";
/**
* 获取翻译文本
* @param key 翻译键
* @returns 翻译后的文本
*/
function t(key: string): string {
return i18n.global.t(key);
}
/**
* 获取快捷键显示文本
* @param command 命令ID
* @returns 快捷键显示文本
*/
function getShortcutText(command: KeyBindingCommand): string {
try {
const keybindingStore = useKeybindingStore();
// 如果找到该命令的快捷键配置
const binding = keybindingStore.keyBindings.find(kb =>
kb.command === command && kb.enabled
);
if (binding && binding.key) {
// 格式化快捷键显示
return formatKeyBinding(binding.key);
}
} catch (error) {
console.warn("An error occurred while getting the shortcut:", error);
}
return "";
}
/**
* 格式化快捷键显示
* @param keyBinding 快捷键字符串
* @returns 格式化后的显示文本
*/
function formatKeyBinding(keyBinding: string): string {
// 获取系统信息
const systemStore = useSystemStore();
const isMac = systemStore.isMacOS;
// 替换修饰键名称为更友好的显示
return keyBinding
.replace("Mod", isMac ? "⌘" : "Ctrl")
.replace("Shift", isMac ? "⇧" : "Shift")
.replace("Alt", isMac ? "⌥" : "Alt")
.replace("Ctrl", isMac ? "⌃" : "Ctrl")
.replace(/-/g, " + ");
}
/**
* 从命令注册表获取命令处理程序和翻译键
* @param command 命令ID
* @returns 命令处理程序和翻译键
*/
function getCommandInfo(command: KeyBindingCommand): { handler: (view: EditorView) => boolean, descriptionKey: string } | undefined {
return commandRegistry[command];
}
/**
* 创建编辑菜单项
*/
function createEditItems(): MenuItem[] {
return [
{
label: t("keybindings.commands.blockCopy"),
command: copyCommand,
shortcut: getShortcutText(KeyBindingCommand.BlockCopyCommand)
},
{
label: t("keybindings.commands.blockCut"),
command: cutCommand,
shortcut: getShortcutText(KeyBindingCommand.BlockCutCommand)
},
{
label: t("keybindings.commands.blockPaste"),
command: pasteCommand,
shortcut: getShortcutText(KeyBindingCommand.BlockPasteCommand)
}
];
}
/**
* 创建历史操作菜单项
*/
function createHistoryItems(): MenuItem[] {
return [
{
label: t("keybindings.commands.historyUndo"),
command: undo,
shortcut: getShortcutText(KeyBindingCommand.HistoryUndoCommand)
},
{
label: t("keybindings.commands.historyRedo"),
command: redo,
shortcut: getShortcutText(KeyBindingCommand.HistoryRedoCommand)
}
];
}
/**
* 创建代码块相关菜单项
*/
function createCodeBlockItems(): MenuItem[] {
const defaultOptions = { defaultBlockToken: 'text', defaultBlockAutoDetect: true };
return [
// 格式化
{
label: t("keybindings.commands.blockFormat"),
command: formatCurrentBlock,
shortcut: getShortcutText(KeyBindingCommand.BlockFormatCommand)
},
// 删除
{
label: t("keybindings.commands.blockDelete"),
command: deleteBlock(defaultOptions),
shortcut: getShortcutText(KeyBindingCommand.BlockDeleteCommand)
},
// 在当前块后添加新块
{
label: t("keybindings.commands.blockAddAfterCurrent"),
command: addNewBlockAfterCurrent(defaultOptions),
shortcut: getShortcutText(KeyBindingCommand.BlockAddAfterCurrentCommand)
},
// 在当前块前添加新块
{
label: t("keybindings.commands.blockAddBeforeCurrent"),
command: addNewBlockBeforeCurrent(defaultOptions),
shortcut: getShortcutText(KeyBindingCommand.BlockAddBeforeCurrentCommand)
},
// 在最后添加新块
{
label: t("keybindings.commands.blockAddAfterLast"),
command: addNewBlockAfterLast(defaultOptions),
shortcut: getShortcutText(KeyBindingCommand.BlockAddAfterLastCommand)
}
];
}
/**
* 创建主菜单项
*/
function createMainMenuItems(): MenuItem[] {
// 基本编辑操作放在主菜单
const basicItems = createEditItems();
// 历史操作放在主菜单
const historyItems = createHistoryItems();
// 构建主菜单
return [
...basicItems,
...historyItems,
{
label: t("extensions.codeblock.name"),
submenu: createCodeBlockItems()
}
];
}
/**
* 创建编辑器上下文菜单
*/
export function createEditorContextMenu(): Extension {
// 为编辑器添加右键事件处理
return EditorView.domEventHandlers({
contextmenu: (event, view) => {
// 阻止默认右键菜单
event.preventDefault();
// 获取菜单项
const menuItems = createMainMenuItems();
// 显示上下文菜单
showContextMenu(view, event.clientX, event.clientY, menuItems);
return true;
}
});
}
/**
* 默认导出
*/
export default createEditorContextMenu;

View File

@@ -1,105 +0,0 @@
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
import { DocumentService } from '../../../../bindings/voidraft/internal/services';
import { useDebounceFn } from '@vueuse/core';
// 定义自动保存配置选项
export interface AutoSaveOptions {
// 保存回调
onSave?: (success: boolean) => void;
// 内容变更延迟传递(毫秒)- 输入时不会立即发送,有一个小延迟,避免频繁调用后端
debounceDelay?: number;
}
/**
* 创建自动保存插件
*
* 新的简化保存策略:
* - 前端只负责将内容变更传递给后端
* - 后端使用定时保存机制,每隔配置的时间间隔自动保存(仅在有变更时)
* - 移除了复杂的阈值保存和最小间隔控制
*
* @param options 配置选项
* @returns EditorView.Plugin
*/
export function createAutoSavePlugin(options: AutoSaveOptions = {}) {
const {
onSave = () => {},
debounceDelay = 2000
} = options;
return ViewPlugin.fromClass(
class {
private isActive: boolean = true;
private isSaving: boolean = false;
private readonly contentUpdateFn: (view: EditorView) => void;
constructor(private view: EditorView) {
// 创建内容更新函数,简单传递内容给后端
this.contentUpdateFn = this.createDebouncedUpdateFn(debounceDelay);
}
/**
* 创建防抖的内容更新函数
*/
private createDebouncedUpdateFn(delay: number): (view: EditorView) => void {
// 使用VueUse的防抖函数创建一个新函数
return useDebounceFn(async (view: EditorView) => {
// 如果插件已不活跃或正在保存中,不发送
if (!this.isActive || this.isSaving) return;
this.isSaving = true;
const content = view.state.doc.toString();
try {
// 简单将内容传递给后端,让后端处理保存策略
await DocumentService.UpdateActiveDocumentContent(content);
onSave(true);
} catch (err) {
// 静默处理错误,不在控制台打印
onSave(false);
} finally {
this.isSaving = false;
}
}, delay);
}
update(update: ViewUpdate) {
// 如果内容没有变化,直接返回
if (!update.docChanged) return;
// 调用防抖函数
this.contentUpdateFn(this.view);
}
destroy() {
// 标记插件不再活跃
this.isActive = false;
// 静默发送最终内容,忽略错误
const content = this.view.state.doc.toString();
DocumentService.UpdateActiveDocumentContent(content).catch(() => {
// 静默忽略销毁时的错误
});
}
}
);
}
/**
* 创建处理保存快捷键的插件
* @param onSave 保存回调
* @returns EditorView.Plugin
*/
export function createSaveShortcutPlugin(onSave: () => void) {
return EditorView.domEventHandlers({
keydown: (event) => {
// Ctrl+S / Cmd+S
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault();
onSave();
return true;
}
return false;
}
});
}

View File

@@ -0,0 +1,186 @@
import { EditorView, Decoration } from "@codemirror/view"
import { WidgetType } from "@codemirror/view"
import { ViewUpdate, ViewPlugin, DecorationSet } from "@codemirror/view"
import { Extension, Compartment, StateEffect } from "@codemirror/state"
// 创建字体变化效果
const fontChangeEffect = StateEffect.define<void>()
/**
* 复选框小部件类
*/
class CheckboxWidget extends WidgetType {
constructor(readonly checked: boolean) {
super()
}
eq(other: CheckboxWidget) {
return other.checked == this.checked
}
toDOM() {
let wrap = document.createElement("span")
wrap.setAttribute("aria-hidden", "true")
wrap.className = "cm-checkbox-toggle"
let box = document.createElement("input")
box.type = "checkbox"
box.checked = this.checked
box.tabIndex = -1
box.style.margin = "0"
box.style.padding = "0"
box.style.cursor = "pointer"
box.style.position = "relative"
box.style.top = "0.1em"
box.style.marginRight = "0.5em"
// 设置相对单位,让复选框跟随字体大小变化
box.style.width = "1em"
box.style.height = "1em"
wrap.appendChild(box)
return wrap
}
ignoreEvent() {
return false
}
}
/**
* 查找并创建复选框装饰
*/
function findCheckboxes(view: EditorView) {
let widgets: any = []
const doc = view.state.doc
for (let { from, to } of view.visibleRanges) {
// 使用正则表达式查找 [x] 或 [ ] 模式
const text = doc.sliceString(from, to)
const checkboxRegex = /\[([ x])\]/gi
let match
while ((match = checkboxRegex.exec(text)) !== null) {
const matchPos = from + match.index
const matchEnd = matchPos + match[0].length
// 检查前后是否有合适的空格或行首
const beforeChar = matchPos > 0 ? doc.sliceString(matchPos - 1, matchPos) : ""
const afterChar = matchEnd < doc.length ? doc.sliceString(matchEnd, matchEnd + 1) : ""
// 只在行首或空格后,且后面跟空格或行尾时才渲染
if ((beforeChar === "" || beforeChar === " " || beforeChar === "\t" || beforeChar === "\n") &&
(afterChar === "" || afterChar === " " || afterChar === "\t" || afterChar === "\n")) {
const isChecked = match[1].toLowerCase() === "x"
let deco = Decoration.replace({
widget: new CheckboxWidget(isChecked),
inclusive: false,
})
widgets.push(deco.range(matchPos, matchEnd))
}
}
}
return Decoration.set(widgets)
}
/**
* 切换复选框状态
*/
function toggleCheckbox(view: EditorView, pos: number) {
const doc = view.state.doc
// 查找当前位置附近的复选框模式
for (let offset = -3; offset <= 0; offset++) {
const checkPos = pos + offset
if (checkPos >= 0 && checkPos + 3 <= doc.length) {
const text = doc.sliceString(checkPos, checkPos + 3).toLowerCase()
let change
if (text === "[x]") {
change = { from: checkPos, to: checkPos + 3, insert: "[ ]" }
} else if (text === "[ ]") {
change = { from: checkPos, to: checkPos + 3, insert: "[x]" }
}
if (change) {
view.dispatch({ changes: change })
return true
}
}
}
return false
}
// 创建字体变化效果的便捷函数
export const triggerFontChange = (view: EditorView) => {
view.dispatch({
effects: fontChangeEffect.of(undefined)
})
}
/**
* 创建复选框扩展
*/
export function createCheckboxExtension(): Extension {
return [
// 主要的复选框插件
ViewPlugin.fromClass(class {
decorations: DecorationSet
constructor(view: EditorView) {
this.decorations = findCheckboxes(view)
}
update(update: ViewUpdate) {
// 检查是否需要重新渲染复选框
const shouldUpdate = update.docChanged ||
update.viewportChanged ||
update.geometryChanged ||
update.transactions.some(tr => tr.effects.some(e => e.is(fontChangeEffect)))
if (shouldUpdate) {
this.decorations = findCheckboxes(update.view)
}
}
}, {
decorations: v => v.decorations,
eventHandlers: {
mousedown: (e, view) => {
let target = e.target as HTMLElement
if (target.nodeName == "INPUT" && target.parentElement!.classList.contains("cm-checkbox-toggle")) {
const pos = view.posAtDOM(target)
return toggleCheckbox(view, pos)
}
}
}
}),
// 复选框样式
EditorView.theme({
".cm-checkbox-toggle": {
display: "inline-block",
verticalAlign: "baseline",
},
".cm-checkbox-toggle input[type=checkbox]": {
margin: "0",
padding: "0",
verticalAlign: "baseline",
cursor: "pointer",
// 确保复选框大小跟随字体
fontSize: "inherit",
}
})
]
}
// 默认导出
export const checkboxExtension = createCheckboxExtension()
// 导出类型和工具函数
export {
CheckboxWidget,
toggleCheckbox,
findCheckboxes
}

View File

@@ -0,0 +1,383 @@
/**
* Block 命令
*/
import { EditorSelection } from "@codemirror/state";
import { Command } from "@codemirror/view";
import { blockState, getActiveNoteBlock, getFirstNoteBlock, getLastNoteBlock, getNoteBlockFromPos } from "./state";
import { Block, EditorOptions, DELIMITER_REGEX } from "./types";
import { formatBlockContent } from "./formatCode";
/**
* 获取块分隔符
*/
export function getBlockDelimiter(defaultToken: string, autoDetect: boolean): string {
return `\n∞∞∞${autoDetect ? defaultToken + '-a' : defaultToken}\n`;
}
/**
* 在光标处插入新块
*/
export const insertNewBlockAtCursor = (options: EditorOptions): Command => ({ state, dispatch }) => {
if (state.readOnly) return false;
const currentBlock = getActiveNoteBlock(state);
let delimText: string;
if (currentBlock) {
delimText = `\n∞∞∞${currentBlock.language.name}${currentBlock.language.auto ? "-a" : ""}\n`;
} else {
delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
}
dispatch(state.replaceSelection(delimText), {
scrollIntoView: true,
userEvent: "input",
});
return true;
};
/**
* 在当前块之前添加新块
*/
export const addNewBlockBeforeCurrent = (options: EditorOptions): Command => ({ state, dispatch }) => {
if (state.readOnly) return false;
const block = getActiveNoteBlock(state);
if (!block) return false;
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
dispatch(state.update({
changes: {
from: block.delimiter.from,
insert: delimText,
},
selection: EditorSelection.cursor(block.delimiter.from + delimText.length),
}, {
scrollIntoView: true,
userEvent: "input",
}));
return true;
};
/**
* 在当前块之后添加新块
*/
export const addNewBlockAfterCurrent = (options: EditorOptions): Command => ({ state, dispatch }) => {
if (state.readOnly) return false;
const block = getActiveNoteBlock(state);
if (!block) return false;
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
dispatch(state.update({
changes: {
from: block.content.to,
insert: delimText,
},
selection: EditorSelection.cursor(block.content.to + delimText.length)
}, {
scrollIntoView: true,
userEvent: "input",
}));
return true;
};
/**
* 在第一个块之前添加新块
*/
export const addNewBlockBeforeFirst = (options: EditorOptions): Command => ({ state, dispatch }) => {
if (state.readOnly) return false;
const block = getFirstNoteBlock(state);
if (!block) return false;
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
dispatch(state.update({
changes: {
from: block.delimiter.from,
insert: delimText,
},
selection: EditorSelection.cursor(delimText.length),
}, {
scrollIntoView: true,
userEvent: "input",
}));
return true;
};
/**
* 在最后一个块之后添加新块
*/
export const addNewBlockAfterLast = (options: EditorOptions): Command => ({ state, dispatch }) => {
if (state.readOnly) return false;
const block = getLastNoteBlock(state);
if (!block) return false;
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
dispatch(state.update({
changes: {
from: block.content.to,
insert: delimText,
},
selection: EditorSelection.cursor(block.content.to + delimText.length)
}, {
scrollIntoView: true,
userEvent: "input",
}));
return true;
};
/**
* 更改块语言
*/
export function changeLanguageTo(state: any, dispatch: any, block: Block, language: string, auto: boolean) {
if (state.readOnly) return false;
const currentDelimiter = state.doc.sliceString(block.delimiter.from, block.delimiter.to);
// 重置正则表达式的 lastIndex
DELIMITER_REGEX.lastIndex = 0;
if (currentDelimiter.match(DELIMITER_REGEX)) {
const newDelimiter = `\n∞∞∞${language}${auto ? '-a' : ''}\n`;
dispatch({
changes: {
from: block.delimiter.from,
to: block.delimiter.to,
insert: newDelimiter,
},
});
return true;
} else {
return false;
}
}
/**
* 更改当前块语言
*/
export function changeCurrentBlockLanguage(state: any, dispatch: any, language: string | null, auto: boolean) {
const block = getActiveNoteBlock(state);
if (!block) {
console.warn("No active block found");
return false;
}
// 如果 language 为 null我们只想更改自动检测标志
if (language === null) {
language = block.language.name;
}
return changeLanguageTo(state, dispatch, block, language, auto);
}
// 选择和移动辅助函数
function updateSel(sel: EditorSelection, by: (range: any) => any): EditorSelection {
return EditorSelection.create(sel.ranges.map(by), sel.mainIndex);
}
function setSel(state: any, selection: EditorSelection) {
return state.update({ selection, scrollIntoView: true, userEvent: "select" });
}
function extendSel(state: any, dispatch: any, how: (range: any) => any) {
let selection = updateSel(state.selection, range => {
let head = how(range);
return EditorSelection.range(range.anchor, head.head, head.goalColumn, head.bidiLevel || undefined);
});
if (selection.eq(state.selection)) return false;
dispatch(setSel(state, selection));
return true;
}
function moveSel(state: any, dispatch: any, how: (range: any) => any) {
let selection = updateSel(state.selection, how);
if (selection.eq(state.selection)) return false;
dispatch(setSel(state, selection));
return true;
}
function previousBlock(state: any, range: any) {
const blocks = state.field(blockState);
const block = getNoteBlockFromPos(state, range.head);
if (!block) return EditorSelection.cursor(0);
if (range.head === block.content.from) {
const index = blocks.indexOf(block);
const previousBlockIndex = index > 0 ? index - 1 : 0;
return EditorSelection.cursor(blocks[previousBlockIndex].content.from);
} else {
return EditorSelection.cursor(block.content.from);
}
}
function nextBlock(state: any, range: any) {
const blocks = state.field(blockState);
const block = getNoteBlockFromPos(state, range.head);
if (!block) return EditorSelection.cursor(state.doc.length);
if (range.head === block.content.to) {
const index = blocks.indexOf(block);
const nextBlockIndex = index < blocks.length - 1 ? index + 1 : index;
return EditorSelection.cursor(blocks[nextBlockIndex].content.to);
} else {
return EditorSelection.cursor(block.content.to);
}
}
/**
* 跳转到下一个块
*/
export function gotoNextBlock({ state, dispatch }: any) {
return moveSel(state, dispatch, (range: any) => nextBlock(state, range));
}
/**
* 选择到下一个块
*/
export function selectNextBlock({ state, dispatch }: any) {
return extendSel(state, dispatch, (range: any) => nextBlock(state, range));
}
/**
* 跳转到上一个块
*/
export function gotoPreviousBlock({ state, dispatch }: any) {
return moveSel(state, dispatch, (range: any) => previousBlock(state, range));
}
/**
* 选择到上一个块
*/
export function selectPreviousBlock({ state, dispatch }: any) {
return extendSel(state, dispatch, (range: any) => previousBlock(state, range));
}
/**
* 删除块
*/
export const deleteBlock = (options: EditorOptions): Command => ({ state, dispatch }) => {
if (state.readOnly) return false;
const block = getActiveNoteBlock(state);
if (!block) return false;
const blocks = state.field(blockState);
if (blocks.length <= 1) return false; // 不能删除最后一个块
const blockIndex = blocks.indexOf(block);
let newCursorPos: number;
if (blockIndex === blocks.length - 1) {
// 如果是最后一个块,将光标移到前一个块的末尾
// 需要计算删除后的位置
const prevBlock = blocks[blockIndex - 1];
newCursorPos = prevBlock.content.to;
} else {
// 否则移到下一个块的开始
// 需要计算删除后的位置,下一个块会向前移动
const nextBlock = blocks[blockIndex + 1];
const blockLength = block.range.to - block.range.from;
newCursorPos = nextBlock.content.from - blockLength;
}
// 确保光标位置在有效范围内
const docLengthAfterDelete = state.doc.length - (block.range.to - block.range.from);
newCursorPos = Math.max(0, Math.min(newCursorPos, docLengthAfterDelete));
dispatch(state.update({
changes: {
from: block.range.from,
to: block.range.to,
insert: ""
},
selection: EditorSelection.cursor(newCursorPos)
}, {
scrollIntoView: true,
userEvent: "delete"
}));
return true;
};
/**
* 向上移动当前块
*/
export function moveCurrentBlockUp({ state, dispatch }: any) {
return moveCurrentBlock(state, dispatch, true);
}
/**
* 向下移动当前块
*/
export function moveCurrentBlockDown({ state, dispatch }: any) {
return moveCurrentBlock(state, dispatch, false);
}
function moveCurrentBlock(state: any, dispatch: any, up: boolean) {
if (state.readOnly) return false;
const block = getActiveNoteBlock(state);
if (!block) return false;
const blocks = state.field(blockState);
const blockIndex = blocks.indexOf(block);
const targetIndex = up ? blockIndex - 1 : blockIndex + 1;
if (targetIndex < 0 || targetIndex >= blocks.length) return false;
const targetBlock = blocks[targetIndex];
// 获取两个块的完整内容
const currentBlockContent = state.doc.sliceString(block.range.from, block.range.to);
const targetBlockContent = state.doc.sliceString(targetBlock.range.from, targetBlock.range.to);
// 交换块的位置
const changes = up ? [
{
from: targetBlock.range.from,
to: block.range.to,
insert: currentBlockContent + targetBlockContent
}
] : [
{
from: block.range.from,
to: targetBlock.range.to,
insert: targetBlockContent + currentBlockContent
}
];
// 计算新的光标位置
const newCursorPos = up ?
targetBlock.range.from + (block.range.to - block.range.from) + (block.content.from - block.range.from) :
block.range.from + (targetBlock.range.to - targetBlock.range.from) + (block.content.from - block.range.from);
dispatch(state.update({
changes,
selection: EditorSelection.cursor(newCursorPos)
}, {
scrollIntoView: true,
userEvent: "move"
}));
return true;
}
/**
* 格式化当前块
*/
export const formatCurrentBlock: Command = (view) => {
return formatBlockContent(view);
}

View File

@@ -0,0 +1,211 @@
/**
* 代码块复制粘贴扩展
* 防止复制分隔符标记,自动替换为换行符
*/
import { EditorState, EditorSelection } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { Command } from "@codemirror/view";
import { SUPPORTED_LANGUAGES } from "./types";
/**
* 构建块分隔符正则表达式
*/
const languageTokensMatcher = SUPPORTED_LANGUAGES.join("|");
const blockSeparatorRegex = new RegExp(`\\n∞∞∞(${languageTokensMatcher})(-a)?\\n`, "g");
/**
* 降级复制方法 - 使用传统的 document.execCommand
*/
function fallbackCopyToClipboard(text: string): boolean {
try {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const result = document.execCommand('copy');
document.body.removeChild(textArea);
return result;
} catch (err) {
console.error('The downgrade replication method also failed:', err);
return false;
}
}
/**
* 获取被复制的范围和内容
*/
function copiedRange(state: EditorState) {
let content: string[] = [];
let ranges: any[] = [];
for (let range of state.selection.ranges) {
if (!range.empty) {
content.push(state.sliceDoc(range.from, range.to));
ranges.push(range);
}
}
if (ranges.length === 0) {
// 如果所有范围都是空的,我们想要复制每个选择的整行(唯一的)
const copiedLines: number[] = [];
for (let range of state.selection.ranges) {
if (range.empty) {
const line = state.doc.lineAt(range.head);
const lineContent = state.sliceDoc(line.from, line.to);
if (!copiedLines.includes(line.from)) {
content.push(lineContent);
ranges.push(range);
copiedLines.push(line.from);
}
}
}
}
return {
text: content.join(state.lineBreak),
ranges
};
}
/**
* 设置浏览器复制和剪切事件处理器,将块分隔符替换为换行符
*/
export const codeBlockCopyCut = EditorView.domEventHandlers({
copy(event, view) {
let { text, ranges } = copiedRange(view.state);
// 将块分隔符替换为双换行符
text = text.replaceAll(blockSeparatorRegex, "\n\n");
const data = event.clipboardData;
if (data) {
event.preventDefault();
data.clearData();
data.setData("text/plain", text);
}
},
cut(event, view) {
let { text, ranges } = copiedRange(view.state);
// 将块分隔符替换为双换行符
text = text.replaceAll(blockSeparatorRegex, "\n\n");
const data = event.clipboardData;
if (data) {
event.preventDefault();
data.clearData();
data.setData("text/plain", text);
}
if (!view.state.readOnly) {
view.dispatch({
changes: ranges,
scrollIntoView: true,
userEvent: "delete.cut"
});
}
}
});
/**
* 复制和剪切的通用函数
*/
const copyCut = (view: EditorView, cut: boolean): boolean => {
let { text, ranges } = copiedRange(view.state);
// 将块分隔符替换为双换行符
text = text.replaceAll(blockSeparatorRegex, "\n\n");
// 使用现代剪贴板 API
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(err => {
fallbackCopyToClipboard(text);
});
} else {
// 降级到传统方法
fallbackCopyToClipboard(text);
}
if (cut && !view.state.readOnly) {
view.dispatch({
changes: ranges,
scrollIntoView: true,
userEvent: "delete.cut"
});
}
return true;
};
/**
* 粘贴函数
*/
function doPaste(view: EditorView, input: string) {
const { state } = view;
const text = state.toText(input);
const byLine = text.lines === state.selection.ranges.length;
let changes: any;
if (byLine) {
let i = 1;
changes = state.changeByRange(range => {
const line = text.line(i++);
return {
changes: { from: range.from, to: range.to, insert: line.text },
range: EditorSelection.cursor(range.from + line.length)
};
});
} else {
changes = state.replaceSelection(text);
}
view.dispatch(changes, {
userEvent: "input.paste",
scrollIntoView: true
});
}
/**
* 复制命令
*/
export const copyCommand: Command = (view) => {
return copyCut(view, false);
};
/**
* 剪切命令
*/
export const cutCommand: Command = (view) => {
return copyCut(view, true);
};
/**
* 粘贴命令
*/
export const pasteCommand: Command = (view) => {
if (navigator.clipboard && navigator.clipboard.readText) {
navigator.clipboard.readText()
.then(text => {
doPaste(view, text);
})
.catch(err => {
console.error('Failed to read from clipboard:', err);
});
} else {
console.warn('The clipboard API is not available, please use your browser\'s native paste feature');
}
return true;
};
/**
* 获取复制粘贴扩展
*/
export function getCopyPasteExtensions() {
return [
codeBlockCopyCut,
];
}

View File

@@ -0,0 +1,265 @@
/**
* Block 装饰系统
*/
import { ViewPlugin, EditorView, Decoration, WidgetType, layer, RectangleMarker } from "@codemirror/view";
import { StateField, RangeSetBuilder, EditorState } from "@codemirror/state";
import { blockState } from "./state";
/**
* 块开始装饰组件
*/
class NoteBlockStart extends WidgetType {
constructor(private isFirst: boolean) {
super();
}
eq(other: NoteBlockStart) {
return this.isFirst === other.isFirst;
}
toDOM() {
let wrap = document.createElement("div");
wrap.className = "code-block-start" + (this.isFirst ? " first" : "");
return wrap;
}
ignoreEvent() {
return false;
}
}
/**
* 块分隔符装饰器
*/
const noteBlockWidget = () => {
const decorate = (state: any) => {
const builder = new RangeSetBuilder<Decoration>();
state.field(blockState).forEach((block: any) => {
let delimiter = block.delimiter;
let deco = Decoration.replace({
widget: new NoteBlockStart(delimiter.from === 0),
inclusive: true,
block: true,
side: 0,
});
builder.add(
delimiter.from === 0 ? delimiter.from : delimiter.from + 1,
delimiter.to - 1,
deco
);
});
return builder.finish();
};
const noteBlockStartField = StateField.define({
create(state: any) {
return decorate(state);
},
update(widgets: any, transaction: any) {
// 如果装饰为空,可能意味着我们没有获得解析的语法树,那么我们希望在所有更新时更新装饰(而不仅仅是文档更改)
if (transaction.docChanged || widgets.isEmpty) {
return decorate(transaction.state);
}
return widgets;
},
provide(field: any) {
return EditorView.decorations.from(field);
}
});
return noteBlockStartField;
};
/**
* 原子范围,防止在分隔符内编辑
*/
function atomicRanges(view: EditorView) {
let builder = new RangeSetBuilder();
view.state.field(blockState).forEach((block: any) => {
builder.add(
block.delimiter.from,
block.delimiter.to,
Decoration.mark({ atomic: true }),
);
});
return builder.finish();
}
const atomicNoteBlock = ViewPlugin.fromClass(
class {
atomicRanges: any;
constructor(view: EditorView) {
this.atomicRanges = atomicRanges(view);
}
update(update: any) {
if (update.docChanged) {
this.atomicRanges = atomicRanges(update.view);
}
}
},
{
provide: plugin => EditorView.atomicRanges.of(view => {
return view.plugin(plugin)?.atomicRanges || [];
})
}
);
/**
* 块背景层 - 修复高度计算问题
*/
const blockLayer = layer({
above: false,
markers(view: EditorView) {
const markers: RectangleMarker[] = [];
let idx = 0;
function rangesOverlaps(range1: any, range2: any) {
return range1.from <= range2.to && range2.from <= range1.to;
}
const blocks = view.state.field(blockState);
blocks.forEach((block: any) => {
// 确保块是可见的
if (!view.visibleRanges.some(range => rangesOverlaps(block.content, range))) {
idx++;
return;
}
// view.coordsAtPos 如果编辑器不可见则返回 null
const fromCoordsTop = view.coordsAtPos(Math.max(block.content.from, view.visibleRanges[0].from))?.top;
let toCoordsBottom = view.coordsAtPos(Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to))?.bottom;
if (fromCoordsTop === undefined || toCoordsBottom === undefined) {
idx++;
return;
}
// 对最后一个块进行特殊处理,让它直接延伸到底部
if (idx === blocks.length - 1) {
const editorHeight = view.dom.clientHeight;
const contentBottom = toCoordsBottom - view.documentTop + view.documentPadding.top;
// 让最后一个块直接延伸到编辑器底部
if (contentBottom < editorHeight) {
const extraHeight = editorHeight - contentBottom-10;
toCoordsBottom += extraHeight;
}
}
markers.push(new RectangleMarker(
idx++ % 2 == 0 ? "block-even" : "block-odd",
0,
// 参考 Heynote 的精确计算方式
fromCoordsTop - (view.documentTop - view.documentPadding.top) - 1 - 6,
null, // 宽度在 CSS 中设置为 100%
(toCoordsBottom - fromCoordsTop) + 15,
));
});
return markers;
},
update(update: any, dom: any) {
return update.docChanged || update.viewportChanged;
},
class: "code-blocks-layer"
});
/**
* 防止第一个块被删除
* 使用 changeFilter 来保护第一个块分隔符不被删除
*/
const preventFirstBlockFromBeingDeleted = EditorState.changeFilter.of((tr: any) => {
const protect: number[] = [];
// 获取块状态并获取第一个块的分隔符大小
const blocks = tr.startState.field(blockState);
if (blocks && blocks.length > 0) {
const firstBlock = blocks[0];
const firstBlockDelimiterSize = firstBlock.delimiter.to;
// 保护第一个块分隔符区域(从 0 到 firstBlockDelimiterSize
if (firstBlockDelimiterSize > 0) {
protect.push(0, firstBlockDelimiterSize);
}
}
// 如果是搜索替换操作,保护所有块分隔符
if (tr.annotations.some((a: any) => a.value === "input.replace" || a.value === "input.replace.all")) {
blocks.forEach((block: any) => {
if (block.delimiter) {
protect.push(block.delimiter.from, block.delimiter.to);
}
});
}
// 返回保护范围数组,如果没有需要保护的范围则返回 false
return protect.length > 0 ? protect : false;
});
/**
* 防止选择在第一个块之前
* 使用 transactionFilter 来确保选择不会在第一个块之前
*/
const preventSelectionBeforeFirstBlock = EditorState.transactionFilter.of((tr: any) => {
// 获取块状态并获取第一个块的分隔符大小
const blocks = tr.startState.field(blockState);
if (!blocks || blocks.length === 0) {
return tr;
}
const firstBlock = blocks[0];
const firstBlockDelimiterSize = firstBlock.delimiter.to;
if (firstBlockDelimiterSize <= 0) {
return tr;
}
// 检查选择范围,如果在第一个块之前,则调整到第一个块的内容开始位置
if (tr.selection) {
tr.selection.ranges.forEach((range: any) => {
if (range && range.from < firstBlockDelimiterSize) {
range.from = firstBlockDelimiterSize;
}
if (range && range.to < firstBlockDelimiterSize) {
range.to = firstBlockDelimiterSize;
}
});
}
return tr;
});
/**
* 获取块装饰扩展 - 简化选项
*/
export function getBlockDecorationExtensions(options: {
showBackground?: boolean;
} = {}) {
const {
showBackground = true,
} = options;
const extensions: any[] = [
noteBlockWidget(),
atomicNoteBlock,
preventFirstBlockFromBeingDeleted,
preventSelectionBeforeFirstBlock,
];
if (showBackground) {
extensions.push(blockLayer);
}
return extensions;
}

View File

@@ -0,0 +1,134 @@
/**
* 删除行功能
* 处理代码块边界
*/
import { EditorSelection, SelectionRange } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { getNoteBlockFromPos } from "./state";
interface LineBlock {
from: number;
to: number;
ranges: SelectionRange[];
}
/**
* 更新选择范围
*/
function updateSel(sel: EditorSelection, by: (range: SelectionRange) => SelectionRange): EditorSelection {
return EditorSelection.create(sel.ranges.map(by), sel.mainIndex);
}
/**
* 获取选中的行块
*/
function selectedLineBlocks(state: any): LineBlock[] {
let blocks: LineBlock[] = [];
let upto = -1;
for (let range of state.selection.ranges) {
let startLine = state.doc.lineAt(range.from);
let endLine = state.doc.lineAt(range.to);
if (!range.empty && range.to == endLine.from) {
endLine = state.doc.lineAt(range.to - 1);
}
if (upto >= startLine.number) {
let prev = blocks[blocks.length - 1];
prev.to = endLine.to;
prev.ranges.push(range);
} else {
blocks.push({
from: startLine.from,
to: endLine.to,
ranges: [range]
});
}
upto = endLine.number + 1;
}
return blocks;
}
/**
* 删除行命令
*/
export const deleteLine = (view: EditorView): boolean => {
if (view.state.readOnly) {
return false;
}
const { state } = view;
const selectedLines = selectedLineBlocks(state);
const changes = state.changes(selectedLines.map(({ from, to }) => {
const block = getNoteBlockFromPos(state, from);
// 如果不是删除整个代码块,需要调整删除范围
if (block && (from !== block.content.from || to !== block.content.to)) {
if (from > 0) {
from--;
} else if (to < state.doc.length) {
to++;
}
}
return { from, to };
}));
const selection = updateSel(
state.selection,
range => view.moveVertically(range, true)
).map(changes);
view.dispatch({
changes,
selection,
scrollIntoView: true,
userEvent: "delete.line"
});
return true;
};
/**
* 删除行命令函数,用于键盘映射
*/
export const deleteLineCommand = ({ state, dispatch }: { state: any; dispatch: any }) => {
if (state.readOnly) {
return false;
}
const selectedLines = selectedLineBlocks(state);
const changes = state.changes(selectedLines.map(({ from, to }: LineBlock) => {
const block = getNoteBlockFromPos(state, from);
// 如果不是删除整个代码块,需要调整删除范围
if (block && (from !== block.content.from || to !== block.content.to)) {
if (from > 0) {
from--;
} else if (to < state.doc.length) {
to++;
}
}
return { from, to };
}));
const selection = updateSel(
state.selection,
range => EditorSelection.cursor(range.from)
).map(changes);
dispatch(state.update({
changes,
selection,
scrollIntoView: true,
userEvent: "delete.line"
}));
return true;
};

View File

@@ -0,0 +1,99 @@
import { EditorSelection } from "@codemirror/state"
import * as prettier from "prettier/standalone"
import { getActiveNoteBlock } from "./state"
import { getLanguage } from "./lang-parser/languages"
import { SupportedLanguage } from "./types"
export const formatBlockContent = (view) => {
if (!view || view.state.readOnly)
return false
// 获取初始信息但不缓存state对象
const initialState = view.state
const block = getActiveNoteBlock(initialState)
if (!block) {
return false
}
const blockFrom = block.content.from
const blockTo = block.content.to
const blockLanguageName = block.language.name as SupportedLanguage
const language = getLanguage(blockLanguageName)
if (!language || !language.prettier) {
return false
}
// 获取初始需要的信息
const cursorPos = initialState.selection.asSingle().ranges[0].head
const content = initialState.sliceDoc(blockFrom, blockTo)
const tabSize = initialState.tabSize
// 检查光标是否在块的开始或结束
const cursorAtEdge = cursorPos == blockFrom || cursorPos == blockTo
// 执行异步格式化
const performFormat = async () => {
let formattedContent
try {
// 格式化代码
const formatted = await prettier.format(content, {
parser: language.prettier!.parser,
plugins: language.prettier!.plugins,
tabWidth: tabSize,
})
// 计算新光标位置
const cursorOffset = cursorAtEdge
? (cursorPos == blockFrom ? 0 : formatted.length)
: Math.min(cursorPos - blockFrom, formatted.length)
formattedContent = {
formatted,
cursorOffset
}
} catch (e) {
return false
}
try {
// 格式化完成后再次获取最新状态
const currentState = view.state
// 重新获取当前块的位置
const currentBlock = getActiveNoteBlock(currentState)
if (!currentBlock) {
console.warn('Block not found after formatting')
return false
}
// 使用当前块的实际位置
const currentBlockFrom = currentBlock.content.from
const currentBlockTo = currentBlock.content.to
// 基于最新状态创建更新
view.dispatch({
changes: {
from: currentBlockFrom,
to: currentBlockTo,
insert: formattedContent.formatted,
},
selection: EditorSelection.cursor(currentBlockFrom + Math.min(formattedContent.cursorOffset, formattedContent.formatted.length)),
scrollIntoView: true,
userEvent: "input"
})
return true;
} catch (error) {
console.error('Failed to apply formatting changes:', error);
return false;
}
}
// 执行异步格式化
performFormat()
return true // 立即返回 true表示命令已开始执行
}

View File

@@ -0,0 +1,211 @@
/**
* CodeBlock 扩展主入口
*
* 配置说明:
* - showBackground: 控制是否显示代码块的背景色区分
* - enableAutoDetection: 控制是否启用内容的语言自动检测功能
* - defaultLanguage: 新建代码块时使用的默认语言(也是自动检测的回退语言)
* - defaultAutoDetect: 新建代码块时是否默认添加-a标记启用自动检测
*
* 注意defaultLanguage 和 defaultAutoDetect 是配合使用的:
* - 如果 defaultAutoDetect=true新建块会是 ∞∞∞javascript-a会根据内容自动检测语言
* - 如果 defaultAutoDetect=false新建块会是 ∞∞∞javascript固定使用指定语言
*/
import {Extension} from '@codemirror/state';
import {lineNumbers} from '@codemirror/view';
// 导入核心模块
import {blockState} from './state';
import {getBlockDecorationExtensions} from './decorations';
import {getBlockSelectExtensions} from './selectAll';
import {getCopyPasteExtensions} from './copyPaste';
import {moveLineDown, moveLineUp} from './moveLines';
import {getCodeBlockLanguageExtension} from './lang-parser';
import {createLanguageDetection} from './language-detection';
import {EditorOptions, SupportedLanguage} from './types';
/**
* 代码块扩展配置选项
*/
export interface CodeBlockOptions {
/** 是否显示块背景色 */
showBackground?: boolean;
/** 是否启用语言自动检测功能 */
enableAutoDetection?: boolean;
/** 新建块时的默认语言 */
defaultLanguage?: SupportedLanguage;
/** 新建块时是否默认启用自动检测(添加-a标记 */
defaultAutoDetect?: boolean;
}
/**
* 获取块内行号信息
*/
function getBlockLineFromPos(state: any, pos: number) {
const line = state.doc.lineAt(pos);
const blocks = state.field(blockState);
const block = blocks.find((block: any) =>
block.content.from <= line.from && block.content.to >= line.from
);
if (block) {
const firstBlockLine = state.doc.lineAt(block.content.from).number;
return {
line: line.number - firstBlockLine + 1,
col: pos - line.from + 1,
length: line.length,
};
}
return null;
}
/**
* 创建块内行号扩展
*/
const blockLineNumbers = lineNumbers({
formatNumber(lineNo, state) {
if (state.doc.lines >= lineNo) {
const lineInfo = getBlockLineFromPos(state, state.doc.line(lineNo).from);
if (lineInfo !== null) {
return lineInfo.line.toString();
}
}
return "";
}
});
/**
* 创建代码块扩展
*/
export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extension {
const {
showBackground = true,
enableAutoDetection = true,
defaultLanguage = 'text',
defaultAutoDetect = true,
} = options;
return [
// 核心状态管理
blockState,
// 块内行号
blockLineNumbers,
// 语言解析支持
...getCodeBlockLanguageExtension(),
// 语言自动检测(如果启用)
...(enableAutoDetection ? [createLanguageDetection({
defaultLanguage: defaultLanguage,
confidenceThreshold: 0.15,
minContentLength: 8
})] : []),
// 视觉装饰系统
...getBlockDecorationExtensions({
showBackground
}),
// 块选择功能
...getBlockSelectExtensions(),
// 复制粘贴功能
...getCopyPasteExtensions(),
];
}
// 导出核心功能
export {
// 类型定义
type Block,
type SupportedLanguage,
type CreateBlockOptions,
SUPPORTED_LANGUAGES
} from './types';
// 状态管理
export {
blockState,
getActiveNoteBlock,
getFirstNoteBlock,
getLastNoteBlock,
getNoteBlockFromPos
} from './state';
// 解析器
export {
getBlocks,
getBlocksFromString,
firstBlockDelimiterSize
} from './parser';
// 命令
export * from './commands';
// 格式化功能
export { formatBlockContent } from './formatCode';
// 选择功能
export {
selectAll,
getBlockSelectExtensions
} from './selectAll';
// 复制粘贴功能
export {
copyCommand,
cutCommand,
pasteCommand,
getCopyPasteExtensions,
} from './copyPaste';
// 删除行功能
export {
deleteLine,
deleteLineCommand
} from './deleteLine';
// 移动行功能
export {
moveLineUp,
moveLineDown
} from './moveLines';
// 字符转置功能
export {
transposeChars
} from './transposeChars';
// 语言解析器
export {
getCodeBlockLanguageExtension,
getLanguage,
getLanguageTokens,
languageMapping,
LanguageInfo,
LANGUAGES as PARSER_LANGUAGES
} from './lang-parser';
// 语言检测
export {
createLanguageDetection,
detectLanguage,
detectLanguages,
levenshteinDistance,
type LanguageDetectionResult
} from './language-detection';
// 行号相关
export {getBlockLineFromPos, blockLineNumbers};
/**
* 默认导出
*/
export default createCodeBlockExtension;

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* 解析器构建脚本
* 使用 lezer-generator 从语法文件生成解析器
* 使用node build-parser.js
*/
import { execSync } from 'child_process';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
console.log('🚀 start building parser...');
try {
// 检查语法文件是否存在
const grammarFile = path.join(__dirname, 'codeblock.grammar');
if (!fs.existsSync(grammarFile)) {
throw new Error('grammarFile codeblock.grammar not found');
}
console.log('📄 grammar file:', grammarFile);
// 运行 lezer-generator
console.log('⚙️ building parser...');
execSync('npx lezer-generator codeblock.grammar -o parser.js', {
cwd: __dirname,
stdio: 'inherit'
});
// 检查生成的文件
const parserFile = path.join(__dirname, 'parser.js');
const termsFile = path.join(__dirname, 'parser.terms.js');
if (fs.existsSync(parserFile) && fs.existsSync(termsFile)) {
console.log('✅ parser file successfully generated');
console.log('📦 parser files:');
console.log(' - parser.js');
console.log(' - parser.terms.js');
} else {
throw new Error('failed to generate parser');
}
console.log('🎉 build success');
} catch (error) {
console.error('❌ build failed:', error.message);
process.exit(1);
}

View File

@@ -0,0 +1,65 @@
/**
* 代码块语言支持
* 提供多语言代码块支持
*/
import { parser } from "./parser.js";
import { configureNesting } from "./nested-parser";
import {
LRLanguage,
LanguageSupport,
foldNodeProp,
} from "@codemirror/language";
import { styleTags, tags as t } from "@lezer/highlight";
import { json } from "@codemirror/lang-json";
/**
* 折叠节点函数
*/
function foldNode(node: any) {
return { from: node.from, to: node.to - 1 };
}
/**
* 代码块语言定义
*/
export const CodeBlockLanguage = LRLanguage.define({
parser: parser.configure({
props: [
styleTags({
BlockDelimiter: t.tagName,
}),
foldNodeProp.add({
BlockContent(node: any) {
return { from: node.from, to: node.to - 1 };
},
}),
],
wrap: configureNesting(),
}),
languageData: {
commentTokens: { line: ";" }
}
});
/**
* 创建代码块语言支持
*/
export function codeBlockLang() {
let wrap = configureNesting();
let lang = CodeBlockLanguage.configure({ dialect: "", wrap: wrap });
return [
new LanguageSupport(lang, [json().support]),
];
}
/**
* 获取代码块语言扩展
*/
export function getCodeBlockLanguageExtension() {
return codeBlockLang();
}

View File

@@ -0,0 +1,24 @@
@external tokens blockContent from "./external-tokens.js" {
BlockContent
}
@top Document { Block* }
Block {
BlockDelimiter BlockContent
}
BlockDelimiter {
"\n∞∞∞" BlockLanguage Auto? "\n"
}
BlockLanguage {
"text" | "json" | "py" | "html" | "sql" | "md" | "java" | "php" |
"css" | "xml" | "cpp" | "rs" | "cs" | "rb" | "sh" | "yaml" | "toml" |
"go" | "clj" | "ex" | "erl" | "js" | "ts" | "swift" | "kt" | "groovy" |
"ps1" | "dart" | "scala"
}
@tokens {
Auto { "-a" }
}

View File

@@ -0,0 +1,51 @@
/**
* 外部标记器
* 用于识别代码块内容的边界
*/
import { ExternalTokenizer } from "@lezer/lr";
import { BlockContent } from "./parser.terms.js";
import { LANGUAGES } from "./languages";
const EOF = -1;
const FIRST_TOKEN_CHAR = "\n".charCodeAt(0);
const SECOND_TOKEN_CHAR = "∞".charCodeAt(0);
// 创建语言标记匹配器
const languageTokensMatcher = LANGUAGES.map(l => l.token).join("|");
const tokenRegEx = new RegExp(`^\\n∞∞∞(${languageTokensMatcher})(-a)?\\n`, "g");
/**
* 代码块内容标记器
* 识别 ∞∞∞ 分隔符之间的内容
*/
export const blockContent = new ExternalTokenizer((input) => {
let current = input.peek(0);
let next = input.peek(1);
if (current === EOF) {
return;
}
while (true) {
// 除非前两个字符是换行符和"∞"字符,否则我们没有代码块内容标记
// 所以我们不需要检查标记的其余部分
if (current === FIRST_TOKEN_CHAR && next === SECOND_TOKEN_CHAR) {
let potentialLang = "";
for (let i = 0; i < 18; i++) {
potentialLang += String.fromCharCode(input.peek(i));
}
if (potentialLang.match(tokenRegEx)) {
input.acceptToken(BlockContent);
return;
}
}
if (next === EOF) {
input.acceptToken(BlockContent, 1);
return;
}
current = input.advance(1);
next = input.peek(1);
}
});

View File

@@ -0,0 +1,38 @@
/**
* 代码块语言解析器入口
* 导出所有语言解析相关的功能
*/
// 主要语言支持
export {
CodeBlockLanguage,
codeBlockLang,
getCodeBlockLanguageExtension
} from './codeblock-lang';
// 语言映射和信息
export {
LanguageInfo,
LANGUAGES,
languageMapping,
getLanguage,
getLanguageTokens
} from './languages';
// 嵌套解析器
export {
configureNesting
} from './nested-parser';
// 解析器术语
export * from './parser.terms.js';
// 外部标记器
export {
blockContent
} from './external-tokens';
// 解析器
export {
parser
} from './parser.js';

View File

@@ -0,0 +1,133 @@
/**
* 语言映射和解析器配置
*/
import { jsonLanguage } from "@codemirror/lang-json";
import { pythonLanguage } from "@codemirror/lang-python";
import { javascriptLanguage, typescriptLanguage } from "@codemirror/lang-javascript";
import { htmlLanguage } from "@codemirror/lang-html";
import { StandardSQL } from "@codemirror/lang-sql";
import { markdownLanguage } from "@codemirror/lang-markdown";
import { javaLanguage } from "@codemirror/lang-java";
import { phpLanguage } from "@codemirror/lang-php";
import { cssLanguage } from "@codemirror/lang-css";
import { cppLanguage } from "@codemirror/lang-cpp";
import { xmlLanguage } from "@codemirror/lang-xml";
import { rustLanguage } from "@codemirror/lang-rust";
import { yamlLanguage } from "@codemirror/lang-yaml";
import { StreamLanguage } from "@codemirror/language";
import { ruby } from "@codemirror/legacy-modes/mode/ruby";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { go } from "@codemirror/legacy-modes/mode/go";
import { csharp } from "@codemirror/legacy-modes/mode/clike";
import { clojure } from "@codemirror/legacy-modes/mode/clojure";
import { erlang } from "@codemirror/legacy-modes/mode/erlang";
import { swift } from "@codemirror/legacy-modes/mode/swift";
import { kotlin } from "@codemirror/legacy-modes/mode/clike";
import { groovy } from "@codemirror/legacy-modes/mode/groovy";
import { powerShell } from "@codemirror/legacy-modes/mode/powershell";
import { scala } from "@codemirror/legacy-modes/mode/clike";
import { toml } from "@codemirror/legacy-modes/mode/toml";
import { elixir } from "codemirror-lang-elixir";
import { SupportedLanguage } from '../types';
import typescriptPlugin from "prettier/plugins/typescript"
import babelPrettierPlugin from "prettier/plugins/babel"
import htmlPrettierPlugin from "prettier/plugins/html"
import cssPrettierPlugin from "prettier/plugins/postcss"
import markdownPrettierPlugin from "prettier/plugins/markdown"
import yamlPrettierPlugin from "prettier/plugins/yaml"
import * as prettierPluginEstree from "prettier/plugins/estree";
/**
* 语言信息类
*/
export class LanguageInfo {
constructor(
public token: SupportedLanguage,
public name: string,
public parser: any,
public prettier?: {
parser: string;
plugins: any[];
}
) {}
}
/**
* 支持的语言列表(与 Worker 中的 LANGUAGES 对应)
*/
export const LANGUAGES: LanguageInfo[] = [
new LanguageInfo("text", "Plain Text", null),
new LanguageInfo("json", "JSON", jsonLanguage.parser, {
parser: "json",
plugins: [babelPrettierPlugin, prettierPluginEstree]
}),
new LanguageInfo("py", "Python", pythonLanguage.parser),
new LanguageInfo("html", "HTML", htmlLanguage.parser, {
parser: "html",
plugins: [htmlPrettierPlugin]
}),
new LanguageInfo("sql", "SQL", StandardSQL.language.parser),
new LanguageInfo("md", "Markdown", markdownLanguage.parser, {
parser: "markdown",
plugins: [markdownPrettierPlugin]
}),
new LanguageInfo("java", "Java", javaLanguage.parser),
new LanguageInfo("php", "PHP", phpLanguage.configure({top:"Program"}).parser),
new LanguageInfo("css", "CSS", cssLanguage.parser, {
parser: "css",
plugins: [cssPrettierPlugin]
}),
new LanguageInfo("xml", "XML", xmlLanguage.parser),
new LanguageInfo("cpp", "C++", cppLanguage.parser),
new LanguageInfo("rs", "Rust", rustLanguage.parser),
new LanguageInfo("cs", "C#", StreamLanguage.define(csharp).parser),
new LanguageInfo("rb", "Ruby", StreamLanguage.define(ruby).parser),
new LanguageInfo("sh", "Shell", StreamLanguage.define(shell).parser),
new LanguageInfo("yaml", "YAML", yamlLanguage.parser, {
parser: "yaml",
plugins: [yamlPrettierPlugin]
}),
new LanguageInfo("toml", "TOML", StreamLanguage.define(toml).parser),
new LanguageInfo("go", "Go", StreamLanguage.define(go).parser),
new LanguageInfo("clj", "Clojure", StreamLanguage.define(clojure).parser),
new LanguageInfo("ex", "Elixir", elixir().language.parser),
new LanguageInfo("erl", "Erlang", StreamLanguage.define(erlang).parser),
new LanguageInfo("js", "JavaScript", javascriptLanguage.parser, {
parser: "babel",
plugins: [babelPrettierPlugin, prettierPluginEstree]
}),
new LanguageInfo("ts", "TypeScript", typescriptLanguage.parser, {
parser: "typescript",
plugins: [typescriptPlugin, prettierPluginEstree]
}),
new LanguageInfo("swift", "Swift", StreamLanguage.define(swift).parser),
new LanguageInfo("kt", "Kotlin", StreamLanguage.define(kotlin).parser),
new LanguageInfo("groovy", "Groovy", StreamLanguage.define(groovy).parser),
new LanguageInfo("ps1", "PowerShell", StreamLanguage.define(powerShell).parser),
new LanguageInfo("dart", "Dart", null), // 暂无解析器
new LanguageInfo("scala", "Scala", StreamLanguage.define(scala).parser),
];
/**
* 语言映射表
*/
export const languageMapping = Object.fromEntries(
LANGUAGES.map(l => [l.token, l.parser])
);
/**
* 根据 token 获取语言信息
*/
export function getLanguage(token: SupportedLanguage): LanguageInfo | undefined {
return LANGUAGES.find(lang => lang.token === token);
}
/**
* 获取所有语言的 token 列表
*/
export function getLanguageTokens(): SupportedLanguage[] {
return LANGUAGES.map(lang => lang.token);
}

View File

@@ -0,0 +1,44 @@
/**
* 嵌套解析器配置
* 为不同语言的代码块提供语法高亮支持
*/
import { parseMixed } from "@lezer/common";
import { BlockContent, BlockLanguage } from "./parser.terms.js";
import { languageMapping } from "./languages";
/**
* 配置嵌套解析器
* 根据代码块的语言标记选择相应的解析器
*/
export function configureNesting() {
return parseMixed((node, input) => {
let id = node.type.id;
if (id === BlockContent) {
// 获取父节点中的语言标记
let blockLang = node.node.parent?.firstChild?.getChildren(BlockLanguage)[0];
let langName = blockLang ? input.read(blockLang.from, blockLang.to) : null;
// 如果 BlockContent 为空,不返回解析器
// 这可以避免 StreamLanguage 解析器在大缓冲区时出错
if (node.node.from === node.node.to) {
return null;
}
// 处理自动检测标记
if (langName && langName.endsWith('-a')) {
langName = langName.slice(0, -2); // 移除 '-a' 后缀
}
// 查找对应的语言解析器
if (langName && langName in languageMapping && languageMapping[langName] !== null) {
return {
parser: languageMapping[langName],
};
}
}
return null;
});
}

View File

@@ -0,0 +1,17 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {blockContent} from "./external-tokens.js"
export const parser = LRParser.deserialize({
version: 14,
states: "!jQQOQOOOVOQO'#C`O#SOPO'#C_OOOO'#Cc'#CcQQOQOOOOOO'#Ca'#CaO#XOSO,58zOOOO,58y,58yOOOO-E6a-E6aOOOP1G.f1G.fO#aOSO1G.fOOOP7+$Q7+$Q",
stateData: "#f~OXPO~OYTOZTO[TO]TO^TO_TO`TOaTObTOcTOdTOeTOfTOgTOhTOiTOjTOkTOlTOmTOnTOoTOpTOqTOrTOsTOtTOuTOvTO~OPVO~OUYOwXO~OwZO~O",
goto: "jWPPPX]aPdTROSTQOSRUPQSORWS",
nodeNames: "⚠ BlockContent Document Block BlockDelimiter BlockLanguage Auto",
maxTerm: 39,
skippedNodes: [0],
repeatNodeCount: 1,
tokenData: ",s~R`YZ!T}!O!n#V#W!y#W#X#z#X#Y$c#Z#[$|#[#]%y#^#_&b#_#`'a#a#b'l#d#e'w#f#g(p#g#h)T#h#i*t#l#m+y#m#n,[R!YPwQ%&x%&y!]P!`P%&x%&y!cP!fP%&x%&y!iP!nOXP~!qP#T#U!t~!yOU~~!|R#`#a#V#d#e#b#g#h#m~#YP#^#_#]~#bOl~~#eP#d#e#h~#mOd~~#rPf~#g#h#u~#zOb~~#}P#T#U$Q~$TP#f#g$W~$ZP#h#i$^~$cOu~~$fQ#f#g$l#l#m$w~$oP#`#a$r~$wOn~~$|Om~~%PQ#c#d%V#f#g%[~%[Ok~~%_P#c#d%b~%eP#c#d%h~%kP#j#k%n~%qP#m#n%t~%yOs~~%|P#h#i&P~&SP#a#b&V~&YP#`#a&]~&bO]~~&eQ#T#U&k#g#h&|~&nP#j#k&q~&tP#T#U&w~&|O`~~'RPo~#c#d'U~'XP#b#c'[~'aOZ~~'dP#h#i'g~'lOr~~'oP#W#X'r~'wO_~~'zR#[#](T#g#h(`#m#n(k~(WP#d#e(Z~(`Oa~~(cP!R!S(f~(kOt~~(pO[~~(sQ#U#V(y#g#h)O~)OOg~~)TOe~~)WS#V#W)d#[#]){#e#f*Q#k#l*]~)gP#T#U)j~)mP#`#a)p~)sP#T#U)v~){Ov~~*QOh~~*TP#`#a*W~*]O^~~*`P#]#^*c~*fP#Y#Z*i~*lP#h#i*o~*tOq~~*wR#X#Y+Q#c#d+c#g#h+t~+TP#l#m+W~+ZP#h#i+^~+cOY~~+fP#a#b+i~+lP#`#a+o~+tOj~~+yOp~~+|P#a#b,P~,SP#`#a,V~,[Oc~~,_P#T#U,b~,eP#a#b,h~,kP#`#a,n~,sOi~",
tokenizers: [blockContent, 0, 1],
topRules: {"Document":[0,2]},
tokenPrec: 0
})

View File

@@ -0,0 +1,8 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
BlockContent = 1,
Document = 2,
Block = 3,
BlockDelimiter = 4,
BlockLanguage = 5,
Auto = 6

View File

@@ -0,0 +1,311 @@
/**
* 基于 Web Worker 的语言自动检测
*/
import { EditorState, Annotation } from '@codemirror/state';
import { EditorView, ViewPlugin } from '@codemirror/view';
import { redoDepth } from '@codemirror/commands';
import { blockState, getActiveNoteBlock } from '../state';
import { levenshteinDistance } from './levenshtein';
import { LANGUAGES } from '../lang-parser/languages';
import { SupportedLanguage, Block } from '../types';
import { changeLanguageTo } from '../commands';
// ===== 类型定义 =====
/**
* 语言检测配置选项
*/
export interface LanguageDetectionConfig {
minContentLength?: number;
confidenceThreshold?: number;
idleDelay?: number;
defaultLanguage?: SupportedLanguage;
}
/**
* 语言检测结果
*/
export interface LanguageDetectionResult {
language: SupportedLanguage;
confidence: number;
}
/**
* Worker 消息接口
*/
interface WorkerMessage {
content: string;
idx: number;
}
/**
* Worker 响应接口
*/
interface WorkerResponse {
language: string;
confidence: number;
idx: number;
}
// ===== 常量配置 =====
/**
* 默认配置
*/
const DEFAULT_CONFIG = {
minContentLength: 20,
confidenceThreshold: 0.15,
idleDelay: 1000,
defaultLanguage: 'text' as SupportedLanguage,
};
/**
* 支持的语言列表
*/
const SUPPORTED_LANGUAGES = new Set([
"json", "py", "html", "sql", "md", "java", "php", "css", "xml",
"cpp", "rs", "cs", "rb", "sh", "yaml", "toml", "go", "clj",
"ex", "erl", "js", "ts", "swift", "kt", "groovy", "ps1", "dart", "scala"
]);
/**
* 语言标记映射表
*/
const LANGUAGE_MAP = new Map(LANGUAGES.map(lang => [lang.token, lang.token]));
// ===== 工具函数 =====
/**
* 兼容性函数requestIdleCallback
*/
function requestIdleCallbackCompat(callback: () => void): number {
if (typeof window !== 'undefined' && window.requestIdleCallback) {
return window.requestIdleCallback(callback);
}
return setTimeout(callback, 0) as any;
}
/**
* 兼容性函数cancelIdleCallback
*/
function cancelIdleCallbackCompat(id: number): void {
if (typeof window !== 'undefined' && window.cancelIdleCallback) {
window.cancelIdleCallback(id);
} else {
clearTimeout(id);
}
}
/**
* 语言更改注解
*/
const languageChangeAnnotation = Annotation.define<boolean>();
// ===== Web Worker 管理器 =====
/**
* 语言检测 Worker 管理器
* 负责 Worker 的生命周期管理和消息通信
*/
class LanguageDetectionWorker {
private worker: Worker | null = null;
private pendingRequests = new Map<number, {
resolve: (result: LanguageDetectionResult) => void;
reject: (error: Error) => void;
}>();
private requestId = 0;
constructor() {
this.initWorker();
}
/**
* 初始化 Worker
*/
private initWorker(): void {
try {
this.worker = new Worker('/langdetect-worker.js');
this.worker.onmessage = (event) => {
const response: WorkerResponse = event.data;
const request = this.pendingRequests.get(response.idx);
if (request) {
this.pendingRequests.delete(response.idx);
if (response.language) {
request.resolve({
language: response.language as SupportedLanguage,
confidence: response.confidence
});
} else {
request.reject(new Error('No detection result'));
}
}
};
this.worker.onerror = () => {
this.pendingRequests.forEach(request => request.reject(new Error('Worker error')));
this.pendingRequests.clear();
};
} catch (error) {
console.error('Failed to initialize worker:', error);
}
}
/**
* 检测语言
*/
async detectLanguage(content: string): Promise<LanguageDetectionResult> {
if (!this.worker) {
throw new Error('Worker not initialized');
}
return new Promise((resolve, reject) => {
const id = ++this.requestId;
this.pendingRequests.set(id, { resolve, reject });
this.worker!.postMessage({ content, idx: id } as WorkerMessage);
// 5秒超时
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error('Detection timeout'));
}
}, 5000);
});
}
/**
* 销毁 Worker
*/
destroy(): void {
if (this.worker) {
this.worker.terminate();
this.worker = null;
}
this.pendingRequests.clear();
}
}
// ===== 语言检测插件 =====
/**
* 创建语言检测插件
*/
export function createLanguageDetection(config: LanguageDetectionConfig = {}): ViewPlugin<any> {
const finalConfig = { ...DEFAULT_CONFIG, ...config };
const contentCache = new Map<number, string>();
let idleCallbackId: number | null = null;
let worker: LanguageDetectionWorker | null = null;
return ViewPlugin.fromClass(
class LanguageDetectionPlugin {
constructor(public view: EditorView) {
worker = new LanguageDetectionWorker();
}
update(update: any) {
if (update.docChanged && !update.transactions.some((tr: any) =>
tr.annotation(languageChangeAnnotation))) {
if (idleCallbackId !== null) {
cancelIdleCallbackCompat(idleCallbackId);
}
idleCallbackId = requestIdleCallbackCompat(() => {
this.performDetection(update.state);
});
}
}
private performDetection(state: EditorState): void {
const block = getActiveNoteBlock(state);
if (!block || !block.language.auto) return;
const blocks = state.field(blockState);
const blockIndex = blocks.indexOf(block);
const content = state.doc.sliceString(block.content.from, block.content.to);
// 内容为空时重置为默认语言
if (content === "" && redoDepth(state) === 0) {
if (block.language.name !== finalConfig.defaultLanguage) {
changeLanguageTo(state, this.view.dispatch, block, finalConfig.defaultLanguage, true);
}
contentCache.delete(blockIndex);
return;
}
// 内容太短则跳过
if (content.length <= finalConfig.minContentLength) return;
// 检查内容变化
const cachedContent = contentCache.get(blockIndex);
if (cachedContent && levenshteinDistance(cachedContent, content) < content.length * 0.1) {
return;
}
this.detectAndUpdate(content, block, blockIndex, state);
}
private async detectAndUpdate(content: string, block: Block, blockIndex: number, state: EditorState): Promise<void> {
if (!worker) return;
try {
const result = await worker.detectLanguage(content);
if (result.confidence >= finalConfig.confidenceThreshold &&
result.language !== block.language.name &&
SUPPORTED_LANGUAGES.has(result.language) &&
LANGUAGE_MAP.has(result.language)) {
// 只有在用户没有撤销操作时才更改语言
if (redoDepth(state) === 0) {
changeLanguageTo(state, this.view.dispatch, block, result.language, true);
}
}
contentCache.set(blockIndex, content);
} catch (error) {
console.warn('Language detection failed:', error);
}
}
destroy() {
if (idleCallbackId !== null) {
cancelIdleCallbackCompat(idleCallbackId);
}
if (worker) {
worker.destroy();
worker = null;
}
contentCache.clear();
}
}
);
}
// ===== 公共 API =====
/**
* 手动检测单个内容的语言
*/
export async function detectLanguage(content: string): Promise<LanguageDetectionResult> {
const worker = new LanguageDetectionWorker();
try {
return await worker.detectLanguage(content);
} finally {
worker.destroy();
}
}
/**
* 批量检测多个内容的语言
*/
export async function detectLanguages(contents: string[]): Promise<LanguageDetectionResult[]> {
const worker = new LanguageDetectionWorker();
try {
return await Promise.all(contents.map(content => worker.detectLanguage(content)));
} finally {
worker.destroy();
}
}

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