13 Commits

Author SHA1 Message Date
b5d90cc59a Added the ability to automatically scroll to active tabs 2025-10-05 18:53:49 +08:00
d49ffc20df 🎨 Refactor and optimize code 2025-10-05 00:58:27 +08:00
c22e349181 🐛 Fixed tab switch issue 2025-10-04 14:31:06 +08:00
45968cd353 Added tab functionality and optimized related configurations 2025-10-04 02:27:32 +08:00
2d02bf7f1f 🚧 Optimize 2025-10-01 22:32:57 +08:00
1216b0b67c 🚧 Optimize 2025-10-01 18:15:22 +08:00
cf8bf688bf 🚧 Optimize 2025-09-30 00:28:15 +08:00
4d6a4ff79f 🐛 Fixed SQLite time field issue 2025-09-29 00:59:59 +08:00
3077d5a7c5 ♻️ Refactor document selector and cache management logic 2025-09-29 00:26:05 +08:00
bc0569af93 🎨 Optimize code structure 2025-09-28 01:09:20 +08:00
0188b618f2 🐛 Fixed docker prettier plugin issue 2025-09-27 20:11:31 +08:00
08860e9a5c ⬆️ Update dependencies 2025-09-24 23:57:22 +08:00
a56d4ef379 Adds multiple code block language support 2025-09-24 23:05:21 +08:00
95 changed files with 6724 additions and 4880 deletions

View File

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

View File

@@ -1,51 +0,0 @@
// 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";
/**
* A Time represents an instant in time with nanosecond precision.
*
* Programs using times should typically store and pass them as values,
* not pointers. That is, time variables and struct fields should be of
* type [time.Time], not *time.Time.
*
* A Time value can be used by multiple goroutines simultaneously except
* that the methods [Time.GobDecode], [Time.UnmarshalBinary], [Time.UnmarshalJSON] and
* [Time.UnmarshalText] are not concurrency-safe.
*
* Time instants can be compared using the [Time.Before], [Time.After], and [Time.Equal] methods.
* The [Time.Sub] method subtracts two instants, producing a [Duration].
* The [Time.Add] method adds a Time and a Duration, producing a Time.
*
* The zero value of type Time is January 1, year 1, 00:00:00.000000000 UTC.
* As this time is unlikely to come up in practice, the [Time.IsZero] method gives
* a simple way of detecting a time that has not been initialized explicitly.
*
* Each time has an associated [Location]. The methods [Time.Local], [Time.UTC], and Time.In return a
* Time with a specific Location. Changing the Location of a Time value with
* these methods does not change the actual instant it represents, only the time
* zone in which to interpret it.
*
* Representations of a Time value saved by the [Time.GobEncode], [Time.MarshalBinary], [Time.AppendBinary],
* [Time.MarshalJSON], [Time.MarshalText] and [Time.AppendText] methods store the [Time.Location]'s offset,
* but not the location name. They therefore lose information about Daylight Saving Time.
*
* In addition to the required “wall clock” reading, a Time may contain an optional
* reading of the current process's monotonic clock, to provide additional precision
* for comparison or subtraction.
* See the “Monotonic Clocks” section in the package documentation for details.
*
* Note that the Go == operator compares not just the time instant but also the
* Location and the monotonic clock reading. Therefore, Time values should not
* be used as map or database keys without first guaranteeing that the
* identical Location has been set for all values, which can be achieved
* through use of the UTC or Local method, and that the monotonic clock reading
* has been stripped by setting t = t.Round(0). In general, prefer t.Equal(u)
* to t == u, since t.Equal uses the most accurate comparison available and
* correctly handles the case when only one of its arguments has a monotonic
* clock reading.
*/
export type Time = any;

View File

@@ -68,4 +68,9 @@ export enum TranslatorType {
* DeeplTranslatorType DeepL翻译器
*/
DeeplTranslatorType = "deepl",
/**
* TartuNLPTranslatorType TartuNLP翻译器
*/
TartuNLPTranslatorType = "tartunlp",
};

View File

@@ -5,10 +5,6 @@
// @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 time$0 from "../../../time/models.js";
/**
* AppConfig 应用配置 - 按照前端设置页面分类组织
*/
@@ -196,8 +192,8 @@ export class Document {
"id": number;
"title": string;
"content": string;
"createdAt": time$0.Time;
"updatedAt": time$0.Time;
"createdAt": string;
"updatedAt": string;
"is_deleted": boolean;
/**
@@ -217,10 +213,10 @@ export class Document {
this["content"] = "";
}
if (!("createdAt" in $$source)) {
this["createdAt"] = null;
this["createdAt"] = "";
}
if (!("updatedAt" in $$source)) {
this["updatedAt"] = null;
this["updatedAt"] = "";
}
if (!("is_deleted" in $$source)) {
this["is_deleted"] = false;
@@ -490,6 +486,11 @@ export class GeneralConfig {
*/
"enableLoadingAnimation": boolean;
/**
* 是否启用标签页模式
*/
"enableTabs": boolean;
/** Creates a new GeneralConfig instance. */
constructor($$source: Partial<GeneralConfig> = {}) {
if (!("alwaysOnTop" in $$source)) {
@@ -516,6 +517,9 @@ export class GeneralConfig {
if (!("enableLoadingAnimation" in $$source)) {
this["enableLoadingAnimation"] = false;
}
if (!("enableTabs" in $$source)) {
this["enableTabs"] = false;
}
Object.assign(this, $$source);
}
@@ -1143,8 +1147,8 @@ export class Theme {
"type": ThemeType;
"colors": ThemeColorConfig;
"isDefault": boolean;
"createdAt": time$0.Time;
"updatedAt": time$0.Time;
"createdAt": string;
"updatedAt": string;
/** Creates a new Theme instance. */
constructor($$source: Partial<Theme> = {}) {
@@ -1164,10 +1168,10 @@ export class Theme {
this["isDefault"] = false;
}
if (!("createdAt" in $$source)) {
this["createdAt"] = null;
this["createdAt"] = "";
}
if (!("updatedAt" in $$source)) {
this["updatedAt"] = null;
this["updatedAt"] = "";
}
Object.assign(this, $$source);

View File

@@ -10,6 +10,9 @@
// @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";
@@ -54,6 +57,11 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
return $resultPromise;
}
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2900331732, options) as any;
return $resultPromise;
}
/**
* StartAutoBackup 启动自动备份定时器
*/

View File

@@ -49,14 +49,6 @@ export function GetDocumentByID(id: number): Promise<models$0.Document | null> &
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;
}
/**
* ListAllDocumentsMeta lists all active (non-deleted) document metadata
*/

View File

@@ -119,6 +119,42 @@ export enum MigrationStatus {
MigrationStatusFailed = "failed",
};
/**
* OSInfo 操作系统信息
*/
export class OSInfo {
"id": string;
"name": string;
"version": string;
"branding": string;
/** Creates a new OSInfo instance. */
constructor($$source: Partial<OSInfo> = {}) {
if (!("id" in $$source)) {
this["id"] = "";
}
if (!("name" in $$source)) {
this["name"] = "";
}
if (!("version" in $$source)) {
this["version"] = "";
}
if (!("branding" in $$source)) {
this["branding"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new OSInfo instance from a string or object.
*/
static createFrom($$source: any = {}): OSInfo {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new OSInfo($$parsedSource as Partial<OSInfo>);
}
}
/**
* SelfUpdateResult 自我更新结果
*/
@@ -202,6 +238,54 @@ export class SelfUpdateResult {
}
}
/**
* SystemInfo 系统信息
*/
export class SystemInfo {
"os": string;
"arch": string;
"debug": boolean;
"osInfo": OSInfo | null;
"platformInfo": { [_: string]: any };
/** Creates a new SystemInfo instance. */
constructor($$source: Partial<SystemInfo> = {}) {
if (!("os" in $$source)) {
this["os"] = "";
}
if (!("arch" in $$source)) {
this["arch"] = "";
}
if (!("debug" in $$source)) {
this["debug"] = false;
}
if (!("osInfo" in $$source)) {
this["osInfo"] = null;
}
if (!("platformInfo" in $$source)) {
this["platformInfo"] = {};
}
Object.assign(this, $$source);
}
/**
* Creates a new SystemInfo instance from a string or object.
*/
static createFrom($$source: any = {}): SystemInfo {
const $$createField3_0 = $$createType1;
const $$createField4_0 = $$createType2;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("osInfo" in $$parsedSource) {
$$parsedSource["osInfo"] = $$createField3_0($$parsedSource["osInfo"]);
}
if ("platformInfo" in $$parsedSource) {
$$parsedSource["platformInfo"] = $$createField4_0($$parsedSource["platformInfo"]);
}
return new SystemInfo($$parsedSource as Partial<SystemInfo>);
}
}
/**
* WindowInfo 窗口信息(简化版)
*/
@@ -229,7 +313,7 @@ export class WindowInfo {
* Creates a new WindowInfo instance from a string or object.
*/
static createFrom($$source: any = {}): WindowInfo {
const $$createField0_0 = $$createType1;
const $$createField0_0 = $$createType4;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("Window" in $$parsedSource) {
$$parsedSource["Window"] = $$createField0_0($$parsedSource["Window"]);
@@ -259,5 +343,8 @@ export class WindowSnapService {
}
// Private type creation functions
const $$createType0 = application$0.WebviewWindow.createFrom;
const $$createType0 = OSInfo.createFrom;
const $$createType1 = $Create.Nullable($$createType0);
const $$createType2 = $Create.Map($Create.Any, $Create.Any);
const $$createType3 = application$0.WebviewWindow.createFrom;
const $$createType4 = $Create.Nullable($$createType3);

View File

@@ -10,6 +10,10 @@
// @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";
@@ -34,6 +38,26 @@ export function GetMemoryStats(): Promise<$models.MemoryStats> & { cancel(): voi
return $typingPromise;
}
/**
* GetSystemInfo 获取系统环境信息
*/
export function GetSystemInfo(): Promise<$models.SystemInfo | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(2629436820) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType2($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* SetAppReferences 设置应用引用
*/
export function SetAppReferences(app: application$0.App | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3873053414, app) as any;
return $resultPromise;
}
/**
* TriggerGC 手动触发垃圾回收
*/
@@ -44,3 +68,5 @@ export function TriggerGC(): Promise<void> & { cancel(): void } {
// Private type creation functions
const $$createType0 = $models.MemoryStats.createFrom;
const $$createType1 = $models.SystemInfo.createFrom;
const $$createType2 = $Create.Nullable($$createType1);

View File

@@ -14,27 +14,6 @@ import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// @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")
@@ -43,6 +22,19 @@ export function GetStandardLanguageCode(translatorType: translator$0.TranslatorT
*/
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 $$createType1($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* GetTranslators 获取所有可用翻译器类型
* @returns {[]string} 翻译器类型列表
*/
export function GetTranslators(): Promise<string[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(3720069432) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType2($result);
}) as any;
@@ -73,6 +65,6 @@ export function TranslateWith(text: string, $from: string, to: string, translato
}
// Private type creation functions
const $$createType0 = $Create.Array($Create.Any);
const $$createType1 = translator$0.LanguageInfo.createFrom;
const $$createType2 = $Create.Map($Create.Any, $$createType1);
const $$createType0 = translator$0.LanguageInfo.createFrom;
const $$createType1 = $Create.Map($Create.Any, $$createType0);
const $$createType2 = $Create.Array($Create.Any);

View File

@@ -16,6 +16,9 @@ declare module 'vue' {
MemoryMonitor: typeof import('./src/components/monitor/MemoryMonitor.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
TabContainer: typeof import('./src/components/tabs/TabContainer.vue')['default']
TabContextMenu: typeof import('./src/components/tabs/TabContextMenu.vue')['default']
TabItem: typeof import('./src/components/tabs/TabItem.vue')['default']
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

@@ -9,10 +9,11 @@
"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" vue-tsc && cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" vite build --mode production",
"preview": "vite preview",
"lint": "eslint",
"lint:fix": "eslint --fix"
"lint:fix": "eslint --fix",
"build:lang-parser": "node src/views/editor/extensions/codeblock/lang-parser/build-parser.js"
},
"dependencies": {
"@codemirror/autocomplete": "^6.18.7",
"@codemirror/autocomplete": "^6.19.0",
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-angular": "^0.1.4",
"@codemirror/lang-cpp": "^6.0.3",
@@ -30,28 +31,28 @@
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sass": "^6.0.2",
"@codemirror/lang-sql": "^6.9.1",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-vue": "^0.1.3",
"@codemirror/lang-wast": "^6.0.2",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.3",
"@codemirror/language-data": "^6.5.1",
"@codemirror/legacy-modes": "^6.5.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/lint": "^6.8.5",
"@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.2",
"@codemirror/view": "^6.38.4",
"@cospaia/prettier-plugin-clojure": "^0.0.2",
"@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.2",
"@prettier/plugin-xml": "^3.4.2",
"@replit/codemirror-lang-svelte": "^6.0.0",
"@toml-tools/lexer": "^1.0.0",
"@toml-tools/parser": "^1.0.0",
"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",
"groovy-beautify": "^0.0.17",
"hsl-matcher": "^1.2.4",
"java-parser": "^3.0.1",
@@ -62,29 +63,29 @@
"pinia-plugin-persistedstate": "^4.5.0",
"prettier": "^3.6.2",
"remarkable": "^2.0.1",
"sass": "^1.92.1",
"vue": "^3.5.21",
"sass": "^1.93.2",
"vue": "^3.5.22",
"vue-i18n": "^11.1.12",
"vue-pick-colors": "^1.8.0",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
"@eslint/js": "^9.36.0",
"@lezer/generator": "^1.8.0",
"@types/node": "^24.3.1",
"@types/node": "^24.5.2",
"@types/remarkable": "^2.0.8",
"@vitejs/plugin-vue": "^6.0.1",
"@wailsio/runtime": "latest",
"cross-env": "^7.0.3",
"eslint": "^9.35.0",
"eslint-plugin-vue": "^10.4.0",
"cross-env": "^10.1.0",
"eslint": "^9.36.0",
"eslint-plugin-vue": "^10.5.0",
"globals": "^16.4.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.43.0",
"unplugin-vue-components": "^29.0.0",
"vite": "^7.1.5",
"typescript": "^5.9.3",
"typescript-eslint": "^8.45.0",
"unplugin-vue-components": "^29.1.0",
"vite": "^7.1.7",
"vite-plugin-node-polyfills": "^0.24.0",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.6"
"vue-tsc": "^3.1.0"
}
}

View File

@@ -1,22 +1,21 @@
<script setup lang="ts">
import {onMounted} from 'vue';
import {onBeforeMount} from 'vue';
import {useConfigStore} from '@/stores/configStore';
import {useSystemStore} from '@/stores/systemStore';
import {useKeybindingStore} from '@/stores/keybindingStore';
import {useThemeStore} from '@/stores/themeStore';
import {useUpdateStore} from '@/stores/updateStore';
import {useBackupStore} from '@/stores/backupStore';
import WindowTitleBar from '@/components/titlebar/WindowTitleBar.vue';
import {useTranslationStore} from "@/stores/translationStore";
const configStore = useConfigStore();
const systemStore = useSystemStore();
const keybindingStore = useKeybindingStore();
const themeStore = useThemeStore();
const updateStore = useUpdateStore();
const backupStore = useBackupStore();
const translationStore = useTranslationStore();
// 应用启动时加载配置和初始化系统信息
onMounted(async () => {
onBeforeMount(async () => {
// 并行初始化配置、系统信息和快捷键配置
await Promise.all([
configStore.initConfig(),
@@ -26,11 +25,9 @@ onMounted(async () => {
// 初始化语言和主题
await configStore.initializeLanguage();
themeStore.initializeTheme();
// 初始化备份服务
await backupStore.initialize();
await themeStore.initializeTheme();
await translationStore.loadTranslators();
// 启动时检查更新
await updateStore.checkOnStartup();
});

View File

@@ -8,6 +8,7 @@
--dark-toolbar-text: #ffffff;
--dark-toolbar-text-secondary: #cccccc;
--dark-toolbar-button-hover: #404040;
--dark-tab-active-line: linear-gradient(90deg, #007acc 0%, #0099ff 100%);
--dark-bg-secondary: #0E1217;
--dark-text-secondary: #a0aec0;
--dark-text-muted: #666;
@@ -40,6 +41,7 @@
--light-toolbar-text: #212529;
--light-toolbar-text-secondary: #495057;
--light-toolbar-button-hover: #e9ecef;
--light-tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
--light-bg-secondary: #f7fef7;
--light-text-secondary: #374151;
--light-text-muted: #6b7280;
@@ -73,6 +75,7 @@
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
--toolbar-button-hover: var(--dark-toolbar-button-hover);
--toolbar-separator: var(--dark-toolbar-button-hover);
--tab-active-line: var(--dark-tab-active-line);
--bg-secondary: var(--dark-bg-secondary);
--text-secondary: var(--dark-text-secondary);
--text-muted: var(--dark-text-muted);
@@ -112,6 +115,7 @@
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
--toolbar-button-hover: var(--dark-toolbar-button-hover);
--toolbar-separator: var(--dark-toolbar-button-hover);
--tab-active-line: var(--dark-tab-active-line);
--bg-secondary: var(--dark-bg-secondary);
--text-secondary: var(--dark-text-secondary);
--text-muted: var(--dark-text-muted);
@@ -149,6 +153,7 @@
--toolbar-text-secondary: var(--light-toolbar-text-secondary);
--toolbar-button-hover: var(--light-toolbar-button-hover);
--toolbar-separator: var(--light-toolbar-button-hover);
--tab-active-line: var(--light-tab-active-line);
--bg-secondary: var(--light-bg-secondary);
--text-secondary: var(--light-text-secondary);
--text-muted: var(--light-text-muted);
@@ -185,6 +190,7 @@
--toolbar-text-secondary: var(--light-toolbar-text-secondary);
--toolbar-button-hover: var(--light-toolbar-button-hover);
--toolbar-separator: var(--light-toolbar-button-hover);
--tab-active-line: var(--light-tab-active-line);
--bg-secondary: var(--light-bg-secondary);
--text-secondary: var(--light-text-secondary);
--text-muted: var(--light-text-muted);
@@ -220,6 +226,7 @@
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
--toolbar-button-hover: var(--dark-toolbar-button-hover);
--toolbar-separator: var(--dark-toolbar-button-hover);
--tab-active-line: var(--dark-tab-active-line);
--bg-secondary: var(--dark-bg-secondary);
--text-secondary: var(--dark-text-secondary);
--text-muted: var(--dark-text-muted);

View File

@@ -46,6 +46,7 @@ export const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = {
globalHotkey: 'general.globalHotkey',
enableWindowSnap: 'general.enableWindowSnap',
enableLoadingAnimation: 'general.enableLoadingAnimation',
enableTabs: 'general.enableTabs',
} as const;
export const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
@@ -113,11 +114,12 @@ export const DEFAULT_CONFIG: AppConfig = {
},
enableWindowSnap: true,
enableLoadingAnimation: true,
enableTabs: false,
},
editing: {
fontSize: CONFIG_LIMITS.fontSize.default,
fontFamily: FONT_OPTIONS[0].value,
fontWeight: 'normal',
fontWeight: '400',
lineHeight: CONFIG_LIMITS.lineHeight.default,
enableTabIndent: true,
tabSize: CONFIG_LIMITS.tabSize.default,

View File

@@ -0,0 +1,13 @@
/**
* 编辑器相关常量配置
*/
// 编辑器实例管理
export const EDITOR_CONFIG = {
/** 最多缓存的编辑器实例数量 */
MAX_INSTANCES: 5,
/** 语法树缓存过期时间(毫秒) */
SYNTAX_TREE_CACHE_TIMEOUT: 30000,
/** 加载状态延迟时间(毫秒) */
LOADING_DELAY: 500,
} as const;

View File

@@ -2,12 +2,12 @@ export type SupportedLocaleType = 'zh-CN' | 'en-US';
// 支持的语言列表
export const SUPPORTED_LOCALES = [
{
code: 'zh-CN' as SupportedLocaleType,
name: '简体中文'
},
{
code: 'en-US' as SupportedLocaleType,
name: 'English'
},
{
code: 'zh-CN' as SupportedLocaleType,
name: '简体中文'
}
] as const;

View File

@@ -0,0 +1,49 @@
/**
* 默认翻译配置
*/
export const DEFAULT_TRANSLATION_CONFIG = {
minSelectionLength: 2,
maxTranslationLength: 5000,
} as const;
/**
* 翻译相关的错误消息
*/
export const TRANSLATION_ERRORS = {
NO_TEXT: 'no text to translate',
TRANSLATION_FAILED: 'translation failed',
} as const;
/**
* 翻译结果接口
*/
export interface TranslationResult {
translatedText: string;
error?: string;
}
/**
* 语言信息接口
*/
export interface LanguageInfo {
Code: string; // 语言代码
Name: string; // 语言名称
}
/**
* 翻译器扩展配置
*/
export interface TranslatorConfig {
/** 最小选择字符数才显示翻译按钮 */
minSelectionLength: number;
/** 最大翻译字符数 */
maxTranslationLength: number;
}
/**
* 翻译图标SVG
*/
export const TRANSLATION_ICON_SVG = `
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M599.68 485.056h-8l30.592 164.672c20.352-7.04 38.72-17.344 54.912-31.104a271.36 271.36 0 0 1-40.704-64.64l32.256-4.032c8.896 17.664 19.072 33.28 30.592 46.72 23.872-27.968 42.24-65.152 55.04-111.744l-154.688 0.128z m121.92 133.76c18.368 15.36 39.36 26.56 62.848 33.472l14.784 4.416-8.64 30.336-14.72-4.352a205.696 205.696 0 0 1-76.48-41.728c-20.672 17.92-44.928 31.552-71.232 40.064l20.736 110.912H519.424l-9.984 72.512h385.152c18.112 0 32.704-14.144 32.704-31.616V295.424a32.128 32.128 0 0 0-32.704-31.552H550.528l35.2 189.696h79.424v-31.552h61.44v31.552h102.4v31.616h-42.688c-14.272 55.488-35.712 100.096-64.64 133.568zM479.36 791.68H193.472c-36.224 0-65.472-28.288-65.472-63.168V191.168C128 156.16 157.312 128 193.472 128h327.68l20.544 104.32h352.832c36.224 0 65.472 28.224 65.472 63.104v537.408c0 34.944-29.312 63.168-65.472 63.168H468.608l10.688-104.32zM337.472 548.352v-33.28H272.768v-48.896h60.16V433.28h-60.16v-41.728h64.704v-32.896h-102.4v189.632h102.4z m158.272 0V453.76c0-17.216-4.032-30.272-12.16-39.488-8.192-9.152-20.288-13.696-36.032-13.696a55.04 55.04 0 0 0-24.768 5.376 39.04 39.04 0 0 0-17.088 15.936h-1.984l-5.056-18.56h-28.352V548.48h37.12V480c0-17.088 2.304-29.376 6.912-36.736 4.608-7.424 12.16-11.072 22.528-11.072 7.616 0 13.248 2.56 16.64 7.872 3.52 5.248 5.312 13.056 5.312 23.488v84.736h36.928z" fill="currentColor"></path>
</svg>`;

View File

@@ -43,7 +43,10 @@ func Format(this js.Value, args []js.Value) any {
SpaceRedirects: spaceRedirects,
}
result := lib.FormatFileLines(originalLines, c)
result, err := lib.FormatFileLines(originalLines, c)
if err != nil {
return []any{true, err.Error()}
}
return []any{false, result}
}

View File

@@ -121,11 +121,11 @@ func FormatOnBuild(n *ExtendedNode, c *Config) string {
return n.OriginalMultiline
}
func FormatFileLines(fileLines []string, c *Config) string {
func FormatFileLines(fileLines []string, c *Config) (string, error) {
result, err := parser.Parse(strings.NewReader(strings.Join(fileLines, "")))
if err != nil {
log.Printf("%s\n", strings.Join(fileLines, ""))
log.Fatalf("Error parsing file: %v", err)
return "", fmt.Errorf("Error parsing file: %v", err)
}
parseState := &ParseState{
@@ -148,7 +148,7 @@ func FormatFileLines(fileLines []string, c *Config) string {
if c.TrailingNewline {
parseState.Output += "\n"
}
return parseState.Output
return parseState.Output, nil
}
func BuildExtendedNode(n *parser.Node, fileLines []string) *ExtendedNode {
@@ -572,9 +572,12 @@ func getCmd(n *ExtendedNode, shouldSplitNode bool) []string {
if shouldSplitNode {
parts, err := shlex.Split(rawValue)
if err != nil {
log.Fatalf("Error splitting: %s\n", node.Node.Value)
// Fallback: if splitting fails, use raw value as a single token to avoid exiting
log.Printf("Error splitting: %s: %v\n", node.Node.Value, err)
cmd = append(cmd, rawValue)
} else {
cmd = append(cmd, parts...)
}
cmd = append(cmd, parts...)
} else {
cmd = append(cmd, rawValue)
}
@@ -587,7 +590,11 @@ func shouldRunInShell(node string) bool {
// https://docs.docker.com/reference/dockerfile/#entrypoint
parts, err := shlex.Split(node)
if err != nil {
log.Fatalf("Error splitting: %s\n", node)
// If we cannot reliably split, heuristically decide based on common shell operators
if strings.Contains(node, "&&") || strings.Contains(node, ";") || strings.Contains(node, "||") {
return true
}
return false
}
needsShell := false
@@ -785,8 +792,8 @@ func formatBash(s string, c *Config) string {
r := strings.NewReader(s)
f, err := syntax.NewParser(syntax.KeepComments(true)).Parse(r, "")
if err != nil {
fmt.Printf("Error parsing: %s\n", s)
panic(err)
// On parse failure, return original input to avoid crashing the WASM runtime
return s
}
buf := new(bytes.Buffer)
syntax.NewPrinter(

View File

@@ -0,0 +1,265 @@
/**
* 操作信息接口
*/
interface OperationInfo {
controller: AbortController;
createdAt: number;
timeout?: number;
timeoutId?: NodeJS.Timeout;
}
/**
* 异步操作管理器
* 用于管理异步操作的竞态条件,确保只有最新的操作有效
* 支持操作超时和自动清理机制
*
* @template T 操作上下文的类型
*/
export class AsyncManager<T = any> {
private operationSequence = 0;
private pendingOperations = new Map<number, OperationInfo>();
private currentContext: T | null = null;
private defaultTimeout: number;
/**
* 创建异步操作管理器
*
* @param defaultTimeout 默认超时时间毫秒0表示不设置超时
*/
constructor(defaultTimeout: number = 0) {
this.defaultTimeout = defaultTimeout;
}
/**
* 生成新的操作ID
*
* @returns 新的操作ID
*/
getNextOperationId(): number {
return ++this.operationSequence;
}
/**
* 开始新的操作
*
* @param context 操作上下文
* @param options 操作选项
* @returns 操作ID和AbortController
*/
startOperation(
context: T,
options?: {
excludeId?: number;
timeout?: number;
}
): { operationId: number; abortController: AbortController } {
const operationId = this.getNextOperationId();
const abortController = new AbortController();
const timeout = options?.timeout ?? this.defaultTimeout;
// 取消之前的操作
this.cancelPreviousOperations(options?.excludeId);
// 创建操作信息
const operationInfo: OperationInfo = {
controller: abortController,
createdAt: Date.now(),
timeout: timeout > 0 ? timeout : undefined
};
// 设置超时处理
if (timeout > 0) {
operationInfo.timeoutId = setTimeout(() => {
this.cancelOperation(operationId, 'timeout');
}, timeout);
}
// 设置当前上下文和操作
this.currentContext = context;
this.pendingOperations.set(operationId, operationInfo);
return { operationId, abortController };
}
/**
* 检查操作是否仍然有效
*
* @param operationId 操作ID
* @param context 操作上下文
* @returns 操作是否有效
*/
isOperationValid(operationId: number, context?: T): boolean {
const operationInfo = this.pendingOperations.get(operationId);
const contextValid = context === undefined || this.currentContext === context;
return (
operationInfo !== undefined &&
!operationInfo.controller.signal.aborted &&
contextValid
);
}
/**
* 完成操作
*
* @param operationId 操作ID
*/
completeOperation(operationId: number): void {
const operationInfo = this.pendingOperations.get(operationId);
if (operationInfo) {
// 清理超时定时器
if (operationInfo.timeoutId) {
clearTimeout(operationInfo.timeoutId);
}
this.pendingOperations.delete(operationId);
}
}
/**
* 取消指定操作
*
* @param operationId 操作ID
* @param reason 取消原因
*/
cancelOperation(operationId: number, reason?: string): void {
const operationInfo = this.pendingOperations.get(operationId);
if (operationInfo) {
// 清理超时定时器
if (operationInfo.timeoutId) {
clearTimeout(operationInfo.timeoutId);
}
// 取消操作
operationInfo.controller.abort(reason);
this.pendingOperations.delete(operationId);
}
}
/**
* 取消之前的操作修复并发bug
*
* @param excludeId 要排除的操作ID不取消该操作
*/
cancelPreviousOperations(excludeId?: number): void {
// 创建要取消的操作ID数组避免在遍历时修改Map
const operationIdsToCancel: number[] = [];
for (const [operationId] of this.pendingOperations) {
if (excludeId === undefined || operationId !== excludeId) {
operationIdsToCancel.push(operationId);
}
}
// 批量取消操作
for (const operationId of operationIdsToCancel) {
this.cancelOperation(operationId, 'superseded');
}
}
/**
* 取消所有操作
*/
cancelAllOperations(): void {
// 创建要取消的操作ID数组避免在遍历时修改Map
const operationIdsToCancel = Array.from(this.pendingOperations.keys());
// 批量取消操作
for (const operationId of operationIdsToCancel) {
this.cancelOperation(operationId, 'cancelled');
}
this.currentContext = null;
}
/**
* 清理过期操作(手动清理超时操作)
*
* @param maxAge 最大存活时间(毫秒)
* @returns 清理的操作数量
*/
cleanupExpiredOperations(maxAge: number): number {
const now = Date.now();
const expiredOperationIds: number[] = [];
for (const [operationId, operationInfo] of this.pendingOperations) {
if (now - operationInfo.createdAt > maxAge) {
expiredOperationIds.push(operationId);
}
}
// 批量取消过期操作
for (const operationId of expiredOperationIds) {
this.cancelOperation(operationId, 'expired');
}
return expiredOperationIds.length;
}
/**
* 获取操作统计信息
*
* @returns 操作统计信息
*/
getOperationStats(): {
total: number;
withTimeout: number;
averageAge: number;
oldestAge: number;
} {
const now = Date.now();
let withTimeout = 0;
let totalAge = 0;
let oldestAge = 0;
for (const operationInfo of this.pendingOperations.values()) {
const age = now - operationInfo.createdAt;
totalAge += age;
oldestAge = Math.max(oldestAge, age);
if (operationInfo.timeout) {
withTimeout++;
}
}
return {
total: this.pendingOperations.size,
withTimeout,
averageAge: this.pendingOperations.size > 0 ? totalAge / this.pendingOperations.size : 0,
oldestAge
};
}
/**
* 获取当前上下文
*
* @returns 当前上下文
*/
getCurrentContext(): T | null {
return this.currentContext;
}
/**
* 设置当前上下文
*
* @param context 新的上下文
*/
setCurrentContext(context: T | null): void {
this.currentContext = context;
}
/**
* 获取待处理操作数量
*
* @returns 待处理操作数量
*/
get pendingCount(): number {
return this.pendingOperations.size;
}
/**
* 检查是否有待处理的操作
*
* @returns 是否有待处理的操作
*/
hasPendingOperations(): boolean {
return this.pendingOperations.size > 0;
}
}

View File

@@ -0,0 +1,111 @@
/**
* 防抖函数工具类
* 用于限制函数的执行频率,在指定时间内只执行最后一次调用
*/
export interface DebounceOptions {
/** 延迟时间(毫秒),默认 300ms */
delay?: number;
/** 是否立即执行第一次调用,默认 false */
immediate?: boolean;
}
/**
* 创建防抖函数
* @param fn 要防抖的函数
* @param options 防抖选项
* @returns 返回防抖后的函数和清理函数
*/
export function createDebounce<T extends (...args: any[]) => any>(
fn: T,
options: DebounceOptions = {}
): {
debouncedFn: T;
cancel: () => void;
flush: () => void;
} {
const { delay = 300, immediate = false } = options;
let timeoutId: number | null = null;
let lastArgs: Parameters<T> | null = null;
let lastThis: any = null;
const debouncedFn = function (this: any, ...args: Parameters<T>) {
lastArgs = args;
lastThis = this;
const callNow = immediate && !timeoutId;
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = window.setTimeout(() => {
timeoutId = null;
if (!immediate && lastArgs) {
fn.apply(lastThis, lastArgs);
}
}, delay);
if (callNow) {
return fn.apply(this, args);
}
} as T;
const cancel = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
lastArgs = null;
lastThis = null;
};
const flush = () => {
if (timeoutId && lastArgs) {
clearTimeout(timeoutId);
fn.apply(lastThis, lastArgs);
timeoutId = null;
lastArgs = null;
lastThis = null;
}
};
return {
debouncedFn,
cancel,
flush
};
}
/**
* 节流函数
* 在指定时间内最多执行一次函数
* @param fn 要节流的函数
* @param delay 节流时间间隔(毫秒)
* @returns 节流后的函数
*/
export function throttle<T extends (...args: any[]) => any>(
fn: T,
delay: number = 300
): T {
let lastExecTime = 0;
let timeoutId: number | null = null;
return ((...args: Parameters<T>) => {
const now = Date.now();
if (now - lastExecTime >= delay) {
lastExecTime = now;
fn(...args);
} else {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = window.setTimeout(() => {
lastExecTime = Date.now();
fn(...args);
}, delay - (now - lastExecTime));
}
}) as T;
}

View File

@@ -0,0 +1,280 @@
/**
* 双向链表节点
*
* @template T 节点数据的类型
*/
export class DoublyLinkedListNode<T> {
public data: T;
public prev: DoublyLinkedListNode<T> | null = null;
public next: DoublyLinkedListNode<T> | null = null;
constructor(data: T) {
this.data = data;
}
}
/**
* 双向链表实现
* 提供 O(1) 时间复杂度的插入、删除和移动操作
*
* @template T 链表数据的类型
*/
export class DoublyLinkedList<T> {
private head: DoublyLinkedListNode<T> | null = null;
private tail: DoublyLinkedListNode<T> | null = null;
private _size = 0;
/**
* 获取链表大小
*
* @returns 链表中节点的数量
*/
get size(): number {
return this._size;
}
/**
* 检查链表是否为空
*
* @returns 链表是否为空
*/
get isEmpty(): boolean {
return this._size === 0;
}
/**
* 获取头节点
*
* @returns 头节点如果链表为空则返回null
*/
get first(): DoublyLinkedListNode<T> | null {
return this.head;
}
/**
* 获取尾节点
*
* @returns 尾节点如果链表为空则返回null
*/
get last(): DoublyLinkedListNode<T> | null {
return this.tail;
}
/**
* 在链表头部添加节点
*
* @param data 要添加的数据
* @returns 新创建的节点
*/
addFirst(data: T): DoublyLinkedListNode<T> {
const newNode = new DoublyLinkedListNode(data);
if (this.head === null) {
this.head = this.tail = newNode;
} else {
newNode.next = this.head;
this.head.prev = newNode;
this.head = newNode;
}
this._size++;
return newNode;
}
/**
* 在链表尾部添加节点
*
* @param data 要添加的数据
* @returns 新创建的节点
*/
addLast(data: T): DoublyLinkedListNode<T> {
const newNode = new DoublyLinkedListNode(data);
if (this.tail === null) {
this.head = this.tail = newNode;
} else {
newNode.prev = this.tail;
this.tail.next = newNode;
this.tail = newNode;
}
this._size++;
return newNode;
}
/**
* 删除指定节点
*
* @param node 要删除的节点
* @returns 被删除节点的数据
*/
remove(node: DoublyLinkedListNode<T>): T {
if (node.prev) {
node.prev.next = node.next;
} else {
this.head = node.next;
}
if (node.next) {
node.next.prev = node.prev;
} else {
this.tail = node.prev;
}
// 清理节点引用,防止内存泄漏
const data = node.data;
node.prev = null;
node.next = null;
this._size--;
return data;
}
/**
* 删除头节点
*
* @returns 被删除节点的数据如果链表为空则返回undefined
*/
removeFirst(): T | undefined {
if (this.head === null) {
return undefined;
}
return this.remove(this.head);
}
/**
* 删除尾节点
*
* @returns 被删除节点的数据如果链表为空则返回undefined
*/
removeLast(): T | undefined {
if (this.tail === null) {
return undefined;
}
return this.remove(this.tail);
}
/**
* 将节点移动到链表头部
*
* @param node 要移动的节点
*/
moveToFirst(node: DoublyLinkedListNode<T>): void {
if (node === this.head) {
return; // 已经在头部
}
// 从当前位置移除
if (node.prev) {
node.prev.next = node.next;
}
if (node.next) {
node.next.prev = node.prev;
} else {
this.tail = node.prev;
}
// 移动到头部
node.prev = null;
node.next = this.head;
if (this.head) {
this.head.prev = node;
}
this.head = node;
// 如果链表之前为空,更新尾节点
if (this.tail === null) {
this.tail = node;
}
}
/**
* 将节点移动到链表尾部
*
* @param node 要移动的节点
*/
moveToLast(node: DoublyLinkedListNode<T>): void {
if (node === this.tail) {
return; // 已经在尾部
}
// 从当前位置移除
if (node.prev) {
node.prev.next = node.next;
} else {
this.head = node.next;
}
if (node.next) {
node.next.prev = node.prev;
}
// 移动到尾部
node.next = null;
node.prev = this.tail;
if (this.tail) {
this.tail.next = node;
}
this.tail = node;
// 如果链表之前为空,更新头节点
if (this.head === null) {
this.head = node;
}
}
/**
* 清空链表
*
* @param onClear 清空时对每个节点数据的回调函数
*/
clear(onClear?: (data: T) => void): void {
let current = this.head;
while (current) {
const next = current.next;
if (onClear) {
onClear(current.data);
}
// 清理节点引用,防止内存泄漏
current.prev = null;
current.next = null;
current = next;
}
this.head = null;
this.tail = null;
this._size = 0;
}
/**
* 将链表转换为数组
*
* @returns 包含所有节点数据的数组,按从头到尾的顺序
*/
toArray(): T[] {
const result: T[] = [];
let current = this.head;
while (current) {
result.push(current.data);
current = current.next;
}
return result;
}
/**
* 遍历链表
*
* @param callback 对每个节点数据执行的回调函数
*/
forEach(callback: (data: T, index: number) => void): void {
let current = this.head;
let index = 0;
while (current) {
callback(current.data, index);
current = current.next;
index++;
}
}
}

View File

@@ -0,0 +1,92 @@
/**
* 高效哈希算法实现
* 针对大量文本内容优化的哈希函数集合
*/
/**
* 使用优化的 xxHash32 算法生成字符串哈希值
* 专为大量文本内容设计,性能优异
*
* xxHash32 特点:
* - 极快的处理速度
* - 优秀的分布质量,冲突率极低
* - 对长文本友好,性能不会随长度线性下降
* - 被广泛应用于数据库、压缩工具等
*
* @param content 要哈希的字符串内容
* @returns 32位哈希值的字符串表示
*/
export const generateContentHash = (content: string): string => {
return (generateContentHashInternal(content) >>> 0).toString(36);
};
/**
* 从字符串中提取 32 位整数(模拟小端序)
*/
function getUint32(str: string, index: number): number {
return (
(str.charCodeAt(index) & 0xff) |
((str.charCodeAt(index + 1) & 0xff) << 8) |
((str.charCodeAt(index + 2) & 0xff) << 16) |
((str.charCodeAt(index + 3) & 0xff) << 24)
);
}
/**
* 32 位左旋转
*/
function rotateLeft(value: number, shift: number): number {
return (value << shift) | (value >>> (32 - shift));
}
/**
* 内部哈希计算函数,返回数值
*/
function generateContentHashInternal(content: string): number {
const PRIME1 = 0x9e3779b1;
const PRIME2 = 0x85ebca77;
const PRIME3 = 0xc2b2ae3d;
const PRIME4 = 0x27d4eb2f;
const PRIME5 = 0x165667b1;
const len = content.length;
let hash: number;
let i = 0;
if (len >= 16) {
let acc1 = PRIME1 + PRIME2;
let acc2 = PRIME2;
let acc3 = 0;
let acc4 = -PRIME1;
for (; i <= len - 16; i += 16) {
acc1 = Math.imul(rotateLeft(acc1 + Math.imul(getUint32(content, i), PRIME2), 13), PRIME1);
acc2 = Math.imul(rotateLeft(acc2 + Math.imul(getUint32(content, i + 4), PRIME2), 13), PRIME1);
acc3 = Math.imul(rotateLeft(acc3 + Math.imul(getUint32(content, i + 8), PRIME2), 13), PRIME1);
acc4 = Math.imul(rotateLeft(acc4 + Math.imul(getUint32(content, i + 12), PRIME2), 13), PRIME1);
}
hash = rotateLeft(acc1, 1) + rotateLeft(acc2, 7) + rotateLeft(acc3, 12) + rotateLeft(acc4, 18);
} else {
hash = PRIME5;
}
hash += len;
for (; i <= len - 4; i += 4) {
hash = Math.imul(rotateLeft(hash + Math.imul(getUint32(content, i), PRIME3), 17), PRIME4);
}
for (; i < len; i++) {
hash = Math.imul(rotateLeft(hash + Math.imul(content.charCodeAt(i), PRIME5), 11), PRIME1);
}
hash ^= hash >>> 15;
hash = Math.imul(hash, PRIME2);
hash ^= hash >>> 13;
hash = Math.imul(hash, PRIME3);
hash ^= hash >>> 16;
return hash;
}

View File

@@ -0,0 +1,157 @@
import { DoublyLinkedList, DoublyLinkedListNode } from './doublyLinkedList';
/**
* LRU缓存项
*
* @template K 键的类型
* @template V 值的类型
*/
interface LruCacheItem<K, V> {
key: K;
value: V;
}
/**
* LRU (Least Recently Used) 缓存实现
* 使用双向链表 + Map 实现 O(1) 时间复杂度的所有操作
*
* @template K 键的类型
* @template V 值的类型
*/
export class LruCache<K, V> {
private readonly maxSize: number;
private readonly cache = new Map<K, DoublyLinkedListNode<LruCacheItem<K, V>>>();
private readonly lru = new DoublyLinkedList<LruCacheItem<K, V>>();
/**
* 创建LRU缓存实例
*
* @param maxSize 最大缓存大小
*/
constructor(maxSize: number) {
if (maxSize <= 0) {
throw new Error('Max size must be greater than 0');
}
this.maxSize = maxSize;
}
/**
* 获取缓存值
*
* @param key 键
* @returns 缓存的值如果不存在则返回undefined
*/
get(key: K): V | undefined {
const node = this.cache.get(key);
if (node) {
// 将访问的节点移动到链表尾部(最近使用)
this.lru.moveToLast(node);
return node.data.value;
}
return undefined;
}
/**
* 设置缓存值
*
* @param key 键
* @param value 值
* @param onEvict 当有项目被驱逐时的回调函数
*/
set(key: K, value: V, onEvict?: (evictedKey: K, evictedValue: V) => void): void {
const existingNode = this.cache.get(key);
// 如果键已存在,更新值并移动到最近使用
if (existingNode) {
existingNode.data.value = value;
this.lru.moveToLast(existingNode);
return;
}
// 如果缓存已满,移除最少使用的项
if (this.cache.size >= this.maxSize) {
const oldestNode = this.lru.first;
if (oldestNode) {
const { key: evictedKey, value: evictedValue } = oldestNode.data;
this.cache.delete(evictedKey);
this.lru.removeFirst();
if (onEvict) {
onEvict(evictedKey, evictedValue);
}
}
}
// 添加新项到链表尾部(最近使用)
const newNode = this.lru.addLast({ key, value });
this.cache.set(key, newNode);
}
/**
* 检查键是否存在
*
* @param key 键
* @returns 是否存在
*/
has(key: K): boolean {
return this.cache.has(key);
}
/**
* 删除指定键的缓存
*
* @param key 键
* @returns 是否成功删除
*/
delete(key: K): boolean {
const node = this.cache.get(key);
if (node) {
this.cache.delete(key);
this.lru.remove(node);
return true;
}
return false;
}
/**
* 清空缓存
*
* @param onEvict 清空时对每个项目的回调函数
*/
clear(onEvict?: (key: K, value: V) => void): void {
if (onEvict) {
this.lru.forEach(item => {
onEvict(item.key, item.value);
});
}
this.cache.clear();
this.lru.clear();
}
/**
* 获取缓存大小
*
* @returns 当前缓存项数量
*/
get size(): number {
return this.cache.size;
}
/**
* 获取所有键
*
* @returns 所有键的数组,按最近使用顺序排列(从最少使用到最近使用)
*/
keys(): K[] {
return this.lru.toArray().map(item => item.key);
}
/**
* 获取所有值
*
* @returns 所有值的数组,按最近使用顺序排列(从最少使用到最近使用)
*/
values(): V[] {
return this.lru.toArray().map(item => item.value);
}
}

View File

@@ -0,0 +1,162 @@
/**
* 定时器管理工具类
* 提供安全的定时器创建、清理和管理功能
*/
/**
* 定时器管理器接口
*/
export interface TimerManager {
/** 当前定时器 ID */
readonly timerId: number | null;
/** 清除定时器 */
clear(): void;
/** 设置定时器 */
set(callback: () => void, delay: number): void;
}
/**
* 创建定时器管理器工厂函数
* 提供安全的定时器管理,自动处理清理和重置
*
* @returns 定时器管理器实例
*
* @example
* ```typescript
* const timer = createTimerManager();
*
* // 设置定时器
* timer.set(() => {
* console.log('Timer executed');
* }, 1000);
*
* // 清除定时器
* timer.clear();
*
* // 检查定时器状态
* if (timer.timerId !== null) {
* console.log('Timer is running');
* }
* ```
*/
export const createTimerManager = (): TimerManager => {
let timerId: number | null = null;
return {
get timerId() {
return timerId;
},
clear() {
if (timerId !== null) {
window.clearTimeout(timerId);
timerId = null;
}
},
set(callback: () => void, delay: number) {
// 先清除现有定时器
this.clear();
// 设置新定时器
timerId = window.setTimeout(() => {
callback();
timerId = null; // 执行完成后自动重置
}, delay);
}
};
};
/**
* 创建带有自动清理功能的定时器
* 适用于需要在组件卸载时自动清理的场景
*
* @param onCleanup 清理回调函数,通常在 onScopeDispose 或 onUnmounted 中调用
* @returns 定时器管理器实例
*
* @example
* ```typescript
* import { onScopeDispose } from 'vue';
*
* const timer = createAutoCleanupTimer(() => {
* // 组件卸载时自动清理
* });
*
* onScopeDispose(() => {
* timer.clear();
* });
* ```
*/
export const createAutoCleanupTimer = (onCleanup?: () => void): TimerManager => {
const timer = createTimerManager();
// 如果提供了清理回调,则包装 clear 方法
if (onCleanup) {
const originalClear = timer.clear.bind(timer);
timer.clear = () => {
originalClear();
onCleanup();
};
}
return timer;
};
/**
* 延迟执行工具函数
* 简化的 Promise 版本延迟执行
*
* @param delay 延迟时间(毫秒)
* @returns Promise
*
* @example
* ```typescript
* await delay(1000); // 延迟 1 秒
* console.log('1 second later');
* ```
*/
export const delay = (delay: number): Promise<void> => {
return new Promise(resolve => {
setTimeout(resolve, delay);
});
};
/**
* 创建可取消的延迟 Promise
*
* @param delay 延迟时间(毫秒)
* @returns 包含 promise 和 cancel 方法的对象
*
* @example
* ```typescript
* const { promise, cancel } = createCancelableDelay(1000);
*
* promise
* .then(() => console.log('Executed'))
* .catch(() => console.log('Cancelled'));
*
* // 取消延迟
* cancel();
* ```
*/
export const createCancelableDelay = (delay: number) => {
let timeoutId: number;
let cancelled = false;
const promise = new Promise<void>((resolve, reject) => {
timeoutId = window.setTimeout(() => {
if (!cancelled) {
resolve();
}
}, delay);
});
const cancel = () => {
cancelled = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
};
return { promise, cancel };
};

View File

@@ -1,18 +1,19 @@
<script setup lang="ts">
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';
import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue';
import type {MemoryStats} from '@/../bindings/voidraft/internal/services';
import {SystemService} 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 {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个数据点)
const historyData = ref<number[]>([]);
@@ -21,209 +22,188 @@ const maxDataPoints = 60;
// 动态最大内存值MB初始为200MB会根据实际使用动态调整
const maxMemoryMB = ref(200);
// 使用themeStore获取当前主题
let intervalId: ReturnType<typeof setInterval> | null = null;
// 使用 computed 获取当前主题状态
const isDarkTheme = computed(() => {
const theme = themeStore.currentTheme;
if (theme === SystemThemeType.SystemThemeAuto) {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return theme === SystemThemeType.SystemThemeDark;
const {currentTheme} = themeStore;
return currentTheme === SystemThemeType.SystemThemeDark;
});
// 监听主题变化,重新绘制图表
watch(() => themeStore.currentTheme, () => {
nextTick(() => drawChart());
nextTick(drawChart);
});
// 静默错误处理包装器
const withSilentErrorHandling = async <T>(
operation: () => Promise<T>,
fallback?: T
): Promise<T | undefined> => {
try {
return await operation();
} catch (_error) {
// 静默处理错误,不输出到控制台
return fallback;
}
// 格式化内存显示函数
const formatMemorySize = (heapMB: number): string => {
if (heapMB < 1) return `${(heapMB * 1024).toFixed(0)}K`;
if (heapMB < 100) return `${heapMB.toFixed(1)}M`;
return `${heapMB.toFixed(0)}M`;
};
// 获取内存统计信息
const fetchMemoryStats = async () => {
const stats = await withSilentErrorHandling(() => SystemService.GetMemoryStats());
if (!stats) {
isLoading.value = false;
return;
}
const fetchMemoryStats = async (): Promise<void> => {
try {
const stats = await SystemService.GetMemoryStats();
memoryStats.value = stats;
// 格式化内存显示 - 主要显示堆内存使用量
const heapMB = (stats.heapInUse / 1024 / 1024);
if (heapMB < 1) {
formattedMemory.value = `${(heapMB * 1024).toFixed(0)}K`;
} else if (heapMB < 100) {
formattedMemory.value = `${heapMB.toFixed(1)}M`;
} else {
formattedMemory.value = `${heapMB.toFixed(0)}M`;
}
const heapMB = stats.heapInUse / (1024 * 1024);
formattedMemory.value = formatMemorySize(heapMB);
// 自动调整最大内存值,确保图表能够显示更大范围
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);
// 保持最大数据点数量
if (historyData.value.length > maxDataPoints) {
historyData.value.shift();
}
historyData.value = [...historyData.value, memoryUsagePercent].slice(-maxDataPoints);
// 更新图表
drawChart();
} catch {
// 静默处理错误
} finally {
isLoading.value = false;
}
};
// 绘制实时曲线图 - 简化版
const drawChart = () => {
if (!canvasRef.value || historyData.value.length === 0) return;
const canvas = canvasRef.value;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// 设置canvas尺寸
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * window.devicePixelRatio;
canvas.height = rect.height * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
const width = rect.width;
const height = rect.height;
// 清除画布
ctx.clearRect(0, 0, width, height);
// 根据主题选择合适的颜色 - 更柔和的颜色
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)';
// 绘制背景网格 - 更加柔和
// 获取主题相关颜色配置
const getThemeColors = () => {
const isDark = isDarkTheme.value;
return {
grid: isDark ? 'rgba(255, 255, 255, 0.03)' : 'rgba(0, 0, 0, 0.07)',
line: isDark ? 'rgba(74, 158, 255, 0.6)' : 'rgba(37, 99, 235, 0.6)',
fill: isDark ? 'rgba(74, 158, 255, 0.05)' : 'rgba(37, 99, 235, 0.05)',
point: isDark ? 'rgba(74, 158, 255, 0.8)' : 'rgba(37, 99, 235, 0.8)'
};
};
// 绘制网格背景
const drawGrid = (ctx: CanvasRenderingContext2D, width: number, height: number, colors: ReturnType<typeof getThemeColors>): void => {
ctx.strokeStyle = colors.grid;
ctx.lineWidth = 0.5;
// 水平网格线
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);
ctx.lineTo(width, y);
ctx.stroke();
}
// 垂直网格线
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);
ctx.lineTo(x, height);
ctx.stroke();
}
};
// 绘制平滑曲线路径
const drawSmoothPath = (
ctx: CanvasRenderingContext2D,
data: number[],
startX: number,
stepX: number,
height: number,
fillArea = false
): void => {
if (data.length < 2) return;
const firstY = height - (data[0] / 100) * height;
ctx.beginPath();
if (fillArea) ctx.moveTo(startX, height);
ctx.moveTo(startX, firstY);
// 使用二次贝塞尔曲线绘制平滑路径
for (let i = 1; i < data.length; i++) {
const x = startX + i * stepX;
const y = height - (data[i] / 100) * height;
if (i === 1) {
ctx.lineTo(x, y);
} else {
const prevX = startX + (i - 1) * stepX;
const prevY = height - (data[i - 1] / 100) * height;
const cpX = (prevX + x) / 2;
const cpY = (prevY + y) / 2;
ctx.quadraticCurveTo(cpX, cpY, x, y);
}
}
if (fillArea) {
const lastX = startX + (data.length - 1) * stepX;
ctx.lineTo(lastX, height);
ctx.closePath();
}
};
// 绘制实时曲线图
const drawChart = (): void => {
const canvas = canvasRef.value;
if (!canvas || historyData.value.length === 0) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// 设置canvas尺寸
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const {width, height} = rect;
// 清除画布
ctx.clearRect(0, 0, width, height);
// 获取主题颜色
const colors = getThemeColors();
// 绘制背景网格
drawGrid(ctx, width, height, colors);
if (historyData.value.length < 2) return;
// 计算数据点位置
const dataLength = historyData.value.length;
const stepX = width / (maxDataPoints - 1);
const startX = width - (dataLength - 1) * stepX;
// 绘制填充区域 - 更柔和的填充
ctx.beginPath();
ctx.moveTo(startX, height);
// 移动到第一个数据点
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 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);
}
}
// 完成填充路径
const lastX = startX + (dataLength - 1) * stepX;
ctx.lineTo(lastX, height);
ctx.closePath();
ctx.fillStyle = fillColor;
// 绘制填充区域
drawSmoothPath(ctx, historyData.value, startX, stepX, height, true);
ctx.fillStyle = colors.fill;
ctx.fill();
// 绘制主曲线 - 平滑连续的曲线
ctx.beginPath();
ctx.moveTo(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 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);
}
}
ctx.strokeStyle = lineColor;
// 绘制主曲线
drawSmoothPath(ctx, historyData.value, startX, stepX, height);
ctx.strokeStyle = colors.line;
ctx.lineWidth = 1.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
// 绘制当前值的高亮点
const lastX = startX + (dataLength - 1) * stepX;
const lastY = height - (historyData.value[dataLength - 1] / 100) * height;
// 外圈
ctx.fillStyle = pointColor;
ctx.fillStyle = colors.point;
ctx.globalAlpha = 0.4;
ctx.beginPath();
ctx.arc(lastX, lastY, 3, 0, Math.PI * 2);
ctx.fill();
// 内圈
ctx.globalAlpha = 1;
ctx.beginPath();
@@ -232,72 +212,32 @@ const drawChart = () => {
};
// 手动触发GC
const triggerGC = async () => {
const success = await withSilentErrorHandling(() => SystemService.TriggerGC());
if (success) {
// 延迟一下再获取新的统计信息
setTimeout(fetchMemoryStats, 100);
const triggerGC = async (): Promise<void> => {
try {
await SystemService.TriggerGC();
} catch (error) {
console.error("Failed to trigger GC: ", error);
}
};
// 处理窗口大小变化
const handleResize = () => {
if (historyData.value.length > 0) {
nextTick(() => drawChart());
}
};
// 仅监听系统主题变化
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();
// 每1秒更新一次内存信息
// 每3秒更新一次内存信息
intervalId = setInterval(fetchMemoryStats, 3000);
// 监听窗口大小变化
window.addEventListener('resize', handleResize);
// 设置系统主题监听器仅用于auto模式
const cleanupThemeListener = setupSystemThemeListener();
// 在卸载时清理
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId);
}
window.removeEventListener('resize', handleResize);
cleanupThemeListener();
});
});
onUnmounted(() => {
intervalId && clearInterval(intervalId);
});
</script>
<template>
<div class="memory-monitor" @click="triggerGC" :title="`${t('monitor.memory')}: ${formattedMemory} | ${t('monitor.clickToClean')}`">
<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">
<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>{{ t('monitor.memory') }}</span>
@@ -306,10 +246,10 @@ onMounted(() => {
<div class="memory-loading" v-else>--</div>
</div>
<div class="chart-area">
<canvas
ref="canvasRef"
class="memory-chart"
:class="{ 'loading': isLoading }"
<canvas
ref="canvasRef"
class="memory-chart"
:class="{ 'loading': isLoading }"
></canvas>
</div>
</div>
@@ -323,28 +263,28 @@ onMounted(() => {
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
&:hover {
.monitor-info {
.memory-label {
color: var(--selection-text);
}
.memory-value {
color: var(--toolbar-text);
}
}
.chart-area .memory-chart {
opacity: 1;
}
}
.monitor-info {
display: flex;
align-items: center;
justify-content: space-between;
.memory-label {
display: flex;
align-items: center;
@@ -353,18 +293,18 @@ onMounted(() => {
font-size: 10px;
font-weight: 500;
transition: color 0.2s ease;
svg {
width: 10px;
height: 10px;
opacity: 0.8;
}
span {
user-select: none;
}
}
.memory-value, .memory-loading {
color: var(--toolbar-text-secondary);
font-family: 'JetBrains Mono', 'Courier New', monospace;
@@ -372,26 +312,26 @@ onMounted(() => {
font-weight: 600;
transition: color 0.2s ease;
}
.memory-loading {
opacity: 0.5;
animation: pulse 1.5s ease-in-out infinite;
}
}
.chart-area {
height: 48px;
position: relative;
overflow: hidden;
border-radius: 3px;
.memory-chart {
width: 100%;
height: 100%;
display: block;
opacity: 0.9;
transition: opacity 0.2s ease;
&.loading {
opacity: 0.3;
}
@@ -407,4 +347,4 @@ onMounted(() => {
opacity: 0.8;
}
}
</style>
</style>

View File

@@ -0,0 +1,242 @@
<template>
<div class="tab-container" style="--wails-draggable:drag">
<div class="tab-bar" ref="tabBarRef">
<div class="tab-scroll-wrapper" ref="tabScrollWrapperRef" style="--wails-draggable:drag" @wheel.prevent.stop="onWheelScroll">
<div class="tab-list" ref="tabListRef">
<TabItem
v-for="tab in tabStore.tabs"
:key="tab.documentId"
:tab="tab"
:isActive="tab.documentId === tabStore.currentDocumentId"
:canClose="tabStore.canCloseTab"
@click="switchToTab"
@close="closeTab"
@dragstart="onDragStart"
@dragover="onDragOver"
@drop="onDrop"
@contextmenu="onContextMenu"
/>
</div>
</div>
</div>
<!-- 右键菜单 -->
<TabContextMenu
:visible="showContextMenu"
:position="contextMenuPosition"
:targetDocumentId="contextMenuTargetId"
@close="closeContextMenu"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
import TabItem from './TabItem.vue';
import TabContextMenu from './TabContextMenu.vue';
import { useTabStore } from '@/stores/tabStore';
const tabStore = useTabStore();
// DOM 引用
const tabBarRef = ref<HTMLElement>();
const tabListRef = ref<HTMLElement>();
const tabScrollWrapperRef = ref<HTMLDivElement | null>(null);
// 右键菜单状态
const showContextMenu = ref(false);
const contextMenuPosition = ref({ x: 0, y: 0 });
const contextMenuTargetId = ref<number | null>(null);
// 标签页操作
const switchToTab = (documentId: number) => {
tabStore.switchToTabAndDocument(documentId);
};
const closeTab = (documentId: number) => {
tabStore.closeTab(documentId);
};
// 拖拽操作
const onDragStart = (event: DragEvent, documentId: number) => {
tabStore.draggedTabId = documentId;
event.dataTransfer!.effectAllowed = 'move';
};
const onDragOver = (event: DragEvent) => {
event.preventDefault();
event.dataTransfer!.dropEffect = 'move';
};
const onDrop = (event: DragEvent, targetDocumentId: number) => {
event.preventDefault();
if (tabStore.draggedTabId && tabStore.draggedTabId !== targetDocumentId) {
const draggedIndex = tabStore.getTabIndex(tabStore.draggedTabId);
const targetIndex = tabStore.getTabIndex(targetDocumentId);
if (draggedIndex !== -1 && targetIndex !== -1) {
tabStore.moveTab(draggedIndex, targetIndex);
}
}
tabStore.draggedTabId = null;
};
// 右键菜单操作
const onContextMenu = (event: MouseEvent, documentId: number) => {
event.preventDefault();
event.stopPropagation();
contextMenuPosition.value = { x: event.clientX, y: event.clientY };
contextMenuTargetId.value = documentId;
showContextMenu.value = true;
};
const closeContextMenu = () => {
showContextMenu.value = false;
contextMenuTargetId.value = null;
};
// 滚轮滚动处理
const onWheelScroll = (event: WheelEvent) => {
const el = tabScrollWrapperRef.value;
if (!el) return;
const delta = event.deltaY || event.deltaX || 0;
el.scrollLeft += delta;
};
// 自动滚动到活跃标签页
const scrollToActiveTab = async () => {
await nextTick();
const scrollWrapper = tabScrollWrapperRef.value;
const tabList = tabListRef.value;
if (!scrollWrapper || !tabList) return;
// 查找当前活跃的标签页元素
const activeTabElement = tabList.querySelector('.tab-item.active') as HTMLElement;
if (!activeTabElement) return;
const scrollWrapperRect = scrollWrapper.getBoundingClientRect();
const activeTabRect = activeTabElement.getBoundingClientRect();
// 计算活跃标签页相对于滚动容器的位置
const activeTabLeft = activeTabRect.left - scrollWrapperRect.left + scrollWrapper.scrollLeft;
const activeTabRight = activeTabLeft + activeTabRect.width;
// 获取滚动容器的可视区域
const scrollLeft = scrollWrapper.scrollLeft;
const scrollRight = scrollLeft + scrollWrapper.clientWidth;
// 如果活跃标签页不在可视区域内,则滚动到合适位置
if (activeTabLeft < scrollLeft) {
// 标签页在左侧不可见,滚动到左边
scrollWrapper.scrollLeft = activeTabLeft - 10; // 留一点边距
} else if (activeTabRight > scrollRight) {
// 标签页在右侧不可见,滚动到右边
scrollWrapper.scrollLeft = activeTabRight - scrollWrapper.clientWidth + 10; // 留一点边距
}
};
onMounted(() => {
// 组件挂载时的初始化逻辑
});
onUnmounted(() => {
// 组件卸载时的清理逻辑
});
// 监听当前活跃标签页的变化
watch(() => tabStore.currentDocumentId, () => {
scrollToActiveTab();
});
// 监听标签页列表变化
watch(() => tabStore.tabs.length, () => {
scrollToActiveTab();
});
</script>
<style scoped lang="scss">
.tab-container {
position: relative;
background: transparent;
height: 32px;
}
.tab-bar {
display: flex;
align-items: center;
height: 100%;
background: var(--toolbar-bg);
min-width: 0; /* 允许子项收缩,确保产生横向溢出 */
}
.tab-scroll-wrapper {
flex: 1;
min-width: 0; // 关键:允许作为 flex 子项收缩,从而产生横向溢出
overflow-x: auto;
overflow-y: hidden;
position: relative;
scrollbar-width: none;
-ms-overflow-style: none;
pointer-events: auto;
&::-webkit-scrollbar {
display: none;
}
}
.tab-list {
display: flex;
width: max-content; /* 令宽度等于所有子项总宽度,必定溢出 */
white-space: nowrap;
pointer-events: auto;
}
.tab-actions {
display: flex;
align-items: center;
padding: 0 4px;
background: var(--toolbar-bg);
flex-shrink: 0; /* 防止被压缩 */
}
.tab-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin: 0 1px;
border-radius: 3px;
cursor: pointer;
color: var(--toolbar-text);
transition: background-color 0.2s ease;
&:hover {
background: var(--toolbar-button-hover);
}
svg {
width: 12px;
height: 12px;
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.tab-action-btn {
width: 20px;
height: 20px;
svg {
width: 10px;
height: 10px;
}
}
}
</style>

View File

@@ -0,0 +1,181 @@
<template>
<div
v-if="visible && canClose"
class="tab-context-menu"
:style="{
left: position.x + 'px',
top: position.y + 'px'
}"
@click.stop
>
<div v-if="canClose" class="menu-item" @click="handleMenuClick('close')">
<svg class="menu-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">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
<span class="menu-text">{{ t('tabs.contextMenu.closeTab') }}</span>
</div>
<div v-if="hasOtherTabs" class="menu-item" @click="handleMenuClick('closeOthers')">
<svg class="menu-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">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<path d="M9 9l6 6M15 9l-6 6"/>
</svg>
<span class="menu-text">{{ t('tabs.contextMenu.closeOthers') }}</span>
</div>
<div v-if="hasTabsToLeft" class="menu-item" @click="handleMenuClick('closeLeft')">
<svg class="menu-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">
<path d="M15 18l-6-6 6-6"/>
<path d="M9 18l-6-6 6-6"/>
</svg>
<span class="menu-text">{{ t('tabs.contextMenu.closeLeft') }}</span>
</div>
<div v-if="hasTabsToRight" class="menu-item" @click="handleMenuClick('closeRight')">
<svg class="menu-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">
<path d="M9 18l6-6-6-6"/>
<path d="M15 18l6-6-6-6"/>
</svg>
<span class="menu-text">{{ t('tabs.contextMenu.closeRight') }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTabStore } from '@/stores/tabStore';
interface Props {
visible: boolean;
position: { x: number; y: number };
targetDocumentId: number | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
close: [];
}>();
const { t } = useI18n();
const tabStore = useTabStore();
// 计算属性
const canClose = computed(() => tabStore.canCloseTab);
const hasOtherTabs = computed(() => {
return tabStore.tabs.length > 1;
});
const currentTabIndex = computed(() => {
if (!props.targetDocumentId) return -1;
return tabStore.getTabIndex(props.targetDocumentId);
});
const hasTabsToRight = computed(() => {
const index = currentTabIndex.value;
return index !== -1 && index < tabStore.tabs.length - 1;
});
const hasTabsToLeft = computed(() => {
const index = currentTabIndex.value;
return index > 0;
});
// 处理菜单项点击
const handleMenuClick = (action: string) => {
if (!props.targetDocumentId) return;
switch (action) {
case 'close':
tabStore.closeTab(props.targetDocumentId);
break;
case 'closeOthers':
tabStore.closeOtherTabs(props.targetDocumentId);
break;
case 'closeLeft':
tabStore.closeTabsToLeft(props.targetDocumentId);
break;
case 'closeRight':
tabStore.closeTabsToRight(props.targetDocumentId);
break;
}
emit('close');
};
// 处理外部点击
const handleClickOutside = (_event: MouseEvent) => {
if (props.visible) {
emit('close');
}
};
// 处理ESC键
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && props.visible) {
emit('close');
}
};
// 生命周期
onMounted(() => {
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleEscapeKey);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleEscapeKey);
});
</script>
<style scoped lang="scss">
.tab-context-menu {
position: fixed;
z-index: 1000;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
overflow: hidden;
min-width: 100px;
user-select: none;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.menu-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
font-size: 12px;
color: var(--text-muted);
transition: all 0.15s ease;
gap: 8px;
&:hover {
background-color: var(--toolbar-button-hover);
color: var(--text-primary);
}
&:active {
background-color: var(--border-color);
}
}
.menu-icon {
flex-shrink: 0;
width: 12px;
height: 12px;
color: var(--text-muted);
transition: color 0.15s ease;
.menu-item:hover & {
color: var(--text-primary);
}
}
.menu-text {
white-space: nowrap;
font-weight: 400;
flex: 1;
}
</style>

View File

@@ -0,0 +1,294 @@
<template>
<div
class="tab-item"
:class="{
active: isActive,
dragging: isDragging
}"
style="--wails-draggable:no-drag"
draggable="true"
@click="handleClick"
@dragstart="handleDragStart"
@dragover="handleDragOver"
@drop="handleDrop"
@dragend="handleDragEnd"
@contextmenu="handleContextMenu"
>
<!-- 文档图标 -->
<div class="tab-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"
>
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
<polyline points="14,2 14,8 20,8"/>
</svg>
</div>
<!-- 标签页标题 -->
<div class="tab-title" :title="tab.title">
{{ displayTitle }}
</div>
<!-- 关闭按钮 -->
<div
v-if="props.canClose"
class="tab-close"
@click.stop="handleClose"
:title="'Close tab'"
>
<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"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</div>
<!-- 拖拽指示器 -->
<div v-if="isDragging" class="drag-indicator"></div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
// 组件属性
interface TabProps {
tab: {
documentId: number; // 直接使用文档ID作为唯一标识
title: string; // 标签页标题
};
isActive: boolean;
canClose?: boolean; // 是否可以关闭标签页
}
const props = defineProps<TabProps>();
// 组件事件
const emit = defineEmits<{
click: [documentId: number];
close: [documentId: number];
dragstart: [event: DragEvent, documentId: number];
dragover: [event: DragEvent];
drop: [event: DragEvent, documentId: number];
contextmenu: [event: MouseEvent, documentId: number];
}>();
// 组件状态
const isDragging = ref(false);
// 计算属性
const displayTitle = computed(() => {
const title = props.tab.title;
// 限制标题长度超过15个字符显示省略号
return title.length > 15 ? title.substring(0, 15) + '...' : title;
});
// 事件处理
const handleClick = () => {
emit('click', props.tab.documentId);
};
const handleClose = () => {
emit('close', props.tab.documentId);
};
const handleDragStart = (event: DragEvent) => {
isDragging.value = true;
emit('dragstart', event, props.tab.documentId);
};
const handleDragOver = (event: DragEvent) => {
emit('dragover', event);
};
const handleDrop = (event: DragEvent) => {
isDragging.value = false;
emit('drop', event, props.tab.documentId);
};
const handleDragEnd = () => {
isDragging.value = false;
};
const handleContextMenu = (event: MouseEvent) => {
event.preventDefault();
emit('contextmenu', event, props.tab.documentId);
};
</script>
<style scoped lang="scss">
.tab-item {
display: flex;
align-items: center;
min-width: 120px;
max-width: 200px;
height: 32px; // 适配标题栏高度
padding: 0 8px;
background-color: transparent;
border-right: 1px solid var(--toolbar-border);
cursor: pointer;
user-select: none;
position: relative;
transition: all 0.2s ease;
box-sizing: border-box; // 防止激活态的边框影响整体高度
&:hover {
background-color: var(--toolbar-button-hover);
.tab-close {
opacity: 1;
}
}
&.active {
background-color: var(--toolbar-button-active, var(--toolbar-button-hover));
color: var(--toolbar-text);
position: relative;
.tab-title {
color: var(--toolbar-text);
font-weight: 600; /* 字体加粗 */
text-shadow: 0 0 1px rgba(0, 0, 0, 0.1); /* 轻微阴影增强可读性 */
}
.tab-icon {
color: var(--accent-color);
filter: brightness(1.1);
}
}
/* 底部活跃线条 */
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 2px;
background: var(--tab-active-line);
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 10;
}
&.active::after {
width: 100%;
}
&.dragging {
opacity: 0.5;
transform: rotate(2deg);
z-index: 1000;
}
}
/* 文档图标 */
.tab-icon {
display: flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--toolbar-text);
transition: color 0.2s ease;
svg {
width: 12px;
height: 12px;
}
}
.tab-title {
flex: 1;
font-size: 12px;
color: var(--toolbar-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.2s ease;
}
.tab-close {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: 4px;
border-radius: 2px;
opacity: 0;
color: var(--toolbar-text);
transition: all 0.2s ease;
&:hover {
background-color: var(--error-color);
color: var(--text-muted);
opacity: 1 !important;
}
svg {
width: 10px;
height: 10px;
}
}
.drag-indicator {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(45deg,
transparent 25%,
rgba(var(--accent-color-rgb), 0.1) 25%,
rgba(var(--accent-color-rgb), 0.1) 50%,
transparent 50%,
transparent 75%,
rgba(var(--accent-color-rgb), 0.1) 75%
);
background-size: 8px 8px;
pointer-events: none;
}
/* 活跃标签页在拖拽时的特殊样式 */
.tab-item.active.dragging {
border-bottom-color: transparent;
}
/* 响应式设计 */
@media (max-width: 768px) {
.tab-item {
min-width: 100px;
max-width: 150px;
padding: 0 6px;
}
.tab-title {
font-size: 11px;
}
.tab-close {
width: 18px;
height: 18px;
svg {
width: 12px;
height: 12px;
}
}
}
</style>

View File

@@ -4,7 +4,13 @@
<div class="titlebar-icon">
<img src="/appicon.png" alt="voidraft"/>
</div>
<div class="titlebar-title">{{ titleText }}</div>
<div v-if="!tabStore.isTabsEnabled && !isInSettings" class="titlebar-title" :title="fullTitleText">{{ titleText }}</div>
<!-- 标签页容器区域 -->
<div class="titlebar-tabs" v-if="tabStore.isTabsEnabled && !isInSettings" style="--wails-draggable:drag">
<TabContainer />
</div>
<!-- 设置页面标题 -->
<div v-if="isInSettings" class="titlebar-title" :title="fullTitleText">{{ titleText }}</div>
</div>
<div class="titlebar-controls" style="--wails-draggable:no-drag" @contextmenu.prevent>
@@ -48,15 +54,43 @@
<script setup lang="ts">
import {computed, onMounted, ref} from 'vue';
import {useI18n} from 'vue-i18n';
import {useRoute} from 'vue-router';
import * as runtime from '@wailsio/runtime';
import {useDocumentStore} from '@/stores/documentStore';
import TabContainer from '@/components/tabs/TabContainer.vue';
import {useTabStore} from "@/stores/tabStore";
const tabStore = useTabStore();
const {t} = useI18n();
const route = useRoute();
const isMaximized = ref(false);
const documentStore = useDocumentStore();
// 判断是否在设置页面
const isInSettings = computed(() => route.path.startsWith('/settings'));
// 计算标题文本
const titleText = computed(() => {
if (isInSettings.value) {
return `voidraft - ` + t('settings.title');
}
const currentDoc = documentStore.currentDocument;
if (currentDoc) {
// 限制文档标题长度,避免标题栏换行
const maxTitleLength = 30;
const truncatedTitle = currentDoc.title.length > maxTitleLength
? currentDoc.title.substring(0, maxTitleLength) + '...'
: currentDoc.title;
return `voidraft - ${truncatedTitle}`;
}
return 'voidraft';
});
// 计算完整标题文本用于tooltip
const fullTitleText = computed(() => {
if (isInSettings.value) {
return `voidraft - ` + t('settings.title');
}
const currentDoc = documentStore.currentDocument;
return currentDoc ? `voidraft - ${currentDoc.title}` : 'voidraft';
});
@@ -126,6 +160,7 @@ onMounted(async () => {
font-size: 13px;
font-weight: 500;
cursor: default;
min-width: 0; /* 允许内容收缩 */
-webkit-context-menu: none;
-moz-context-menu: none;
@@ -135,6 +170,7 @@ onMounted(async () => {
.titlebar-content .titlebar-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
img {
width: 100%;
@@ -149,6 +185,15 @@ onMounted(async () => {
font-weight: 500;
}
.titlebar-tabs {
flex: 1;
height: 100%;
align-items: center;
overflow: hidden;
margin-left: 8px;
min-width: 0;
}
.titlebar-controls {
display: flex;
height: 100%;

View File

@@ -43,8 +43,13 @@
</button>
</div>
<div class="titlebar-content" @dblclick="toggleMaximize" @contextmenu.prevent>
<div class="titlebar-title">{{ titleText }}</div>
<!-- 标签页容器区域 -->
<div class="titlebar-tabs" v-if="tabStore.isTabsEnabled && !isInSettings" style="--wails-draggable:drag">
<TabContainer />
</div>
<div class="titlebar-content" @dblclick="toggleMaximize" @contextmenu.prevent v-if="!tabStore.isTabsEnabled || isInSettings">
<div class="titlebar-title" :title="fullTitleText">{{ titleText }}</div>
</div>
</div>
</template>
@@ -52,14 +57,22 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import * as runtime from '@wailsio/runtime';
import { useDocumentStore } from '@/stores/documentStore';
import TabContainer from '@/components/tabs/TabContainer.vue';
import { useTabStore } from "@/stores/tabStore";
const tabStore = useTabStore();
const { t } = useI18n();
const route = useRoute();
const isMaximized = ref(false);
const showControlIcons = ref(false);
const documentStore = useDocumentStore();
// 判断是否在设置页面
const isInSettings = computed(() => route.path.startsWith('/settings'));
const minimizeWindow = async () => {
try {
await runtime.Window.Minimise();
@@ -95,6 +108,26 @@ const checkMaximizedState = async () => {
// 计算标题文本
const titleText = computed(() => {
if (isInSettings.value) {
return `voidraft - ` + t('settings.title');
}
const currentDoc = documentStore.currentDocument;
if (currentDoc) {
// 限制文档标题长度,避免标题栏换行
const maxTitleLength = 30;
const truncatedTitle = currentDoc.title.length > maxTitleLength
? currentDoc.title.substring(0, maxTitleLength) + '...'
: currentDoc.title;
return `voidraft - ${truncatedTitle}`;
}
return 'voidraft';
});
// 计算完整标题文本用于tooltip
const fullTitleText = computed(() => {
if (isInSettings.value) {
return `voidraft - ` + t('settings.title');
}
const currentDoc = documentStore.currentDocument;
return currentDoc ? `voidraft - ${currentDoc.title}` : 'voidraft';
});
@@ -134,6 +167,7 @@ onMounted(async () => {
align-items: center;
padding-left: 8px;
gap: 8px;
flex-shrink: 0;
-webkit-context-menu: none;
-moz-context-menu: none;
@@ -212,12 +246,67 @@ onMounted(async () => {
justify-content: center;
flex: 1;
cursor: default;
min-width: 0;
-webkit-context-menu: none;
-moz-context-menu: none;
context-menu: none;
}
.titlebar-tabs {
flex: 1;
height: 100%;
display: flex;
align-items: center;
margin-left: 8px;
margin-right: 8px;
min-width: 0;
overflow: visible; /* 允许TabContainer内部处理滚动 */
/* 确保TabContainer能够正确处理滚动 */
:deep(.tab-container) {
width: 100%;
height: 100%;
}
:deep(.tab-bar) {
width: 100%;
height: 100%;
}
:deep(.tab-scroll-wrapper) {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
/* 确保底部线条能够正确显示 */
:deep(.tab-item) {
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 2px;
background: var(--tab-active-line, var(--accent-color, #007acc));
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 10;
}
&.active::after {
width: 100%;
}
}
}
.titlebar-title {
font-size: 13px;
font-weight: 500;

View File

@@ -4,7 +4,13 @@
<div class="titlebar-icon">
<img src="/appicon.png" alt="voidraft"/>
</div>
<div class="titlebar-title">{{ titleText }}</div>
<div v-if="!tabStore.isTabsEnabled && !isInSettings" class="titlebar-title" :title="fullTitleText">{{ titleText }}</div>
<!-- 标签页容器区域 -->
<div class="titlebar-tabs" v-if="tabStore.isTabsEnabled && !isInSettings" style="--wails-draggable:drag">
<TabContainer />
</div>
<!-- 设置页面标题 -->
<div v-if="isInSettings" class="titlebar-title" :title="fullTitleText">{{ titleText }}</div>
</div>
<div class="titlebar-controls" style="--wails-draggable:no-drag" @contextmenu.prevent>
@@ -38,18 +44,46 @@
<script setup lang="ts">
import {computed, onMounted, ref} from 'vue';
import {useI18n} from 'vue-i18n';
import {useRoute} from 'vue-router';
import * as runtime from '@wailsio/runtime';
import {useDocumentStore} from '@/stores/documentStore';
import TabContainer from '@/components/tabs/TabContainer.vue';
import {useTabStore} from "@/stores/tabStore";
const tabStore = useTabStore();
const {t} = useI18n();
const route = useRoute();
const isMaximized = ref(false);
const documentStore = useDocumentStore();
// 计算属性用于图标,减少重复渲染
const maximizeIcon = computed(() => isMaximized.value ? '&#xE923;' : '&#xE922;');
// 判断是否在设置页面
const isInSettings = computed(() => route.path.startsWith('/settings'));
// 计算标题文本
const titleText = computed(() => {
if (isInSettings.value) {
return `voidraft - ` + t('settings.title');
}
const currentDoc = documentStore.currentDocument;
if (currentDoc) {
// 限制文档标题长度,避免标题栏换行
const maxTitleLength = 30;
const truncatedTitle = currentDoc.title.length > maxTitleLength
? currentDoc.title.substring(0, maxTitleLength) + '...'
: currentDoc.title;
return `voidraft - ${truncatedTitle}`;
}
return 'voidraft';
});
// 计算完整标题文本用于tooltip
const fullTitleText = computed(() => {
if (isInSettings.value) {
return `voidraft - ` + t('settings.title');
}
const currentDoc = documentStore.currentDocument;
return currentDoc ? `voidraft - ${currentDoc.title}` : 'voidraft';
});
@@ -118,6 +152,7 @@ onMounted(async () => {
font-size: 12px;
font-weight: 400;
cursor: default;
min-width: 0; /* 允许内容收缩 */
-webkit-context-menu: none;
-moz-context-menu: none;
@@ -127,6 +162,7 @@ onMounted(async () => {
.titlebar-content .titlebar-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
img {
width: 100%;
@@ -135,9 +171,14 @@ onMounted(async () => {
}
}
.titlebar-title {
font-size: 12px;
color: var(--toolbar-text);
.titlebar-tabs {
flex: 1;
height: 100%;
align-items: center;
overflow: hidden;
margin-left: 8px;
min-width: 0;
//margin-right: 8px;
}
.titlebar-controls {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { ref, computed, onMounted, onUnmounted, watch, nextTick, shallowRef, readonly, effectScope, onScopeDispose } from 'vue';
import { useI18n } from 'vue-i18n';
import { useEditorStore } from '@/stores/editorStore';
import { type SupportedLanguage } from '@/views/editor/extensions/codeblock/types';
@@ -8,56 +8,46 @@ import { getActiveNoteBlock } from '@/views/editor/extensions/codeblock/state';
import { changeCurrentBlockLanguage } from '@/views/editor/extensions/codeblock/commands';
const { t } = useI18n();
const editorStore = useEditorStore();
const editorStore = readonly(useEditorStore());
// 组件状态
const showLanguageMenu = ref(false);
const showLanguageMenu = shallowRef(false);
const searchQuery = ref('');
const searchInputRef = ref<HTMLInputElement>();
// 支持的语言列表
const supportedLanguages = getAllSupportedLanguages();
// 动态生成语言显示名称映射
const languageNames = computed(() => {
// 优化语言数据处理
const languageData = computed(() => {
const supportedLanguages = getAllSupportedLanguages();
const names: Record<string, string> = {
auto: 'Auto',
text: 'Plain Text'
};
LANGUAGES.forEach(lang => {
names[lang.token] = lang.name;
});
return names;
});
// 动态生成语言别名映射
const languageAliases = computed(() => {
const aliases: Record<string, string> = {
auto: 'auto',
text: 'txt'
};
// 一次遍历完成所有映射
LANGUAGES.forEach(lang => {
// 使用语言名称的小写作为别名
names[lang.token] = lang.name;
aliases[lang.token] = lang.name.toLowerCase();
});
return aliases;
return {
supportedLanguages,
names,
aliases
};
});
// 当前活动块的语言信息
const currentBlockLanguage = ref<{ name: SupportedLanguage; auto: boolean }>({
const currentBlockLanguage = shallowRef<{ name: SupportedLanguage; auto: boolean }>({
name: 'text',
auto: false
});
// 事件监听器引用
const eventListeners = ref<{
updateListener?: () => void;
selectionUpdateListener?: () => void;
}>({});
// 事件监听器管理
let editorScope: ReturnType<typeof effectScope> | null = null;
// 更新当前块语言信息
const updateCurrentBlockLanguage = () => {
@@ -81,7 +71,7 @@ const updateCurrentBlockLanguage = () => {
currentBlockLanguage.value = newLanguage;
}
} else {
if (currentBlockLanguage.value.name !== 'text' || currentBlockLanguage.value.auto !== false) {
if (currentBlockLanguage.value.name !== 'text' || currentBlockLanguage.value.auto) {
currentBlockLanguage.value = { name: 'text', auto: false };
}
}
@@ -93,57 +83,47 @@ const updateCurrentBlockLanguage = () => {
// 清理事件监听器
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);
}
if (editorScope) {
editorScope.stop();
editorScope = null;
}
eventListeners.value = {};
};
// 设置事件监听器
// 设置事件监听器 - 使用 effectScope 管理
const setupEventListeners = (view: any) => {
cleanupEventListeners();
// 监听编辑器状态更新
const updateListener = () => {
// 使用 requestAnimationFrame 确保在下一帧更新,性能更好
requestAnimationFrame(() => {
updateCurrentBlockLanguage();
editorScope = effectScope();
editorScope.run(() => {
// 监听编辑器状态更新
const updateListener = () => {
// 使用 requestAnimationFrame 确保在下一帧更新
requestAnimationFrame(() => {
updateCurrentBlockLanguage();
});
};
// 监听关键事件:光标位置变化、文档变化、焦点变化
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);
view.dom.addEventListener('selectionchange', updateListener);
// 在 scope 销毁时清理
onScopeDispose(() => {
view.dom.removeEventListener('click', updateListener);
view.dom.removeEventListener('keyup', updateListener);
view.dom.removeEventListener('keydown', updateListener);
view.dom.removeEventListener('focus', updateListener);
view.dom.removeEventListener('mouseup', updateListener);
view.dom.removeEventListener('selectionchange', updateListener);
});
};
// 监听选择变化
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();
// 立即更新一次当前状态
updateCurrentBlockLanguage();
});
};
// 监听编辑器状态变化
@@ -159,16 +139,18 @@ watch(
{ immediate: true }
);
// 过滤后的语言列表
// 过滤后的语言列表 - 优化搜索性能
const filteredLanguages = computed(() => {
const { supportedLanguages, names, aliases } = languageData.value;
if (!searchQuery.value) {
return supportedLanguages;
}
const query = searchQuery.value.toLowerCase();
return supportedLanguages.filter(langId => {
const name = languageNames.value[langId];
const alias = languageAliases.value[langId];
const name = names[langId];
const alias = aliases[langId];
return langId.toLowerCase().includes(query) ||
(name && name.toLowerCase().includes(query)) ||
(alias && alias.toLowerCase().includes(query));
@@ -191,7 +173,7 @@ const closeLanguageMenu = () => {
searchQuery.value = '';
};
// 选择语言
// 选择语言 - 优化性能
const selectLanguage = (languageId: SupportedLanguage) => {
if (!editorStore.editorView) {
closeLanguageMenu();
@@ -203,18 +185,9 @@ const selectLanguage = (languageId: SupportedLanguage) => {
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 [targetLanguage, autoDetect] = languageId === 'auto'
? ['text', true]
: [languageId, false];
// 使用修复后的函数来更改语言
const success = changeCurrentBlockLanguage(state as any, dispatch, targetLanguage, autoDetect);
@@ -231,72 +204,75 @@ const selectLanguage = (languageId: SupportedLanguage) => {
closeLanguageMenu();
};
// 点击外部关闭
// 全局事件处理器 - 使用 effectScope 管理
const globalScope = effectScope();
// 点击外部关闭 - 只在菜单打开时处理
const handleClickOutside = (event: MouseEvent) => {
if (!showLanguageMenu.value) return;
const target = event.target as HTMLElement;
if (!target.closest('.block-language-selector')) {
closeLanguageMenu();
}
};
// 键盘事件处理
// 键盘事件处理 - 只在菜单打开时处理
const handleKeydown = (event: KeyboardEvent) => {
if (!showLanguageMenu.value) return;
if (event.key === 'Escape') {
closeLanguageMenu();
}
};
onMounted(() => {
// 在 setup 阶段就设置全局事件监听器
globalScope.run(() => {
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleKeydown);
onScopeDispose(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleKeydown);
});
});
onMounted(() => {
// 立即更新一次当前语言状态
updateCurrentBlockLanguage();
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleKeydown);
globalScope.stop();
cleanupEventListeners();
});
// 获取当前语言的显示名称
const getCurrentLanguageName = computed(() => {
// 优化计算属性
const languageDisplayInfo = computed(() => {
const lang = currentBlockLanguage.value;
if (lang.auto) {
return `${lang.name} (auto)`;
}
return lang.name;
const displayName = lang.auto ? `${lang.name} (auto)` : lang.name;
const displayLanguage = lang.auto ? 'auto' : lang.name;
return {
name: displayName,
language: displayLanguage
};
});
// 获取当前显示的语言选项
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'
});
}
const currentLang = languageDisplayInfo.value.language;
const activeOption = document.querySelector(`.language-option[data-language="${currentLang}"]`) as HTMLElement;
if (activeOption) {
// 使用 scrollIntoView 进行平滑滚动
activeOption.scrollIntoView({
behavior: 'auto',
block: 'nearest',
inline: 'nearest'
});
}
});
};
</script>
@@ -314,7 +290,7 @@ const scrollToCurrentLanguage = () => {
<polyline points="8 6 2 12 8 18"></polyline>
</svg>
</span>
<span class="language-name">{{ getCurrentLanguageName }}</span>
<span class="language-name">{{ languageDisplayInfo.name }}</span>
<span class="arrow" :class="{ 'open': showLanguageMenu }"></span>
</button>
@@ -341,11 +317,11 @@ const scrollToCurrentLanguage = () => {
v-for="language in filteredLanguages"
:key="language"
class="language-option"
:class="{ 'active': getCurrentDisplayLanguage === language }"
:class="{ 'active': languageDisplayInfo.language === language }"
:data-language="language"
@click="selectLanguage(language)"
>
<span class="language-name">{{ languageNames[language] || language }}</span>
<span class="language-name">{{ languageData.names[language] || language }}</span>
<span class="language-alias">{{ language }}</span>
</div>
@@ -517,4 +493,4 @@ const scrollToCurrentLanguage = () => {
background-color: var(--text-muted);
}
}
</style>
</style>

View File

@@ -1,45 +1,49 @@
<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";
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useDocumentStore } from '@/stores/documentStore';
import { useTabStore } from '@/stores/tabStore';
import { useWindowStore } from '@/stores/windowStore';
import { useI18n } from 'vue-i18n';
import type { Document } from '@/../bindings/voidraft/internal/models/models';
const documentStore = useDocumentStore();
const tabStore = useTabStore();
const windowStore = useWindowStore();
const {t} = useI18n();
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 MAX_TITLE_LENGTH = 50;
// 计算属性
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 filteredItems = computed(() => {
const docs = documentStore.documentList;
const query = inputValue.value.trim();
if (!query) {
return docs;
}
if (!query) return docs;
// 过滤匹配的文档
const filtered = docs.filter(doc =>
doc.title.toLowerCase().includes(query.toLowerCase())
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,
{ id: -1, title: t('toolbar.createDocument') + ` "${query}"`, isCreateOption: true } as any,
...filtered
];
}
@@ -47,164 +51,135 @@ const filteredItems = computed(() => {
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 {
// 如果选择的就是当前文档,直接关闭菜单
if (documentStore.currentDocument?.id === doc.id) {
closeMenu();
return;
}
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()) return t('toolbar.documentNameRequired');
if (title.trim().length > MAX_TITLE_LENGTH) {
return t('toolbar.documentNameTooLong', {max: 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) {
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 {
return t('toolbar.timeError');
}
};
// 核心操作
const openMenu = async () => {
documentStore.openDocumentSelector();
await documentStore.getDocumentMetaList();
await nextTick();
inputRef.value?.focus();
};
const closeMenu = () => {
documentStore.closeDocumentSelector();
inputValue.value = '';
editingId.value = null;
editingTitle.value = '';
deleteConfirmId.value = null;
};
const selectDoc = async (doc: Document) => {
// 如果选择的就是当前文档,直接关闭菜单
if (documentStore.currentDocument?.id === doc.id) {
closeMenu();
return;
}
const hasOpen = await windowStore.isDocumentWindowOpen(doc.id);
if (hasOpen) {
documentStore.setError(doc.id, t('toolbar.alreadyOpenInNewWindow'));
return;
}
const success = await documentStore.openDocument(doc.id);
if (success) {
if (tabStore.isTabsEnabled) {
tabStore.addOrActivateTab(doc);
}
closeMenu();
}
};
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);
}
if (newDoc) await selectDoc(newDoc);
} catch (error) {
console.error('Failed to create document:', error);
}
};
// 开始重命名
const selectItem = async (item: any) => {
if (item.isCreateOption) {
await createDoc(inputValue.value.trim());
} else {
await selectDoc(item);
}
};
// 编辑操作
const startRename = (doc: Document, event: Event) => {
event.stopPropagation();
editingId.value = doc.id;
editingTitle.value = doc.title;
deleteConfirmId.value = null; // 清除删除确认状态
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;
}
if (!editingId.value || !editingTitle.value.trim()) {
editingId.value = null;
editingTitle.value = '';
return;
}
const trimmedTitle = editingTitle.value.trim();
const error = validateTitle(trimmedTitle);
if (error) return;
try {
await documentStore.updateDocumentMetadata(editingId.value, trimmedTitle);
await documentStore.getDocumentMetaList();
// 如果tabs功能开启且该文档有标签页更新标签页标题
if (tabStore.isTabsEnabled && tabStore.hasTab(editingId.value)) {
tabStore.updateTabTitle(editingId.value, trimmedTitle);
}
} catch (error) {
console.error('Failed to update document:', error);
} finally {
editingId.value = null;
editingTitle.value = '';
}
editingId.value = null;
editingTitle.value = '';
};
// 在新窗口打开文档
// 其他操作
const openInNewWindow = async (doc: Document, event: Event) => {
event.stopPropagation();
try {
@@ -214,56 +189,32 @@ const openInNewWindow = async (doc: Document, event: Event) => {
}
};
// 处理删除
const handleDelete = async (doc: Document, event: Event) => {
event.stopPropagation();
if (deleteConfirmId.value === doc.id) {
// 确认删除前检查文档是否在其他窗口打开
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);
// 取消删除确认状态
deleteConfirmId.value = null;
return;
}
const deleteSuccess = await documentStore.deleteDocument(doc.id);
const hasOpen = await windowStore.isDocumentWindowOpen(doc.id);
if (hasOpen) {
documentStore.setError(doc.id, t('toolbar.alreadyOpenInNewWindow'));
deleteConfirmId.value = null;
return;
}
if (!deleteSuccess) {
return;
}
await documentStore.updateDocuments();
const deleteSuccess = await documentStore.deleteDocument(doc.id);
if (deleteSuccess) {
await documentStore.getDocumentMetaList();
// 如果删除的是当前文档,切换到第一个文档
if (documentStore.currentDocument?.id === doc.id && documentStore.documentList.length > 0) {
const firstDoc = documentStore.documentList[0];
if (firstDoc) {
await selectDoc(firstDoc);
}
if (firstDoc) await selectDoc(firstDoc);
}
} catch (error) {
console.error('deleted failed:', error);
}
deleteConfirmId.value = null;
} else {
// 进入确认状态
deleteConfirmId.value = doc.id;
editingId.value = null; // 清除编辑状态
editingId.value = null;
// 3秒后自动取消确认状态
setTimeout(() => {
@@ -274,32 +225,18 @@ const handleDelete = async (doc: Document, event: Event) => {
}
};
// 格式化时间
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 createKeyHandler = (handlers: Record<string, () => void>) => (event: KeyboardEvent) => {
const handler = handlers[event.key];
if (handler) {
event.preventDefault();
event.stopPropagation();
handler();
}
};
// 键盘事件
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
const handleGlobalKeydown = createKeyHandler({
Escape: () => {
if (editingId.value) {
editingId.value = null;
editingTitle.value = '';
@@ -309,38 +246,25 @@ const handleKeydown = (event: KeyboardEvent) => {
closeMenu();
}
}
};
});
// 输入框键盘事件
const handleInputKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault();
const handleInputKeydown = createKeyHandler({
Enter: () => {
const query = inputValue.value.trim();
if (query) {
// 如果有匹配的项目,选择第一个
if (filteredItems.value.length > 0) {
selectItem(filteredItems.value[0]);
}
if (query && filteredItems.value.length > 0) {
selectItem(filteredItems.value[0]);
}
} else if (event.key === 'Escape') {
event.preventDefault();
closeMenu();
}
event.stopPropagation();
};
},
Escape: closeMenu
});
// 编辑键盘事件
const handleEditKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault();
saveEdit();
} else if (event.key === 'Escape') {
event.preventDefault();
const handleEditKeydown = createKeyHandler({
Enter: saveEdit,
Escape: () => {
editingId.value = null;
editingTitle.value = '';
}
event.stopPropagation();
};
});
// 点击外部关闭
const handleClickOutside = (event: Event) => {
@@ -353,15 +277,18 @@ const handleClickOutside = (event: Event) => {
// 生命周期
onMounted(() => {
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleKeydown);
document.addEventListener('keydown', handleGlobalKeydown);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleKeydown);
// 清理定时器
if (errorMessageTimer.value) {
clearTimeout(errorMessageTimer.value);
document.removeEventListener('keydown', handleGlobalKeydown);
});
// 监听菜单状态变化
watch(() => documentStore.showDocumentSelector, (isOpen) => {
if (isOpen) {
openMenu();
}
});
</script>
@@ -369,7 +296,7 @@ onUnmounted(() => {
<template>
<div class="document-selector">
<!-- 选择器按钮 -->
<button class="doc-btn" @click="toggleMenu">
<button class="doc-btn" @click="documentStore.toggleDocumentSelector">
<span class="doc-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">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path>
@@ -377,21 +304,21 @@ onUnmounted(() => {
</svg>
</span>
<span class="doc-name">{{ currentDocName }}</span>
<span class="arrow" :class="{ open: showMenu }"></span>
<span class="arrow" :class="{ open: documentStore.showDocumentSelector }"></span>
</button>
<!-- 菜单 -->
<div v-if="showMenu" class="doc-menu">
<div v-if="documentStore.showDocumentSelector" 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"
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">
@@ -403,14 +330,14 @@ onUnmounted(() => {
<!-- 项目列表 -->
<div class="item-list">
<div
v-for="item in filteredItems"
:key="item.id"
class="list-item"
:class="{
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)"
@click="selectItem(item)"
>
<!-- 创建选项 -->
<div v-if="item.isCreateOption" class="create-option">
@@ -428,23 +355,23 @@ onUnmounted(() => {
<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-if="documentStore.selectorError?.docId === item.id" class="doc-error">
{{ documentStore.selectorError?.message }}
</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
: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>
@@ -452,17 +379,17 @@ onUnmounted(() => {
<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')"
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>
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>
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')">
@@ -472,11 +399,11 @@ onUnmounted(() => {
</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')"
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"
@@ -557,10 +484,12 @@ onUnmounted(() => {
border-radius: 3px;
margin-bottom: 4px;
width: 260px;
max-height: 320px;
max-height: calc(100vh - 40px); // 限制最大高度留出titlebar空间(32px)和一些边距
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
overflow: hidden;
display: flex;
flex-direction: column;
.input-box {
position: relative;
@@ -598,8 +527,9 @@ onUnmounted(() => {
}
.item-list {
max-height: 240px;
max-height: calc(100vh - 100px); // 为输入框和边距预留空间
overflow-y: auto;
flex: 1;
.list-item {
cursor: pointer;
@@ -709,37 +639,25 @@ onUnmounted(() => {
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);
background-color: var(--bg-hover);
color: var(--text-secondary);
}
&.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;
&.delete-btn {
&:hover {
color: var(--text-danger);
}
&:hover {
&.delete-confirm {
background-color: var(--text-danger);
color: white !important; // 确保确认状态下文字始终为白色
opacity: 0.8;
color: white;
.confirm-text {
font-size: 9px;
font-weight: 500;
}
}
}
}
@@ -752,44 +670,19 @@ onUnmounted(() => {
}
.empty, .loading {
padding: 12px 8px;
padding: 16px 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;
}
0% { opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { opacity: 0; }
}
</style>

View File

@@ -1,42 +1,45 @@
<script setup lang="ts">
import {useI18n} from 'vue-i18n';
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
import {computed, onMounted, onUnmounted, ref, watch, shallowRef, readonly, toRefs, effectScope, onScopeDispose} from 'vue';
import {useConfigStore} from '@/stores/configStore';
import {useEditorStore} from '@/stores/editorStore';
import {useUpdateStore} from '@/stores/updateStore';
import {useWindowStore} from '@/stores/windowStore';
import {useSystemStore} from '@/stores/systemStore';
import * as runtime from '@wailsio/runtime';
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';
import {createDebounce} from '@/common/utils/debounce';
const editorStore = useEditorStore();
const configStore = useConfigStore();
const updateStore = useUpdateStore();
const windowStore = useWindowStore();
const systemStore = useSystemStore();
const editorStore = readonly(useEditorStore());
const configStore = readonly(useConfigStore());
const updateStore = readonly(useUpdateStore());
const windowStore = readonly(useWindowStore());
const systemStore = readonly(useSystemStore());
const {t} = useI18n();
const router = useRouter();
// 当前块是否支持格式化的响应式状态
const canFormatCurrentBlock = ref(false);
const isLoaded = shallowRef(false);
// 窗口置顶状态 - 合并配置和临时状态
const { documentStats } = toRefs(editorStore);
const { config } = toRefs(configStore);
// 窗口置顶状态
const isCurrentWindowOnTop = computed(() => {
return configStore.config.general.alwaysOnTop || systemStore.isWindowOnTop;
return config.value.general.alwaysOnTop || systemStore.isWindowOnTop;
});
// 切换窗口置顶状态
const toggleAlwaysOnTop = async () => {
const currentlyOnTop = isCurrentWindowOnTop.value;
if (currentlyOnTop) {
// 如果当前是置顶状态,彻底关闭所有置顶
if (configStore.config.general.alwaysOnTop) {
if (config.value.general.alwaysOnTop) {
await configStore.setAlwaysOnTop(false);
}
await systemStore.setWindowOnTop(false);
@@ -57,9 +60,8 @@ const formatCurrentBlock = () => {
formatBlockContent(editorStore.editorView);
};
// 格式化按钮状态更新
// 格式化按钮状态更新 - 使用更高效的检查逻辑
const updateFormatButtonState = () => {
// 安全检查
const view = editorStore.editorView;
if (!view) {
canFormatCurrentBlock.value = false;
@@ -67,156 +69,158 @@ const updateFormatButtonState = () => {
}
try {
// 获取活动块和语言信息
const state = view.state;
const activeBlock = getActiveNoteBlock(state as any);
// 提前返回,减少不必要的计算
if (!activeBlock) {
canFormatCurrentBlock.value = false;
return;
}
// 检查块和语言格式化支持
canFormatCurrentBlock.value = !!(
activeBlock &&
getLanguage(activeBlock.language.name as any)?.prettier
);
const language = getLanguage(activeBlock.language.name as any);
canFormatCurrentBlock.value = Boolean(language?.prettier);
} catch (error) {
console.warn('Error checking format capability:', error);
canFormatCurrentBlock.value = false;
}
};
// 创建带300ms防抖的更新函数
const debouncedUpdateFormatButton = (() => {
let timeout: number | null = null;
// 创建带1s防抖的更新函数
const { debouncedFn: debouncedUpdateFormat, cancel: cancelDebounce } = createDebounce(
updateFormatButtonState,
{ delay: 1000 }
);
return () => {
if (timeout) clearTimeout(timeout);
timeout = window.setTimeout(() => {
updateFormatButtonState();
timeout = null;
}, 1000);
};
})();
// 使用 effectScope 管理编辑器事件监听器
const editorScope = effectScope();
let cleanupListeners: (() => void)[] = [];
// 编辑器事件管理
// 优化的事件监听器管理
const setupEditorListeners = (view: any) => {
if (!view?.dom) return [];
const events = [
{type: 'click', handler: updateFormatButtonState},
{type: 'keyup', handler: debouncedUpdateFormatButton},
{type: 'focus', handler: updateFormatButtonState}
];
// 使用对象缓存事件处理器,避免重复创建
const eventHandlers = {
click: updateFormatButtonState,
keyup: debouncedUpdateFormat,
focus: updateFormatButtonState
} as const;
// 注册所有事件
events.forEach(event => view.dom.addEventListener(event.type, event.handler));
const events = Object.entries(eventHandlers).map(([type, handler]) => ({
type,
handler,
cleanup: () => view.dom.removeEventListener(type, handler)
}));
// 返回清理函数数组
return events.map(event =>
() => view.dom.removeEventListener(event.type, event.handler)
);
// 批量注册事件
events.forEach(event => view.dom.addEventListener(event.type, event.handler, { passive: true }));
return events.map(event => event.cleanup);
};
// 监听编辑器视图变化
let cleanupListeners: (() => void)[] = [];
watch(
() => editorStore.editorView,
(newView) => {
// 清理旧监听器
cleanupListeners.forEach(cleanup => cleanup());
cleanupListeners = [];
// 在 scope 中管理副作用
editorScope.run(() => {
// 清理旧监听器
cleanupListeners.forEach(cleanup => cleanup());
cleanupListeners = [];
if (newView) {
// 初始更新状态
updateFormatButtonState();
// 设置新监听器
cleanupListeners = setupEditorListeners(newView);
} else {
canFormatCurrentBlock.value = false;
}
if (newView) {
// 初始更新状态
updateFormatButtonState();
// 设置新监听器
cleanupListeners = setupEditorListeners(newView);
} else {
canFormatCurrentBlock.value = false;
}
});
},
{immediate: true}
{ immediate: true, flush: 'post' }
);
// 组件生命周期
const isLoaded = ref(false);
onMounted(() => {
onMounted(async () => {
isLoaded.value = true;
// 首次更新格式化状态
updateFormatButtonState();
await systemStore.setWindowOnTop(isCurrentWindowOnTop.value);
});
// 使用 onScopeDispose 确保 scope 清理
onScopeDispose(() => {
cleanupListeners.forEach(cleanup => cleanup());
cleanupListeners = [];
cancelDebounce();
});
onUnmounted(() => {
// 清理所有事件监听器
cleanupListeners.forEach(cleanup => cleanup());
cleanupListeners = [];
// 停止 effect scope
editorScope.stop();
// 清理防抖函数
cancelDebounce();
});
// 组件加载后初始化置顶状态
watch(isLoaded, async (loaded) => {
if (loaded) {
// 应用合并后的置顶状态
const shouldBeOnTop = configStore.config.general.alwaysOnTop || systemStore.isWindowOnTop;
try {
await runtime.Window.SetAlwaysOnTop(shouldBeOnTop);
} catch (error) {
console.error('Failed to apply window pin state:', error);
}
}
});
// 监听配置变化,同步窗口状态
watch(
() => isCurrentWindowOnTop.value,
async (shouldBeOnTop) => {
try {
await runtime.Window.SetAlwaysOnTop(shouldBeOnTop);
} catch (error) {
console.error('Failed to sync window pin state:', error);
}
}
);
// 更新按钮处理
const handleUpdateButtonClick = async () => {
if (updateStore.hasUpdate && !updateStore.isUpdating && !updateStore.updateSuccess) {
// 开始下载更新
const { hasUpdate, isUpdating, updateSuccess } = updateStore;
if (hasUpdate && !isUpdating && !updateSuccess) {
await updateStore.applyUpdate();
} else if (updateStore.updateSuccess) {
// 更新成功后,点击重启
} else if (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 || ''}`;
const { isChecking, isUpdating, updateSuccess, hasUpdate, updateResult } = updateStore;
if (isChecking) return t('settings.checking');
if (isUpdating) return t('settings.updating');
if (updateSuccess) return t('settings.updateSuccessRestartRequired');
if (hasUpdate) return `${t('settings.newVersionAvailable')}: ${updateResult?.latestVersion || ''}`;
return '';
});
// 统计数据的计算属性
const statsData = computed(() => ({
lines: documentStats.value.lines,
characters: documentStats.value.characters,
selectedCharacters: documentStats.value.selectedCharacters
}));
</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">{{
editorStore.documentStats.lines
}}</span></span>
<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.lines')">
{{ t('toolbar.editor.lines') }}:
<span class="stat-value">{{ statsData.lines }}</span>
</span>
<span class="stat-item" :title="t('toolbar.editor.characters')">
{{ t('toolbar.editor.characters') }}:
<span class="stat-value">{{ statsData.characters }}</span>
</span>
<span
v-if="statsData.selectedCharacters > 0"
class="stat-item"
:title="t('toolbar.editor.selected')"
>
{{ t('toolbar.editor.selected') }}:
<span class="stat-value">{{ statsData.selectedCharacters }}</span>
</span>
</div>
<div class="actions">
<span class="font-size" :title="t('toolbar.fontSizeTooltip')" @click="() => configStore.resetFontSize()">
{{ configStore.config.editing.fontSize }}px
<span
class="font-size"
:title="t('toolbar.fontSizeTooltip')"
@click="configStore.resetFontSize"
>
{{ config.editing.fontSize }}px
</span>
<!-- 文档选择器 -->
@@ -302,7 +306,6 @@ const updateButtonTitle = computed(() => {
</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">

View File

@@ -112,6 +112,14 @@ export default {
textHighlightToggle: 'Toggle text highlight',
}
},
tabs: {
contextMenu: {
closeTab: 'Close Tab',
closeOthers: 'Close Others',
closeLeft: 'Close Left',
closeRight: 'Close Right'
}
},
settings: {
title: 'Settings',
backToEditor: 'Back to Editor',
@@ -136,6 +144,7 @@ export default {
alwaysOnTop: 'Always on Top',
enableWindowSnap: 'Enable Window Snapping',
enableLoadingAnimation: 'Enable Loading Animation',
enableTabs: 'Enable Tabs',
startup: 'Startup Settings',
startAtLogin: 'Start at Login',
dataStorage: 'Data Storage',

View File

@@ -112,6 +112,14 @@ export default {
textHighlightToggle: '切换文本高亮',
}
},
tabs: {
contextMenu: {
closeTab: '关闭标签',
closeOthers: '关闭其他',
closeLeft: '关闭左侧',
closeRight: '关闭右侧'
}
},
settings: {
title: '设置',
backToEditor: '返回编辑器',
@@ -137,6 +145,7 @@ export default {
alwaysOnTop: '窗口始终置顶',
enableWindowSnap: '启用窗口吸附',
enableLoadingAnimation: '启用加载动画',
enableTabs: '启用标签页',
startup: '启动设置',
startAtLogin: '开机自启动',
dataStorage: '数据存储',

View File

@@ -1,123 +1,175 @@
import {defineStore} from 'pinia';
import {computed, readonly, ref} from 'vue';
import type {GitBackupConfig} from '@/../bindings/voidraft/internal/models';
import {BackupService} from '@/../bindings/voidraft/internal/services';
import {useConfigStore} from '@/stores/configStore';
import { defineStore } from 'pinia';
import { computed, readonly, ref, shallowRef, watchEffect, onScopeDispose } from 'vue';
import type { GitBackupConfig } from '@/../bindings/voidraft/internal/models';
import { BackupService } from '@/../bindings/voidraft/internal/services';
import { useConfigStore } from '@/stores/configStore';
import { createTimerManager } from '@/common/utils/timerUtils';
// 备份状态枚举
export enum BackupStatus {
IDLE = 'idle',
PUSHING = 'pushing',
SUCCESS = 'success',
ERROR = 'error'
}
// 备份操作结果类型
export interface BackupResult {
status: BackupStatus;
message?: string;
timestamp?: number;
}
// 类型守卫函数
const isBackupError = (error: unknown): error is Error => {
return error instanceof Error;
};
// 工具类型:提取错误消息
type ErrorMessage<T> = T extends Error ? string : string;
/**
* Minimalist Backup Store
*/
export const useBackupStore = defineStore('backup', () => {
// Core state
const config = ref<GitBackupConfig | null>(null);
const isPushing = ref(false);
const error = ref<string | null>(null);
const isInitialized = ref(false);
// === 核心状态 ===
const config = shallowRef<GitBackupConfig | null>(null);
// 统一的备份结果状态
const backupResult = ref<BackupResult>({
status: BackupStatus.IDLE
});
// === 定时器管理 ===
const statusTimer = createTimerManager();
// 组件卸载时清理定时器
onScopeDispose(() => {
statusTimer.clear();
});
// === 外部依赖 ===
const configStore = useConfigStore();
// === 计算属性 ===
const isEnabled = computed(() => configStore.config.backup.enabled);
const isConfigured = computed(() => Boolean(configStore.config.backup.repo_url?.trim()));
// 派生状态计算属性
const isPushing = computed(() => backupResult.value.status === BackupStatus.PUSHING);
const isSuccess = computed(() => backupResult.value.status === BackupStatus.SUCCESS);
const isError = computed(() => backupResult.value.status === BackupStatus.ERROR);
const errorMessage = computed(() =>
backupResult.value.status === BackupStatus.ERROR ? backupResult.value.message : null
);
// === 状态管理方法 ===
/**
* 设置备份状态
* @param status 备份状态
* @param message 可选消息
* @param autoHide 是否自动隐藏(毫秒)
*/
const setBackupStatus = <T extends BackupStatus>(
status: T,
message?: T extends BackupStatus.ERROR ? string : string,
autoHide?: number
): void => {
statusTimer.clear();
// Backup result states
const pushSuccess = ref(false);
const pushError = ref(false);
backupResult.value = {
status,
message,
timestamp: Date.now()
};
// 自动隐藏逻辑
if (autoHide && (status === BackupStatus.SUCCESS || status === BackupStatus.ERROR)) {
statusTimer.set(() => {
if (backupResult.value.status === status) {
backupResult.value = { status: BackupStatus.IDLE };
}
}, autoHide);
}
};
/**
* 清除当前状态
*/
const clearStatus = (): void => {
statusTimer.clear();
backupResult.value = { status: BackupStatus.IDLE };
};
/**
* 处理错误的通用方法
*/
const handleError = (error: unknown): void => {
const message: ErrorMessage<typeof error> = isBackupError(error)
? error.message
: 'Backup operation failed';
// Timers for auto-hiding status icons and error messages
let pushStatusTimer: number | null = null;
let errorTimer: number | null = null;
setBackupStatus(BackupStatus.ERROR, message, 5000);
};
// 获取configStore
const configStore = useConfigStore();
// === 业务逻辑方法 ===
/**
* 推送到远程仓库
* 使用现代 async/await 和错误处理
*/
const pushToRemote = async (): Promise<void> => {
// 前置条件检查
if (isPushing.value || !isConfigured.value) {
return;
}
// Computed properties
const isEnabled = computed(() => configStore.config.backup.enabled);
const isConfigured = computed(() => configStore.config.backup.repo_url);
try {
setBackupStatus(BackupStatus.PUSHING);
await BackupService.PushToRemote();
setBackupStatus(BackupStatus.SUCCESS, 'Backup completed successfully', 3000);
} catch (error) {
handleError(error);
}
};
// 清除状态显示
const clearPushStatus = () => {
if (pushStatusTimer !== null) {
window.clearTimeout(pushStatusTimer);
pushStatusTimer = null;
}
pushSuccess.value = false;
pushError.value = false;
};
/**
* 重试备份操作
*/
const retryBackup = async (): Promise<void> => {
if (isError.value) {
await pushToRemote();
}
};
// 清除错误信息和错误图标
const clearError = () => {
if (errorTimer !== null) {
window.clearTimeout(errorTimer);
errorTimer = null;
}
error.value = null;
pushError.value = false;
};
// === 响应式副作用 ===
// 监听配置变化,自动清除错误状态
watchEffect(() => {
if (isEnabled.value && isConfigured.value && isError.value) {
// 配置修复后清除错误状态
clearStatus();
}
});
// 设置错误信息和错误图标并自动清除
const setErrorWithAutoHide = (errorMessage: string, hideAfter: number = 3000) => {
clearError();
clearPushStatus();
error.value = errorMessage;
pushError.value = true;
errorTimer = window.setTimeout(() => {
error.value = null;
pushError.value = false;
errorTimer = null;
}, hideAfter);
};
// === 返回的 API ===
return {
// 只读状态
config: readonly(config),
backupResult: readonly(backupResult),
// Push to remote repository
const pushToRemote = async () => {
if (isPushing.value || !isConfigured.value) return;
// 计算属性
isEnabled,
isConfigured,
isPushing,
isSuccess,
isError,
errorMessage,
isPushing.value = true;
clearError(); // 清除之前的错误信息
clearPushStatus();
try {
await BackupService.PushToRemote();
// 显示成功状态并设置3秒后自动消失
pushSuccess.value = true;
pushStatusTimer = window.setTimeout(() => {
pushSuccess.value = false;
pushStatusTimer = null;
}, 3000);
} catch (err: any) {
setErrorWithAutoHide(err?.message || 'Backup operation failed');
} finally {
isPushing.value = false;
}
};
// 初始化备份服务
const initialize = async () => {
if (!isEnabled.value) return;
// 避免重复初始化
if (isInitialized.value) return;
clearError(); // 清除之前的错误信息
try {
await BackupService.Initialize();
isInitialized.value = true;
} catch (err: any) {
setErrorWithAutoHide(err?.message || 'Failed to initialize backup service');
}
};
return {
// State
config: readonly(config),
isPushing: readonly(isPushing),
error: readonly(error),
isInitialized: readonly(isInitialized),
pushSuccess: readonly(pushSuccess),
pushError: readonly(pushError),
// Computed
isEnabled,
isConfigured,
// Methods
pushToRemote,
initialize,
clearError
};
// 方法
pushToRemote,
retryBackup,
clearStatus
} as const;
});

View File

@@ -4,44 +4,31 @@ import {ConfigService, StartupService} from '@/../bindings/voidraft/internal/ser
import {
AppConfig,
AppearanceConfig,
AuthMethod,
EditingConfig,
GeneralConfig,
GitBackupConfig,
LanguageType,
SystemThemeType,
TabType,
UpdatesConfig,
GitBackupConfig,
AuthMethod
UpdatesConfig
} from '@/../bindings/voidraft/internal/models/models';
import {useI18n} from 'vue-i18n';
import {ConfigUtils} from '@/common/utils/configUtils';
import {FONT_OPTIONS} from '@/common/constant/fonts';
import {SupportedLocaleType, SUPPORTED_LOCALES} from '@/common/constant/locales';
import {SUPPORTED_LOCALES} from '@/common/constant/locales';
import {
NumberConfigKey,
GENERAL_CONFIG_KEY_MAP,
EDITING_CONFIG_KEY_MAP,
APPEARANCE_CONFIG_KEY_MAP,
UPDATES_CONFIG_KEY_MAP,
BACKUP_CONFIG_KEY_MAP,
CONFIG_LIMITS,
DEFAULT_CONFIG
DEFAULT_CONFIG,
EDITING_CONFIG_KEY_MAP,
GENERAL_CONFIG_KEY_MAP,
NumberConfigKey,
UPDATES_CONFIG_KEY_MAP
} from '@/common/constant/config';
import * as runtime from '@wailsio/runtime';
// 获取浏览器的默认语言
const getBrowserLanguage = (): SupportedLocaleType => {
const browserLang = navigator.language;
const langCode = browserLang.split('-')[0];
// 检查是否支持此语言
const supportedLang = SUPPORTED_LOCALES.find(locale =>
locale.code.startsWith(langCode) || locale.code.split('-')[0] === langCode
);
return supportedLang?.code || 'zh-CN';
};
export const useConfigStore = defineStore('config', () => {
const {locale} = useI18n();
@@ -51,7 +38,7 @@ export const useConfigStore = defineStore('config', () => {
isLoading: false,
configLoaded: false
});
// Font options (no longer localized)
const fontOptions = computed(() => FONT_OPTIONS);
@@ -218,7 +205,6 @@ export const useConfigStore = defineStore('config', () => {
};
// 初始化语言设置
const initializeLanguage = async (): Promise<void> => {
try {
@@ -231,7 +217,7 @@ export const useConfigStore = defineStore('config', () => {
const frontendLocale = ConfigUtils.backendLanguageToFrontend(state.config.appearance.language);
locale.value = frontendLocale as any;
} catch (_error) {
const browserLang = getBrowserLanguage();
const browserLang = SUPPORTED_LOCALES[0].code;
locale.value = browserLang as any;
}
};
@@ -268,7 +254,7 @@ export const useConfigStore = defineStore('config', () => {
configLoaded: computed(() => state.configLoaded),
isLoading: computed(() => state.isLoading),
fontOptions,
// 限制常量
...limits,
@@ -330,19 +316,26 @@ export const useConfigStore = defineStore('config', () => {
// 再调用系统设置API
await StartupService.SetEnabled(value);
},
// 窗口吸附配置相关方法
setEnableWindowSnap: async (value: boolean) => await updateGeneralConfig('enableWindowSnap', value),
// 加载动画配置相关方法
setEnableLoadingAnimation: async (value: boolean) => await updateGeneralConfig('enableLoadingAnimation', value),
// 标签页配置相关方法
setEnableTabs: async (value: boolean) => await updateGeneralConfig('enableTabs', value),
// 更新配置相关方法
setAutoUpdate: async (value: boolean) => await updateUpdatesConfig('autoUpdate', value),
// 备份配置相关方法
setEnableBackup: async (value: boolean) => {await updateBackupConfig('enabled', value);},
setAutoBackup: async (value: boolean) => {await updateBackupConfig('auto_backup', value);},
setEnableBackup: async (value: boolean) => {
await updateBackupConfig('enabled', value);
},
setAutoBackup: async (value: boolean) => {
await updateBackupConfig('auto_backup', value);
},
setRepoUrl: async (value: string) => await updateBackupConfig('repo_url', value),
setAuthMethod: async (value: AuthMethod) => await updateBackupConfig('auth_method', value),
setUsername: async (value: string) => await updateBackupConfig('username', value),

View File

@@ -4,45 +4,27 @@ import {DocumentService} from '@/../bindings/voidraft/internal/services';
import {OpenDocumentWindow} from '@/../bindings/voidraft/internal/services/windowservice';
import {Document} from '@/../bindings/voidraft/internal/models/models';
export const useDocumentStore = defineStore('document', () => {
const DEFAULT_DOCUMENT_ID = ref<number>(1); // 默认草稿文档ID
// === 核心状态 ===
const documents = ref<Record<number, Document>>({});
const recentDocumentIds = ref<number[]>([DEFAULT_DOCUMENT_ID.value]);
const currentDocumentId = ref<number | null>(null);
const currentDocument = ref<Document | null>(null);
// === UI状态 ===
const showDocumentSelector = ref(false);
const selectorError = ref<{ docId: number; message: string } | null>(null);
const isLoading = ref(false);
// === 计算属性 ===
const documentList = computed(() =>
Object.values(documents.value).sort((a, b) => {
const aIndex = recentDocumentIds.value.indexOf(a.id);
const bIndex = recentDocumentIds.value.indexOf(b.id);
// 按最近使用排序
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 => {
@@ -50,7 +32,41 @@ export const useDocumentStore = defineStore('document', () => {
});
};
// === 公共API ===
// === 错误处理 ===
const setError = (docId: number, message: string) => {
selectorError.value = {docId, message};
// 3秒后自动清除错误状态
setTimeout(() => {
if (selectorError.value?.docId === docId) {
selectorError.value = null;
}
}, 3000);
};
const clearError = () => {
selectorError.value = null;
};
// === UI控制方法 ===
const openDocumentSelector = () => {
showDocumentSelector.value = true;
clearError();
};
const closeDocumentSelector = () => {
showDocumentSelector.value = false;
clearError();
};
const toggleDocumentSelector = () => {
if (showDocumentSelector.value) {
closeDocumentSelector();
} else {
openDocumentSelector();
}
};
// === 文档操作方法 ===
// 在新窗口中打开文档
const openDocumentInNewWindow = async (docId: number): Promise<boolean> => {
@@ -63,22 +79,40 @@ export const useDocumentStore = defineStore('document', () => {
}
};
// 新文档列表
const updateDocuments = async () => {
// 创建新文档
const createNewDocument = async (title: string): Promise<Document | null> => {
try {
const doc = await DocumentService.CreateDocument(title);
if (doc) {
documents.value[doc.id] = doc;
return doc;
}
return null;
} catch (error) {
console.error('Failed to create document:', error);
return null;
}
};
// 获取文档列表
const getDocumentMetaList = async () => {
try {
isLoading.value = true;
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);
} finally {
isLoading.value = false;
}
};
// 打开文档
const openDocument = async (docId: number): Promise<boolean> => {
try {
closeDialog();
closeDocumentSelector();
// 获取完整文档数据
const doc = await DocumentService.GetDocumentByID(docId);
@@ -88,7 +122,6 @@ export const useDocumentStore = defineStore('document', () => {
currentDocumentId.value = docId;
currentDocument.value = doc;
addRecentDocument(docId);
return true;
} catch (error) {
@@ -97,41 +130,6 @@ export const useDocumentStore = defineStore('document', () => {
}
};
// 创建新文档
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): Promise<boolean> => {
try {
@@ -141,12 +139,12 @@ export const useDocumentStore = defineStore('document', () => {
const doc = documents.value[docId];
if (doc) {
doc.title = title;
doc.updatedAt = new Date();
doc.updatedAt = new Date().toISOString();
}
if (currentDocument.value?.id === docId) {
currentDocument.value.title = title;
currentDocument.value.updatedAt = new Date();
currentDocument.value.updatedAt = new Date().toISOString();
}
return true;
@@ -168,7 +166,6 @@ export const useDocumentStore = defineStore('document', () => {
// 更新本地状态
delete documents.value[docId];
recentDocumentIds.value = recentDocumentIds.value.filter(id => id !== docId);
// 如果删除的是当前文档,切换到第一个可用文档
if (currentDocumentId.value === docId) {
@@ -188,20 +185,10 @@ export const useDocumentStore = defineStore('document', () => {
}
};
// === UI控制 ===
const openDocumentSelector = () => {
closeDialog();
showDocumentSelector.value = true;
};
const closeDialog = () => {
showDocumentSelector.value = false;
};
// === 初始化 ===
const initialize = async (urlDocumentId?: number): Promise<void> => {
try {
await updateDocuments();
await getDocumentMetaList();
// 优先使用URL参数中的文档ID
if (urlDocumentId && documents.value[urlDocumentId]) {
@@ -210,11 +197,8 @@ export const useDocumentStore = defineStore('document', () => {
// 如果URL中没有指定文档ID则使用持久化的文档ID
await openDocument(currentDocumentId.value);
} else {
// 否则获取第一个文档ID并打开
const firstDocId = await DocumentService.GetFirstDocumentID();
if (firstDocId && documents.value[firstDocId]) {
await openDocument(firstDocId);
}
// 否则打开默认文档
await openDocument(DEFAULT_DOCUMENT_ID.value);
}
} catch (error) {
console.error('Failed to initialize document store:', error);
@@ -226,28 +210,30 @@ export const useDocumentStore = defineStore('document', () => {
// 状态
documents,
documentList,
recentDocumentIds,
currentDocumentId,
currentDocument,
showDocumentSelector,
selectorError,
isLoading,
// 方法
updateDocuments,
getDocumentMetaList,
openDocument,
openDocumentInNewWindow,
createNewDocument,
saveNewDocument,
updateDocumentMetadata,
deleteDocument,
openDocumentSelector,
closeDialog,
closeDocumentSelector,
toggleDocumentSelector,
setError,
clearError,
initialize,
};
}, {
persist: {
key: 'voidraft-document',
storage: localStorage,
pick: ['currentDocumentId']
pick: ['currentDocumentId', 'documents']
}
});

View File

@@ -1,5 +1,5 @@
import {defineStore} from 'pinia';
import {nextTick, ref, watch} from 'vue';
import {computed, nextTick, ref, watch} from 'vue';
import {EditorView} from '@codemirror/view';
import {EditorState, Extension} from '@codemirror/state';
import {useConfigStore} from './configStore';
@@ -18,8 +18,11 @@ import {createDynamicKeymapExtension, updateKeymapExtension} from '@/views/edito
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个编辑器实例
import {LruCache} from '@/common/utils/lruCache';
import {AsyncManager} from '@/common/utils/asyncManager';
import {generateContentHash} from "@/common/utils/hashUtils";
import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
import {EDITOR_CONFIG} from '@/common/constant/editor';
export interface DocumentStats {
lines: number;
@@ -33,7 +36,7 @@ interface EditorInstance {
content: string;
isDirty: boolean;
lastModified: Date;
autoSaveTimer: number | null;
autoSaveTimer: TimerManager;
syntaxTreeCache: {
lastDocLength: number;
lastContentHash: string;
@@ -49,15 +52,8 @@ export const useEditorStore = defineStore('editor', () => {
const extensionStore = useExtensionStore();
// === 核心状态 ===
const editorCache = ref<{
lru: number[];
instances: Record<number, EditorInstance>;
containerElement: HTMLElement | null;
}>({
lru: [],
instances: {},
containerElement: null
});
const editorCache = new LruCache<number, EditorInstance>(EDITOR_CONFIG.MAX_INSTANCES);
const containerElement = ref<HTMLElement | null>(null);
const currentEditor = ref<EditorView | null>(null);
const documentStats = ref<DocumentStats>({
@@ -69,52 +65,17 @@ export const useEditorStore = defineStore('editor', () => {
// 编辑器加载状态
const isLoading = ref(false);
// 异步操作竞态条件控制
const operationSequence = ref(0);
const pendingOperations = ref(new Map<number, AbortController>());
const currentLoadingDocumentId = ref<number | null>(null);
// 异步操作管理器
const operationManager = new AsyncManager<number>();
// 自动保存设置 - 从配置动态获取
const getAutoSaveDelay = () => configStore.config.editing.autoSaveDelay;
// 生成新的操作序列号
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];
const instance = editorCache.get(documentId);
if (!instance) return;
const docLength = view.state.doc.length;
@@ -127,7 +88,7 @@ export const useEditorStore = defineStore('editor', () => {
const shouldRebuild = !cache ||
cache.lastDocLength !== docLength ||
cache.lastContentHash !== contentHash ||
(now.getTime() - cache.lastParsed.getTime()) > 30000; // 30秒过期
(now.getTime() - cache.lastParsed.getTime()) > EDITOR_CONFIG.SYNTAX_TREE_CACHE_TIMEOUT;
if (shouldRebuild) {
try {
@@ -151,12 +112,12 @@ export const useEditorStore = defineStore('editor', () => {
operationId: number,
documentId: number
): Promise<EditorView> => {
if (!editorCache.value.containerElement) {
if (!containerElement.value) {
throw new Error('Editor container not set');
}
// 检查操作是否仍然有效
if (!isOperationValid(operationId, documentId)) {
if (!operationManager.isOperationValid(operationId, documentId)) {
throw new Error('Operation cancelled');
}
@@ -196,7 +157,7 @@ export const useEditorStore = defineStore('editor', () => {
});
// 再次检查操作有效性
if (!isOperationValid(operationId, documentId)) {
if (!operationManager.isOperationValid(operationId, documentId)) {
throw new Error('Operation cancelled');
}
@@ -204,7 +165,7 @@ export const useEditorStore = defineStore('editor', () => {
const keymapExtension = await createDynamicKeymapExtension();
// 检查操作有效性
if (!isOperationValid(operationId, documentId)) {
if (!operationManager.isOperationValid(operationId, documentId)) {
throw new Error('Operation cancelled');
}
@@ -212,7 +173,7 @@ export const useEditorStore = defineStore('editor', () => {
const dynamicExtensions = await createDynamicExtensions(documentId);
// 最终检查操作有效性
if (!isOperationValid(operationId, documentId)) {
if (!operationManager.isOperationValid(operationId, documentId)) {
throw new Error('Operation cancelled');
}
@@ -252,52 +213,31 @@ export const useEditorStore = defineStore('editor', () => {
// 添加编辑器到缓存
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] = {
const instance: EditorInstance = {
view,
documentId,
content,
isDirty: false,
lastModified: new Date(),
autoSaveTimer: null,
autoSaveTimer: createTimerManager(),
syntaxTreeCache: null
};
// 添加到LRU列表
editorCache.value.lru.push(documentId);
// 使用LRU缓存的onEvict回调处理被驱逐的实例
editorCache.set(documentId, instance, (_evictedKey, evictedInstance) => {
// 清除自动保存定时器
evictedInstance.autoSaveTimer.clear();
// 移除DOM元素
if (evictedInstance.view.dom.parentElement) {
evictedInstance.view.dom.remove();
}
evictedInstance.view.destroy();
});
// 初始化语法树缓存
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,
@@ -305,14 +245,13 @@ export const useEditorStore = defineStore('editor', () => {
operationId: number
): Promise<EditorView> => {
// 检查缓存
const cached = editorCache.value.instances[documentId];
const cached = editorCache.get(documentId);
if (cached) {
updateLRU(documentId);
return cached.view;
}
// 检查操作是否仍然有效
if (!isOperationValid(operationId, documentId)) {
if (!operationManager.isOperationValid(operationId, documentId)) {
throw new Error('Operation cancelled');
}
@@ -320,7 +259,7 @@ export const useEditorStore = defineStore('editor', () => {
const view = await createEditorInstance(content, operationId, documentId);
// 最终检查操作有效性
if (!isOperationValid(operationId, documentId)) {
if (!operationManager.isOperationValid(operationId, documentId)) {
// 如果操作已取消,清理创建的实例
view.destroy();
throw new Error('Operation cancelled');
@@ -333,8 +272,8 @@ export const useEditorStore = defineStore('editor', () => {
// 显示编辑器
const showEditor = (documentId: number) => {
const instance = editorCache.value.instances[documentId];
if (!instance || !editorCache.value.containerElement) return;
const instance = editorCache.get(documentId);
if (!instance || !containerElement.value) return;
try {
// 移除当前编辑器DOM
@@ -343,18 +282,15 @@ export const useEditorStore = defineStore('editor', () => {
}
// 确保容器为空
editorCache.value.containerElement.innerHTML = '';
containerElement.value.innerHTML = '';
// 将目标编辑器DOM添加到容器
editorCache.value.containerElement.appendChild(instance.view.dom);
containerElement.value.appendChild(instance.view.dom);
currentEditor.value = instance.view;
// 设置扩展管理器视图
setExtensionManagerView(instance.view, documentId);
// 更新LRU
updateLRU(documentId);
// 重新测量和聚焦编辑器
nextTick(() => {
// 将光标定位到文档末尾并滚动到该位置
@@ -377,7 +313,7 @@ export const useEditorStore = defineStore('editor', () => {
// 保存编辑器内容
const saveEditorContent = async (documentId: number): Promise<boolean> => {
const instance = editorCache.value.instances[documentId];
const instance = editorCache.get(documentId);
if (!instance || !instance.isDirty) return true;
try {
@@ -403,7 +339,7 @@ export const useEditorStore = defineStore('editor', () => {
// 内容变化处理
const onContentChange = (documentId: number) => {
const instance = editorCache.value.instances[documentId];
const instance = editorCache.get(documentId);
if (!instance) return;
instance.isDirty = true;
@@ -412,13 +348,8 @@ export const useEditorStore = defineStore('editor', () => {
// 清理语法树缓存,下次访问时重新构建
instance.syntaxTreeCache = null;
// 清除之前的定时器
if (instance.autoSaveTimer) {
clearTimeout(instance.autoSaveTimer);
}
// 设置新的自动保存定时器
instance.autoSaveTimer = window.setTimeout(() => {
// 设置自动保存定时器
instance.autoSaveTimer.set(() => {
saveEditorContent(documentId);
}, getAutoSaveDelay());
};
@@ -427,7 +358,7 @@ export const useEditorStore = defineStore('editor', () => {
// 设置编辑器容器
const setEditorContainer = (container: HTMLElement | null) => {
editorCache.value.containerElement = container;
containerElement.value = container;
// 如果设置容器时已有当前文档,立即加载编辑器
if (container && documentStore.currentDocument) {
@@ -439,9 +370,9 @@ export const useEditorStore = defineStore('editor', () => {
const loadEditor = async (documentId: number, content: string) => {
// 设置加载状态
isLoading.value = true;
// 生成新的操作ID
const operationId = getNextOperationId();
const abortController = new AbortController();
// 开始新的操作
const { operationId } = operationManager.startOperation(documentId);
try {
// 验证参数
@@ -449,11 +380,6 @@ export const useEditorStore = defineStore('editor', () => {
throw new Error('Invalid parameters for loadEditor');
}
// 取消之前的操作并设置当前操作
cancelPreviousOperations();
currentLoadingDocumentId.value = documentId;
pendingOperations.value.set(operationId, abortController);
// 保存当前编辑器内容
if (currentEditor.value) {
const currentDocId = documentStore.currentDocumentId;
@@ -461,7 +387,7 @@ export const useEditorStore = defineStore('editor', () => {
await saveEditorContent(currentDocId);
// 检查操作是否仍然有效
if (!isOperationValid(operationId, documentId)) {
if (!operationManager.isOperationValid(operationId, documentId)) {
return;
}
}
@@ -471,12 +397,12 @@ export const useEditorStore = defineStore('editor', () => {
const view = await getOrCreateEditor(documentId, content, operationId);
// 检查操作是否仍然有效
if (!isOperationValid(operationId, documentId)) {
if (!operationManager.isOperationValid(operationId, documentId)) {
return;
}
// 更新内容(如果需要)
const instance = editorCache.value.instances[documentId];
const instance = editorCache.get(documentId);
if (instance && instance.content !== content) {
// 确保编辑器视图有效
if (view && view.state && view.dispatch) {
@@ -495,7 +421,7 @@ export const useEditorStore = defineStore('editor', () => {
}
// 最终检查操作有效性
if (!isOperationValid(operationId, documentId)) {
if (!operationManager.isOperationValid(operationId, documentId)) {
return;
}
@@ -509,35 +435,28 @@ export const useEditorStore = defineStore('editor', () => {
console.error('Failed to load editor:', error);
}
} finally {
// 清理操作记录
pendingOperations.value.delete(operationId);
if (currentLoadingDocumentId.value === documentId) {
currentLoadingDocumentId.value = null;
}
// 完成操作
operationManager.completeOperation(operationId);
// 延迟一段时间后再取消加载状态
setTimeout(() => {
isLoading.value = false;
}, 800);
}, EDITOR_CONFIG.LOADING_DELAY);
}
};
// 移除编辑器
const removeEditor = (documentId: number) => {
const instance = editorCache.value.instances[documentId];
const instance = editorCache.get(documentId);
if (instance) {
try {
// 如果正在加载这个文档,取消操作
if (currentLoadingDocumentId.value === documentId) {
cancelPreviousOperations();
currentLoadingDocumentId.value = null;
if (operationManager.getCurrentContext() === documentId) {
operationManager.cancelAllOperations();
}
// 清除自动保存定时器
if (instance.autoSaveTimer) {
clearTimeout(instance.autoSaveTimer);
instance.autoSaveTimer = null;
}
instance.autoSaveTimer.clear();
// 从扩展管理器中移除视图
removeExtensionManagerView(documentId);
@@ -557,12 +476,8 @@ export const useEditorStore = defineStore('editor', () => {
currentEditor.value = null;
}
delete editorCache.value.instances[documentId];
const lruIndex = editorCache.value.lru.indexOf(documentId);
if (lruIndex > -1) {
editorCache.value.lru.splice(lruIndex, 1);
}
// 从缓存中删除
editorCache.delete(documentId);
} catch (error) {
console.error('Error removing editor:', error);
}
@@ -576,7 +491,7 @@ export const useEditorStore = defineStore('editor', () => {
// 应用字体设置
const applyFontSettings = () => {
Object.values(editorCache.value.instances).forEach(instance => {
editorCache.values().forEach(instance => {
updateFontConfig(instance.view, {
fontFamily: configStore.config.editing.fontFamily,
fontSize: configStore.config.editing.fontSize,
@@ -588,7 +503,7 @@ export const useEditorStore = defineStore('editor', () => {
// 应用主题设置
const applyThemeSettings = () => {
Object.values(editorCache.value.instances).forEach(instance => {
editorCache.values().forEach(instance => {
updateEditorTheme(instance.view,
themeStore.currentTheme || SystemThemeType.SystemThemeAuto
);
@@ -597,7 +512,7 @@ export const useEditorStore = defineStore('editor', () => {
// 应用Tab设置
const applyTabSettings = () => {
Object.values(editorCache.value.instances).forEach(instance => {
editorCache.values().forEach(instance => {
updateTabConfig(
instance.view,
configStore.config.editing.tabSize,
@@ -611,7 +526,7 @@ export const useEditorStore = defineStore('editor', () => {
const applyKeymapSettings = async () => {
// 确保所有编辑器实例的快捷键都更新
await Promise.all(
Object.values(editorCache.value.instances).map(instance =>
editorCache.values().map(instance =>
updateKeymapExtension(instance.view)
)
);
@@ -620,14 +535,11 @@ export const useEditorStore = defineStore('editor', () => {
// 清空所有编辑器
const clearAllEditors = () => {
// 取消所有挂起的操作
cancelPreviousOperations();
currentLoadingDocumentId.value = null;
operationManager.cancelAllOperations();
Object.values(editorCache.value.instances).forEach(instance => {
editorCache.clear((_documentId, instance) => {
// 清除自动保存定时器
if (instance.autoSaveTimer) {
clearTimeout(instance.autoSaveTimer);
}
instance.autoSaveTimer.clear();
// 从扩展管理器移除
removeExtensionManagerView(instance.documentId);
@@ -640,8 +552,6 @@ export const useEditorStore = defineStore('editor', () => {
instance.view.destroy();
});
editorCache.value.instances = {};
editorCache.value.lru = [];
currentEditor.value = null;
};
@@ -665,30 +575,36 @@ export const useEditorStore = defineStore('editor', () => {
// 重新加载扩展配置
await extensionStore.loadExtensions();
// 不再需要单独更新当前编辑器的快捷键映射,因为扩展管理器会更新所有实例
// 但我们仍需要确保快捷键配置在所有编辑器上更新
await applyKeymapSettings();
};
// 监听文档切换
watch(() => documentStore.currentDocument, (newDoc) => {
if (newDoc && editorCache.value.containerElement) {
watch(() => documentStore.currentDocument, async (newDoc) => {
if (newDoc && containerElement.value) {
// 使用 nextTick 确保DOM更新完成后再加载编辑器
nextTick(() => {
await 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);
// 创建字体配置的计算属性
const fontConfig = computed(() => ({
fontSize: configStore.config.editing.fontSize,
fontFamily: configStore.config.editing.fontFamily,
lineHeight: configStore.config.editing.lineHeight,
fontWeight: configStore.config.editing.fontWeight
}));
// 创建Tab配置的计算属性
const tabConfig = computed(() => ({
tabSize: configStore.config.editing.tabSize,
enableTabIndent: configStore.config.editing.enableTabIndent,
tabType: configStore.config.editing.tabType
}));
// 监听字体配置变化
watch(fontConfig, applyFontSettings, { deep: true });
// 监听Tab配置变化
watch(tabConfig, applyTabSettings, { deep: true });
return {
// 状态

View File

@@ -1,5 +1,7 @@
import {defineStore} from 'pinia';
import {computed, ref} from 'vue';
import {GetSystemInfo} from '@/../bindings/voidraft/internal/services/systemservice';
import type {SystemInfo} from '@/../bindings/voidraft/internal/services/models';
import * as runtime from '@wailsio/runtime';
export interface SystemEnvironment {
@@ -19,7 +21,7 @@ export const useSystemStore = defineStore('system', () => {
// 状态
const environment = ref<SystemEnvironment | null>(null);
const isLoading = ref(false);
// 窗口置顶状态管理
const isWindowOnTop = ref<boolean>(false);
@@ -42,7 +44,24 @@ export const useSystemStore = defineStore('system', () => {
isLoading.value = true;
try {
environment.value = await runtime.System.Environment();
const systemInfo: SystemInfo | null = await GetSystemInfo();
if (systemInfo) {
environment.value = {
OS: systemInfo.os,
Arch: systemInfo.arch,
Debug: systemInfo.debug,
OSInfo: {
Name: systemInfo.osInfo?.name || '',
Branding: systemInfo.osInfo?.branding || '',
Version: systemInfo.osInfo?.version || '',
ID: systemInfo.osInfo?.id || '',
},
PlatformInfo: systemInfo.platformInfo || {},
};
} else {
environment.value = null;
}
} catch (_err) {
environment.value = null;
} finally {
@@ -94,4 +113,4 @@ export const useSystemStore = defineStore('system', () => {
storage: localStorage,
pick: ['isWindowOnTop']
}
});
});

View File

@@ -0,0 +1,259 @@
import {defineStore} from 'pinia';
import {computed, readonly, ref} from 'vue';
import {useConfigStore} from './configStore';
import {useDocumentStore} from './documentStore';
import type {Document} from '@/../bindings/voidraft/internal/models/models';
export interface Tab {
documentId: number; // 直接使用文档ID作为唯一标识
title: string; // 标签页标题
}
export const useTabStore = defineStore('tab', () => {
// === 依赖store ===
const configStore = useConfigStore();
const documentStore = useDocumentStore();
// === 核心状态 ===
const tabsMap = ref<Map<number, Tab>>(new Map());
const tabOrder = ref<number[]>([]); // 维护标签页顺序
const draggedTabId = ref<number | null>(null);
// === 计算属性 ===
const isTabsEnabled = computed(() => configStore.config.general.enableTabs);
const canCloseTab = computed(() => tabOrder.value.length > 1);
const currentDocumentId = computed(() => documentStore.currentDocumentId);
// 按顺序返回标签页数组用于UI渲染
const tabs = computed(() => {
return tabOrder.value
.map(documentId => tabsMap.value.get(documentId))
.filter(tab => tab !== undefined) as Tab[];
});
// === 私有方法 ===
const hasTab = (documentId: number): boolean => {
return tabsMap.value.has(documentId);
};
const getTab = (documentId: number): Tab | undefined => {
return tabsMap.value.get(documentId);
};
const updateTabTitle = (documentId: number, title: string) => {
const tab = tabsMap.value.get(documentId);
if (tab) {
tab.title = title;
}
};
// === 公共方法 ===
/**
* 添加或激活标签页
*/
const addOrActivateTab = (document: Document) => {
const documentId = document.id;
if (hasTab(documentId)) {
// 标签页已存在,无需重复添加
return;
}
// 创建新标签页
const newTab: Tab = {
documentId,
title: document.title
};
tabsMap.value.set(documentId, newTab);
tabOrder.value.push(documentId);
};
/**
* 关闭标签页
*/
const closeTab = (documentId: number) => {
if (!hasTab(documentId)) return;
const tabIndex = tabOrder.value.indexOf(documentId);
if (tabIndex === -1) return;
// 从映射和顺序数组中移除
tabsMap.value.delete(documentId);
tabOrder.value.splice(tabIndex, 1);
// 如果关闭的是当前文档,需要切换到其他文档
if (documentStore.currentDocument?.id === documentId) {
// 优先选择下一个标签页,如果没有则选择上一个
let nextIndex = tabIndex;
if (nextIndex >= tabOrder.value.length) {
nextIndex = tabOrder.value.length - 1;
}
if (nextIndex >= 0 && tabOrder.value[nextIndex]) {
const nextDocumentId = tabOrder.value[nextIndex];
switchToTabAndDocument(nextDocumentId);
}
}
};
/**
* 批量关闭标签页
* @param documentIds 要关闭的文档ID数组
*/
const closeTabs = (documentIds: number[]) => {
documentIds.forEach(documentId => {
if (!hasTab(documentId)) return;
const tabIndex = tabOrder.value.indexOf(documentId);
if (tabIndex === -1) return;
// 从映射和顺序数组中移除
tabsMap.value.delete(documentId);
tabOrder.value.splice(tabIndex, 1);
});
};
/**
* 切换到指定标签页并打开对应文档
*/
const switchToTabAndDocument = (documentId: number) => {
if (!hasTab(documentId)) return;
// 如果点击的是当前已激活的文档,不需要重复请求
if (documentStore.currentDocumentId === documentId) {
return;
}
documentStore.openDocument(documentId);
};
/**
* 移动标签页位置
*/
const moveTab = (fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 ||
fromIndex >= tabOrder.value.length || toIndex >= tabOrder.value.length) {
return;
}
const [movedTab] = tabOrder.value.splice(fromIndex, 1);
tabOrder.value.splice(toIndex, 0, movedTab);
};
/**
* 获取标签页在顺序中的索引
*/
const getTabIndex = (documentId: number): number => {
return tabOrder.value.indexOf(documentId);
};
/**
* 初始化标签页(当前文档)
*/
const initializeTab = () => {
if (isTabsEnabled.value) {
const currentDoc = documentStore.currentDocument;
if (currentDoc) {
addOrActivateTab(currentDoc);
}
}
};
// === 公共方法 ===
/**
* 关闭其他标签页(除了指定的标签页)
*/
const closeOtherTabs = (keepDocumentId: number) => {
if (!hasTab(keepDocumentId)) return;
// 获取所有其他标签页的ID
const otherTabIds = tabOrder.value.filter(id => id !== keepDocumentId);
// 批量关闭其他标签页
closeTabs(otherTabIds);
// 如果当前打开的文档在被关闭的标签中,需要切换到保留的文档
if (otherTabIds.includes(documentStore.currentDocumentId!)) {
switchToTabAndDocument(keepDocumentId);
}
};
/**
* 关闭指定标签页右侧的所有标签页
*/
const closeTabsToRight = (documentId: number) => {
const index = getTabIndex(documentId);
if (index === -1) return;
// 获取右侧所有标签页的ID
const rightTabIds = tabOrder.value.slice(index + 1);
// 批量关闭右侧标签页
closeTabs(rightTabIds);
// 如果当前打开的文档在被关闭的右侧标签中,需要切换到指定的文档
if (rightTabIds.includes(documentStore.currentDocumentId!)) {
switchToTabAndDocument(documentId);
}
};
/**
* 关闭指定标签页左侧的所有标签页
*/
const closeTabsToLeft = (documentId: number) => {
const index = getTabIndex(documentId);
if (index <= 0) return;
// 获取左侧所有标签页的ID
const leftTabIds = tabOrder.value.slice(0, index);
// 批量关闭左侧标签页
closeTabs(leftTabIds);
// 如果当前打开的文档在被关闭的左侧标签中,需要切换到指定的文档
if (leftTabIds.includes(documentStore.currentDocumentId!)) {
switchToTabAndDocument(documentId);
}
};
/**
* 清空所有标签页
*/
const clearAllTabs = () => {
tabsMap.value.clear();
tabOrder.value = [];
};
// === 公共API ===
return {
// 状态
tabs: readonly(tabs),
draggedTabId,
// 计算属性
isTabsEnabled,
canCloseTab,
currentDocumentId,
// 方法
addOrActivateTab,
closeTab,
closeOtherTabs,
closeTabsToLeft,
closeTabsToRight,
switchToTabAndDocument,
moveTab,
getTabIndex,
initializeTab,
clearAllTabs,
updateTabTitle,
// 工具方法
hasTab,
getTab
};
});

View File

@@ -1,11 +1,11 @@
import {defineStore} from 'pinia';
import {computed, reactive} from 'vue';
import {SystemThemeType, ThemeType, ThemeColorConfig} from '@/../bindings/voidraft/internal/models/models';
import {ThemeService} from '@/../bindings/voidraft/internal/services';
import {useConfigStore} from './configStore';
import {useEditorStore} from './editorStore';
import {defaultDarkColors} from '@/views/editor/theme/dark';
import {defaultLightColors} from '@/views/editor/theme/light';
import { defineStore } from 'pinia';
import { computed, reactive } from 'vue';
import { SystemThemeType, ThemeType, ThemeColorConfig } from '@/../bindings/voidraft/internal/models/models';
import { ThemeService } from '@/../bindings/voidraft/internal/services';
import { useConfigStore } from './configStore';
import { useEditorStore } from './editorStore';
import { defaultDarkColors } from '@/views/editor/theme/dark';
import { defaultLightColors } from '@/views/editor/theme/light';
/**
* 主题管理 Store
@@ -14,25 +14,44 @@ import {defaultLightColors} from '@/views/editor/theme/light';
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 getDefaultColors = (themeType: ThemeType) =>
themeType === ThemeType.ThemeTypeDark ? defaultDarkColors : defaultLightColors;
// 应用主题到 DOM
const applyThemeToDOM = (theme: SystemThemeType) => {
const themeMap = {
[SystemThemeType.SystemThemeAuto]: 'auto',
[SystemThemeType.SystemThemeDark]: 'dark',
[SystemThemeType.SystemThemeLight]: 'light'
};
document.documentElement.setAttribute('data-theme', themeMap[theme]);
};
// 初始化主题颜色
const initializeThemeColors = async () => {
try {
const themes = await ThemeService.GetDefaultThemes();
// 如果没有获取到主题数据,使用默认值
if (!themes) {
Object.assign(themeColors.darkTheme, defaultDarkColors);
Object.assign(themeColors.lightTheme, defaultLightColors);
Object.assign(themeColors.darkTheme, defaultDarkColors);
Object.assign(themeColors.lightTheme, defaultLightColors);
return;
}
// 更新主题颜色
if (themes[ThemeType.ThemeTypeDark]) {
Object.assign(themeColors.darkTheme, themes[ThemeType.ThemeTypeDark].colors);
}
@@ -47,17 +66,9 @@ export const useThemeStore = defineStore('theme', () => {
}
};
// 应用主题到 DOM
const applyThemeToDOM = (theme: SystemThemeType) => {
document.documentElement.setAttribute('data-theme',
theme === SystemThemeType.SystemThemeAuto ? 'auto' :
theme === SystemThemeType.SystemThemeDark ? 'dark' : 'light'
);
};
// 初始化主题
const initializeTheme = async () => {
const theme = configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto;
const theme = currentTheme.value;
applyThemeToDOM(theme);
await initializeThemeColors();
};
@@ -69,27 +80,25 @@ export const useThemeStore = defineStore('theme', () => {
refreshEditorTheme();
};
// 更新主题颜色
const updateThemeColors = (darkColors: any = null, lightColors: any = null): boolean => {
// 更新主题颜色 - 合并逻辑,减少重复代码
const updateThemeColors = (darkColors?: any, lightColors?: any): 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;
const updateColors = (target: any, source: any) => {
if (!source) return false;
let changed = false;
Object.entries(source).forEach(([key, value]) => {
if (value !== undefined && target[key] !== value) {
target[key] = value;
changed = true;
}
});
}
return changed;
};
if (lightColors) {
Object.entries(lightColors).forEach(([key, value]) => {
if (value !== undefined && themeColors.lightTheme[key] !== value) {
themeColors.lightTheme[key] = value;
hasChanges = true;
}
});
}
hasChanges = updateColors(themeColors.darkTheme, darkColors) || hasChanges;
hasChanges = updateColors(themeColors.lightTheme, lightColors) || hasChanges;
return hasChanges;
};
@@ -100,8 +109,10 @@ export const useThemeStore = defineStore('theme', () => {
const darkColors = ThemeColorConfig.createFrom(themeColors.darkTheme);
const lightColors = ThemeColorConfig.createFrom(themeColors.lightTheme);
await ThemeService.UpdateThemeColors(ThemeType.ThemeTypeDark, darkColors);
await ThemeService.UpdateThemeColors(ThemeType.ThemeTypeLight, lightColors);
await Promise.all([
ThemeService.UpdateThemeColors(ThemeType.ThemeTypeDark, darkColors),
ThemeService.UpdateThemeColors(ThemeType.ThemeTypeLight, lightColors)
]);
} catch (error) {
console.error('Failed to save theme colors:', error);
throw error;
@@ -117,11 +128,8 @@ export const useThemeStore = defineStore('theme', () => {
await ThemeService.ResetThemeColors(dbThemeType);
// 2. 更新内存中的颜色状态
if (themeType === 'darkTheme') {
Object.assign(themeColors.darkTheme, defaultDarkColors);
} else {
Object.assign(themeColors.lightTheme, defaultLightColors);
}
const defaultColors = getDefaultColors(dbThemeType);
Object.assign(themeColors[themeType], defaultColors);
// 3. 刷新编辑器主题
refreshEditorTheme();
@@ -133,16 +141,13 @@ export const useThemeStore = defineStore('theme', () => {
}
};
// 刷新编辑器主题(在主题颜色更改后调用)
// 刷新编辑器主题
const refreshEditorTheme = () => {
// 使用当前主题重新应用DOM主题
const theme = currentTheme.value;
applyThemeToDOM(theme);
applyThemeToDOM(currentTheme.value);
const editorStore = useEditorStore();
if (editorStore) {
editorStore.applyThemeSettings();
}
editorStore?.applyThemeSettings();
};
return {

View File

@@ -1,594 +1,117 @@
import {defineStore} from 'pinia';
import {computed, ref, watch} from 'vue';
import {ref} from 'vue';
import {TranslationService} from '@/../bindings/voidraft/internal/services';
import {franc} from 'franc-min';
import {LanguageInfo, TRANSLATION_ERRORS, TranslationResult} from '@/common/constant/translation';
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 translators = ref<string[]>([]);
const isTranslating = ref<boolean>(false);
// 语言映射
const translatorLanguages = ref<Record<string, Record<string, LanguageInfo>>>({});
/**
* 加载可用翻译器
* 加载可用翻译器列表并预先加载所有语言映射
*/
const loadAvailableTranslators = async (): Promise<void> => {
const loadTranslators = 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;
translators.value = await TranslationService.GetTranslators();
const loadPromises = translators.value.map(async (translatorType) => {
try {
const languages = await TranslationService.GetTranslatorLanguages(translatorType as any);
if (languages) {
translatorLanguages.value[translatorType] = languages;
}
} catch (err) {
console.error(`Failed to preload languages for ${translatorType}:`, err);
}
}
} catch (_err) {
error.value = 'no available translators';
});
// 等待所有语言映射加载完成
await Promise.all(loadPromises);
} catch (err) {
console.error('Failed to load available translators:', err);
}
};
/**
* 加载指定翻译器的语言列表
* @param translatorType 翻译器类型
*/
const loadTranslatorLanguages = async (translatorType: string): Promise<void> => {
try {
const languages = await TranslationService.GetTranslatorLanguages(translatorType as any);
if (languages) {
languageMaps.value[translatorType] = languages;
translatorLanguages.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
text: string,
sourceLang: string,
targetLang: string,
translatorType: string
): Promise<TranslationResult> => {
// 使用提供的参数或默认值
const targetLang = to || defaultTargetLang.value;
const translator = translatorType || defaultTranslator.value;
// 处理空文本
if (!text) {
if (!text.trim()) {
return {
sourceText: '',
translatedText: '',
sourceLang: '',
targetLang: targetLang,
translatorType: translator,
error: 'no text to translate'
error: TRANSLATION_ERRORS.NO_TEXT
};
}
// 检测源语言
const detected = detectLanguage(text);
if (detected) {
detectedSourceLang.value = detected;
}
// 使用检测到的语言或回退到auto
let actualSourceLang = detectedSourceLang.value || 'auto';
// 特殊处理有道翻译器:有道翻译器允许源语言和目标语言都是auto
const isYoudaoTranslator = translatorType === 'youdao';
const bothAuto = sourceLang === 'auto' && targetLang === 'auto';
// 确认语言代码有效并针对当前翻译器进行匹配
actualSourceLang = validateLanguage(actualSourceLang, translator);
const actualTargetLang = validateLanguage(targetLang, translator);
// 如果源语言和目标语言相同,则直接返回原文
if (actualSourceLang !== 'auto' && actualSourceLang === actualTargetLang) {
if (sourceLang === targetLang && !(isYoudaoTranslator && bothAuto)) {
return {
sourceText: text,
translatedText: text,
sourceLang: actualSourceLang,
targetLang: actualTargetLang,
translatorType: translator
translatedText: text
};
}
isTranslating.value = true;
error.value = null;
try {
console.log(`翻译文本: 从 ${actualSourceLang}${actualTargetLang} 使用 ${translator} 翻译器`);
// 调用翻译服务
const translatedText = await TranslationService.TranslateWith(
text,
actualSourceLang,
actualTargetLang,
translator
text,
sourceLang,
targetLang,
translatorType
);
// 增加目标语言的使用频率,使用较大的权重
incrementLanguageUsage(actualTargetLang, 3);
// 如果源语言不是auto也记录其使用情况但权重较小
if (actualSourceLang !== 'auto') {
incrementLanguageUsage(actualSourceLang, 1);
}
// 构建结果
const result: TranslationResult = {
sourceText: text,
translatedText,
sourceLang: actualSourceLang,
targetLang: actualTargetLang,
translatorType: translator
return {
translatedText
};
lastResult.value = result;
return result;
} catch (err) {
// 处理错误
const errorMessage = err instanceof Error ? err.message : 'translation failed';
const errorMessage = err instanceof Error ? err.message : TRANSLATION_ERRORS.TRANSLATION_FAILED;
error.value = errorMessage;
const result: TranslationResult = {
sourceText: text,
return {
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,
translators,
isTranslating,
lastResult,
error,
detectedSourceLang,
defaultTargetLang,
defaultTranslator,
languageMaps,
languageUsageCount,
recentLanguages,
// 计算属性
hasTranslators,
currentLanguageMap,
translatorLanguages,
// 方法
loadAvailableTranslators,
loadTranslators,
loadTranslatorLanguages,
translateText,
setDefaultConfig,
detectLanguage,
validateLanguage,
findSimilarLanguage,
getSortedLanguages,
incrementLanguageUsage
};
}, {
persist: {
key: 'voidraft-translation',
storage: localStorage,
pick: ['languageUsageCount', 'defaultTargetLang', 'defaultTranslator', 'recentLanguages']
}
});
});

View File

@@ -1,135 +1,236 @@
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 { defineStore } from 'pinia';
import { computed, readonly, ref, shallowRef, onScopeDispose } 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 { createTimerManager } from '@/common/utils/timerUtils';
import * as runtime from "@wailsio/runtime";
// 更新状态枚举
export enum UpdateStatus {
IDLE = 'idle',
CHECKING = 'checking',
UPDATE_AVAILABLE = 'update_available',
UPDATING = 'updating',
UPDATE_SUCCESS = 'update_success',
ERROR = 'error'
}
// 更新操作结果类型
export interface UpdateOperationResult {
status: UpdateStatus;
result?: SelfUpdateResult;
message?: string;
timestamp?: number;
}
// 类型守卫函数
const isUpdateError = (error: unknown): error is Error => {
return error instanceof Error;
};
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 hasCheckedOnStartup = ref(false);
// 统一的更新操作结果状态
const updateOperation = ref<UpdateOperationResult>({
status: UpdateStatus.IDLE
});
// === 定时器管理 ===
const statusTimer = createTimerManager();
// 组件卸载时清理定时器
onScopeDispose(() => {
statusTimer.clear();
});
// === 外部依赖 ===
const configStore = useConfigStore();
// === 计算属性 ===
// 派生状态计算属性
const isChecking = computed(() => updateOperation.value.status === UpdateStatus.CHECKING);
const isUpdating = computed(() => updateOperation.value.status === UpdateStatus.UPDATING);
const hasUpdate = computed(() => updateOperation.value.status === UpdateStatus.UPDATE_AVAILABLE);
const updateSuccess = computed(() => updateOperation.value.status === UpdateStatus.UPDATE_SUCCESS);
const isError = computed(() => updateOperation.value.status === UpdateStatus.ERROR);
// 数据访问计算属性
const updateResult = computed(() => updateOperation.value.result || undefined);
const errorMessage = computed(() =>
updateOperation.value.status === UpdateStatus.ERROR ? updateOperation.value.message : ''
);
// === 状态管理方法 ===
/**
* 设置更新状态
* @param status 更新状态
* @param result 可选的更新结果
* @param message 可选消息
* @param autoHide 是否自动隐藏(毫秒)
*/
const setUpdateStatus = <T extends UpdateStatus>(
status: T,
result?: SelfUpdateResult,
message?: string,
autoHide?: number
): void => {
updateOperation.value = {
status,
result,
message,
timestamp: Date.now()
};
// 自动隐藏功能
if (autoHide && autoHide > 0) {
statusTimer.set(() => {
if (updateOperation.value.status === status) {
updateOperation.value = { status: UpdateStatus.IDLE };
}
}, autoHide);
}
};
/**
* 清除状态
*/
const clearStatus = (): void => {
statusTimer.clear();
updateOperation.value = { status: UpdateStatus.IDLE };
};
// === 业务方法 ===
/**
* 检查更新
* @returns Promise<boolean> 是否成功检查
*/
const checkForUpdates = async (): Promise<boolean> => {
if (isChecking.value) return false;
setUpdateStatus(UpdateStatus.CHECKING);
try {
const result = await CheckForUpdates();
if (result?.error) {
setUpdateStatus(UpdateStatus.ERROR, result, result.error);
return false;
}
if (result?.hasUpdate) {
setUpdateStatus(UpdateStatus.UPDATE_AVAILABLE, result);
return true;
}
// 没有更新,设置为空闲状态
setUpdateStatus(UpdateStatus.IDLE, result || undefined);
return true;
} catch (error) {
const message = isUpdateError(error) ? error.message : 'Network error';
setUpdateStatus(UpdateStatus.ERROR, undefined, message);
return false;
}
};
/**
* 应用更新
* @returns Promise<boolean> 是否成功应用更新
*/
const applyUpdate = async (): Promise<boolean> => {
if (isUpdating.value) return false;
setUpdateStatus(UpdateStatus.UPDATING);
try {
const result = await ApplyUpdate();
if (result?.error) {
setUpdateStatus(UpdateStatus.ERROR, result || undefined, result.error);
return false;
}
if (result?.updateApplied) {
setUpdateStatus(UpdateStatus.UPDATE_SUCCESS, result || undefined);
return true;
}
setUpdateStatus(UpdateStatus.ERROR, result || undefined, 'Update failed');
return false;
} catch (error) {
const message = isUpdateError(error) ? error.message : 'Update failed';
setUpdateStatus(UpdateStatus.ERROR, undefined, message);
return false;
}
};
/**
* 重启应用
* @returns Promise<boolean> 是否成功重启
*/
const restartApplication = async (): Promise<boolean> => {
try {
await RestartApplication();
return true;
} catch (error) {
const message = isUpdateError(error) ? error.message : 'Restart failed';
setUpdateStatus(UpdateStatus.ERROR, undefined, message);
return false;
}
};
/**
* 启动时检查更新
*/
const checkOnStartup = async (): Promise<void> => {
if (hasCheckedOnStartup.value) return;
if (configStore.config.updates.autoUpdate) {
await checkForUpdates();
}
hasCheckedOnStartup.value = true;
};
/**
* 打开发布页面
*/
const openReleaseURL = async (): Promise<void> => {
const result = updateResult.value;
if (result?.assetURL) {
await runtime.Browser.OpenURL(result.assetURL);
}
};
// === 公共接口 ===
return {
// 只读状态
hasCheckedOnStartup: readonly(hasCheckedOnStartup),
// 计算属性
const hasUpdate = computed(() => updateResult.value?.hasUpdate || false);
isChecking,
isUpdating,
hasUpdate,
updateSuccess,
isError,
updateResult,
errorMessage,
// 检查更新
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
};
});
// 方法
checkForUpdates,
applyUpdate,
restartApplication,
checkOnStartup,
openReleaseURL,
clearStatus,
// 内部状态管理
setUpdateStatus
};
});

View File

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import {onBeforeUnmount, onMounted, ref} from 'vue';
import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
import {useEditorStore} from '@/stores/editorStore';
import {useDocumentStore} from '@/stores/documentStore';
import {useConfigStore} from '@/stores/configStore';
@@ -7,14 +7,18 @@ import {createWheelZoomHandler} from './basic/wheelZoomExtension';
import Toolbar from '@/components/toolbar/Toolbar.vue';
import {useWindowStore} from "@/stores/windowStore";
import LoadingScreen from '@/components/loading/LoadingScreen.vue';
import {useTabStore} from "@/stores/tabStore";
const editorStore = useEditorStore();
const documentStore = useDocumentStore();
const configStore = useConfigStore();
const windowStore = useWindowStore();
const tabStore = useTabStore();
const editorElement = ref<HTMLElement | null>(null);
const enableLoadingAnimation = computed(() => configStore.config.general.enableLoadingAnimation);
// 创建滚轮缩放处理器
const wheelHandler = createWheelZoomHandler(
configStore.increaseFontSize,
@@ -26,13 +30,15 @@ onMounted(async () => {
// 从URL查询参数中获取documentId
const urlDocumentId = windowStore.currentDocumentId ? parseInt(windowStore.currentDocumentId) : undefined;
// 初始化文档存储优先使用URL参数中的文档ID
await documentStore.initialize(urlDocumentId);
// 设置编辑器容器
editorStore.setEditorContainer(editorElement.value);
await tabStore.initializeTab();
// 添加滚轮事件监听
editorElement.value.addEventListener('wheel', wheelHandler, {passive: false});
});
@@ -42,12 +48,14 @@ onBeforeUnmount(() => {
if (editorElement.value) {
editorElement.value.removeEventListener('wheel', wheelHandler);
}
editorStore.clearAllEditors();
});
</script>
<template>
<div class="editor-container">
<LoadingScreen v-if="editorStore.isLoading && configStore.config.general?.enableLoadingAnimation" text="VOIDRAFT" />
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
<div ref="editorElement" class="editor"></div>
<Toolbar/>
</div>

View File

@@ -16,7 +16,7 @@ export const tabHandler = (view: EditorView, tabSize: number, tabType: TabType):
}
// 根据tabType创建缩进字符
const indent = tabType === 'spaces' ? ' '.repeat(tabSize) : '\t';
const indent = tabType === TabType.TabTypeSpaces ? ' '.repeat(tabSize) : '\t';
// 在光标位置插入缩进字符
const {state, dispatch} = view;
@@ -29,7 +29,7 @@ export const getTabExtensions = (tabSize: number, enableTabIndent: boolean, tabT
const extensions: Extension[] = [];
// 根据tabType设置缩进单位
const indentStr = tabType === 'spaces' ? ' '.repeat(tabSize) : '\t';
const indentStr = tabType === TabType.TabTypeSpaces ? ' '.repeat(tabSize) : '\t';
extensions.push(tabSizeCompartment.of(indentUnit.of(indentStr)));
// 如果启用了Tab缩进添加自定义Tab键映射
@@ -59,7 +59,7 @@ export const updateTabConfig = (
if (!view) return;
// 根据tabType更新indentUnit配置
const indentStr = tabType === 'spaces' ? ' '.repeat(tabSize) : '\t';
const indentStr = tabType === TabType.TabTypeSpaces ? ' '.repeat(tabSize) : '\t';
view.dispatch({
effects: tabSizeCompartment.reconfigure(indentUnit.of(indentStr))
});

View File

@@ -1,7 +1,5 @@
/**
* 上下文菜单视图实现
* 处理菜单的创建、定位和事件绑定
* 优化为单例模式避免频繁创建和销毁DOM元素
*/
import { EditorView } from "@codemirror/view";
@@ -15,97 +13,253 @@ declare global {
}
}
// 菜单DOM元素缓存
let menuElement: HTMLElement | null = null;
let clickOutsideHandler: ((e: MouseEvent) => void) | null = null;
// 子菜单缓存池
const submenuPool: Map<string, HTMLElement> = new Map();
/**
* 获取或创建菜单DOM元素
* 菜单项元素池,用于复用DOM元素
*/
function getOrCreateMenuElement(): HTMLElement {
if (!menuElement) {
menuElement = document.createElement("div");
menuElement.className = "cm-context-menu";
menuElement.style.display = "none";
document.body.appendChild(menuElement);
class MenuItemPool {
private pool: HTMLElement[] = [];
private maxPoolSize = 50; // 最大池大小
/**
* 获取或创建菜单项元素
*/
get(): HTMLElement {
if (this.pool.length > 0) {
return this.pool.pop()!;
}
// 阻止菜单内右键点击冒泡
menuElement.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
return false;
});
const menuItem = document.createElement("div");
menuItem.className = "cm-context-menu-item";
return menuItem;
}
/**
* 回收菜单项元素
*/
release(element: HTMLElement): void {
if (this.pool.length < this.maxPoolSize) {
// 清理元素状态
element.className = "cm-context-menu-item";
element.innerHTML = "";
element.style.cssText = "";
// 移除所有事件监听器(通过克隆节点)
const cleanElement = element.cloneNode(false) as HTMLElement;
this.pool.push(cleanElement);
}
}
/**
* 清空池
*/
clear(): void {
this.pool.length = 0;
}
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)!;
}
class ContextMenuManager {
private static instance: ContextMenuManager;
private menuElement: HTMLElement | null = null;
private submenuPool: Map<string, HTMLElement> = new Map();
private menuItemPool = new MenuItemPool();
private clickOutsideHandler: ((e: MouseEvent) => void) | null = null;
private keyDownHandler: ((e: KeyboardEvent) => void) | null = null;
private currentView: EditorView | null = null;
private activeSubmenus: Set<HTMLElement> = new Set();
private ripplePool: HTMLElement[] = [];
// 事件委托处理器
private menuClickHandler: ((e: MouseEvent) => void) | null = null;
private menuMouseHandler: ((e: MouseEvent) => void) | null = null;
/**
* 创建菜单项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");
private constructor() {
this.initializeEventHandlers();
}
// 创建内容容器
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);
/**
* 获取单例实例
*/
static getInstance(): ContextMenuManager {
if (!ContextMenuManager.instance) {
ContextMenuManager.instance = new ContextMenuManager();
}
return ContextMenuManager.instance;
}
// 如果有子菜单,创建或获取子菜单
if (item.submenu && item.submenu.length > 0) {
// 使用菜单项标签作为子菜单ID
const submenuId = `submenu-${item.label.replace(/\s+/g, '-').toLowerCase()}`;
const submenu = getOrCreateSubmenu(submenuId);
/**
* 初始化事件处理器
*/
private initializeEventHandlers(): void {
// 点击事件委托
this.menuClickHandler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const menuItem = target.closest('.cm-context-menu-item') as HTMLElement;
if (menuItem && menuItem.dataset.command) {
e.preventDefault();
e.stopPropagation();
// 添加点击动画
this.addRippleEffect(menuItem, e);
// 执行命令
const commandName = menuItem.dataset.command;
const command = this.getCommandByName(commandName);
if (command && this.currentView) {
command(this.currentView);
}
// 隐藏菜单
this.hide();
}
};
// 鼠标事件委托
this.menuMouseHandler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const menuItem = target.closest('.cm-context-menu-item') as HTMLElement;
if (!menuItem) return;
if (e.type === 'mouseenter') {
this.handleMenuItemMouseEnter(menuItem);
} else if (e.type === 'mouseleave') {
this.handleMenuItemMouseLeave(menuItem, e);
}
};
// 键盘事件处理器
this.keyDownHandler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
this.hide();
}
};
// 点击外部关闭处理器
this.clickOutsideHandler = (e: MouseEvent) => {
if (this.menuElement && !this.isClickInsideMenu(e.target as Node)) {
this.hide();
}
};
}
/**
* 获取或创建主菜单元素
*/
private getOrCreateMenuElement(): HTMLElement {
if (!this.menuElement) {
this.menuElement = document.createElement("div");
this.menuElement.className = "cm-context-menu";
this.menuElement.style.display = "none";
document.body.appendChild(this.menuElement);
// 阻止菜单内右键点击冒泡
this.menuElement.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
return false;
});
// 添加事件委托
this.menuElement.addEventListener('click', this.menuClickHandler!);
this.menuElement.addEventListener('mouseenter', this.menuMouseHandler!, true);
this.menuElement.addEventListener('mouseleave', this.menuMouseHandler!, true);
}
return this.menuElement;
}
/**
* 创建或获取子菜单元素
*/
private getOrCreateSubmenu(id: string): HTMLElement {
if (!this.submenuPool.has(id)) {
const submenu = document.createElement("div");
submenu.className = "cm-context-menu cm-context-submenu";
submenu.style.display = "none";
document.body.appendChild(submenu);
this.submenuPool.set(id, submenu);
// 阻止子菜单点击事件冒泡
submenu.addEventListener('click', (e) => {
e.stopPropagation();
});
// 添加事件委托
submenu.addEventListener('click', this.menuClickHandler!);
submenu.addEventListener('mouseenter', this.menuMouseHandler!, true);
submenu.addEventListener('mouseleave', this.menuMouseHandler!, true);
}
return this.submenuPool.get(id)!;
}
/**
* 创建菜单项DOM元素
*/
private createMenuItemElement(item: MenuItem): HTMLElement {
const menuItem = this.menuItemPool.get();
// 清空现有子菜单内容
// 如果有子菜单,添加相应类
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.command) {
menuItem.dataset.command = this.registerCommand(item.command);
}
// 处理子菜单
if (item.submenu && item.submenu.length > 0) {
const submenuId = `submenu-${item.label.replace(/\s+/g, '-').toLowerCase()}`;
menuItem.dataset.submenuId = submenuId;
const submenu = this.getOrCreateSubmenu(submenuId);
this.populateSubmenu(submenu, item.submenu);
// 记录子菜单
if (!window.cmSubmenus) {
window.cmSubmenus = new Map();
}
window.cmSubmenus.set(submenuId, submenu);
}
return menuItem;
}
/**
* 填充子菜单内容
*/
private populateSubmenu(submenu: HTMLElement, items: MenuItem[]): void {
// 清空现有内容
while (submenu.firstChild) {
submenu.removeChild(submenu.firstChild);
}
// 添加子菜单项
item.submenu.forEach(subItem => {
const subMenuItemElement = createMenuItemElement(subItem, view);
items.forEach(item => {
const subMenuItemElement = this.createMenuItemElement(item);
submenu.appendChild(subMenuItemElement);
});
@@ -114,313 +268,318 @@ function createMenuItemElement(item: MenuItem, view: EditorView): HTMLElement {
submenu.style.pointerEvents = 'none';
submenu.style.visibility = 'hidden';
submenu.style.display = 'block';
}
/**
* 命令注册和管理
*/
private commands: Map<string, (view: EditorView) => void> = new Map();
private commandCounter = 0;
private registerCommand(command: (view: EditorView) => void): string {
const commandId = `cmd_${this.commandCounter++}`;
this.commands.set(commandId, command);
return commandId;
}
private getCommandByName(commandId: string): ((view: EditorView) => void) | undefined {
return this.commands.get(commandId);
}
/**
* 处理菜单项鼠标进入事件
*/
private handleMenuItemMouseEnter(menuItem: HTMLElement): void {
const submenuId = menuItem.dataset.submenuId;
if (!submenuId) return;
const submenu = this.submenuPool.get(submenuId);
if (!submenu) return;
const rect = menuItem.getBoundingClientRect();
// 当鼠标悬停在菜单项上时,显示子菜单
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)';
});
// 计算子菜单位置
submenu.style.left = `${rect.right}px`;
submenu.style.top = `${rect.top}px`;
// 当鼠标离开菜单项时,隐藏子菜单
menuItem.addEventListener('mouseleave', (e) => {
// 检查是否移动到子菜单上
const toElement = e.relatedTarget as HTMLElement;
if (submenu.contains(toElement)) {
return; // 如果移动到子菜单上,不隐藏
// 检查子菜单是否会超出屏幕
requestAnimationFrame(() => {
const submenuRect = submenu.getBoundingClientRect();
if (submenuRect.right > window.innerWidth) {
submenu.style.left = `${rect.left - submenuRect.width}px`;
}
// 隐藏子菜单
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; // 如果移动回父菜单项,不隐藏
if (submenuRect.bottom > window.innerHeight) {
const newTop = rect.top - (submenuRect.bottom - window.innerHeight);
submenu.style.top = `${Math.max(0, newTop)}px`;
}
// 隐藏子菜单
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();
// 显示子菜单
submenu.style.opacity = '1';
submenu.style.pointerEvents = 'auto';
submenu.style.visibility = 'visible';
submenu.style.transform = 'translateX(0)';
this.activeSubmenus.add(submenu);
}
/**
* 处理菜单项鼠标离开事件
*/
private handleMenuItemMouseLeave(menuItem: HTMLElement, e: MouseEvent): void {
const submenuId = menuItem.dataset.submenuId;
if (!submenuId) return;
const submenu = this.submenuPool.get(submenuId);
if (!submenu) return;
// 检查是否移动到子菜单上
const toElement = e.relatedTarget as HTMLElement;
if (submenu.contains(toElement)) {
return;
}
window.cmSubmenus.set(submenuId, submenu);
this.hideSubmenu(submenu);
}
// 点击事件仅当有command时添加
if (item.command) {
menuItem.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
// 添加点击动画效果
const ripple = document.createElement("div");
/**
* 隐藏子菜单
*/
private hideSubmenu(submenu: HTMLElement): void {
submenu.style.opacity = '0';
submenu.style.pointerEvents = 'none';
submenu.style.transform = 'translateX(10px)';
setTimeout(() => {
if (submenu.style.opacity === '0') {
submenu.style.visibility = 'hidden';
}
}, 200);
this.activeSubmenus.delete(submenu);
}
/**
* 添加点击波纹效果
*/
private addRippleEffect(menuItem: HTMLElement, e: MouseEvent): void {
let ripple: HTMLElement;
if (this.ripplePool.length > 0) {
ripple = this.ripplePool.pop()!;
} else {
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";
ripple.style.transform = "scale(0)";
ripple.style.opacity = "1";
menuItem.appendChild(ripple);
// 执行动画
requestAnimationFrame(() => {
ripple.style.transform = "scale(1)";
ripple.style.opacity = "0";
// 计算相对位置
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();
if (ripple.parentNode === menuItem) {
menuItem.removeChild(ripple);
this.ripplePool.push(ripple);
}
}, 300);
});
}
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);
/**
* 检查点击是否在菜单内
*/
private isClickInsideMenu(target: Node): boolean {
if (this.menuElement && this.menuElement.contains(target)) {
return true;
}
// 检查是否在子菜单内
for (const submenu of this.activeSubmenus) {
if (submenu.contains(target)) {
return true;
}
}
return false;
}
/**
* 定位菜单元素
*/
private positionMenu(menu: HTMLElement, clientX: number, clientY: number): void {
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
let left = clientX;
let top = clientY;
requestAnimationFrame(() => {
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`;
});
}
/**
* 显示上下文菜单
*/
show(view: EditorView, clientX: number, clientY: number, items: MenuItem[]): void {
this.currentView = view;
// 获取或创建菜单元素
const menu = this.getOrCreateMenuElement();
// 隐藏所有子菜单
this.hideAllSubmenus();
// 清空现有菜单项并回收到池中
while (menu.firstChild) {
const child = menu.firstChild as HTMLElement;
if (child.classList.contains('cm-context-menu-item')) {
this.menuItemPool.release(child);
}
menu.removeChild(child);
}
// 清空命令注册
this.commands.clear();
this.commandCounter = 0;
// 添加主菜单项
items.forEach(item => {
const menuItemElement = this.createMenuItemElement(item);
menu.appendChild(menuItemElement);
});
// 显示菜单
menu.style.display = "block";
// 定位菜单
this.positionMenu(menu, clientX, clientY);
// 添加全局事件监听器
document.addEventListener("click", this.clickOutsideHandler!, true);
document.addEventListener("keydown", this.keyDownHandler!);
// 触发显示动画
requestAnimationFrame(() => {
if (menu) {
menu.classList.add("show");
}
});
}
/**
* 隐藏所有子菜单
*/
private hideAllSubmenus(): void {
this.activeSubmenus.forEach(submenu => {
this.hideSubmenu(submenu);
});
this.activeSubmenus.clear();
if (window.cmSubmenus) {
window.cmSubmenus.forEach((submenu) => {
submenu.style.opacity = '0';
submenu.style.pointerEvents = 'none';
submenu.style.visibility = 'hidden';
submenu.style.transform = 'translateX(10px)';
});
}
}
/**
* 隐藏上下文菜单
*/
hide(): void {
// 隐藏所有子菜单
this.hideAllSubmenus();
if (this.menuElement) {
// 添加淡出动画
this.menuElement.classList.remove("show");
this.menuElement.classList.add("hide");
// 等待动画完成后隐藏
setTimeout(() => {
if (this.menuElement) {
this.menuElement.style.display = "none";
this.menuElement.classList.remove("hide");
}
}, 150);
}
// 移除全局事件监听器
if (this.clickOutsideHandler) {
document.removeEventListener("click", this.clickOutsideHandler, true);
}
if (this.keyDownHandler) {
document.removeEventListener("keydown", this.keyDownHandler);
}
this.currentView = null;
}
/**
* 销毁管理器
*/
destroy(): void {
this.hide();
if (this.menuElement) {
document.body.removeChild(this.menuElement);
this.menuElement = null;
}
this.submenuPool.forEach(submenu => {
if (submenu.parentNode) {
document.body.removeChild(submenu);
}
});
this.submenuPool.clear();
this.menuItemPool.clear();
this.commands.clear();
this.activeSubmenus.clear();
this.ripplePool.length = 0;
if (window.cmSubmenus) {
window.cmSubmenus.clear();
}
}
// 添加菜单项
items.forEach(item => {
const menuItemElement = createMenuItemElement(item, view);
menuElement.appendChild(menuItemElement);
});
}
// 获取单例实例
const contextMenuManager = ContextMenuManager.getInstance();
/**
* 显示上下文菜单
*/
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);
}
contextMenuManager.show(view, clientX, clientY, items);
}

View File

@@ -11,11 +11,6 @@ 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";
@@ -134,44 +129,6 @@ function createHistoryItems(): MenuItem[] {
];
}
/**
* 创建代码块相关菜单项
*/
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)
}
];
}
/**
* 创建主菜单项
@@ -186,11 +143,7 @@ function createMainMenuItems(): MenuItem[] {
// 构建主菜单
return [
...basicItems,
...historyItems,
{
label: t("extensions.codeblock.name"),
submenu: createCodeBlockItems()
}
...historyItems
];
}

View File

@@ -17,7 +17,7 @@ const blockSeparatorRegex = new RegExp(`\\n∞∞∞(${languageTokensMatcher})(-
/**
* 获取被复制的范围和内容
*/
function copiedRange(state: EditorState) {
function copiedRange(state: EditorState, forCut: boolean = false) {
const content: string[] = [];
const ranges: any[] = [];
@@ -37,7 +37,13 @@ function copiedRange(state: EditorState) {
const lineContent = state.sliceDoc(line.from, line.to);
if (!copiedLines.includes(line.from)) {
content.push(lineContent);
ranges.push(range);
// 对于剪切操作,需要包含整行范围(包括换行符)
if (forCut) {
const lineEnd = line.to < state.doc.length ? line.to + 1 : line.to;
ranges.push({ from: line.from, to: lineEnd });
} else {
ranges.push(range);
}
copiedLines.push(line.from);
}
}
@@ -68,7 +74,7 @@ export const codeBlockCopyCut = EditorView.domEventHandlers({
},
cut(event, view) {
let { text, ranges } = copiedRange(view.state);
let { text, ranges } = copiedRange(view.state, true);
// 将块分隔符替换为双换行符
text = text.replaceAll(blockSeparatorRegex, "\n\n");
@@ -93,7 +99,7 @@ export const codeBlockCopyCut = EditorView.domEventHandlers({
* 复制和剪切的通用函数
*/
const copyCut = (view: EditorView, cut: boolean): boolean => {
let { text, ranges } = copiedRange(view.state);
let { text, ranges } = copiedRange(view.state, cut);
// 将块分隔符替换为双换行符
text = text.replaceAll(blockSeparatorRegex, "\n\n");

View File

@@ -38,9 +38,6 @@ export interface CodeBlockOptions {
/** 新建块时的默认语言 */
defaultLanguage?: SupportedLanguage;
/** 新建块时是否默认启用自动检测(添加-a标记 */
defaultAutoDetect?: boolean;
}
/**
@@ -87,7 +84,6 @@ export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extens
showBackground = true,
enableAutoDetection = true,
defaultLanguage = 'text',
defaultAutoDetect = true,
} = options;
return [

View File

@@ -16,7 +16,8 @@ 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" | "math" | "dockerfile" | "lua"
"ps1" | "dart" | "scala" | "math" | "dockerfile" | "lua" | "vue" | "lezer" |
"liquid" | "wast" | "sass" | "less" | "angular" | "svelte"
}
@tokens {

View File

@@ -15,6 +15,14 @@ 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 {vueLanguage} from "@codemirror/lang-vue";
import {lezerLanguage} from "@codemirror/lang-lezer";
import {liquidLanguage} from "@codemirror/lang-liquid";
import {wastLanguage} from "@codemirror/lang-wast";
import {sassLanguage} from "@codemirror/lang-sass";
import {lessLanguage} from "@codemirror/lang-less";
import {angularLanguage} from "@codemirror/lang-angular";
import { svelteLanguage } from "@replit/codemirror-lang-svelte";
import {StreamLanguage} from "@codemirror/language";
import {ruby} from "@codemirror/legacy-modes/mode/ruby";
@@ -53,6 +61,7 @@ import clangPrettierPlugin from "@/common/prettier/plugins/clang";
import pythonPrettierPlugin from "@/common/prettier/plugins/python";
import dartPrettierPlugin from "@/common/prettier/plugins/dart";
import luaPrettierPlugin from "@/common/prettier/plugins/lua";
import webPrettierPlugin from "@/common/prettier/plugins/web";
import * as prettierPluginEstree from "prettier/plugins/estree";
/**
@@ -73,7 +82,7 @@ export class LanguageInfo {
}
/**
* 支持的语言列表(与 Worker 中的 LANGUAGES 对应)
* 支持的语言列表
*/
export const LANGUAGES: LanguageInfo[] = [
new LanguageInfo("text", "Plain Text", null),
@@ -89,6 +98,13 @@ export const LANGUAGES: LanguageInfo[] = [
parser: "html",
plugins: [htmlPrettierPlugin]
}),
new LanguageInfo("vue", "Vue", vueLanguage.parser, ["vue"], {
parser: "web-fmt",
plugins: [webPrettierPlugin],
options: {
filename: "index.vue"
}
}),
new LanguageInfo("sql", "SQL", StandardSQL.language.parser, ["sql"], {
parser: "sql",
plugins: [sqlPrettierPlugin]
@@ -124,7 +140,7 @@ export const LANGUAGES: LanguageInfo[] = [
parser: "rust",
plugins: [rustPrettierPlugin]
}),
new LanguageInfo("cs", "C#", StreamLanguage.define(csharp).parser, ["cs"],{
new LanguageInfo("cs", "C#", StreamLanguage.define(csharp).parser, ["cs"], {
parser: "clang-format",
plugins: [clangPrettierPlugin],
options: {
@@ -183,6 +199,32 @@ export const LANGUAGES: LanguageInfo[] = [
plugins: [luaPrettierPlugin]
}),
new LanguageInfo("math", "Math", null, ["math"]),
new LanguageInfo("lezer", "Lezer", lezerLanguage.parser, ["lezer"]),
new LanguageInfo("liquid", "Liquid", liquidLanguage.parser, ["liquid"]),
new LanguageInfo("wast", "WebAssembly", wastLanguage.parser, ["wast"]),
new LanguageInfo("sass", "Sass", sassLanguage.parser, ["sass"], {
parser: "web-fmt",
plugins: [webPrettierPlugin],
options: {
filename: "index.sass"
}
}),
new LanguageInfo("less", "Less", lessLanguage.parser, ["less"], {
parser: "web-fmt",
plugins: [webPrettierPlugin],
options: {
filename: "index.less"
}
}),
new LanguageInfo("angular", "Angular", angularLanguage.parser, ["angular"]),
new LanguageInfo("svelte", "Svelte", svelteLanguage.parser, ["svelte"], {
parser: "web-fmt",
plugins: [webPrettierPlugin],
options: {
filename: "index.svelte"
}
}),
];
/**

View File

@@ -3,14 +3,14 @@ import {LRParser} from "@lezer/lr"
import {blockContent} from "./external-tokens.js"
export const parser = LRParser.deserialize({
version: 14,
states: "!jQQOQOOOVOQO'#C`O#]OPO'#C_OOOO'#Cc'#CcQQOQOOOOOO'#Ca'#CaO#bOSO,58zOOOO,58y,58yOOOO-E6a-E6aOOOP1G.f1G.fO#jOSO1G.fOOOP7+$Q7+$Q",
stateData: "#o~OXPO~OYTOZTO[TO]TO^TO_TO`TOaTObTOcTOdTOeTOfTOgTOhTOiTOjTOkTOlTOmTOnTOoTOpTOqTOrTOsTOtTOuTOvTOwTOxTOyTO~OPVO~OUYOzXO~OzZO~O",
states: "!jQQOQOOOVOQO'#C`O#uOPO'#C_OOOO'#Cc'#CcQQOQOOOOOO'#Ca'#CaO#zOSO,58zOOOO,58y,58yOOOO-E6a-E6aOOOP1G.f1G.fO$SOSO1G.fOOOP7+$Q7+$Q",
stateData: "$X~OXPO~OYTOZTO[TO]TO^TO_TO`TOaTObTOcTOdTOeTOfTOgTOhTOiTOjTOkTOlTOmTOnTOoTOpTOqTOrTOsTOtTOuTOvTOwTOxTOyTOzTO{TO|TO}TO!OTO!PTO!QTO!RTO~OPVO~OUYO!SXO~O!SZO~O",
goto: "jWPPPX]aPdTROSTQOSRUPQSORWS",
nodeNames: "⚠ BlockContent Document Block BlockDelimiter BlockLanguage Auto",
maxTerm: 42,
maxTerm: 50,
skippedNodes: [0],
repeatNodeCount: 1,
tokenData: ".w~RaYZ!W}!O!q#V#W!|#W#X#}#X#Y%p#Z#[&Z#[#]'W#^#_'o#_#`(n#`#a(y#a#b)[#d#e){#f#g*t#g#h+X#h#i,x#l#m-}#m#n.`R!]PzQ%&x%&y!`P!cP%&x%&y!fP!iP%&x%&y!lP!qOXP~!tP#T#U!w~!|OU~~#PR#`#a#Y#d#e#e#g#h#p~#]P#^#_#`~#eOl~~#hP#d#e#k~#pOd~~#uPf~#g#h#x~#}Ob~~$QQ#T#U$W#c#d$i~$ZP#f#g$^~$aP#h#i$d~$iOu~~$lP#V#W$o~$rP#_#`$u~$xP#X#Y${~%OP#f#g%R~%UP#Y#Z%X~%[P#]#^%_~%bP#`#a%e~%hP#X#Y%k~%pOx~~%sQ#f#g%y#l#m&U~%|P#`#a&P~&UOn~~&ZOm~~&^Q#c#d&d#f#g&i~&iOk~~&lP#c#d&o~&rP#c#d&u~&xP#j#k&{~'OP#m#n'R~'WOs~~'ZP#h#i'^~'aP#a#b'd~'gP#`#a'j~'oO]~~'rQ#T#U'x#g#h(Z~'{P#j#k(O~(RP#T#U(U~(ZO`~~(`Po~#c#d(c~(fP#b#c(i~(nOZ~~(qP#h#i(t~(yOr~~(|P#i#j)P~)SP#T#U)V~)[Oy~~)_Q#T#U)e#W#X)v~)hP#h#i)k~)nP#[#])q~)vOw~~){O_~~*OR#[#]*X#g#h*d#m#n*o~*[P#d#e*_~*dOa~~*gP!R!S*j~*oOt~~*tO[~~*wQ#U#V*}#g#h+S~+SOg~~+XOe~~+[S#V#W+h#[#],P#e#f,U#k#l,a~+kP#T#U+n~+qP#`#a+t~+wP#T#U+z~,POv~~,UOh~~,XP#`#a,[~,aO^~~,dP#]#^,g~,jP#Y#Z,m~,pP#h#i,s~,xOq~~,{R#X#Y-U#c#d-g#g#h-x~-XP#l#m-[~-_P#h#i-b~-gOY~~-jP#a#b-m~-pP#`#a-s~-xOj~~-}Op~~.QP#a#b.T~.WP#`#a.Z~.`Oc~~.cP#T#U.f~.iP#a#b.l~.oP#`#a.r~.wOi~",
tokenData: "3g~RdYZ!a}!O!z#T#U#V#V#W$Q#W#X%R#X#Y&t#Z#['_#[#]([#^#_(s#_#`)r#`#a)}#a#b+z#d#e,k#f#g-d#g#h-w#h#i0n#j#k1s#k#l2U#l#m2m#m#n3OR!fP!SQ%&x%&y!iP!lP%&x%&y!oP!rP%&x%&y!uP!zOXP~!}P#T#U#Q~#VOU~~#YP#b#c#]~#`P#Z#[#c~#fP#i#j#i~#lP#`#a#o~#rP#T#U#u~#xP#f#g#{~$QO!Q~~$TR#`#a$^#d#e$i#g#h$t~$aP#^#_$d~$iOl~~$lP#d#e$o~$tOd~~$yPf~#g#h$|~%ROb~~%UQ#T#U%[#c#d%m~%_P#f#g%b~%eP#h#i%h~%mOu~~%pP#V#W%s~%vP#_#`%y~%|P#X#Y&P~&SP#f#g&V~&YP#Y#Z&]~&`P#]#^&c~&fP#`#a&i~&lP#X#Y&o~&tOx~~&wQ#f#g&}#l#m'Y~'QP#`#a'T~'YOn~~'_Om~~'bQ#c#d'h#f#g'm~'mOk~~'pP#c#d's~'vP#c#d'y~'|P#j#k(P~(SP#m#n(V~([Os~~(_P#h#i(b~(eP#a#b(h~(kP#`#a(n~(sO]~~(vQ#T#U(|#g#h)_~)PP#j#k)S~)VP#T#U)Y~)_O`~~)dPo~#c#d)g~)jP#b#c)m~)rOZ~~)uP#h#i)x~)}Or~~*QR#X#Y*Z#]#^+Q#i#j+o~*^Q#g#h*d#n#o*o~*gP#g#h*j~*oO!P~~*rP#X#Y*u~*xP#f#g*{~+QO{~~+TP#e#f+W~+ZP#i#j+^~+aP#]#^+d~+gP#W#X+j~+oO|~~+rP#T#U+u~+zOy~~+}Q#T#U,T#W#X,f~,WP#h#i,Z~,^P#[#],a~,fOw~~,kO_~~,nR#[#],w#g#h-S#m#n-_~,zP#d#e,}~-SOa~~-VP!R!S-Y~-_Ot~~-dO[~~-gQ#U#V-m#g#h-r~-rOg~~-wOe~~-zU#T#U.^#V#W.o#[#]/W#e#f/]#j#k/h#k#l0V~.aP#g#h.d~.gP#g#h.j~.oO!O~~.rP#T#U.u~.xP#`#a.{~/OP#T#U/R~/WOv~~/]Oh~~/`P#`#a/c~/hO^~~/kP#X#Y/n~/qP#`#a/t~/wP#h#i/z~/}P#X#Y0Q~0VO!R~~0YP#]#^0]~0`P#Y#Z0c~0fP#h#i0i~0nOq~~0qR#X#Y0z#c#d1]#g#h1n~0}P#l#m1Q~1TP#h#i1W~1]OY~~1`P#a#b1c~1fP#`#a1i~1nOj~~1sOp~~1vP#i#j1y~1|P#X#Y2P~2UOz~~2XP#T#U2[~2_P#g#h2b~2eP#h#i2h~2mO}~~2pP#a#b2s~2vP#`#a2y~3OOc~~3RP#T#U3U~3XP#a#b3[~3_P#`#a3b~3gOi~",
tokenizers: [blockContent, 0, 1],
topRules: {"Document":[0,2]},
tokenPrec: 0

View File

@@ -57,6 +57,14 @@ export type SupportedLanguage =
| 'dockerfile'
| 'lua'
| 'math'
| 'vue'
| 'lezer'
| 'liquid'
| 'wast'
| 'sass'
| 'less'
| 'angular'
| 'svelte'
/**
* 创建块的选项
@@ -75,56 +83,10 @@ export interface EditorOptions {
defaultBlockAutoDetect: boolean;
}
// 语言信息接口
export interface LanguageInfo {
name: SupportedLanguage;
auto: boolean; // 是否自动检测语言
}
// 位置范围接口
export interface Range {
from: number;
to: number;
}
// 代码块核心接口
export interface CodeBlock {
language: LanguageInfo;
content: Range; // 内容区域
delimiter: Range; // 分隔符区域
range: Range; // 整个块区域(包括分隔符和内容)
}
// 代码块解析选项
export interface ParseOptions {
fallbackLanguage?: SupportedLanguage;
enableAutoDetection?: boolean;
}
// 分隔符格式常量
export const DELIMITER_REGEX = /^\n∞∞∞([a-zA-Z0-9_-]+)(-a)?\n/gm;
export const DELIMITER_PREFIX = '\n∞∞∞';
export const DELIMITER_SUFFIX = '\n';
export const AUTO_DETECT_SUFFIX = '-a';
// 代码块操作类型
export type BlockOperation =
| 'insert-after'
| 'insert-before'
| 'delete'
| 'move-up'
| 'move-down'
| 'change-language';
// 代码块状态更新事件
export interface BlockStateUpdate {
blocks: CodeBlock[];
activeBlockIndex: number;
operation?: BlockOperation;
}
// 语言检测结果
export interface LanguageDetectionResult {
language: SupportedLanguage;
confidence: number;
}

View File

@@ -1,364 +1,355 @@
import { Extension, StateField, StateEffect } from '@codemirror/state';
import { Extension, StateField, StateEffect, StateEffectType } from '@codemirror/state';
import { EditorView, showTooltip, Tooltip } from '@codemirror/view';
import { createTranslationTooltip } from './tooltip';
/**
* 翻译器扩展配置
*/
export interface TranslatorConfig {
/** 默认翻译服务提供商 */
defaultTranslator: string;
/** 最小选择字符数才显示翻译按钮 */
minSelectionLength: number;
/** 最大翻译字符数 */
maxTranslationLength: number;
}
/**
* 默认翻译器配置
*/
export const defaultConfig: TranslatorConfig = {
defaultTranslator: 'bing',
minSelectionLength: 2,
maxTranslationLength: 5000,
};
// 全局配置存储
let currentConfig: TranslatorConfig = {...defaultConfig};
// 存储选择的文本用于翻译
let selectedTextForTranslation = "";
import {
TranslatorConfig,
DEFAULT_TRANSLATION_CONFIG,
TRANSLATION_ICON_SVG
} from '@/common/constant/translation';
/**
* 翻译图标SVG
*/
const translationIconSvg = `
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M599.68 485.056h-8l30.592 164.672c20.352-7.04 38.72-17.344 54.912-31.104a271.36 271.36 0 0 1-40.704-64.64l32.256-4.032c8.896 17.664 19.072 33.28 30.592 46.72 23.872-27.968 42.24-65.152 55.04-111.744l-154.688 0.128z m121.92 133.76c18.368 15.36 39.36 26.56 62.848 33.472l14.784 4.416-8.64 30.336-14.72-4.352a205.696 205.696 0 0 1-76.48-41.728c-20.672 17.92-44.928 31.552-71.232 40.064l20.736 110.912H519.424l-9.984 72.512h385.152c18.112 0 32.704-14.144 32.704-31.616V295.424a32.128 32.128 0 0 0-32.704-31.552H550.528l35.2 189.696h79.424v-31.552h61.44v31.552h102.4v31.616h-42.688c-14.272 55.488-35.712 100.096-64.64 133.568zM479.36 791.68H193.472c-36.224 0-65.472-28.288-65.472-63.168V191.168C128 156.16 157.312 128 193.472 128h327.68l20.544 104.32h352.832c36.224 0 65.472 28.224 65.472 63.104v537.408c0 34.944-29.312 63.168-65.472 63.168H468.608l10.688-104.32zM337.472 548.352v-33.28H272.768v-48.896h60.16V433.28h-60.16v-41.728h64.704v-32.896h-102.4v189.632h102.4z m158.272 0V453.76c0-17.216-4.032-30.272-12.16-39.488-8.192-9.152-20.288-13.696-36.032-13.696a55.04 55.04 0 0 0-24.768 5.376 39.04 39.04 0 0 0-17.088 15.936h-1.984l-5.056-18.56h-28.352V548.48h37.12V480c0-17.088 2.304-29.376 6.912-36.736 4.608-7.424 12.16-11.072 22.528-11.072 7.616 0 13.248 2.56 16.64 7.872 3.52 5.248 5.312 13.056 5.312 23.488v84.736h36.928z" fill="currentColor"></path>
</svg>`;
class TranslatorExtension {
private config: TranslatorConfig;
private setTranslationTooltip: StateEffectType<Tooltip | null>;
private translationTooltipField: StateField<readonly Tooltip[]>;
private translationButtonField: StateField<readonly Tooltip[]>;
// 用于设置翻译气泡的状态效果
const setTranslationTooltip = StateEffect.define<Tooltip | null>();
constructor(config?: Partial<TranslatorConfig>) {
// 初始化配置
this.config = {
minSelectionLength: DEFAULT_TRANSLATION_CONFIG.minSelectionLength,
maxTranslationLength: DEFAULT_TRANSLATION_CONFIG.maxTranslationLength,
...config
};
/**
* 翻译气泡的状态字段
*/
const translationTooltipField = StateField.define<readonly Tooltip[]>({
create() {
return [];
},
update(tooltips, tr) {
// 如果文档或选择变化,隐藏气泡
if (tr.docChanged || tr.selection) {
return [];
}
// 检查是否有特定的状态效果来更新tooltips
for (const effect of tr.effects) {
if (effect.is(setTranslationTooltip)) {
return effect.value ? [effect.value] : [];
}
}
return tooltips;
},
provide: field => showTooltip.computeN([field], state => state.field(field))
});
// 初始化状态效果
this.setTranslationTooltip = StateEffect.define<Tooltip | null>();
/**
* 根据当前选择获取翻译按钮tooltip
*/
function getTranslationButtonTooltips(state: any): readonly Tooltip[] {
// 如果气泡已显示,则不显示按钮
if (state.field(translationTooltipField).length > 0) return [];
const selection = state.selection.main;
// 如果没有选中文本,不显示按钮
if (selection.empty) return [];
// 获取选中的文本
const selectedText = state.sliceDoc(selection.from, selection.to);
// 检查文本是否只包含空格
if (!selectedText.trim()) {
return [];
}
// 检查文本长度条件
if (selectedText.length < currentConfig.minSelectionLength ||
selectedText.length > currentConfig.maxTranslationLength) {
return [];
}
// 保存选中的文本用于翻译
selectedTextForTranslation = selectedText;
// 返回翻译按钮tooltip配置
return [{
pos: selection.to,
above: false,
strictSide: true,
arrow: false,
create: (view) => {
// 创建按钮DOM
const dom = document.createElement('div');
dom.className = 'cm-translator-button';
dom.innerHTML = translationIconSvg;
// 点击事件
dom.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
// 初始化翻译气泡状态字段
this.translationTooltipField = StateField.define<readonly Tooltip[]>({
create: () => [],
update: (tooltips, tr) => {
// 检查是否有特定的状态效果来更新tooltips
for (const effect of tr.effects) {
if (effect.is(this.setTranslationTooltip)) {
return effect.value ? [effect.value] : [];
}
}
// 显示翻译气泡
showTranslationTooltip(view);
});
// 如果文档或选择变化,隐藏气泡
if (tr.docChanged || tr.selection) {
return [];
}
return tooltips;
},
provide: field => showTooltip.computeN([field], state => state.field(field))
});
return { dom };
}
}];
}
// 初始化翻译按钮状态字段
this.translationButtonField = StateField.define<readonly Tooltip[]>({
create: (state) => this.getTranslationButtonTooltips(state),
update: (tooltips, tr) => {
// 如果文档或选择变化重新计算tooltip
if (tr.docChanged || tr.selection) {
return this.getTranslationButtonTooltips(tr.state);
}
// 检查是否有翻译气泡显示,如果有则不显示按钮
if (tr.state.field(this.translationTooltipField).length > 0) {
return [];
}
return tooltips;
},
provide: field => showTooltip.computeN([field], state => state.field(field))
});
}
/**
* 显示翻译气泡
*/
function showTranslationTooltip(view: EditorView) {
if (!selectedTextForTranslation) return;
// 创建翻译气泡
const tooltip = createTranslationTooltip(view, selectedTextForTranslation);
// 更新状态以显示气泡
view.dispatch({
effects: setTranslationTooltip.of(tooltip)
});
}
/**
* 翻译按钮的状态字段
*/
const translationButtonField = StateField.define<readonly Tooltip[]>({
create(state) {
return getTranslationButtonTooltips(state);
},
update(tooltips, tr) {
// 如果文档或选择变化重新计算tooltip
if (tr.docChanged || tr.selection) {
return getTranslationButtonTooltips(tr.state);
}
/**
* 根据当前选择获取翻译按钮tooltip
*/
private getTranslationButtonTooltips(state: any): readonly Tooltip[] {
// 如果气泡已显示,则不显示按钮
if (state.field(this.translationTooltipField).length > 0) return [];
// 检查是否有翻译气泡显示,如果有则不显示按钮
if (tr.state.field(translationTooltipField).length > 0) {
const selection = state.selection.main;
// 如果没有选中文本,不显示按钮
if (selection.empty) return [];
// 获取选中的文本
const selectedText = state.sliceDoc(selection.from, selection.to);
// 检查文本是否只包含空格
if (!selectedText.trim()) {
return [];
}
return tooltips;
},
// 检查文本长度条件
if (selectedText.length < this.config.minSelectionLength ||
selectedText.length > this.config.maxTranslationLength) {
return [];
}
// 返回翻译按钮tooltip配置
return [{
pos: selection.to,
above: false,
strictSide: true,
arrow: false,
create: (view) => {
// 创建按钮DOM
const dom = document.createElement('div');
dom.className = 'cm-translator-button';
dom.innerHTML = TRANSLATION_ICON_SVG;
provide: field => showTooltip.computeN([field], state => state.field(field))
});
// 点击事件
dom.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
// 显示翻译气泡
this.showTranslationTooltip(view);
});
return { dom };
}
}];
}
/**
* 显示翻译气泡
*/
private showTranslationTooltip(view: EditorView) {
// 直接从当前选择获取文本
const selection = view.state.selection.main;
if (selection.empty) return;
const selectedText = view.state.sliceDoc(selection.from, selection.to);
if (!selectedText.trim()) return;
// 创建翻译气泡
const tooltip = createTranslationTooltip(view, selectedText);
// 更新状态以显示气泡
view.dispatch({
effects: this.setTranslationTooltip.of(tooltip)
});
}
/**
* 创建扩展
*/
createExtension(): Extension {
return [
// 翻译按钮tooltip
this.translationButtonField,
// 翻译气泡tooltip
this.translationTooltipField,
// 添加基础样式
EditorView.baseTheme({
".cm-translator-button": {
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
background: "var(--bg-secondary, transparent)",
color: "var(--text-muted, #4285f4)",
border: "1px solid var(--border-color, #dadce0)",
borderRadius: "3px",
padding: "2px",
width: "24px",
height: "24px",
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.08)",
userSelect: "none",
"&:hover": {
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))"
}
},
// 翻译气泡样式
".cm-translation-tooltip": {
background: "var(--bg-secondary, #fff)",
color: "var(--text-primary, #333)",
border: "1px solid var(--border-color, #dadce0)",
borderRadius: "3px",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
padding: "8px",
maxWidth: "300px",
maxHeight: "200px",
display: "flex",
flexDirection: "column",
overflow: "hidden",
fontFamily: "var(--font-family, system-ui, -apple-system, sans-serif)",
fontSize: "11px",
userSelect: "none",
cursor: "grab"
},
// 拖拽状态样式
".cm-translation-dragging": {
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.2)",
zIndex: "1000",
cursor: "grabbing !important"
},
".cm-translation-header": {
marginBottom: "8px",
flexShrink: "0"
},
".cm-translation-controls": {
display: "flex",
alignItems: "center",
gap: "4px",
flexWrap: "nowrap"
},
".cm-translation-select": {
padding: "2px 4px",
borderRadius: "3px",
border: "1px solid var(--border-color, #dadce0)",
background: "var(--bg-primary, #f5f5f5)",
fontSize: "11px",
color: "var(--text-primary, #333)",
flex: "1",
minWidth: "0",
maxWidth: "80px"
},
".cm-translation-swap": {
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "16px",
height: "16px",
borderRadius: "3px",
border: "1px solid var(--border-color, #dadce0)",
background: "var(--bg-primary, transparent)",
color: "var(--text-muted, #666)",
cursor: "pointer",
padding: "0",
flexShrink: "0",
"&:hover": {
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))"
}
},
// 滚动容器
".cm-translation-scroll-container": {
overflowY: "auto",
flex: "1",
minHeight: "0"
},
".cm-translation-result": {
display: "flex",
flexDirection: "column"
},
".cm-translation-result-header": {
display: "flex",
justifyContent: "flex-end",
marginBottom: "4px"
},
".cm-translation-result-wrapper": {
position: "relative",
width: "100%"
},
".cm-translation-copy-btn": {
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "20px",
height: "20px",
borderRadius: "3px",
border: "1px solid var(--border-color, #dadce0)",
background: "var(--bg-primary, transparent)",
color: "var(--text-muted, #666)",
cursor: "pointer",
padding: "0",
position: "absolute",
top: "4px",
right: "4px",
zIndex: "2",
opacity: "0.7",
"&:hover": {
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))",
opacity: "1"
},
"&.copied": {
background: "var(--bg-success, #4caf50)",
color: "white",
border: "1px solid var(--bg-success, #4caf50)",
opacity: "1"
}
},
".cm-translation-target": {
padding: "6px",
paddingRight: "28px", // 为复制按钮留出空间
background: "var(--bg-primary, rgba(66, 133, 244, 0.05))",
color: "var(--text-primary, #333)",
borderRadius: "3px",
whiteSpace: "pre-wrap",
wordBreak: "break-word"
},
".cm-translation-notice": {
fontSize: "10px",
color: "var(--text-muted, #888)",
padding: "2px 0",
fontStyle: "italic",
textAlign: "center",
marginBottom: "2px"
},
".cm-translation-error": {
color: "var(--text-danger, #d32f2f)",
fontStyle: "italic"
},
".cm-translation-loading": {
padding: "8px",
textAlign: "center",
color: "var(--text-muted, #666)",
fontSize: "11px",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "6px"
},
".cm-translation-loading::before": {
content: "''",
display: "inline-block",
width: "12px",
height: "12px",
borderRadius: "50%",
border: "2px solid var(--text-muted, #666)",
borderTopColor: "transparent",
animation: "cm-translation-spin 1s linear infinite"
},
"@keyframes cm-translation-spin": {
"0%": { transform: "rotate(0deg)" },
"100%": { transform: "rotate(360deg)" }
}
})
];
}
}
/**
* 创建翻译扩展
*/
export function createTranslatorExtension(config?: Partial<TranslatorConfig>): Extension {
// 更新配置
currentConfig = { ...defaultConfig, ...config };
return [
// 翻译按钮tooltip
translationButtonField,
// 翻译气泡tooltip
translationTooltipField,
// 添加基础样式
EditorView.baseTheme({
".cm-translator-button": {
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
background: "var(--bg-secondary, transparent)",
color: "var(--text-muted, #4285f4)",
border: "1px solid var(--border-color, #dadce0)",
borderRadius: "3px",
padding: "2px",
width: "24px",
height: "24px",
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.08)",
userSelect: "none",
"&:hover": {
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))"
}
},
// 翻译气泡样式
".cm-translation-tooltip": {
background: "var(--bg-secondary, #fff)",
color: "var(--text-primary, #333)",
border: "1px solid var(--border-color, #dadce0)",
borderRadius: "3px",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
padding: "8px",
maxWidth: "300px",
maxHeight: "200px",
display: "flex",
flexDirection: "column",
overflow: "hidden",
fontFamily: "var(--font-family, system-ui, -apple-system, sans-serif)",
fontSize: "11px"
},
".cm-translation-header": {
marginBottom: "8px",
flexShrink: "0"
},
".cm-translation-controls": {
display: "flex",
alignItems: "center",
gap: "4px",
flexWrap: "nowrap"
},
".cm-translation-select": {
padding: "2px 4px",
borderRadius: "3px",
border: "1px solid var(--border-color, #dadce0)",
background: "var(--bg-primary, #f5f5f5)",
fontSize: "11px",
color: "var(--text-primary, #333)",
flex: "1",
minWidth: "0",
maxWidth: "80px"
},
".cm-translation-swap": {
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "16px",
height: "16px",
borderRadius: "3px",
border: "1px solid var(--border-color, #dadce0)",
background: "var(--bg-primary, transparent)",
color: "var(--text-muted, #666)",
cursor: "pointer",
padding: "0",
flexShrink: "0",
"&:hover": {
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))"
}
},
// 滚动容器
".cm-translation-scroll-container": {
overflowY: "auto",
flex: "1",
minHeight: "0"
},
".cm-translation-result": {
display: "flex",
flexDirection: "column"
},
".cm-translation-result-header": {
display: "flex",
justifyContent: "flex-end",
marginBottom: "4px"
},
".cm-translation-result-wrapper": {
position: "relative",
width: "100%"
},
".cm-translation-copy-btn": {
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "20px",
height: "20px",
borderRadius: "3px",
border: "1px solid var(--border-color, #dadce0)",
background: "var(--bg-primary, transparent)",
color: "var(--text-muted, #666)",
cursor: "pointer",
padding: "0",
position: "absolute",
top: "4px",
right: "4px",
zIndex: "2",
opacity: "0.7",
"&:hover": {
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))",
opacity: "1"
},
"&.copied": {
background: "var(--bg-success, #4caf50)",
color: "white",
border: "1px solid var(--bg-success, #4caf50)",
opacity: "1"
}
},
".cm-translation-target": {
padding: "6px",
paddingRight: "28px", // 为复制按钮留出空间
background: "var(--bg-primary, rgba(66, 133, 244, 0.05))",
color: "var(--text-primary, #333)",
borderRadius: "3px",
whiteSpace: "pre-wrap",
wordBreak: "break-word"
},
".cm-translation-notice": {
fontSize: "10px",
color: "var(--text-muted, #888)",
padding: "2px 0",
fontStyle: "italic",
textAlign: "center",
marginBottom: "2px"
},
".cm-translation-error": {
color: "var(--text-danger, #d32f2f)",
fontStyle: "italic"
},
".cm-translation-loading": {
padding: "8px",
textAlign: "center",
color: "var(--text-muted, #666)",
fontSize: "11px",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "6px"
},
".cm-translation-loading::before": {
content: "''",
display: "inline-block",
width: "12px",
height: "12px",
borderRadius: "50%",
border: "2px solid var(--text-muted, #666)",
borderTopColor: "transparent",
animation: "cm-translation-spin 1s linear infinite"
},
"@keyframes cm-translation-spin": {
"0%": { transform: "rotate(0deg)" },
"100%": { transform: "rotate(360deg)" }
}
})
];
const translatorExtension = new TranslatorExtension(config);
return translatorExtension.createExtension();
}
export default createTranslatorExtension;
export default createTranslatorExtension;

File diff suppressed because it is too large Load Diff

View File

@@ -66,7 +66,7 @@ const defaultEditorOptions = {
*
* command字段映射到具体的前端方法和翻译键
*/
export const commandRegistry = {
export const commands = {
[KeyBindingCommand.ShowSearchCommand]: {
handler: showSearchVisibilityCommand,
descriptionKey: 'keybindings.commands.showSearch'
@@ -299,7 +299,7 @@ export const commandRegistry = {
* @returns undefined
*/
export const getCommandHandler = (command: KeyBindingCommand) => {
return commandRegistry[command]?.handler;
return commands[command]?.handler;
};
/**
@@ -308,7 +308,7 @@ export const getCommandHandler = (command: KeyBindingCommand) => {
* @returns undefined
*/
export const getCommandDescription = (command: KeyBindingCommand) => {
const descriptionKey = commandRegistry[command]?.descriptionKey;
const descriptionKey = commands[command]?.descriptionKey;
return descriptionKey ? i18n.global.t(descriptionKey) : undefined;
};
@@ -318,7 +318,7 @@ export const getCommandDescription = (command: KeyBindingCommand) => {
* @returns
*/
export const isCommandRegistered = (command: KeyBindingCommand): boolean => {
return command in commandRegistry;
return command in commands;
};
/**
@@ -326,5 +326,5 @@ export const isCommandRegistered = (command: KeyBindingCommand): boolean => {
* @returns
*/
export const getRegisteredCommands = (): KeyBindingCommand[] => {
return Object.keys(commandRegistry) as KeyBindingCommand[];
return Object.keys(commands) as KeyBindingCommand[];
};

View File

@@ -2,11 +2,9 @@ import { Extension } from '@codemirror/state';
import { useKeybindingStore } from '@/stores/keybindingStore';
import { useExtensionStore } from '@/stores/extensionStore';
import { KeymapManager } from './keymapManager';
import { ExtensionID } from '@/../bindings/voidraft/internal/models/models';
/**
* 异步创建快捷键扩展
* 确保快捷键配置和扩展配置已加载
*/
export const createDynamicKeymapExtension = async (): Promise<Extension> => {
const keybindingStore = useKeybindingStore();
@@ -42,17 +40,7 @@ export const updateKeymapExtension = (view: any): void => {
KeymapManager.updateKeymap(view, keybindingStore.keyBindings, enabledExtensionIds);
};
/**
* 获取指定扩展的快捷键
* @param extensionId 扩展ID
* @returns 该扩展的快捷键列表
*/
export const getExtensionKeyBindings = (extensionId: ExtensionID) => {
const keybindingStore = useKeybindingStore();
return keybindingStore.getKeyBindingsByExtension(extensionId);
};
// 导出相关模块
export { KeymapManager } from './keymapManager';
export { commandRegistry, getCommandHandler, getCommandDescription, isCommandRegistered, getRegisteredCommands } from './commandRegistry';
export { commands, getCommandHandler, getCommandDescription, isCommandRegistered, getRegisteredCommands } from './commands';
export type { KeyBinding, CommandHandler, CommandDefinition, KeymapResult } from './types';

View File

@@ -2,7 +2,7 @@ import {keymap} from '@codemirror/view';
import {Extension, Compartment} from '@codemirror/state';
import {KeyBinding as KeyBindingConfig, ExtensionID} from '@/../bindings/voidraft/internal/models/models';
import {KeyBinding, KeymapResult} from './types';
import {getCommandHandler, isCommandRegistered} from './commandRegistry';
import {getCommandHandler, isCommandRegistered} from './commands';
/**
* 快捷键管理器
@@ -82,45 +82,4 @@ export class KeymapManager {
effects: this.compartment.reconfigure(keymap.of(cmKeyBindings))
});
}
/**
* 按扩展分组快捷键
* @param keyBindings 快捷键配置列表
* @returns 按扩展分组的快捷键映射
*/
static groupByExtension(keyBindings: KeyBindingConfig[]): Map<ExtensionID, KeyBindingConfig[]> {
const groups = new Map<ExtensionID, KeyBindingConfig[]>();
for (const binding of keyBindings) {
if (!groups.has(binding.extension)) {
groups.set(binding.extension, []);
}
groups.get(binding.extension)!.push(binding);
}
return groups;
}
/**
* 验证快捷键配置
* @param keyBindings 快捷键配置列表
* @returns 验证结果
*/
static validateKeyBindings(keyBindings: KeyBindingConfig[]): {
valid: KeyBindingConfig[]
invalid: KeyBindingConfig[]
} {
const valid: KeyBindingConfig[] = [];
const invalid: KeyBindingConfig[] = [];
for (const binding of keyBindings) {
if (binding.enabled && binding.key && isCommandRegistered(binding.command)) {
valid.push(binding);
} else {
invalid.push(binding);
}
}
return {valid, invalid};
}
}

View File

@@ -1,53 +1,10 @@
import {Compartment, Extension, StateEffect} from '@codemirror/state';
import {Compartment, Extension} from '@codemirror/state';
import {EditorView} from '@codemirror/view';
import {Extension as ExtensionConfig, ExtensionID} from '@/../bindings/voidraft/internal/models/models';
import {ExtensionState, EditorViewInfo, ExtensionFactory} from './types'
import {createDebounce} from '@/common/utils/debounce';
/**
*
*
*/
export interface ExtensionFactory {
/**
*
* @param config
* @returns CodeMirror扩展
*/
create(config: any): Extension
/**
*
* @returns
*/
getDefaultConfig(): any
/**
*
* @param config
* @returns
*/
validateConfig?(config: any): boolean
}
/**
*
*/
interface ExtensionState {
id: ExtensionID
factory: ExtensionFactory
config: any
enabled: boolean
compartment: Compartment
extension: Extension
}
/**
*
*/
interface EditorViewInfo {
view: EditorView
documentId: number
registered: boolean
}
/**
*
@@ -66,8 +23,11 @@ export class ExtensionManager {
private extensionFactories = new Map<ExtensionID, ExtensionFactory>();
// 防抖处理
private debounceTimers = new Map<ExtensionID, number>();
private debounceDelay = 300; // 默认防抖时间为300毫秒
private debouncedUpdateFunctions = new Map<ExtensionID, {
debouncedFn: (enabled: boolean, config: any) => void;
cancel: () => void;
flush: () => void;
}>();
/**
*
@@ -91,6 +51,22 @@ export class ExtensionManager {
extension: [] // 默认为空扩展(禁用状态)
});
}
// 为每个扩展创建防抖函数
if (!this.debouncedUpdateFunctions.has(id)) {
const { debouncedFn, cancel, flush } = createDebounce(
(enabled: boolean, config: any) => {
this.updateExtensionImmediate(id, enabled, config);
},
{ delay: 300 }
);
this.debouncedUpdateFunctions.set(id, {
debouncedFn,
cancel,
flush
});
}
}
/**
@@ -197,18 +173,13 @@ export class ExtensionManager {
* @param config
*/
updateExtension(id: ExtensionID, enabled: boolean, config: any = {}): void {
// 清除之前的定时器
if (this.debounceTimers.has(id)) {
window.clearTimeout(this.debounceTimers.get(id));
}
// 设置新的定时器
const timerId = window.setTimeout(() => {
const debouncedUpdate = this.debouncedUpdateFunctions.get(id);
if (debouncedUpdate) {
debouncedUpdate.debouncedFn(enabled, config);
} else {
// 如果没有防抖函数,直接执行
this.updateExtensionImmediate(id, enabled, config);
this.debounceTimers.delete(id);
}, this.debounceDelay);
this.debounceTimers.set(id, timerId);
}
}
/**
@@ -268,72 +239,6 @@ export class ExtensionManager {
}
}
/**
*
* @param updates
*/
updateExtensions(updates: Array<{
id: ExtensionID
enabled: boolean
config: any
}>): void {
// 清除所有相关的防抖定时器
for (const update of updates) {
if (this.debounceTimers.has(update.id)) {
window.clearTimeout(this.debounceTimers.get(update.id));
this.debounceTimers.delete(update.id);
}
}
// 更新所有扩展状态
for (const update of updates) {
// 获取扩展状态
const state = this.extensionStates.get(update.id);
if (!state) continue;
// 获取工厂
const factory = state.factory;
// 验证配置
if (factory.validateConfig && !factory.validateConfig(update.config)) {
continue;
}
try {
// 创建新的扩展实例
const extension = update.enabled ? factory.create(update.config) : [];
// 更新内部状态
state.config = update.config;
state.enabled = update.enabled;
state.extension = extension;
} catch (error) {
console.error(`Failed to update extension ${update.id}:`, error);
}
}
// 将更改应用到所有视图
for (const viewInfo of this.viewsMap.values()) {
if (!viewInfo.registered) continue;
const effects: StateEffect<any>[] = [];
for (const update of updates) {
const state = this.extensionStates.get(update.id);
if (!state) continue;
effects.push(state.compartment.reconfigure(state.extension));
}
if (effects.length > 0) {
try {
viewInfo.view.dispatch({ effects });
} catch (error) {
console.error(`Failed to apply extensions to document ${viewInfo.documentId}:`, error);
}
}
}
}
/**
*
@@ -380,15 +285,15 @@ export class ExtensionManager {
*
*/
destroy(): void {
// 清除所有防抖定时器
for (const timerId of this.debounceTimers.values()) {
window.clearTimeout(timerId);
// 清除所有防抖函数
for (const { cancel } of this.debouncedUpdateFunctions.values()) {
cancel();
}
this.debounceTimers.clear();
this.debouncedUpdateFunctions.clear();
this.viewsMap.clear();
this.activeViewId = null;
this.extensionFactories.clear();
this.extensionStates.clear();
}
}
}

View File

@@ -1,6 +1,7 @@
import {ExtensionFactory, ExtensionManager} from './ExtensionManager';
import {ExtensionManager} from './extensionManager';
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
import i18n from '@/i18n';
import {ExtensionFactory} from './types'
// 导入现有扩展的创建函数
import rainbowBracketsExtension from '../extensions/rainbowBracket/rainbowBracketsExtension';
@@ -158,14 +159,12 @@ export const checkboxFactory: ExtensionFactory = {
export const translatorFactory: ExtensionFactory = {
create(config: any) {
return createTranslatorExtension({
defaultTranslator: config.defaultTranslator || 'bing',
minSelectionLength: config.minSelectionLength || 2,
maxTranslationLength: config.maxTranslationLength || 5000,
});
},
getDefaultConfig() {
return {
defaultTranslator: 'bing',
minSelectionLength: 2,
maxTranslationLength: 5000,
};

View File

@@ -1,8 +1,8 @@
import {Extension} from '@codemirror/state';
import {EditorView} from '@codemirror/view';
import {useExtensionStore} from '@/stores/extensionStore';
import {ExtensionManager} from './ExtensionManager';
import {registerAllExtensions} from './factories';
import {ExtensionManager} from './extensionManager';
import {registerAllExtensions} from './extensions';
/**
* 全局扩展管理器实例
@@ -58,6 +58,5 @@ export const removeExtensionManagerView = (documentId: number): void => {
};
// 导出相关模块
export {ExtensionManager} from './ExtensionManager';
export {registerAllExtensions, getExtensionDisplayName, getExtensionDescription} from './factories';
export type {ExtensionFactory} from './ExtensionManager';
export {ExtensionManager} from './extensionManager';
export {registerAllExtensions, getExtensionDisplayName, getExtensionDescription} from './extensions';

View File

@@ -0,0 +1,49 @@
import {Compartment, Extension} from '@codemirror/state';
import {EditorView} from '@codemirror/view';
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
/**
* 扩展工厂接口
* 每个扩展需要实现此接口来创建和配置扩展
*/
export interface ExtensionFactory {
/**
* 创建扩展实例
* @param config 扩展配置
* @returns CodeMirror扩展
*/
create(config: any): Extension
/**
* 获取默认配置
* @returns 默认配置对象
*/
getDefaultConfig(): any
/**
* 验证配置
* @param config 配置对象
* @returns 是否有效
*/
validateConfig?(config: any): boolean
}
/**
* 扩展状态
*/
export interface ExtensionState {
id: ExtensionID
factory: ExtensionFactory
config: any
enabled: boolean
compartment: Compartment
extension: Extension
}
/**
* 视图信息
*/
export interface EditorViewInfo {
view: EditorView
documentId: number
registered: boolean
}

View File

@@ -8,12 +8,39 @@ import SettingItem from '../components/SettingItem.vue';
import { SystemThemeType, LanguageType } from '@/../bindings/voidraft/internal/models/models';
import { defaultDarkColors } from '@/views/editor/theme/dark';
import { defaultLightColors } from '@/views/editor/theme/light';
import { createDebounce } from '@/common/utils/debounce';
import { createTimerManager } from '@/common/utils/timerUtils';
import PickColors from 'vue-pick-colors';
const { t } = useI18n();
const configStore = useConfigStore();
const themeStore = useThemeStore();
// 创建防抖函数实例
const { debouncedFn: debouncedUpdateColor } = createDebounce(
(colorKey: string, value: string) => updateLocalColor(colorKey, value),
{ delay: 100 }
);
const { debouncedFn: debouncedResetTheme } = createDebounce(
async () => {
const themeType = activeThemeType.value;
const success = await themeStore.resetThemeColors(themeType);
if (success) {
tempColors.value = {
darkTheme: { ...themeStore.themeColors.darkTheme },
lightTheme: { ...themeStore.themeColors.lightTheme }
};
hasUnsavedChanges.value = false;
}
},
{ delay: 300 }
);
// 创建定时器管理器
const resetTimer = createTimerManager();
// 添加临时颜色状态
const tempColors = ref({
darkTheme: { ...defaultDarkColors },
@@ -25,36 +52,19 @@ const hasUnsavedChanges = ref(false);
// 重置按钮状态
const resetButtonState = ref({
confirming: false,
timer: null as number | null
confirming: false
});
// 防抖函数
const debounce = <T extends (...args: any[]) => any>(
func: T,
wait: number
): ((...args: Parameters<T>) => void) => {
let timeout: number | undefined;
return function(...args: Parameters<T>): void {
clearTimeout(timeout);
timeout = window.setTimeout(() => {
func(...args);
}, wait);
};
};
// 当前激活的主题类型
const isDarkMode = computed(() =>
themeStore.currentTheme === SystemThemeType.SystemThemeDark ||
(themeStore.currentTheme === SystemThemeType.SystemThemeAuto &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
);
// 当前激活的主题类型(基于当前系统主题)
const activeThemeType = computed(() => {
const isDark =
themeStore.currentTheme === SystemThemeType.SystemThemeDark ||
(themeStore.currentTheme === SystemThemeType.SystemThemeAuto &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
return isDark ? 'darkTheme' : 'lightTheme';
});
const activeThemeType = computed(() => isDarkMode.value ? 'darkTheme' : 'lightTheme');
// 当前主题的颜色配置 - 使用临时状态
// 当前主题的颜色配置
const currentColors = computed(() => {
const themeType = activeThemeType.value;
return tempColors.value[themeType] ||
@@ -62,144 +72,85 @@ const currentColors = computed(() => {
});
// 获取当前主题模式
const currentThemeMode = computed(() => {
const isDark =
themeStore.currentTheme === SystemThemeType.SystemThemeDark ||
(themeStore.currentTheme === SystemThemeType.SystemThemeAuto &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
return isDark ? 'dark' : 'light';
});
const currentThemeMode = computed(() => isDarkMode.value ? 'dark' : 'light');
// 监听主题颜色变更,更新临时颜色
// 监听主题颜色变更,
watch(
() => themeStore.themeColors,
(newValue) => {
if (!hasUnsavedChanges.value) {
tempColors.value = {
darkTheme: { ...newValue.darkTheme },
lightTheme: { ...newValue.lightTheme }
};
tempColors.value.darkTheme = { ...newValue.darkTheme };
tempColors.value.lightTheme = { ...newValue.lightTheme };
}
},
{ deep: true, immediate: true }
);
// 初始化时加载主题颜色
onMounted(() => {
// 使用themeStore中的颜色作为初始值
tempColors.value = {
darkTheme: { ...themeStore.themeColors.darkTheme },
lightTheme: { ...themeStore.themeColors.lightTheme }
};
});
// 颜色配置分组
const colorGroups = computed(() => [
// 颜色配置
const colorConfig = [
{
key: 'basic',
title: t('settings.themeColors.basic'),
colors: [
{ key: 'background', label: t('settings.themeColors.background') },
{ key: 'backgroundSecondary', label: t('settings.themeColors.backgroundSecondary') },
{ key: 'surface', label: t('settings.themeColors.surface') }
]
colors: ['background', 'backgroundSecondary', 'surface']
},
{
key: 'text',
title: t('settings.themeColors.text'),
colors: [
{ key: 'foreground', label: t('settings.themeColors.foreground') },
{ key: 'foregroundSecondary', label: t('settings.themeColors.foregroundSecondary') },
{ key: 'comment', label: t('settings.themeColors.comment') }
]
key: 'text',
colors: ['foreground', 'foregroundSecondary', 'comment']
},
{
key: 'syntax',
title: t('settings.themeColors.syntax'),
colors: [
{ key: 'keyword', label: t('settings.themeColors.keyword') },
{ key: 'string', label: t('settings.themeColors.string') },
{ key: 'function', label: t('settings.themeColors.function') },
{ key: 'number', label: t('settings.themeColors.number') },
{ key: 'operator', label: t('settings.themeColors.operator') },
{ key: 'variable', label: t('settings.themeColors.variable') },
{ key: 'type', label: t('settings.themeColors.type') }
]
colors: ['keyword', 'string', 'function', 'number', 'operator', 'variable', 'type']
},
{
key: 'interface',
title: t('settings.themeColors.interface'),
colors: [
{ key: 'cursor', label: t('settings.themeColors.cursor') },
{ key: 'selection', label: t('settings.themeColors.selection') },
{ key: 'selectionBlur', label: t('settings.themeColors.selectionBlur') },
{ key: 'activeLine', label: t('settings.themeColors.activeLine') },
{ key: 'lineNumber', label: t('settings.themeColors.lineNumber') },
{ key: 'activeLineNumber', label: t('settings.themeColors.activeLineNumber') }
]
colors: ['cursor', 'selection', 'selectionBlur', 'activeLine', 'lineNumber', 'activeLineNumber']
},
{
key: 'border',
title: t('settings.themeColors.border'),
colors: [
{ key: 'borderColor', label: t('settings.themeColors.borderColor') },
{ key: 'borderLight', label: t('settings.themeColors.borderLight') }
]
colors: ['borderColor', 'borderLight']
},
{
key: 'search',
title: t('settings.themeColors.search'),
colors: [
{ key: 'searchMatch', label: t('settings.themeColors.searchMatch') },
{ key: 'matchingBracket', label: t('settings.themeColors.matchingBracket') }
]
colors: ['searchMatch', 'matchingBracket']
}
]);
];
// 颜色配置分组
const colorGroups = computed(() =>
colorConfig.map(group => ({
key: group.key,
title: t(`settings.themeColors.${group.key}`),
colors: group.colors.map(colorKey => ({
key: colorKey,
label: t(`settings.themeColors.${colorKey}`)
}))
}))
);
// 处理重置按钮点击
const handleResetClick = () => {
if (resetButtonState.value.confirming) {
// 如果已经在确认状态,执行重置操作
resetCurrentTheme();
// 重置按钮状态
debouncedResetTheme();
resetButtonState.value.confirming = false;
if (resetButtonState.value.timer !== null) {
clearTimeout(resetButtonState.value.timer);
resetButtonState.value.timer = null;
}
resetTimer.clear();
} else {
// 进入确认状态
resetButtonState.value.confirming = true;
// 设置3秒后自动恢复
resetButtonState.value.timer = window.setTimeout(() => {
resetTimer.set(() => {
resetButtonState.value.confirming = false;
resetButtonState.value.timer = null;
}, 3000);
}
};
// 重置当前主题为默认配置
const resetCurrentTheme = debounce(async () => {
// 使用themeStore的原子重置操作
const themeType = activeThemeType.value;
const success = await themeStore.resetThemeColors(themeType);
if (success) {
// 更新临时颜色状态
tempColors.value = {
darkTheme: { ...themeStore.themeColors.darkTheme },
lightTheme: { ...themeStore.themeColors.lightTheme }
};
// 标记没有未保存的更改
hasUnsavedChanges.value = false;
}
}, 300);
// 更新本地颜色配置 - 仅更新临时状态,不提交到后端
// 更新本地颜色配置
const updateLocalColor = (colorKey: string, value: string) => {
const themeType = activeThemeType.value;
@@ -211,14 +162,10 @@ const updateLocalColor = (colorKey: string, value: string) => {
[colorKey]: value
}
};
// 标记有未保存的更改
hasUnsavedChanges.value = true;
};
// 防抖包装的颜色更新函数
const updateColor = debounce(updateLocalColor, 100);
// 应用颜色更改到系统
const applyChanges = async () => {
try {
@@ -290,7 +237,7 @@ const showPickerMap = ref<Record<string, boolean>>({});
// 颜色变更处理
const handleColorChange = (colorKey: string, value: string) => {
updateColor(colorKey, value);
debouncedUpdateColor(colorKey, value);
};
// 颜色选择器关闭处理
@@ -374,7 +321,7 @@ const handlePickerClose = () => {
<input
type="text"
:value="currentColors[color.key] || ''"
@input="updateColor(color.key, ($event.target as HTMLInputElement).value)"
@input="debouncedUpdateColor(color.key, ($event.target as HTMLInputElement).value)"
class="color-text-input"
:placeholder="t('settings.colorValue')"
/>

View File

@@ -2,7 +2,7 @@
import {useConfigStore} from '@/stores/configStore';
import {useBackupStore} from '@/stores/backupStore';
import {useI18n} from 'vue-i18n';
import {computed, onMounted, onUnmounted} from 'vue';
import {computed, onUnmounted} from 'vue';
import SettingSection from '../components/SettingSection.vue';
import SettingItem from '../components/SettingItem.vue';
import ToggleSwitch from '../components/ToggleSwitch.vue';
@@ -13,14 +13,8 @@ const {t} = useI18n();
const configStore = useConfigStore();
const backupStore = useBackupStore();
// 确保配置已加载
onMounted(async () => {
if (!configStore.configLoaded) {
await configStore.initConfig();
}
});
onUnmounted(() => {
backupStore.clearError();
backupStore.clearStatus();
});
// 认证方式选项
@@ -142,6 +136,11 @@ const pushToRemote = async () => {
await backupStore.pushToRemote();
};
// 重试备份
const retryBackup = async () => {
await backupStore.retryBackup();
};
// 选择SSH密钥文件
const selectSshKeyFile = async () => {
// 使用DialogService选择文件
@@ -311,8 +310,8 @@ const selectSshKeyFile = async () => {
>
<div class="backup-operation-container">
<div class="backup-status-icons">
<span v-if="backupStore.pushSuccess" class="success-icon"></span>
<span v-if="backupStore.pushError" class="error-icon"></span>
<span v-if="backupStore.isSuccess" class="success-icon"></span>
<span v-if="backupStore.isError" class="error-icon"></span>
</div>
<button
class="push-button"
@@ -323,10 +322,18 @@ const selectSshKeyFile = async () => {
<span v-if="backupStore.isPushing" class="loading-spinner"></span>
{{ backupStore.isPushing ? t('settings.backup.pushing') : t('settings.backup.actions.push') }}
</button>
<button
v-if="backupStore.isError"
class="retry-button"
@click="() => retryBackup()"
:disabled="backupStore.isPushing"
>
{{ t('settings.backup.actions.retry') }}
</button>
</div>
</SettingItem>
<div v-if="backupStore.error" class="error-message-row">
{{ backupStore.error }}
<div v-if="backupStore.errorMessage" class="error-message-row">
{{ backupStore.errorMessage }}
</div>
</SettingSection>
</div>
@@ -428,7 +435,8 @@ const selectSshKeyFile = async () => {
}
// 按钮样式
.push-button {
.push-button,
.retry-button {
padding: 8px 16px;
background-color: var(--settings-input-bg);
border: 1px solid var(--settings-input-border);
@@ -472,6 +480,17 @@ const selectSshKeyFile = async () => {
}
}
.retry-button {
background-color: #ff9800;
border-color: #ff9800;
color: white;
&:hover:not(:disabled) {
background-color: #f57c00;
border-color: #f57c00;
}
}
// 错误信息行样式
.error-message-row {
color: #f44336;

View File

@@ -11,7 +11,7 @@ import {
getExtensionDescription,
getExtensionDisplayName,
hasExtensionConfig
} from '@/views/editor/manager/factories';
} from '@/views/editor/manager/extensions';
import SettingSection from '../components/SettingSection.vue';
import SettingItem from '../components/SettingItem.vue';
import ToggleSwitch from '../components/ToggleSwitch.vue';

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import {useConfigStore} from '@/stores/configStore';
import {useTabStore} from '@/stores/tabStore';
import {useI18n} from 'vue-i18n';
import {computed, onUnmounted, ref} from 'vue';
import SettingSection from '../components/SettingSection.vue';
@@ -11,11 +12,12 @@ import {
MigrationService,
MigrationStatus
} from '@/../bindings/voidraft/internal/services';
import * as runtime from '@wailsio/runtime';
import {useSystemStore} from "@/stores/systemStore";
const {t} = useI18n();
const configStore = useConfigStore();
const systemStore = useSystemStore();
const tabStore = useTabStore();
// 迁移进度状态
const migrationProgress = ref<MigrationProgress>(new MigrationProgress({
status: MigrationStatus.MigrationStatusCompleted,
@@ -148,8 +150,7 @@ const alwaysOnTop = computed({
set: async (value: boolean) => {
// 先更新配置
await configStore.setAlwaysOnTop(value);
// 然后立即应用窗口置顶状态
await runtime.Window.SetAlwaysOnTop(value);
await systemStore.setWindowOnTop(value);
}
});
@@ -171,6 +172,21 @@ const enableLoadingAnimation = computed({
set: (value: boolean) => configStore.setEnableLoadingAnimation(value)
});
// 计算属性 - 启用标签页
const enableTabs = computed({
get: () => configStore.config.general.enableTabs,
set: async (value: boolean) => {
await configStore.setEnableTabs(value);
if (value) {
// 开启tabs功能时初始化当前文档到标签页
tabStore.initializeTab();
} else {
// 关闭tabs功能时清空所有标签页
tabStore.clearAllTabs();
}
}
});
// 计算属性 - 开机启动
const startAtLogin = computed({
get: () => configStore.config.general.startAtLogin,
@@ -352,6 +368,9 @@ onUnmounted(() => {
<SettingItem :title="t('settings.enableLoadingAnimation')">
<ToggleSwitch v-model="enableLoadingAnimation"/>
</SettingItem>
<SettingItem :title="t('settings.enableTabs')">
<ToggleSwitch v-model="enableTabs"/>
</SettingItem>
</SettingSection>
<SettingSection :title="t('settings.startup')">
@@ -734,4 +753,4 @@ onUnmounted(() => {
opacity: 0;
transform: translateY(-4px);
}
</style>
</style>

View File

@@ -5,7 +5,7 @@ import SettingSection from '../components/SettingSection.vue';
import { useKeybindingStore } from '@/stores/keybindingStore';
import { useExtensionStore } from '@/stores/extensionStore';
import { useSystemStore } from '@/stores/systemStore';
import { getCommandDescription } from '@/views/editor/keymap/commandRegistry';
import { getCommandDescription } from '@/views/editor/keymap/commands';
import {KeyBindingCommand} from "@/../bindings/voidraft/internal/models";
const { t } = useI18n();
@@ -61,7 +61,7 @@ const parseKeyBinding = (keyStr: string, command?: string): string[] => {
if (systemStore.isMacOS) {
return ['⌘', '⌥', '[']; // macOS: Cmd+Alt+[
} else {
return ['Ctrl', '', '[']; // Windows/Linux: Ctrl+Shift+[
return ['Ctrl', 'Shift', '[']; // Windows/Linux: Ctrl+Shift+[
}
}
@@ -69,7 +69,7 @@ const parseKeyBinding = (keyStr: string, command?: string): string[] => {
if (systemStore.isMacOS) {
return ['⌘', '⌥', ']']; // macOS: Cmd+Alt+]
} else {
return ['Ctrl', '', ']']; // Windows/Linux: Ctrl+Shift+]
return ['Ctrl', 'Shift', ']']; // Windows/Linux: Ctrl+Shift+]
}
}
@@ -142,7 +142,7 @@ const parseKeyBinding = (keyStr: string, command?: string): string[] => {
if (systemStore.isMacOS) {
return ['⇧', '⌘', '\\']; // macOS: Shift+Cmd+\
} else {
return ['', 'Ctrl', '\\']; // Windows/Linux: Shift+Ctrl+\
return ['Shift', 'Ctrl', '\\']; // Windows/Linux: Shift+Ctrl+\
}
}
@@ -300,4 +300,4 @@ const parseKeyBinding = (keyStr: string, command?: string): string[] => {
font-style: italic;
font-size: 13px;
}
</style>
</style>

View File

@@ -1,17 +1,22 @@
<script setup lang="ts">
import {useI18n} from 'vue-i18n';
import {computed} from 'vue';
import {useConfigStore} from '@/stores/configStore';
import {useUpdateStore} from '@/stores/updateStore';
import { useI18n } from 'vue-i18n';
import { computed, onUnmounted } from 'vue';
import { useConfigStore } from '@/stores/configStore';
import { useUpdateStore } from '@/stores/updateStore';
import SettingSection from '../components/SettingSection.vue';
import SettingItem from '../components/SettingItem.vue';
import ToggleSwitch from '../components/ToggleSwitch.vue';
import { Remarkable } from 'remarkable';
const {t} = useI18n();
const { t } = useI18n();
const configStore = useConfigStore();
const updateStore = useUpdateStore();
// 清理状态
onUnmounted(() => {
updateStore.clearStatus();
});
// 初始化Remarkable实例并配置
const md = new Remarkable({
html: true, // 允许HTML
@@ -93,13 +98,21 @@ const currentVersion = computed(() => {
</SettingItem>
<!-- 检查结果 -->
<div class="check-results" v-if="updateStore.updateResult || updateStore.errorMessage">
<div class="check-results" v-if="updateStore.updateResult || updateStore.isError">
<!-- 错误信息 -->
<div v-if="updateStore.errorMessage" class="result-item error-result">
<div v-if="updateStore.isError" class="result-item error-result">
<div class="result-text">
<span class="result-icon"></span>
<div class="result-message">{{ updateStore.errorMessage }}</div>
</div>
<button
v-if="updateStore.isError"
class="retry-button"
@click="updateStore.checkForUpdates"
:disabled="updateStore.isChecking"
>
{{ t('common.retry') }}
</button>
</div>
<!-- 更新成功 -->
@@ -128,7 +141,7 @@ const currentVersion = computed(() => {
</div>
<!-- 已是最新版本 -->
<div v-else-if="updateStore.updateResult && !updateStore.hasUpdate && !updateStore.errorMessage"
<div v-else-if="updateStore.updateResult && !updateStore.hasUpdate && !updateStore.isError"
class="result-item latest-version">
<div class="result-text">
<span class="result-icon"></span>
@@ -232,6 +245,28 @@ const currentVersion = computed(() => {
overflow: visible;
padding-right: 8px; // 添加右侧内边距,防止文本贴近容器边缘
}
.retry-button {
margin-top: 8px;
padding: 6px 12px;
background-color: #ff9800;
border: 1px solid #ff9800;
border-radius: 4px;
color: white;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background-color: #f57c00;
border-color: #f57c00;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
}
@@ -421,4 +456,4 @@ const currentVersion = computed(() => {
padding: 6px 12px;
}
}
</style>
</style>

2
go.mod
View File

@@ -9,7 +9,6 @@ require (
github.com/knadh/koanf/providers/file v1.2.0
github.com/knadh/koanf/providers/structs v1.0.0
github.com/knadh/koanf/v2 v2.2.2
github.com/robertkrimen/otto v0.5.1
github.com/stretchr/testify v1.10.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.25
golang.org/x/net v0.43.0
@@ -82,7 +81,6 @@ require (
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/time v0.12.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/sourcemap.v1 v1.0.5 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.66.7 // indirect

4
go.sum
View File

@@ -144,8 +144,6 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/robertkrimen/otto v0.5.1 h1:avDI4ToRk8k1hppLdYFTuuzND41n37vPGJU7547dGf0=
github.com/robertkrimen/otto v0.5.1/go.mod h1:bS433I4Q9p+E5pZLu7r17vP6FkE6/wLxBdmKjoqJXF8=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI=
@@ -230,8 +228,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@@ -2,15 +2,14 @@
package translator
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"strings"
"time"
@@ -21,68 +20,61 @@ import (
type BingTranslator struct {
httpClient *http.Client // HTTP客户端
Timeout time.Duration // 请求超时时间
session *BingSession // Bing会话
token *tokenInfo // Token信息
languages map[string]LanguageInfo // 支持的语言列表
}
// BingSession 保持Bing翻译会话状态
type BingSession struct {
Cookie map[string]string // 会话Cookie
Headers map[string]string // 会话请求头
Token string // 翻译Token
Key string // 翻译Key
IG string // IG参数
// tokenInfo 存储token信息
type tokenInfo struct {
Value string
ExpiresAt time.Time
}
// TranslateRequest 翻译请求结构
type TranslateRequest struct {
Text string `json:"Text"`
}
// TranslateResponse 翻译响应结构
type TranslateResponse struct {
Translations []struct {
Text string `json:"text"`
} `json:"translations"`
}
// ErrorResponse 错误响应结构
type ErrorResponse struct {
Error struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// 常量定义
const (
bingDefaultTimeout = 30 * time.Second
bingTranslatorURL = "https://cn.bing.com/translator"
bingTranslateAPIURL = "https://cn.bing.com/ttranslatev3"
bingTokenURL = "https://edge.microsoft.com/translate/auth"
bingTranslateAPIURL = "https://api-edge.cognitive.microsofttranslator.com/translate"
tokenValidDuration = 25 * time.Minute // Token有效期比实际30分钟稍短以确保安全
)
// 错误定义
var (
ErrBingNetworkError = errors.New("bing translator network error")
ErrBingParseError = errors.New("bing translator parse error")
ErrBingTokenError = errors.New("failed to get bing translator token")
ErrBingEmptyResponse = errors.New("empty response from bing translator")
ErrBingRateLimit = errors.New("bing translator rate limit reached")
)
// 用户代理列表
var userAgents = []string{
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
}
// NewBingTranslator 创建一个新的Bing翻译器实例
func NewBingTranslator() *BingTranslator {
// 初始化随机数种子
rand.New(rand.NewSource(time.Now().UnixNano()))
// 创建带Cookie存储的HTTP客户端
jar, _ := cookiejar.New(nil)
translator := &BingTranslator{
httpClient: &http.Client{
Timeout: bingDefaultTimeout,
// 启用Cookie存储
Jar: jar,
},
Timeout: bingDefaultTimeout,
session: &BingSession{
Headers: make(map[string]string),
Cookie: make(map[string]string),
},
Timeout: bingDefaultTimeout,
languages: initBingLanguages(),
}
// 初始化会话
translator.refreshSession()
return translator
}
@@ -91,129 +83,13 @@ func initBingLanguages() map[string]LanguageInfo {
// 创建语言映射表
languages := make(map[string]LanguageInfo)
// 添加所有支持的语言
// 基于 Microsoft Translator 支持的语言列表
// 参考: https://learn.microsoft.com/en-us/azure/ai-services/translator/language-support
// 添加支持的语言
// 基于 Microsoft Translator API 支持的语言列表
// 参考: https://docs.microsoft.com/en-us/azure/cognitive-services/translator/language-support
// 常用语言
languages["en"] = LanguageInfo{Code: "en", Name: "English"}
languages["zh-Hans"] = LanguageInfo{Code: "zh-Hans", Name: "Chinese Simplified"}
languages["zh-Hant"] = LanguageInfo{Code: "zh-Hant", Name: "Chinese Traditional"}
languages["ja"] = LanguageInfo{Code: "ja", Name: "Japanese"}
languages["ko"] = LanguageInfo{Code: "ko", Name: "Korean"}
languages["fr"] = LanguageInfo{Code: "fr", Name: "French"}
languages["fr-ca"] = LanguageInfo{Code: "fr-ca", Name: "French (Canada)"}
languages["de"] = LanguageInfo{Code: "de", Name: "German"}
languages["es"] = LanguageInfo{Code: "es", Name: "Spanish"}
languages["ru"] = LanguageInfo{Code: "ru", Name: "Russian"}
languages["pt"] = LanguageInfo{Code: "pt", Name: "Portuguese (Brazil)"}
languages["pt-br"] = LanguageInfo{Code: "pt-br", Name: "Portuguese (Brazil)"}
languages["pt-pt"] = LanguageInfo{Code: "pt-pt", Name: "Portuguese (Portugal)"}
languages["it"] = LanguageInfo{Code: "it", Name: "Italian"}
languages["ar"] = LanguageInfo{Code: "ar", Name: "Arabic"}
// 特殊语言
languages["yue"] = LanguageInfo{Code: "yue", Name: "Cantonese (Traditional)"}
languages["lzh"] = LanguageInfo{Code: "lzh", Name: "Chinese (Literary)"}
// 其他语言
languages["af"] = LanguageInfo{Code: "af", Name: "Afrikaans"}
languages["am"] = LanguageInfo{Code: "am", Name: "Amharic"}
languages["as"] = LanguageInfo{Code: "as", Name: "Assamese"}
languages["az"] = LanguageInfo{Code: "az", Name: "Azerbaijani (Latin)"}
languages["ba"] = LanguageInfo{Code: "ba", Name: "Bashkir"}
languages["bg"] = LanguageInfo{Code: "bg", Name: "Bulgarian"}
languages["bn"] = LanguageInfo{Code: "bn", Name: "Bangla"}
languages["bo"] = LanguageInfo{Code: "bo", Name: "Tibetan"}
languages["bs"] = LanguageInfo{Code: "bs", Name: "Bosnian (Latin)"}
languages["ca"] = LanguageInfo{Code: "ca", Name: "Catalan"}
languages["cs"] = LanguageInfo{Code: "cs", Name: "Czech"}
languages["cy"] = LanguageInfo{Code: "cy", Name: "Welsh"}
languages["da"] = LanguageInfo{Code: "da", Name: "Danish"}
languages["dv"] = LanguageInfo{Code: "dv", Name: "Divehi"}
languages["el"] = LanguageInfo{Code: "el", Name: "Greek"}
languages["et"] = LanguageInfo{Code: "et", Name: "Estonian"}
languages["eu"] = LanguageInfo{Code: "eu", Name: "Basque"}
languages["fa"] = LanguageInfo{Code: "fa", Name: "Persian"}
languages["fi"] = LanguageInfo{Code: "fi", Name: "Finnish"}
languages["fil"] = LanguageInfo{Code: "fil", Name: "Filipino"}
languages["fj"] = LanguageInfo{Code: "fj", Name: "Fijian"}
languages["fo"] = LanguageInfo{Code: "fo", Name: "Faroese"}
languages["ga"] = LanguageInfo{Code: "ga", Name: "Irish"}
languages["gl"] = LanguageInfo{Code: "gl", Name: "Galician"}
languages["gu"] = LanguageInfo{Code: "gu", Name: "Gujarati"}
languages["ha"] = LanguageInfo{Code: "ha", Name: "Hausa"}
languages["he"] = LanguageInfo{Code: "he", Name: "Hebrew"}
languages["hi"] = LanguageInfo{Code: "hi", Name: "Hindi"}
languages["hr"] = LanguageInfo{Code: "hr", Name: "Croatian"}
languages["ht"] = LanguageInfo{Code: "ht", Name: "Haitian Creole"}
languages["hu"] = LanguageInfo{Code: "hu", Name: "Hungarian"}
languages["hy"] = LanguageInfo{Code: "hy", Name: "Armenian"}
languages["id"] = LanguageInfo{Code: "id", Name: "Indonesian"}
languages["ig"] = LanguageInfo{Code: "ig", Name: "Igbo"}
languages["is"] = LanguageInfo{Code: "is", Name: "Icelandic"}
languages["ka"] = LanguageInfo{Code: "ka", Name: "Georgian"}
languages["kk"] = LanguageInfo{Code: "kk", Name: "Kazakh"}
languages["km"] = LanguageInfo{Code: "km", Name: "Khmer"}
languages["kn"] = LanguageInfo{Code: "kn", Name: "Kannada"}
languages["ku"] = LanguageInfo{Code: "ku", Name: "Kurdish (Arabic) (Central)"}
languages["ky"] = LanguageInfo{Code: "ky", Name: "Kyrgyz (Cyrillic)"}
languages["lo"] = LanguageInfo{Code: "lo", Name: "Lao"}
languages["lt"] = LanguageInfo{Code: "lt", Name: "Lithuanian"}
languages["lv"] = LanguageInfo{Code: "lv", Name: "Latvian"}
languages["mg"] = LanguageInfo{Code: "mg", Name: "Malagasy"}
languages["mi"] = LanguageInfo{Code: "mi", Name: "Maori"}
languages["mk"] = LanguageInfo{Code: "mk", Name: "Macedonian"}
languages["ml"] = LanguageInfo{Code: "ml", Name: "Malayalam"}
languages["mn-Cyrl"] = LanguageInfo{Code: "mn-Cyrl", Name: "Mongolian (Cyrillic)"}
languages["mr"] = LanguageInfo{Code: "mr", Name: "Marathi"}
languages["ms"] = LanguageInfo{Code: "ms", Name: "Malay (Latin)"}
languages["mt"] = LanguageInfo{Code: "mt", Name: "Maltese"}
languages["mww"] = LanguageInfo{Code: "mww", Name: "Hmong Daw (Latin)"}
languages["my"] = LanguageInfo{Code: "my", Name: "Myanmar (Burmese)"}
languages["nb"] = LanguageInfo{Code: "nb", Name: "Norwegian Bokmål"}
languages["ne"] = LanguageInfo{Code: "ne", Name: "Nepali"}
languages["nl"] = LanguageInfo{Code: "nl", Name: "Dutch"}
languages["or"] = LanguageInfo{Code: "or", Name: "Odia"}
languages["otq"] = LanguageInfo{Code: "otq", Name: "Queretaro Otomi"}
languages["pa"] = LanguageInfo{Code: "pa", Name: "Punjabi"}
languages["pl"] = LanguageInfo{Code: "pl", Name: "Polish"}
languages["prs"] = LanguageInfo{Code: "prs", Name: "Dari"}
languages["ps"] = LanguageInfo{Code: "ps", Name: "Pashto"}
languages["ro"] = LanguageInfo{Code: "ro", Name: "Romanian"}
languages["rw"] = LanguageInfo{Code: "rw", Name: "Kinyarwanda"}
languages["sk"] = LanguageInfo{Code: "sk", Name: "Slovak"}
languages["sl"] = LanguageInfo{Code: "sl", Name: "Slovenian"}
languages["sm"] = LanguageInfo{Code: "sm", Name: "Samoan (Latin)"}
languages["sn"] = LanguageInfo{Code: "sn", Name: "chiShona"}
languages["so"] = LanguageInfo{Code: "so", Name: "Somali"}
languages["sq"] = LanguageInfo{Code: "sq", Name: "Albanian"}
languages["sr-Cyrl"] = LanguageInfo{Code: "sr-Cyrl", Name: "Serbian (Cyrillic)"}
languages["sr"] = LanguageInfo{Code: "sr", Name: "Serbian (Latin)"}
languages["sr-latn"] = LanguageInfo{Code: "sr-latn", Name: "Serbian (Latin)"}
languages["sv"] = LanguageInfo{Code: "sv", Name: "Swedish"}
languages["sw"] = LanguageInfo{Code: "sw", Name: "Swahili (Latin)"}
languages["ta"] = LanguageInfo{Code: "ta", Name: "Tamil"}
languages["te"] = LanguageInfo{Code: "te", Name: "Telugu"}
languages["th"] = LanguageInfo{Code: "th", Name: "Thai"}
languages["ti"] = LanguageInfo{Code: "ti", Name: "Tigrinya"}
languages["tk"] = LanguageInfo{Code: "tk", Name: "Turkmen (Latin)"}
languages["tlh-Latn"] = LanguageInfo{Code: "tlh-Latn", Name: "Klingon"}
languages["tlh-Piqd"] = LanguageInfo{Code: "tlh-Piqd", Name: "Klingon (plqaD)"}
languages["to"] = LanguageInfo{Code: "to", Name: "Tongan"}
languages["tr"] = LanguageInfo{Code: "tr", Name: "Turkish"}
languages["tt"] = LanguageInfo{Code: "tt", Name: "Tatar (Latin)"}
languages["ty"] = LanguageInfo{Code: "ty", Name: "Tahitian"}
languages["ug"] = LanguageInfo{Code: "ug", Name: "Uyghur (Arabic)"}
languages["uk"] = LanguageInfo{Code: "uk", Name: "Ukrainian"}
languages["ur"] = LanguageInfo{Code: "ur", Name: "Urdu"}
languages["uz"] = LanguageInfo{Code: "uz", Name: "Uzbek (Latin)"}
languages["vi"] = LanguageInfo{Code: "vi", Name: "Vietnamese"}
languages["yua"] = LanguageInfo{Code: "yua", Name: "Yucatec Maya"}
languages["zu"] = LanguageInfo{Code: "zu", Name: "Zulu"}
// 添加一些特殊情况的映射
languages["zh"] = LanguageInfo{Code: "zh-Hans", Name: "Chinese Simplified"} // 将zh映射到zh-Hans
languages["zh-Hans"] = LanguageInfo{Code: "zh-Hans", Name: "Chinese (Simplified)"}
languages["zh-Hant"] = LanguageInfo{Code: "zh-Hant", Name: "Chinese (Traditional)"}
return languages
}
@@ -224,91 +100,59 @@ func (t *BingTranslator) SetTimeout(timeout time.Duration) {
t.httpClient.Timeout = timeout
}
// getRandomUserAgent 获取随机用户代理
func getRandomUserAgent() string {
return userAgents[rand.Intn(len(userAgents))]
}
// refreshSession 刷新翻译会话
func (t *BingTranslator) refreshSession() error {
// 设置随机用户代理
userAgent := getRandomUserAgent()
t.session.Headers["User-Agent"] = userAgent
t.session.Headers["Referer"] = bingTranslatorURL
t.session.Headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"
t.session.Headers["Accept-Language"] = "en-US,en;q=0.5"
t.session.Headers["Connection"] = "keep-alive"
t.session.Headers["Upgrade-Insecure-Requests"] = "1"
t.session.Headers["Cache-Control"] = "max-age=0"
// getToken 获取访问token
func (t *BingTranslator) getToken(ctx context.Context) (string, error) {
// 检查token是否有效
if t.token != nil && time.Now().Before(t.token.ExpiresAt) {
return t.token.Value, nil
}
// 创建请求
req, err := http.NewRequest("GET", bingTranslatorURL, nil)
req, err := http.NewRequestWithContext(ctx, "GET", bingTokenURL, nil)
if err != nil {
return fmt.Errorf("the creation request failed: %w", err)
return "", fmt.Errorf("failed to create token request: %w", err)
}
// 设置请求头
for k, v := range t.session.Headers {
req.Header.Set(k, v)
}
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Language", "zh-TW,zh;q=0.9,ja;q=0.8,zh-CN;q=0.7,en-US;q=0.6,en;q=0.5")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
req.Header.Set("Referrer-Policy", "strict-origin-when-cross-origin")
req.Header.Set("Sec-Fetch-Dest", "empty")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Site", "none")
// 发送请求
resp, err := t.httpClient.Do(req)
if err != nil {
return fmt.Errorf("%w: %v", ErrBingNetworkError, err)
return "", fmt.Errorf("failed to get token: %w", err)
}
defer resp.Body.Close()
// 保存Cookie
for _, cookie := range resp.Cookies() {
t.session.Cookie[cookie.Name] = cookie.Value
// 检查状态码
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token request failed with status %d", resp.StatusCode)
}
// 读取响应内容
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response failed: %w", err)
return "", fmt.Errorf("failed to read token response: %w", err)
}
content := string(body)
// 提取参数
// 1. 提取key和token
paramsPattern := regexp.MustCompile(`params_AbusePreventionHelper\s*=\s*(\[.*?\]);`)
paramsMatch := paramsPattern.FindStringSubmatch(content)
if paramsMatch == nil || len(paramsMatch) < 2 {
return fmt.Errorf("%w: params_AbusePreventionHelper could not be extracted", ErrBingTokenError)
token := strings.TrimSpace(string(body))
if token == "" {
return "", fmt.Errorf("empty token received")
}
// 解析参数数组
paramsStr := paramsMatch[1]
paramsStr = strings.ReplaceAll(paramsStr, "[", "")
paramsStr = strings.ReplaceAll(paramsStr, "]", "")
paramsParts := strings.Split(paramsStr, ",")
if len(paramsParts) < 2 {
return fmt.Errorf("%w: params_AbusePreventionHelper format is incorrect", ErrBingTokenError)
// 缓存token
t.token = &tokenInfo{
Value: token,
ExpiresAt: time.Now().Add(tokenValidDuration),
}
// 提取key和token
t.session.Key = strings.Trim(paramsParts[0], `"' `)
t.session.Token = strings.Trim(paramsParts[1], `"' `)
// 2. 提取IG值
igPattern := regexp.MustCompile(`IG:"(\w+)"`)
igMatch := igPattern.FindStringSubmatch(content)
if igMatch == nil || len(igMatch) < 2 {
return fmt.Errorf("%w: Unable to extract IG values", ErrBingTokenError)
}
t.session.IG = igMatch[1]
// 更新会话头部
t.session.Headers["IG"] = t.session.IG
t.session.Headers["key"] = t.session.Key
t.session.Headers["token"] = t.session.Token
return nil
return token, nil
}
// Translate 使用标准语言标签进行文本翻译
@@ -328,139 +172,94 @@ func (t *BingTranslator) TranslateWithParams(text string, params TranslationPara
// translate 执行实际翻译操作
func (t *BingTranslator) translate(text, from, to string) (string, error) {
// 如果没有会话或关键参数缺失,刷新会话
if t.session == nil || t.session.Token == "" || t.session.Key == "" || t.session.IG == "" {
if err := t.refreshSession(); err != nil {
return "", fmt.Errorf("the refresh session failed: %w", err)
}
if text == "" {
return "", fmt.Errorf("text cannot be empty")
}
// 生成随机IID
randNum := rand.Intn(10) // 0-9的随机数
iid := fmt.Sprintf("translator.5019.%d", 1+randNum%3) // 生成随机IID
// 创建带超时的context
ctx, cancel := context.WithTimeout(context.Background(), t.Timeout)
defer cancel()
// 构建URL - 确保使用双&符号
reqURL := fmt.Sprintf("%s?isVertical=1&&IG=%s&IID=%s",
bingTranslateAPIURL, t.session.IG, iid)
// 标准化语言代码
fromLang := t.GetStandardLanguageCode(from)
toLang := t.GetStandardLanguageCode(to)
// 构建表单数据
formData := url.Values{}
formData.Set("fromLang", fromLang)
formData.Set("text", text)
formData.Set("to", toLang)
formData.Set("token", t.session.Token)
formData.Set("key", t.session.Key)
formDataStr := formData.Encode()
// 创建请求
req, err := http.NewRequest("POST", reqURL, strings.NewReader(formDataStr))
// 获取token
token, err := t.getToken(ctx)
if err != nil {
return "", fmt.Errorf("The creation request failed: %w", err)
return "", fmt.Errorf("failed to get token: %w", err)
}
// 构建请求URL
params := url.Values{}
if from != "auto" {
params.Set("from", from)
}
params.Set("to", to)
params.Set("api-version", "3.0")
params.Set("includeSentenceLength", "true")
fullURL := fmt.Sprintf("%s?%s", bingTranslateAPIURL, params.Encode())
// 构建请求体
requestBody := []TranslateRequest{{Text: text}}
jsonBody, err := json.Marshal(requestBody)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
// 创建HTTP请求
req, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewBuffer(jsonBody))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
// 设置请求头
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", t.session.Headers["User-Agent"])
req.Header.Set("Referer", bingTranslatorURL)
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
req.Header.Set("Origin", "https://cn.bing.com")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
// 添加Cookie
for name, value := range t.session.Cookie {
req.AddCookie(&http.Cookie{
Name: name,
Value: value,
})
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
// 发送请求
resp, err := t.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrBingNetworkError, err)
}
defer resp.Body.Close()
// 读取响应
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return "", fmt.Errorf("read response failed: %w", err)
return "", fmt.Errorf("failed to read response: %w", err)
}
if len(body) == 0 {
return "", ErrBingEmptyResponse
// 检查HTTP状态码
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body))
}
// 尝试解析响应
var result interface{}
if err := json.Unmarshal(body, &result); err != nil {
// 首先尝试解析为错误响应
var errorResp ErrorResponse
if err := json.Unmarshal(body, &errorResp); err == nil && errorResp.Error.Code != 0 {
return "", fmt.Errorf("translation error %d: %s", errorResp.Error.Code, errorResp.Error.Message)
}
// 解析翻译响应
var translateResp []TranslateResponse
if err := json.Unmarshal(body, &translateResp); err != nil {
return "", fmt.Errorf("%w: %v", ErrBingParseError, err)
}
// 检查是否是字典类型
if resultDict, ok := result.(map[string]interface{}); ok {
// 检查是否需要验证码
if _, hasCaptcha := resultDict["ShowCaptcha"]; hasCaptcha {
return "", ErrBingRateLimit
}
// 检查状态码
if statusCode, hasStatus := resultDict["statusCode"]; hasStatus {
if statusCode.(float64) == 400 {
// 检查是否有错误消息
if errorMsg, hasError := resultDict["errorMessage"]; hasError && errorMsg.(string) != "" {
return "", fmt.Errorf("translation failed: %s", errorMsg)
}
// 如果没有明确的错误消息可能是API变更或其他问题
return "", fmt.Errorf("translation request failed (status code: 400)")
} else if statusCode.(float64) == 429 {
return "", ErrBingRateLimit
}
}
// 尝试从错误响应中提取详细信息
if message, hasMessage := resultDict["message"]; hasMessage {
return "", fmt.Errorf("translation failed: %v", message)
}
// 尝试从响应中获取翻译结果
if translations, hasTranslations := resultDict["translations"]; hasTranslations {
if translationsArray, ok := translations.([]interface{}); ok && len(translationsArray) > 0 {
if translation, ok := translationsArray[0].(map[string]interface{}); ok {
if text, ok := translation["text"].(string); ok {
return text, nil
}
}
}
}
// 其他错误
return "", fmt.Errorf("translation failed: %v", resultDict)
if len(translateResp) == 0 || len(translateResp[0].Translations) == 0 {
return "", ErrBingEmptyResponse
}
// 应该是数组类型
if resultArray, ok := result.([]interface{}); ok && len(resultArray) > 0 {
firstItem := resultArray[0]
if itemDict, ok := firstItem.(map[string]interface{}); ok {
if translations, ok := itemDict["translations"].([]interface{}); ok && len(translations) > 0 {
if translation, ok := translations[0].(map[string]interface{}); ok {
if text, ok := translation["text"].(string); ok {
return text, nil
}
}
}
// 合并所有翻译片段
var result strings.Builder
for i, translation := range translateResp[0].Translations {
if i > 0 {
result.WriteString(" ")
}
result.WriteString(translation.Text)
}
return "", fmt.Errorf("%w: The response format is not as expected", ErrBingParseError)
return result.String(), nil
}
// GetSupportedLanguages 获取翻译器支持的语言列表
@@ -473,11 +272,3 @@ func (t *BingTranslator) IsLanguageSupported(languageCode string) bool {
_, exists := t.languages[languageCode]
return exists
}
// GetStandardLanguageCode 获取标准化的语言代码
func (t *BingTranslator) GetStandardLanguageCode(languageCode string) string {
if info, exists := t.languages[languageCode]; exists {
return info.Code
}
return languageCode // 如果没有找到映射,返回原始代码
}

View File

@@ -112,40 +112,8 @@ func initDeeplLanguages() map[string]LanguageInfo {
// 基于 DeepL API 支持的语言列表
// 参考: https://developers.deepl.com/docs/resources/supported-languages
// 源语言和目标语言
languages["ar"] = LanguageInfo{Code: "AR", Name: "Arabic"}
languages["bg"] = LanguageInfo{Code: "BG", Name: "Bulgarian"}
languages["cs"] = LanguageInfo{Code: "CS", Name: "Czech"}
languages["da"] = LanguageInfo{Code: "DA", Name: "Danish"}
languages["de"] = LanguageInfo{Code: "DE", Name: "German"}
languages["el"] = LanguageInfo{Code: "EL", Name: "Greek"}
// 源语言和目标语言 - 精简为中英互译
languages["en"] = LanguageInfo{Code: "EN", Name: "English"}
languages["en-gb"] = LanguageInfo{Code: "EN-GB", Name: "English (British)"}
languages["en-us"] = LanguageInfo{Code: "EN-US", Name: "English (American)"}
languages["es"] = LanguageInfo{Code: "ES", Name: "Spanish"}
languages["et"] = LanguageInfo{Code: "ET", Name: "Estonian"}
languages["fi"] = LanguageInfo{Code: "FI", Name: "Finnish"}
languages["fr"] = LanguageInfo{Code: "FR", Name: "French"}
languages["hu"] = LanguageInfo{Code: "HU", Name: "Hungarian"}
languages["id"] = LanguageInfo{Code: "ID", Name: "Indonesian"}
languages["it"] = LanguageInfo{Code: "IT", Name: "Italian"}
languages["ja"] = LanguageInfo{Code: "JA", Name: "Japanese"}
languages["ko"] = LanguageInfo{Code: "KO", Name: "Korean"}
languages["lt"] = LanguageInfo{Code: "LT", Name: "Lithuanian"}
languages["lv"] = LanguageInfo{Code: "LV", Name: "Latvian"}
languages["nb"] = LanguageInfo{Code: "NB", Name: "Norwegian Bokmål"}
languages["nl"] = LanguageInfo{Code: "NL", Name: "Dutch"}
languages["pl"] = LanguageInfo{Code: "PL", Name: "Polish"}
languages["pt"] = LanguageInfo{Code: "PT", Name: "Portuguese"}
languages["pt-br"] = LanguageInfo{Code: "PT-BR", Name: "Portuguese (Brazilian)"}
languages["pt-pt"] = LanguageInfo{Code: "PT-PT", Name: "Portuguese (Portugal)"}
languages["ro"] = LanguageInfo{Code: "RO", Name: "Romanian"}
languages["ru"] = LanguageInfo{Code: "RU", Name: "Russian"}
languages["sk"] = LanguageInfo{Code: "SK", Name: "Slovak"}
languages["sl"] = LanguageInfo{Code: "SL", Name: "Slovenian"}
languages["sv"] = LanguageInfo{Code: "SV", Name: "Swedish"}
languages["tr"] = LanguageInfo{Code: "TR", Name: "Turkish"}
languages["uk"] = LanguageInfo{Code: "UK", Name: "Ukrainian"}
languages["zh"] = LanguageInfo{Code: "ZH", Name: "Chinese"}
return languages
@@ -157,11 +125,6 @@ func (t *DeeplTranslator) SetTimeout(timeout time.Duration) {
t.httpClient.Timeout = timeout
}
// SetDeeplHost 设置DeepL主机
func (t *DeeplTranslator) SetDeeplHost(host string) {
t.DeeplHost = host
}
// Translate 使用标准语言标签进行文本翻译
func (t *DeeplTranslator) Translate(text string, from language.Tag, to language.Tag) (string, error) {
return t.translate(text, from.String(), to.String())
@@ -319,9 +282,3 @@ func (t *DeeplTranslator) IsLanguageSupported(languageCode string) bool {
_, ok := t.languages[strings.ToLower(languageCode)]
return ok
}
// GetStandardLanguageCode 获取标准化的语言代码
func (t *DeeplTranslator) GetStandardLanguageCode(languageCode string) string {
// 简单返回小写版本作为标准代码
return strings.ToLower(languageCode)
}

View File

@@ -2,20 +2,17 @@
package translator
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/robertkrimen/otto"
"golang.org/x/text/language"
)
@@ -24,405 +21,228 @@ var (
ErrBadNetwork = errors.New("bad network, please check your internet connection")
)
// GoogleTranslator Google翻译器结构体统一管理翻译功能
// 常量定义
const (
googleTranslateTKK = "448487.932609646" // 固定TKK值
)
// GoogleTranslator 带token的Google翻译器使用translate.google.com
type GoogleTranslator struct {
GoogleHost string // Google服务主机
vm *otto.Otto // JavaScript虚拟机
ttk otto.Value // 翻译token缓存
httpClient *http.Client // HTTP客户端
Timeout time.Duration // 请求超时时间
languages map[string]LanguageInfo // 支持的语言列表
}
// NewGoogleTranslator 创建一个新的Google翻译器实例
// GoogleTranslatorTokenFree 无token的Google翻译器使用translate.googleapis.com
type GoogleTranslatorTokenFree struct {
httpClient *http.Client // HTTP客户端
Timeout time.Duration // 请求超时时间
languages map[string]LanguageInfo // 支持的语言列表
}
// NewGoogleTranslator 创建一个新的带token的Google翻译器实例
func NewGoogleTranslator() *GoogleTranslator {
translator := &GoogleTranslator{
GoogleHost: "google.com",
vm: otto.New(),
Timeout: defaultTimeout,
return &GoogleTranslator{
httpClient: &http.Client{Timeout: defaultTimeout},
Timeout: defaultTimeout,
languages: initGoogleLanguages(),
}
// 初始化ttk
translator.ttk, _ = otto.ToValue("0")
return translator
}
// initGoogleLanguages 初始化Google翻译器支持的语言列表
func initGoogleLanguages() map[string]LanguageInfo {
// 创建语言映射表
languages := make(map[string]LanguageInfo)
// 添加所有支持的语言
// 参考: https://cloud.google.com/translate/docs/languages
// 添加自动检测
languages["auto"] = LanguageInfo{Code: "auto", Name: "Auto Detect"}
// 主要语言
// 只支持三种语言
languages["en"] = LanguageInfo{Code: "en", Name: "English"}
languages["zh-cn"] = LanguageInfo{Code: "zh-CN", Name: "Chinese (Simplified)"}
languages["zh-tw"] = LanguageInfo{Code: "zh-TW", Name: "Chinese (Traditional)"}
languages["ja"] = LanguageInfo{Code: "ja", Name: "Japanese"}
languages["ko"] = LanguageInfo{Code: "ko", Name: "Korean"}
languages["fr"] = LanguageInfo{Code: "fr", Name: "French"}
languages["de"] = LanguageInfo{Code: "de", Name: "German"}
languages["es"] = LanguageInfo{Code: "es", Name: "Spanish"}
languages["ru"] = LanguageInfo{Code: "ru", Name: "Russian"}
languages["it"] = LanguageInfo{Code: "it", Name: "Italian"}
languages["pt"] = LanguageInfo{Code: "pt", Name: "Portuguese"}
// 其他语言
languages["af"] = LanguageInfo{Code: "af", Name: "Afrikaans"}
languages["sq"] = LanguageInfo{Code: "sq", Name: "Albanian"}
languages["am"] = LanguageInfo{Code: "am", Name: "Amharic"}
languages["ar"] = LanguageInfo{Code: "ar", Name: "Arabic"}
languages["hy"] = LanguageInfo{Code: "hy", Name: "Armenian"}
languages["az"] = LanguageInfo{Code: "az", Name: "Azerbaijani"}
languages["eu"] = LanguageInfo{Code: "eu", Name: "Basque"}
languages["be"] = LanguageInfo{Code: "be", Name: "Belarusian"}
languages["bn"] = LanguageInfo{Code: "bn", Name: "Bengali"}
languages["bs"] = LanguageInfo{Code: "bs", Name: "Bosnian"}
languages["bg"] = LanguageInfo{Code: "bg", Name: "Bulgarian"}
languages["ca"] = LanguageInfo{Code: "ca", Name: "Catalan"}
languages["ceb"] = LanguageInfo{Code: "ceb", Name: "Cebuano"}
languages["zh"] = LanguageInfo{Code: "zh", Name: "Chinese"}
languages["co"] = LanguageInfo{Code: "co", Name: "Corsican"}
languages["hr"] = LanguageInfo{Code: "hr", Name: "Croatian"}
languages["cs"] = LanguageInfo{Code: "cs", Name: "Czech"}
languages["da"] = LanguageInfo{Code: "da", Name: "Danish"}
languages["nl"] = LanguageInfo{Code: "nl", Name: "Dutch"}
languages["eo"] = LanguageInfo{Code: "eo", Name: "Esperanto"}
languages["et"] = LanguageInfo{Code: "et", Name: "Estonian"}
languages["fi"] = LanguageInfo{Code: "fi", Name: "Finnish"}
languages["fy"] = LanguageInfo{Code: "fy", Name: "Frisian"}
languages["gl"] = LanguageInfo{Code: "gl", Name: "Galician"}
languages["ka"] = LanguageInfo{Code: "ka", Name: "Georgian"}
languages["el"] = LanguageInfo{Code: "el", Name: "Greek"}
languages["gu"] = LanguageInfo{Code: "gu", Name: "Gujarati"}
languages["ht"] = LanguageInfo{Code: "ht", Name: "Haitian Creole"}
languages["ha"] = LanguageInfo{Code: "ha", Name: "Hausa"}
languages["haw"] = LanguageInfo{Code: "haw", Name: "Hawaiian"}
languages["he"] = LanguageInfo{Code: "he", Name: "Hebrew"}
languages["hi"] = LanguageInfo{Code: "hi", Name: "Hindi"}
languages["hmn"] = LanguageInfo{Code: "hmn", Name: "Hmong"}
languages["hu"] = LanguageInfo{Code: "hu", Name: "Hungarian"}
languages["is"] = LanguageInfo{Code: "is", Name: "Icelandic"}
languages["ig"] = LanguageInfo{Code: "ig", Name: "Igbo"}
languages["id"] = LanguageInfo{Code: "id", Name: "Indonesian"}
languages["ga"] = LanguageInfo{Code: "ga", Name: "Irish"}
languages["jw"] = LanguageInfo{Code: "jw", Name: "Javanese"}
languages["kn"] = LanguageInfo{Code: "kn", Name: "Kannada"}
languages["kk"] = LanguageInfo{Code: "kk", Name: "Kazakh"}
languages["km"] = LanguageInfo{Code: "km", Name: "Khmer"}
languages["ku"] = LanguageInfo{Code: "ku", Name: "Kurdish"}
languages["ky"] = LanguageInfo{Code: "ky", Name: "Kyrgyz"}
languages["lo"] = LanguageInfo{Code: "lo", Name: "Lao"}
languages["la"] = LanguageInfo{Code: "la", Name: "Latin"}
languages["lv"] = LanguageInfo{Code: "lv", Name: "Latvian"}
languages["lt"] = LanguageInfo{Code: "lt", Name: "Lithuanian"}
languages["lb"] = LanguageInfo{Code: "lb", Name: "Luxembourgish"}
languages["mk"] = LanguageInfo{Code: "mk", Name: "Macedonian"}
languages["mg"] = LanguageInfo{Code: "mg", Name: "Malagasy"}
languages["ms"] = LanguageInfo{Code: "ms", Name: "Malay"}
languages["ml"] = LanguageInfo{Code: "ml", Name: "Malayalam"}
languages["mt"] = LanguageInfo{Code: "mt", Name: "Maltese"}
languages["mi"] = LanguageInfo{Code: "mi", Name: "Maori"}
languages["mr"] = LanguageInfo{Code: "mr", Name: "Marathi"}
languages["mn"] = LanguageInfo{Code: "mn", Name: "Mongolian"}
languages["my"] = LanguageInfo{Code: "my", Name: "Myanmar (Burmese)"}
languages["ne"] = LanguageInfo{Code: "ne", Name: "Nepali"}
languages["no"] = LanguageInfo{Code: "no", Name: "Norwegian"}
languages["ny"] = LanguageInfo{Code: "ny", Name: "Nyanja (Chichewa)"}
languages["ps"] = LanguageInfo{Code: "ps", Name: "Pashto"}
languages["fa"] = LanguageInfo{Code: "fa", Name: "Persian"}
languages["pl"] = LanguageInfo{Code: "pl", Name: "Polish"}
languages["pt-br"] = LanguageInfo{Code: "pt-BR", Name: "Portuguese (Brazil)"}
languages["pt-pt"] = LanguageInfo{Code: "pt-PT", Name: "Portuguese (Portugal)"}
languages["pa"] = LanguageInfo{Code: "pa", Name: "Punjabi"}
languages["ro"] = LanguageInfo{Code: "ro", Name: "Romanian"}
languages["sm"] = LanguageInfo{Code: "sm", Name: "Samoan"}
languages["gd"] = LanguageInfo{Code: "gd", Name: "Scots Gaelic"}
languages["sr"] = LanguageInfo{Code: "sr", Name: "Serbian"}
languages["st"] = LanguageInfo{Code: "st", Name: "Sesotho"}
languages["sn"] = LanguageInfo{Code: "sn", Name: "Shona"}
languages["sd"] = LanguageInfo{Code: "sd", Name: "Sindhi"}
languages["si"] = LanguageInfo{Code: "si", Name: "Sinhala (Sinhalese)"}
languages["sk"] = LanguageInfo{Code: "sk", Name: "Slovak"}
languages["sl"] = LanguageInfo{Code: "sl", Name: "Slovenian"}
languages["so"] = LanguageInfo{Code: "so", Name: "Somali"}
languages["su"] = LanguageInfo{Code: "su", Name: "Sundanese"}
languages["sw"] = LanguageInfo{Code: "sw", Name: "Swahili"}
languages["sv"] = LanguageInfo{Code: "sv", Name: "Swedish"}
languages["tl"] = LanguageInfo{Code: "tl", Name: "Tagalog (Filipino)"}
languages["tg"] = LanguageInfo{Code: "tg", Name: "Tajik"}
languages["ta"] = LanguageInfo{Code: "ta", Name: "Tamil"}
languages["te"] = LanguageInfo{Code: "te", Name: "Telugu"}
languages["th"] = LanguageInfo{Code: "th", Name: "Thai"}
languages["tr"] = LanguageInfo{Code: "tr", Name: "Turkish"}
languages["uk"] = LanguageInfo{Code: "uk", Name: "Ukrainian"}
languages["ur"] = LanguageInfo{Code: "ur", Name: "Urdu"}
languages["uz"] = LanguageInfo{Code: "uz", Name: "Uzbek"}
languages["vi"] = LanguageInfo{Code: "vi", Name: "Vietnamese"}
languages["cy"] = LanguageInfo{Code: "cy", Name: "Welsh"}
languages["xh"] = LanguageInfo{Code: "xh", Name: "Xhosa"}
languages["yi"] = LanguageInfo{Code: "yi", Name: "Yiddish"}
languages["yo"] = LanguageInfo{Code: "yo", Name: "Yoruba"}
languages["zu"] = LanguageInfo{Code: "zu", Name: "Zulu"}
return languages
}
// generateToken 生成翻译token
func generateToken(query string) string {
// 实现TypeScript中的token生成逻辑
tkkSplited := strings.Split(googleTranslateTKK, ".")
tkkIndex, _ := strconv.Atoi(tkkSplited[0])
tkkKey, _ := strconv.Atoi(tkkSplited[1])
// 转换查询字符串为字节数组
bytesArray := transformQuery(query)
// 计算hash
encodingRound := tkkIndex
for _, b := range bytesArray {
encodingRound += int(b)
encodingRound = shiftLeftOrRightThenSumOrXor(encodingRound, "+-a^+6")
}
encodingRound = shiftLeftOrRightThenSumOrXor(encodingRound, "+-3^+b+-f")
encodingRound ^= tkkKey
if encodingRound <= 0 {
encodingRound = (encodingRound & 2147483647) + 2147483648
}
normalizedResult := encodingRound % 1000000
return fmt.Sprintf("%d.%d", normalizedResult, normalizedResult^tkkIndex)
}
// transformQuery 转换查询字符串
func transformQuery(query string) []byte {
var bytesArray []byte
runes := []rune(query)
for i := 0; i < len(runes); i++ {
charCode := int(runes[i])
if charCode < 128 {
bytesArray = append(bytesArray, byte(charCode))
} else if charCode < 2048 {
bytesArray = append(bytesArray, byte((charCode>>6)|192))
bytesArray = append(bytesArray, byte((charCode&63)|128))
} else {
if (charCode&64512) == 55296 && i+1 < len(runes) && (int(runes[i+1])&64512) == 56320 {
charCode = 65536 + ((charCode & 1023) << 10) + (int(runes[i+1]) & 1023)
i++
bytesArray = append(bytesArray, byte((charCode>>18)|240))
bytesArray = append(bytesArray, byte(((charCode>>12)&63)|128))
} else {
bytesArray = append(bytesArray, byte((charCode>>12)|224))
}
bytesArray = append(bytesArray, byte(((charCode>>6)&63)|128))
bytesArray = append(bytesArray, byte((charCode&63)|128))
}
}
return bytesArray
}
// shiftLeftOrRightThenSumOrXor 位运算操作
func shiftLeftOrRightThenSumOrXor(num int, optString string) int {
for i := 0; i < len(optString)-2; i += 3 {
acc := int(optString[i+2])
if acc >= 'a' {
acc = acc - 87
} else {
acc = acc - '0'
}
if optString[i+1] == '+' {
acc = num >> uint(acc)
} else {
acc = num << uint(acc)
}
if optString[i] == '+' {
num = (num + acc) & 4294967295
} else {
num ^= acc
}
}
return num
}
// SetTimeout 设置请求超时时间
func (t *GoogleTranslator) SetTimeout(timeout time.Duration) {
t.Timeout = timeout
t.httpClient.Timeout = timeout
}
// SetGoogleHost 设置Google主机
func (t *GoogleTranslator) SetGoogleHost(host string) {
t.GoogleHost = host
}
// Translate 使用Go语言提供的标准语言标签进行文本翻译
func (t *GoogleTranslator) Translate(text string, from language.Tag, to language.Tag) (string, error) {
return t.translate(text, from.String(), to.String(), false)
return t.translate(text, from.String(), to.String())
}
// TranslateWithParams 使用简单字符串参数进行文本翻译
func (t *GoogleTranslator) TranslateWithParams(text string, params TranslationParams) (string, error) {
// 设置超时时间(如果有指定)
if params.Timeout > 0 {
t.SetTimeout(params.Timeout)
}
return t.translate(text, params.From, params.To, true)
return t.translate(text, params.From, params.To)
}
// translate 执行实际翻译操作
func (t *GoogleTranslator) translate(text, from, to string, withVerification bool) (string, error) {
if withVerification {
if _, err := language.Parse(from); err != nil && from != "auto" {
log.Println("[WARNING], '" + from + "' is a invalid language, switching to 'auto'")
from = "auto"
}
if _, err := language.Parse(to); err != nil {
log.Println("[WARNING], '" + to + "' is a invalid language, switching to 'en'")
to = "en"
}
// translate 执行实际翻译操作带token版本
func (t *GoogleTranslator) translate(text, from, to string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), t.Timeout)
defer cancel()
// 生成token
token := generateToken(text)
// 构建请求URL
apiURL := "https://translate.google.com/translate_a/single"
params := url.Values{}
params.Set("client", "t")
params.Set("sl", from)
params.Set("tl", to)
params.Set("hl", to)
params.Set("ie", "UTF-8")
params.Set("oe", "UTF-8")
params.Set("otf", "1")
params.Set("ssel", "0")
params.Set("tsel", "0")
params.Set("kc", "7")
params.Set("q", text)
params.Set("tk", token)
// 添加dt参数
dtParams := []string{"at", "bd", "ex", "ld", "md", "qca", "rw", "rm", "ss", "t"}
for _, dt := range dtParams {
params.Add("dt", dt)
}
textValue, _ := otto.ToValue(text)
urlStr := fmt.Sprintf("https://translate.%s/translate_a/single", t.GoogleHost)
token := t.getToken(textValue)
fullURL := apiURL + "?" + params.Encode()
data := map[string]string{
"client": "gtx",
"sl": from,
"tl": to,
"hl": to,
"ie": "UTF-8",
"oe": "UTF-8",
"otf": "1",
"ssel": "0",
"tsel": "0",
"kc": "7",
"q": text,
}
u, err := url.Parse(urlStr)
// 创建请求
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
if err != nil {
return "", err
}
parameters := url.Values{}
for k, v := range data {
parameters.Add(k, v)
}
for _, v := range []string{"at", "bd", "ex", "ld", "md", "qca", "rw", "rm", "ss", "t"} {
parameters.Add("dt", v)
}
parameters.Add("tk", token)
u.RawQuery = parameters.Encode()
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return "", err
}
r, err := t.httpClient.Do(req)
if err != nil {
if errors.Is(err, http.ErrHandlerTimeout) {
return "", ErrBadNetwork
}
return "", err
}
if r.StatusCode != http.StatusOK {
return "", fmt.Errorf("API error: status code %d", r.StatusCode)
}
raw, err := io.ReadAll(r.Body)
if err != nil {
return "", err
}
defer r.Body.Close()
var resp []interface{}
err = json.Unmarshal(raw, &resp)
if err != nil {
return "", err
}
responseText := ""
for _, obj := range resp[0].([]interface{}) {
if len(obj.([]interface{})) == 0 {
break
}
t, ok := obj.([]interface{})[0].(string)
if ok {
responseText += t
}
}
return responseText, nil
}
// getToken 获取翻译API所需的token
func (t *GoogleTranslator) getToken(text otto.Value) string {
ttk, err := t.updateTTK()
if err != nil {
return ""
}
tk, err := t.generateToken(text, ttk)
if err != nil {
return ""
}
return strings.Replace(tk.String(), "&tk=", "", -1)
}
// updateTTK 更新TTK值
func (t *GoogleTranslator) updateTTK() (otto.Value, error) {
timestamp := time.Now().UnixNano() / 3600000
now := math.Floor(float64(timestamp))
ttk, err := strconv.ParseFloat(t.ttk.String(), 64)
if err != nil {
return otto.UndefinedValue(), err
}
if ttk == now {
return t.ttk, nil
}
req, err := http.NewRequest("GET", fmt.Sprintf("https://translate.%s", t.GoogleHost), nil)
if err != nil {
return otto.UndefinedValue(), err
}
// 发送请求
resp, err := t.httpClient.Do(req)
if err != nil {
return otto.UndefinedValue(), err
return "", ErrBadNetwork
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("API error: status code %d", resp.StatusCode)
}
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
return otto.UndefinedValue(), err
return "", err
}
matches := regexp.MustCompile(`tkk:\s?'(.+?)'`).FindStringSubmatch(string(body))
if len(matches) > 0 {
v, err := otto.ToValue(matches[0])
if err != nil {
return otto.UndefinedValue(), err
}
t.ttk = v
return v, nil
// 解析JSON响应
var result []interface{}
if err := json.Unmarshal(body, &result); err != nil {
return "", err
}
return t.ttk, nil
}
// generateToken 生成翻译API所需的token
func (t *GoogleTranslator) generateToken(a otto.Value, TTK otto.Value) (otto.Value, error) {
err := t.vm.Set("x", a)
if err != nil {
return otto.UndefinedValue(), err
if len(result) == 0 {
return "", errors.New("unexpected response format")
}
_ = t.vm.Set("internalTTK", TTK)
// 提取翻译文本
translations, ok := result[0].([]interface{})
if !ok {
return "", errors.New("unexpected response format")
}
result, err := t.vm.Run(`
function sM(a) {
var b;
if (null !== yr)
b = yr;
else {
b = wr(String.fromCharCode(84));
var c = wr(String.fromCharCode(75));
b = [b(), b()];
b[1] = c();
b = (yr = window[b.join(c())] || "") || ""
}
var d = wr(String.fromCharCode(116))
, c = wr(String.fromCharCode(107))
, d = [d(), d()];
d[1] = c();
c = "&" + d.join("") + "=";
d = b.split(".");
b = Number(d[0]) || 0;
for (var e = [], f = 0, g = 0; g < a.length; g++) {
var l = a.charCodeAt(g);
128 > l ? e[f++] = l : (2048 > l ? e[f++] = l >> 6 | 192 : (55296 == (l & 64512) && g + 1 < a.length && 56320 == (a.charCodeAt(g + 1) & 64512) ? (l = 65536 + ((l & 1023) << 10) + (a.charCodeAt(++g) & 1023),
e[f++] = l >> 18 | 240,
e[f++] = l >> 12 & 63 | 128) : e[f++] = l >> 12 | 224,
e[f++] = l >> 6 & 63 | 128),
e[f++] = l & 63 | 128)
}
a = b;
for (f = 0; f < e.length; f++)
a += e[f],
a = xr(a, "+-a^+6");
a = xr(a, "+-3^+b+-f");
a ^= Number(d[1]) || 0;
0 > a && (a = (a & 2147483647) + 2147483648);
a %= 1E6;
return c + (a.toString() + "." + (a ^ b))
}
var yr = null;
var wr = function(a) {
return function() {
return a
var translatedText strings.Builder
for _, translation := range translations {
if chunk, ok := translation.([]interface{}); ok && len(chunk) > 0 {
if text, ok := chunk[0].(string); ok {
translatedText.WriteString(text)
}
}
, xr = function(a, b) {
for (var c = 0; c < b.length - 2; c += 3) {
var d = b.charAt(c + 2)
, d = "a" <= d ? d.charCodeAt(0) - 87 : Number(d)
, d = "+" == b.charAt(c + 1) ? a >>> d : a << d;
a = "+" == b.charAt(c) ? a + d & 4294967295 : a ^ d
}
return a
};
var window = {
TKK: internalTTK
};
sM(x)
`)
if err != nil {
return otto.UndefinedValue(), err
}
return result, nil
return translatedText.String(), nil
}
// GetSupportedLanguages 获取翻译器支持的语言列表
@@ -436,8 +256,24 @@ func (t *GoogleTranslator) IsLanguageSupported(languageCode string) bool {
return ok
}
// GetStandardLanguageCode 获取标准化的语言代码
func (t *GoogleTranslator) GetStandardLanguageCode(languageCode string) string {
// 简单返回小写版本作为标准代码
return strings.ToLower(languageCode)
// visitArrayItems 递归访问数组项
func visitArrayItems(arr []interface{}, visitor func(interface{})) {
for _, obj := range arr {
if subArr, ok := obj.([]interface{}); ok {
visitArrayItems(subArr, visitor)
} else {
visitor(obj)
}
}
}
// GetSupportedLanguages 获取翻译器支持的语言列表
func (t *GoogleTranslatorTokenFree) GetSupportedLanguages() map[string]LanguageInfo {
return t.languages
}
// IsLanguageSupported 检查指定的语言代码是否受支持
func (t *GoogleTranslatorTokenFree) IsLanguageSupported(languageCode string) bool {
_, ok := t.languages[strings.ToLower(languageCode)]
return ok
}

View File

@@ -0,0 +1,103 @@
package translator
import (
"context"
"encoding/json"
"errors"
"fmt"
"golang.org/x/text/language"
"io"
"net/http"
"net/url"
"time"
)
// NewGoogleTranslatorTokenFree 创建一个新的无token的Google翻译器实例
func NewGoogleTranslatorTokenFree() *GoogleTranslatorTokenFree {
return &GoogleTranslatorTokenFree{
httpClient: &http.Client{Timeout: defaultTimeout},
Timeout: defaultTimeout,
languages: initGoogleLanguages(),
}
}
// SetTimeout 设置请求超时时间
func (t *GoogleTranslatorTokenFree) SetTimeout(timeout time.Duration) {
t.Timeout = timeout
t.httpClient.Timeout = timeout
}
// Translate 使用Go语言提供的标准语言标签进行文本翻译
func (t *GoogleTranslatorTokenFree) Translate(text string, from language.Tag, to language.Tag) (string, error) {
return t.translate(text, from.String(), to.String())
}
// TranslateWithParams 使用简单字符串参数进行文本翻译
func (t *GoogleTranslatorTokenFree) TranslateWithParams(text string, params TranslationParams) (string, error) {
if params.Timeout > 0 {
t.SetTimeout(params.Timeout)
}
return t.translate(text, params.From, params.To)
}
// translate 执行实际翻译操作无token版本
func (t *GoogleTranslatorTokenFree) translate(text, from, to string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), t.Timeout)
defer cancel()
// 构建请求URL无token版本
apiURL := "https://translate.googleapis.com/translate_a/t"
params := url.Values{}
params.Set("client", "dict-chrome-ex")
params.Set("sl", from)
params.Set("tl", to)
params.Set("q", text)
fullURL := apiURL + "?" + params.Encode()
// 创建请求
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// 发送请求
resp, err := t.httpClient.Do(req)
if err != nil {
return "", ErrBadNetwork
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("API error: status code %d", resp.StatusCode)
}
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
// 解析JSON响应
var result []interface{}
if err := json.Unmarshal(body, &result); err != nil {
return "", err
}
// 提取翻译文本
var translatedTexts []string
visitArrayItems(result, func(obj interface{}) {
if text, ok := obj.(string); ok {
translatedTexts = append(translatedTexts, text)
}
})
if len(translatedTexts) == 0 {
return "", errors.New("no translation found")
}
// 返回第一个翻译结果
return translatedTexts[0], nil
}

View File

@@ -0,0 +1,210 @@
// Package translator 提供文本翻译功能
package translator
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"golang.org/x/text/language"
)
// TartuNLPTranslator TartuNLP翻译器结构体
type TartuNLPTranslator struct {
httpClient *http.Client // HTTP客户端
Timeout time.Duration // 请求超时时间
languages map[string]LanguageInfo // 支持的语言列表
}
// 常量定义
const (
tartuNLPDefaultTimeout = 300 * time.Second // 默认超时时间300秒
tartuNLPAPIURL = "https://api.tartunlp.ai/translation/v2" // TartuNLP API地址
tartuNLPLengthLimit = 5000 // 长度限制5000字符
)
// 错误定义
var (
ErrTartuNLPNetworkError = errors.New("tartunlp translator network error")
ErrTartuNLPUnsupportedLang = errors.New("tartunlp translator unsupported language")
ErrTartuNLPResponseError = errors.New("tartunlp translator response error")
ErrTartuNLPLengthExceeded = errors.New("tartunlp translator text length exceeded")
)
// TartuNLPRequest TartuNLP请求结构体
type TartuNLPRequest struct {
Text []string `json:"text"` // 要翻译的文本数组
Src string `json:"src"` // 源语言
Tgt string `json:"tgt"` // 目标语言
}
// TartuNLPResponse TartuNLP响应结构体
type TartuNLPResponse struct {
Result []string `json:"result"` // 翻译结果数组
}
// NewTartuNLPTranslator 创建一个新的TartuNLP翻译器实例
func NewTartuNLPTranslator() *TartuNLPTranslator {
translator := &TartuNLPTranslator{
httpClient: &http.Client{
Timeout: tartuNLPDefaultTimeout,
},
Timeout: tartuNLPDefaultTimeout,
languages: initTartuNLPLanguages(),
}
return translator
}
// initTartuNLPLanguages 初始化TartuNLP翻译器支持的语言列表
func initTartuNLPLanguages() map[string]LanguageInfo {
// 创建语言映射表
languages := make(map[string]LanguageInfo)
// 添加支持的语言
// 基于 TartuNLP API 支持的语言列表
languages["en"] = LanguageInfo{Code: "en", Name: "English"}
languages["et"] = LanguageInfo{Code: "et", Name: "Estonian"}
languages["de"] = LanguageInfo{Code: "de", Name: "German"}
languages["lt"] = LanguageInfo{Code: "lt", Name: "Lithuanian"}
languages["lv"] = LanguageInfo{Code: "lv", Name: "Latvian"}
languages["fi"] = LanguageInfo{Code: "fi", Name: "Finnish"}
languages["ru"] = LanguageInfo{Code: "ru", Name: "Russian"}
languages["no"] = LanguageInfo{Code: "no", Name: "Norwegian"}
languages["hu"] = LanguageInfo{Code: "hu", Name: "Hungarian"}
languages["se"] = LanguageInfo{Code: "se", Name: "Swedish"}
return languages
}
// SetTimeout 设置请求超时时间
func (t *TartuNLPTranslator) SetTimeout(timeout time.Duration) {
t.Timeout = timeout
t.httpClient.Timeout = timeout
}
// Translate 使用标准语言标签进行文本翻译
func (t *TartuNLPTranslator) Translate(text string, from language.Tag, to language.Tag) (string, error) {
return t.translate(text, from.String(), to.String())
}
// TranslateWithParams 使用简单字符串参数进行文本翻译
func (t *TartuNLPTranslator) TranslateWithParams(text string, params TranslationParams) (string, error) {
// 设置超时时间(如果有指定)
if params.Timeout > 0 {
t.SetTimeout(params.Timeout)
}
return t.translate(text, params.From, params.To)
}
// checkLengthLimit 检查文本长度是否超出限制
func (t *TartuNLPTranslator) checkLengthLimit(text string) error {
if len(text) > tartuNLPLengthLimit {
return fmt.Errorf("%w: text length %d exceeds limit %d", ErrTartuNLPLengthExceeded, len(text), tartuNLPLengthLimit)
}
return nil
}
// translate 执行实际翻译操作
func (t *TartuNLPTranslator) translate(text, from, to string) (string, error) {
if text == "" {
return "", fmt.Errorf("text cannot be empty")
}
// 检查文本长度限制
if err := t.checkLengthLimit(text); err != nil {
return "", err
}
// 转换语言代码为TartuNLP格式
fromLower := strings.ToLower(from)
toLower := strings.ToLower(to)
// 验证源语言支持
if _, ok := t.languages[fromLower]; !ok {
return "", fmt.Errorf("%w: source language '%s' not supported by TartuNLP", ErrTartuNLPUnsupportedLang, from)
}
// 验证目标语言支持
if _, ok := t.languages[toLower]; !ok {
return "", fmt.Errorf("%w: target language '%s' not supported by TartuNLP", ErrTartuNLPUnsupportedLang, to)
}
// 创建带超时的context
ctx, cancel := context.WithTimeout(context.Background(), t.Timeout)
defer cancel()
// 构建请求体
request := TartuNLPRequest{
Text: []string{text},
Src: fromLower,
Tgt: toLower,
}
// 序列化请求
jsonData, err := json.Marshal(request)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
// 创建HTTP请求
req, err := http.NewRequestWithContext(ctx, "POST", tartuNLPAPIURL, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
// 设置请求头
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
// 发送请求
resp, err := t.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrTartuNLPNetworkError, err)
}
defer resp.Body.Close()
// 检查HTTP状态码
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("%w: HTTP %d - %s", ErrTartuNLPResponseError, resp.StatusCode, string(body))
}
// 读取响应体
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
// 解析响应
var response TartuNLPResponse
if err := json.Unmarshal(body, &response); err != nil {
return "", fmt.Errorf("%w: failed to parse response: %v", ErrTartuNLPResponseError, err)
}
// 检查响应结果
if len(response.Result) == 0 {
return "", fmt.Errorf("%w: empty translation result", ErrTartuNLPResponseError)
}
return response.Result[0], nil
}
// GetSupportedLanguages 获取翻译器支持的语言列表
func (t *TartuNLPTranslator) GetSupportedLanguages() map[string]LanguageInfo {
return t.languages
}
// IsLanguageSupported 检查指定的语言代码是否受支持
func (t *TartuNLPTranslator) IsLanguageSupported(languageCode string) bool {
_, exists := t.languages[strings.ToLower(languageCode)]
return exists
}

View File

@@ -32,6 +32,8 @@ const (
YoudaoTranslatorType TranslatorType = "youdao"
// DeeplTranslatorType DeepL翻译器
DeeplTranslatorType TranslatorType = "deepl"
// TartuNLPTranslatorType TartuNLP翻译器
TartuNLPTranslatorType TranslatorType = "tartunlp"
)
// LanguageInfo 语言信息结构体
@@ -56,9 +58,6 @@ type Translator interface {
// IsLanguageSupported 检查指定的语言代码是否受支持
IsLanguageSupported(languageCode string) bool
// GetStandardLanguageCode 获取标准化的语言代码
GetStandardLanguageCode(languageCode string) string
}
// TranslatorFactory 翻译器工厂,用于创建不同类型的翻译器
@@ -80,6 +79,8 @@ func (f *TranslatorFactory) Create(translatorType TranslatorType) (Translator, e
return NewYoudaoTranslator(), nil
case DeeplTranslatorType:
return NewDeeplTranslator(), nil
case TartuNLPTranslatorType:
return NewTartuNLPTranslator(), nil
default:
return nil, fmt.Errorf("unsupported translator type: %s", translatorType)
}

View File

@@ -50,8 +50,7 @@ func initYoudaoLanguages() map[string]LanguageInfo {
// 创建语言映射表
languages := make(map[string]LanguageInfo)
// 自动检测
languages["auto"] = LanguageInfo{Code: "auto", Name: "Auto"}
languages["auto"] = LanguageInfo{Code: "AUTO", Name: "Auto"}
return languages
}
@@ -64,8 +63,7 @@ func (t *YoudaoTranslator) SetTimeout(timeout time.Duration) {
// Translate 使用标准语言标签进行文本翻译
func (t *YoudaoTranslator) Translate(text string, from language.Tag, to language.Tag) (string, error) {
// 有道翻译不需要指定源语言和目标语言,它会自动检测
return t.translate(text)
return t.translate(text, to.String())
}
// TranslateWithParams 使用简单字符串参数进行文本翻译
@@ -75,16 +73,15 @@ func (t *YoudaoTranslator) TranslateWithParams(text string, params TranslationPa
t.SetTimeout(params.Timeout)
}
// 有道翻译不需要指定源语言和目标语言,它会自动检测
return t.translate(text)
return t.translate(text, params.To)
}
// translate 执行实际翻译操作
func (t *YoudaoTranslator) translate(text string) (string, error) {
func (t *YoudaoTranslator) translate(text string, typeName string) (string, error) {
// 构建表单数据
form := url.Values{}
form.Add("inputtext", text)
form.Add("type", "AUTO")
form.Add("type", typeName)
// 创建请求
req, err := http.NewRequest("POST", youdaoTranslateURL, strings.NewReader(form.Encode()))

View File

@@ -78,6 +78,7 @@ type GeneralConfig struct {
// 界面设置
EnableLoadingAnimation bool `json:"enableLoadingAnimation"` // 是否启用加载动画
EnableTabs bool `json:"enableTabs"` // 是否启用标签页模式
}
// HotkeyCombo 热键组合定义
@@ -154,7 +155,8 @@ func NewDefaultAppConfig() *AppConfig {
StartAtLogin: false,
EnableWindowSnap: true, // 默认启用窗口吸附
EnableGlobalHotkey: false,
EnableLoadingAnimation: true, // 默认启用加载动画
EnableLoadingAnimation: true, // 默认启用加载动画
EnableTabs: false, // 默认不启用标签页模式
GlobalHotkey: HotkeyCombo{
Ctrl: false,
Shift: false,
@@ -166,8 +168,8 @@ func NewDefaultAppConfig() *AppConfig {
Editing: EditingConfig{
// 字体设置
FontSize: 13,
FontFamily: `"HarmonyOS SC", "HarmonyOS", "Microsoft YaHei", "PingFang SC", "Helvetica Neue", Arial, sans-serif`,
FontWeight: "normal",
FontFamily: `"HarmonyOS"`,
FontWeight: "400",
LineHeight: 1.5,
// Tab设置
EnableTabIndent: true,

View File

@@ -6,13 +6,13 @@ import (
// Document represents a document in the system
type Document struct {
ID int64 `json:"id" db:"id"`
Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
IsDeleted bool `json:"is_deleted" db:"is_deleted"`
IsLocked bool `json:"is_locked" db:"is_locked"` // 锁定标志,锁定的文档无法被删除
ID int64 `json:"id" db:"id"`
Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"`
CreatedAt string `json:"createdAt" db:"created_at"`
UpdatedAt string `json:"updatedAt" db:"updated_at"`
IsDeleted bool `json:"is_deleted" db:"is_deleted"`
IsLocked bool `json:"is_locked" db:"is_locked"` // 锁定标志,锁定的文档无法被删除
}
// NewDocument 创建新文档
@@ -21,8 +21,8 @@ func NewDocument(title, content string) *Document {
return &Document{
Title: title,
Content: content,
CreatedAt: now,
UpdatedAt: now,
CreatedAt: now.String(),
UpdatedAt: now.String(),
IsDeleted: false,
IsLocked: false, // 默认不锁定
}

View File

@@ -4,7 +4,6 @@ import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
)
// ThemeType 主题类型枚举
@@ -58,8 +57,8 @@ type Theme struct {
Type ThemeType `db:"type" json:"type"`
Colors ThemeColorConfig `db:"colors" json:"colors"`
IsDefault bool `db:"is_default" json:"isDefault"`
CreatedAt time.Time `db:"created_at" json:"createdAt"`
UpdatedAt time.Time `db:"updated_at" json:"updatedAt"`
CreatedAt string `db:"created_at" json:"createdAt"`
UpdatedAt string `db:"updated_at" json:"updatedAt"`
}
// Value 实现 driver.Valuer 接口,用于将 ThemeColorConfig 存储到数据库

View File

@@ -1,8 +1,10 @@
package services
import (
"context"
"errors"
"fmt"
"github.com/wailsapp/wails/v3/pkg/application"
"os"
"path/filepath"
"strings"
@@ -45,6 +47,13 @@ func NewBackupService(configService *ConfigService, dbService *DatabaseService,
}
}
func (ds *BackupService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
if err := ds.Initialize(); err != nil {
return fmt.Errorf("initializing backup service: %w", err)
}
return nil
}
// Initialize 初始化备份服务
func (s *BackupService) Initialize() error {
config, repoPath, err := s.getConfigAndPath()

View File

@@ -8,7 +8,6 @@ import (
"path/filepath"
"reflect"
"sync"
"time"
"voidraft/internal/models"
"github.com/wailsapp/wails/v3/pkg/application"
@@ -33,8 +32,8 @@ CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT DEFAULT '∞∞∞text-a',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
is_deleted INTEGER DEFAULT 0,
is_locked INTEGER DEFAULT 0
)`
@@ -46,8 +45,8 @@ CREATE TABLE IF NOT EXISTS extensions (
enabled INTEGER NOT NULL DEFAULT 1,
is_default INTEGER NOT NULL DEFAULT 0,
config TEXT DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`
// Key bindings table
@@ -59,8 +58,8 @@ CREATE TABLE IF NOT EXISTS key_bindings (
key TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
is_default INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(command, extension)
)`
@@ -72,8 +71,8 @@ CREATE TABLE IF NOT EXISTS themes (
type TEXT NOT NULL,
colors TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(type, is_default)
)`
)
@@ -363,10 +362,6 @@ func getSQLTypeAndDefault(t reflect.Type) (string, string) {
case reflect.String:
return "TEXT", "''"
default:
// 处理特殊类型
if t == reflect.TypeOf(time.Time{}) {
return "DATETIME", "CURRENT_TIMESTAMP"
}
return "TEXT", "NULL"
}
}

View File

@@ -141,15 +141,14 @@ func (ds *DocumentService) GetDocumentByID(id int64) (*models.Document, error) {
}
doc := &models.Document{}
var createdAt, updatedAt string
var isDeleted, isLocked int
err := ds.databaseService.db.QueryRow(sqlGetDocumentByID, id).Scan(
&doc.ID,
&doc.Title,
&doc.Content,
&createdAt,
&updatedAt,
&doc.CreatedAt,
&doc.UpdatedAt,
&isDeleted,
&isLocked,
)
@@ -161,14 +160,6 @@ func (ds *DocumentService) GetDocumentByID(id int64) (*models.Document, error) {
return nil, fmt.Errorf("failed to get document by ID: %w", err)
}
// 转换时间字段
if t, err := time.Parse("2006-01-02 15:04:05", createdAt); err == nil {
doc.CreatedAt = t
}
if t, err := time.Parse("2006-01-02 15:04:05", updatedAt); err == nil {
doc.UpdatedAt = t
}
// 转换布尔字段
doc.IsDeleted = isDeleted == 1
doc.IsLocked = isLocked == 1
@@ -186,7 +177,7 @@ func (ds *DocumentService) CreateDocument(title string) (*models.Document, error
}
// Create document with default content
now := time.Now()
now := time.Now().Format("2006-01-02 15:04:05")
doc := &models.Document{
Title: title,
Content: "∞∞∞text-a\n",
@@ -198,7 +189,7 @@ func (ds *DocumentService) CreateDocument(title string) (*models.Document, error
// 执行插入操作
result, err := ds.databaseService.db.Exec(sqlInsertDocument,
doc.Title, doc.Content, doc.CreatedAt.Format("2006-01-02 15:04:05"), doc.UpdatedAt.Format("2006-01-02 15:04:05"))
doc.Title, doc.Content, doc.CreatedAt, doc.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("failed to create document: %w", err)
}
@@ -220,7 +211,7 @@ func (ds *DocumentService) LockDocument(id int64) error {
return errors.New("database service not available")
}
// 先检查文档是否存在且未删除(不加锁避免死锁)
// 先检查文档是否存在且未删除
doc, err := ds.GetDocumentByID(id)
if err != nil {
return fmt.Errorf("failed to get document: %w", err)
@@ -254,7 +245,7 @@ func (ds *DocumentService) UnlockDocument(id int64) error {
return errors.New("database service not available")
}
// 先检查文档是否存在(不加锁避免死锁)
// 先检查文档是否存在
doc, err := ds.GetDocumentByID(id)
if err != nil {
return fmt.Errorf("failed to get document: %w", err)
@@ -380,14 +371,13 @@ func (ds *DocumentService) ListAllDocumentsMeta() ([]*models.Document, error) {
var documents []*models.Document
for rows.Next() {
doc := &models.Document{IsDeleted: false}
var createdAt, updatedAt string
var isLocked int
err := rows.Scan(
&doc.ID,
&doc.Title,
&createdAt,
&updatedAt,
&doc.CreatedAt,
&doc.UpdatedAt,
&isLocked,
)
@@ -395,14 +385,6 @@ func (ds *DocumentService) ListAllDocumentsMeta() ([]*models.Document, error) {
return nil, fmt.Errorf("failed to scan document row: %w", err)
}
// 转换时间字段
if t, err := time.Parse("2006-01-02 15:04:05", createdAt); err == nil {
doc.CreatedAt = t
}
if t, err := time.Parse("2006-01-02 15:04:05", updatedAt); err == nil {
doc.UpdatedAt = t
}
doc.IsLocked = isLocked == 1
documents = append(documents, doc)
}
@@ -432,14 +414,13 @@ func (ds *DocumentService) ListDeletedDocumentsMeta() ([]*models.Document, error
var documents []*models.Document
for rows.Next() {
doc := &models.Document{IsDeleted: true}
var createdAt, updatedAt string
var isLocked int
err := rows.Scan(
&doc.ID,
&doc.Title,
&createdAt,
&updatedAt,
&doc.CreatedAt,
&doc.UpdatedAt,
&isLocked,
)
@@ -447,14 +428,6 @@ func (ds *DocumentService) ListDeletedDocumentsMeta() ([]*models.Document, error
return nil, fmt.Errorf("failed to scan document row: %w", err)
}
// 转换时间字段
if t, err := time.Parse("2006-01-02 15:04:05", createdAt); err == nil {
doc.CreatedAt = t
}
if t, err := time.Parse("2006-01-02 15:04:05", updatedAt); err == nil {
doc.UpdatedAt = t
}
doc.IsLocked = isLocked == 1
documents = append(documents, doc)
}
@@ -465,24 +438,3 @@ func (ds *DocumentService) ListDeletedDocumentsMeta() ([]*models.Document, error
return documents, nil
}
// GetFirstDocumentID gets the first active document's ID for frontend initialization
func (ds *DocumentService) GetFirstDocumentID() (int64, error) {
ds.mu.RLock()
defer ds.mu.RUnlock()
if ds.databaseService == nil || ds.databaseService.db == nil {
return 0, errors.New("database service not available")
}
var id int64
err := ds.databaseService.db.QueryRow(sqlGetFirstDocumentID).Scan(&id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, nil // No documents exist
}
return 0, fmt.Errorf("failed to get first document ID: %w", err)
}
return id, nil
}

View File

@@ -266,3 +266,8 @@ func (sm *ServiceManager) GetBadgeService() *badge.BadgeService {
func (sm *ServiceManager) GetNotificationService() *notifications.NotificationService {
return sm.notificationService
}
// GetSystemService 获取系统服务实例
func (sm *ServiceManager) GetSystemService() *SystemService {
return sm.systemService
}

View File

@@ -4,12 +4,14 @@ import (
"fmt"
"runtime"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/log"
)
// SystemService 系统监控服务
type SystemService struct {
logger *log.LogService
app *application.App
}
// MemoryStats 内存统计信息
@@ -28,6 +30,23 @@ type MemoryStats struct {
NumGoroutine int `json:"numGoroutine"`
}
// SystemInfo 系统信息
type SystemInfo struct {
OS string `json:"os"`
Arch string `json:"arch"`
Debug bool `json:"debug"`
OSInfo *OSInfo `json:"osInfo"`
PlatformInfo map[string]interface{} `json:"platformInfo"`
}
// OSInfo 操作系统信息
type OSInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Branding string `json:"branding"`
}
// NewSystemService 创建新的系统服务实例
func NewSystemService(logger *log.LogService) *SystemService {
return &SystemService{
@@ -35,6 +54,11 @@ func NewSystemService(logger *log.LogService) *SystemService {
}
}
// SetAppReferences 设置应用引用
func (ss *SystemService) SetAppReferences(app *application.App) {
ss.app = app
}
// GetMemoryStats 获取当前内存统计信息
func (ss *SystemService) GetMemoryStats() MemoryStats {
var m runtime.MemStats
@@ -50,6 +74,34 @@ func (ss *SystemService) GetMemoryStats() MemoryStats {
}
}
// GetSystemInfo 获取系统环境信息
func (ss *SystemService) GetSystemInfo() (*SystemInfo, error) {
if ss.app == nil {
return nil, fmt.Errorf("app reference not set")
}
envInfo := ss.app.Env.Info()
systemInfo := &SystemInfo{
OS: envInfo.OS,
Arch: envInfo.Arch,
Debug: envInfo.Debug,
PlatformInfo: envInfo.PlatformInfo,
}
// 转换OSInfo
if envInfo.OSInfo != nil {
systemInfo.OSInfo = &OSInfo{
ID: envInfo.OSInfo.ID,
Name: envInfo.OSInfo.Name,
Version: envInfo.OSInfo.Version,
Branding: envInfo.OSInfo.Branding,
}
}
return systemInfo, nil
}
// FormatBytes 格式化字节数为人类可读的格式
func (ss *SystemService) FormatBytes(bytes uint64) string {
const unit = 1024

View File

@@ -62,13 +62,14 @@ func (ts *ThemeService) initializeDefaultThemes() error {
}
// 创建默认深色主题
now := time.Now().Format("2006-01-02 15:04:05")
darkTheme := &models.Theme{
Name: "Default Dark",
Type: models.ThemeTypeDark,
Colors: *models.NewDefaultDarkTheme(),
IsDefault: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: now,
UpdatedAt: now,
}
// 创建默认浅色主题
@@ -77,8 +78,8 @@ func (ts *ThemeService) initializeDefaultThemes() error {
Type: models.ThemeTypeLight,
Colors: *models.NewDefaultLightTheme(),
IsDefault: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: now,
UpdatedAt: now,
}
// 插入默认主题
@@ -174,7 +175,7 @@ func (ts *ThemeService) UpdateThemeColors(themeType models.ThemeType, colors mod
`
db := ts.getDB()
_, err := db.Exec(query, colors, time.Now(), themeType)
_, err := db.Exec(query, colors, time.Now().Format("2006-01-02 15:04:05"), themeType)
if err != nil {
return fmt.Errorf("failed to update theme colors: %w", err)
}

View File

@@ -89,14 +89,15 @@ func (s *TranslationService) TranslateWith(text string, from string, to string,
return trans.TranslateWithParams(text, params)
}
// GetAvailableTranslators 获取所有可用翻译器类型
// GetTranslators 获取所有可用翻译器类型
// @returns {[]string} 翻译器类型列表
func (s *TranslationService) GetAvailableTranslators() []string {
func (s *TranslationService) GetTranslators() []string {
return []string{
string(translator.GoogleTranslatorType),
string(translator.BingTranslatorType),
string(translator.GoogleTranslatorType),
string(translator.YoudaoTranslatorType),
string(translator.DeeplTranslatorType),
string(translator.TartuNLPTranslatorType),
}
}
@@ -122,12 +123,3 @@ func (s *TranslationService) IsLanguageSupported(translatorType translator.Trans
}
return translator.IsLanguageSupported(languageCode)
}
// GetStandardLanguageCode 获取标准化的语言代码
func (s *TranslationService) GetStandardLanguageCode(translatorType translator.TranslatorType, languageCode string) string {
translator, err := s.getTranslator(translatorType)
if err != nil {
return ""
}
return translator.GetStandardLanguageCode(languageCode)
}

View File

@@ -64,7 +64,6 @@ func main() {
},
},
})
// Create a new window with the necessary options.
// 'Title' is the title of the window.
// 'Mac' options tailor the window when running on macOS.
@@ -92,6 +91,10 @@ func main() {
mainWindow.Center()
window = mainWindow
// 获取系统服务并设置应用引用
systemService := serviceManager.GetSystemService()
systemService.SetAppReferences(app)
// 获取托盘服务并设置应用引用
trayService := serviceManager.GetTrayService()
trayService.SetAppReferences(app, mainWindow)

View File

@@ -1 +1 @@
VERSION=1.4.1
VERSION=1.4.2