20 Commits

Author SHA1 Message Date
852424356a 🐛 Fixed startup failure issue 2025-10-20 23:07:29 +08:00
b704dd2438 🎨 Optimize code 2025-10-20 23:00:04 +08:00
aa8139884b 🎨 Optimize preset theme code 2025-10-20 21:58:37 +08:00
9a15df01ee Added preset theme 2025-10-19 23:57:03 +08:00
03780b5bc7 ⬆️ Upgrade dependencies 2025-10-10 23:40:23 +08:00
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
f5bfff80b7 🚨 Format code 2025-09-24 21:44:42 +08:00
1462d8a753 🐛 Fixed docker prettier plugin issue 2025-09-24 19:14:10 +08:00
195 changed files with 11071 additions and 7298 deletions

View File

@@ -5,303 +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 slog$0 from "../../../../../../log/slog/models.js";
export class App {
/**
* Manager pattern for organized API
*/
"Window": WindowManager | null;
"ContextMenu": ContextMenuManager | null;
"KeyBinding": KeyBindingManager | null;
"Browser": BrowserManager | null;
"Env": EnvironmentManager | null;
"Dialog": DialogManager | null;
"Event": EventManager | null;
"Menu": MenuManager | null;
"Screen": ScreenManager | null;
"Clipboard": ClipboardManager | null;
"SystemTray": SystemTrayManager | null;
"Logger": slog$0.Logger | null;
/** Creates a new App instance. */
constructor($$source: Partial<App> = {}) {
if (!("Window" in $$source)) {
this["Window"] = null;
}
if (!("ContextMenu" in $$source)) {
this["ContextMenu"] = null;
}
if (!("KeyBinding" in $$source)) {
this["KeyBinding"] = null;
}
if (!("Browser" in $$source)) {
this["Browser"] = null;
}
if (!("Env" in $$source)) {
this["Env"] = null;
}
if (!("Dialog" in $$source)) {
this["Dialog"] = null;
}
if (!("Event" in $$source)) {
this["Event"] = null;
}
if (!("Menu" in $$source)) {
this["Menu"] = null;
}
if (!("Screen" in $$source)) {
this["Screen"] = null;
}
if (!("Clipboard" in $$source)) {
this["Clipboard"] = null;
}
if (!("SystemTray" in $$source)) {
this["SystemTray"] = null;
}
if (!("Logger" in $$source)) {
this["Logger"] = null;
}
Object.assign(this, $$source);
}
/**
* Creates a new App instance from a string or object.
*/
static createFrom($$source: any = {}): App {
const $$createField0_0 = $$createType1;
const $$createField1_0 = $$createType3;
const $$createField2_0 = $$createType5;
const $$createField3_0 = $$createType7;
const $$createField4_0 = $$createType9;
const $$createField5_0 = $$createType11;
const $$createField6_0 = $$createType13;
const $$createField7_0 = $$createType15;
const $$createField8_0 = $$createType17;
const $$createField9_0 = $$createType19;
const $$createField10_0 = $$createType21;
const $$createField11_0 = $$createType23;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("Window" in $$parsedSource) {
$$parsedSource["Window"] = $$createField0_0($$parsedSource["Window"]);
}
if ("ContextMenu" in $$parsedSource) {
$$parsedSource["ContextMenu"] = $$createField1_0($$parsedSource["ContextMenu"]);
}
if ("KeyBinding" in $$parsedSource) {
$$parsedSource["KeyBinding"] = $$createField2_0($$parsedSource["KeyBinding"]);
}
if ("Browser" in $$parsedSource) {
$$parsedSource["Browser"] = $$createField3_0($$parsedSource["Browser"]);
}
if ("Env" in $$parsedSource) {
$$parsedSource["Env"] = $$createField4_0($$parsedSource["Env"]);
}
if ("Dialog" in $$parsedSource) {
$$parsedSource["Dialog"] = $$createField5_0($$parsedSource["Dialog"]);
}
if ("Event" in $$parsedSource) {
$$parsedSource["Event"] = $$createField6_0($$parsedSource["Event"]);
}
if ("Menu" in $$parsedSource) {
$$parsedSource["Menu"] = $$createField7_0($$parsedSource["Menu"]);
}
if ("Screen" in $$parsedSource) {
$$parsedSource["Screen"] = $$createField8_0($$parsedSource["Screen"]);
}
if ("Clipboard" in $$parsedSource) {
$$parsedSource["Clipboard"] = $$createField9_0($$parsedSource["Clipboard"]);
}
if ("SystemTray" in $$parsedSource) {
$$parsedSource["SystemTray"] = $$createField10_0($$parsedSource["SystemTray"]);
}
if ("Logger" in $$parsedSource) {
$$parsedSource["Logger"] = $$createField11_0($$parsedSource["Logger"]);
}
return new App($$parsedSource as Partial<App>);
}
}
/**
* BrowserManager manages browser-related operations
*/
export class BrowserManager {
/** Creates a new BrowserManager instance. */
constructor($$source: Partial<BrowserManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new BrowserManager instance from a string or object.
*/
static createFrom($$source: any = {}): BrowserManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new BrowserManager($$parsedSource as Partial<BrowserManager>);
}
}
/**
* ClipboardManager manages clipboard operations
*/
export class ClipboardManager {
/** Creates a new ClipboardManager instance. */
constructor($$source: Partial<ClipboardManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new ClipboardManager instance from a string or object.
*/
static createFrom($$source: any = {}): ClipboardManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new ClipboardManager($$parsedSource as Partial<ClipboardManager>);
}
}
/**
* ContextMenuManager manages all context menu operations
*/
export class ContextMenuManager {
/** Creates a new ContextMenuManager instance. */
constructor($$source: Partial<ContextMenuManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new ContextMenuManager instance from a string or object.
*/
static createFrom($$source: any = {}): ContextMenuManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new ContextMenuManager($$parsedSource as Partial<ContextMenuManager>);
}
}
/**
* DialogManager manages dialog-related operations
*/
export class DialogManager {
/** Creates a new DialogManager instance. */
constructor($$source: Partial<DialogManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new DialogManager instance from a string or object.
*/
static createFrom($$source: any = {}): DialogManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new DialogManager($$parsedSource as Partial<DialogManager>);
}
}
/**
* EnvironmentManager manages environment-related operations
*/
export class EnvironmentManager {
/** Creates a new EnvironmentManager instance. */
constructor($$source: Partial<EnvironmentManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new EnvironmentManager instance from a string or object.
*/
static createFrom($$source: any = {}): EnvironmentManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new EnvironmentManager($$parsedSource as Partial<EnvironmentManager>);
}
}
/**
* EventManager manages event-related operations
*/
export class EventManager {
/** Creates a new EventManager instance. */
constructor($$source: Partial<EventManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new EventManager instance from a string or object.
*/
static createFrom($$source: any = {}): EventManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new EventManager($$parsedSource as Partial<EventManager>);
}
}
/**
* KeyBindingManager manages all key binding operations
*/
export class KeyBindingManager {
/** Creates a new KeyBindingManager instance. */
constructor($$source: Partial<KeyBindingManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new KeyBindingManager instance from a string or object.
*/
static createFrom($$source: any = {}): KeyBindingManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new KeyBindingManager($$parsedSource as Partial<KeyBindingManager>);
}
}
/**
* MenuManager manages menu-related operations
*/
export class MenuManager {
/** Creates a new MenuManager instance. */
constructor($$source: Partial<MenuManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new MenuManager instance from a string or object.
*/
static createFrom($$source: any = {}): MenuManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new MenuManager($$parsedSource as Partial<MenuManager>);
}
}
export class ScreenManager {
/** Creates a new ScreenManager instance. */
constructor($$source: Partial<ScreenManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new ScreenManager instance from a string or object.
*/
static createFrom($$source: any = {}): ScreenManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new ScreenManager($$parsedSource as Partial<ScreenManager>);
}
}
/**
* ServiceOptions provides optional parameters for calls to [NewService].
*/
@@ -359,26 +62,6 @@ export class ServiceOptions {
}
}
/**
* SystemTrayManager manages system tray-related operations
*/
export class SystemTrayManager {
/** Creates a new SystemTrayManager instance. */
constructor($$source: Partial<SystemTrayManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new SystemTrayManager instance from a string or object.
*/
static createFrom($$source: any = {}): SystemTrayManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new SystemTrayManager($$parsedSource as Partial<SystemTrayManager>);
}
}
export class WebviewWindow {
/** Creates a new WebviewWindow instance. */
@@ -395,49 +78,3 @@ export class WebviewWindow {
return new WebviewWindow($$parsedSource as Partial<WebviewWindow>);
}
}
/**
* WindowManager manages all window-related operations
*/
export class WindowManager {
/** Creates a new WindowManager instance. */
constructor($$source: Partial<WindowManager> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new WindowManager instance from a string or object.
*/
static createFrom($$source: any = {}): WindowManager {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new WindowManager($$parsedSource as Partial<WindowManager>);
}
}
// Private type creation functions
const $$createType0 = WindowManager.createFrom;
const $$createType1 = $Create.Nullable($$createType0);
const $$createType2 = ContextMenuManager.createFrom;
const $$createType3 = $Create.Nullable($$createType2);
const $$createType4 = KeyBindingManager.createFrom;
const $$createType5 = $Create.Nullable($$createType4);
const $$createType6 = BrowserManager.createFrom;
const $$createType7 = $Create.Nullable($$createType6);
const $$createType8 = EnvironmentManager.createFrom;
const $$createType9 = $Create.Nullable($$createType8);
const $$createType10 = DialogManager.createFrom;
const $$createType11 = $Create.Nullable($$createType10);
const $$createType12 = EventManager.createFrom;
const $$createType13 = $Create.Nullable($$createType12);
const $$createType14 = MenuManager.createFrom;
const $$createType15 = $Create.Nullable($$createType14);
const $$createType16 = ScreenManager.createFrom;
const $$createType17 = $Create.Nullable($$createType16);
const $$createType18 = ClipboardManager.createFrom;
const $$createType19 = $Create.Nullable($$createType18);
const $$createType20 = SystemTrayManager.createFrom;
const $$createType21 = $Create.Nullable($$createType20);
const $$createType22 = slog$0.Logger.createFrom;
const $$createType23 = $Create.Nullable($$createType22);

View File

@@ -2,7 +2,7 @@
// This file is automatically generated. DO NOT EDIT
/**
* Service represents the notifications service
* Service represents the dock service
* @module
*/
@@ -18,11 +18,19 @@ import * as application$0 from "../../application/models.js";
// @ts-ignore: Unused imports
import * as $models from "./models.js";
/**
* HideAppIcon hides the app icon in the dock/taskbar.
*/
export function HideAppIcon(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3413658144) as any;
return $resultPromise;
}
/**
* RemoveBadge removes the badge label from the application icon.
*/
export function RemoveBadge(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2374916939) as any;
let $resultPromise = $Call.ByID(2752757297) as any;
return $resultPromise;
}
@@ -30,7 +38,7 @@ export function RemoveBadge(): Promise<void> & { cancel(): void } {
* ServiceName returns the name of the service.
*/
export function ServiceName(): Promise<string> & { cancel(): void } {
let $resultPromise = $Call.ByID(2428202016) as any;
let $resultPromise = $Call.ByID(2949906614) as any;
return $resultPromise;
}
@@ -38,7 +46,7 @@ export function ServiceName(): Promise<string> & { cancel(): void } {
* ServiceShutdown is called when the service is unloaded.
*/
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3893755233) as any;
let $resultPromise = $Call.ByID(307064411) as any;
return $resultPromise;
}
@@ -46,7 +54,7 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
* ServiceStartup is called when the service is loaded.
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(4078800764, options) as any;
let $resultPromise = $Call.ByID(1350118426, options) as any;
return $resultPromise;
}
@@ -54,11 +62,22 @@ export function ServiceStartup(options: application$0.ServiceOptions): Promise<v
* SetBadge sets the badge label on the application icon.
*/
export function SetBadge(label: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(784276339, label) as any;
let $resultPromise = $Call.ByID(1717705661, label) as any;
return $resultPromise;
}
export function SetCustomBadge(label: string, options: $models.Options): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3058653106, label, options) as any;
/**
* SetCustomBadge sets the badge label on the application icon with custom options.
*/
export function SetCustomBadge(label: string, options: $models.BadgeOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2730169760, label, options) as any;
return $resultPromise;
}
/**
* ShowAppIcon shows the app icon in the dock/taskbar.
*/
export function ShowAppIcon(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3409697379) as any;
return $resultPromise;
}

View File

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

View File

@@ -9,15 +9,18 @@ import {Create as $Create} from "@wailsio/runtime";
// @ts-ignore: Unused imports
import * as color$0 from "../../../../../../../image/color/models.js";
export class Options {
/**
* BadgeOptions represents options for customizing badge appearance
*/
export class BadgeOptions {
"TextColour": color$0.RGBA;
"BackgroundColour": color$0.RGBA;
"FontName": string;
"FontSize": number;
"SmallFontSize": number;
/** Creates a new Options instance. */
constructor($$source: Partial<Options> = {}) {
/** Creates a new BadgeOptions instance. */
constructor($$source: Partial<BadgeOptions> = {}) {
if (!("TextColour" in $$source)) {
this["TextColour"] = (new color$0.RGBA());
}
@@ -38,9 +41,9 @@ export class Options {
}
/**
* Creates a new Options instance from a string or object.
* Creates a new BadgeOptions instance from a string or object.
*/
static createFrom($$source: any = {}): Options {
static createFrom($$source: any = {}): BadgeOptions {
const $$createField0_0 = $$createType0;
const $$createField1_0 = $$createType0;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
@@ -50,7 +53,7 @@ export class Options {
if ("BackgroundColour" in $$parsedSource) {
$$parsedSource["BackgroundColour"] = $$createField1_0($$parsedSource["BackgroundColour"]);
}
return new Options($$parsedSource as Partial<Options>);
return new BadgeOptions($$parsedSource as Partial<BadgeOptions>);
}
}

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,31 +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 Logger records structured information about each call to its
* Log, Debug, Info, Warn, and Error methods.
* For each call, it creates a [Record] and passes it to a [Handler].
*
* To create a new Logger, call [New] or a Logger method
* that begins "With".
*/
export class Logger {
/** Creates a new Logger instance. */
constructor($$source: Partial<Logger> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new Logger instance from a string or object.
*/
static createFrom($$source: any = {}): Logger {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new Logger($$parsedSource as Partial<Logger>);
}
}

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 应用配置 - 按照前端设置页面分类组织
*/
@@ -114,6 +110,11 @@ export class AppearanceConfig {
*/
"systemTheme": SystemThemeType;
/**
* 当前选择的预设主题名称
*/
"currentTheme": string;
/** Creates a new AppearanceConfig instance. */
constructor($$source: Partial<AppearanceConfig> = {}) {
if (!("language" in $$source)) {
@@ -122,6 +123,9 @@ export class AppearanceConfig {
if (!("systemTheme" in $$source)) {
this["systemTheme"] = ("" as SystemThemeType);
}
if (!("currentTheme" in $$source)) {
this["currentTheme"] = "";
}
Object.assign(this, $$source);
}
@@ -196,8 +200,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 +221,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 +494,11 @@ export class GeneralConfig {
*/
"enableLoadingAnimation": boolean;
/**
* 是否启用标签页模式
*/
"enableTabs": boolean;
/** Creates a new GeneralConfig instance. */
constructor($$source: Partial<GeneralConfig> = {}) {
if (!("alwaysOnTop" in $$source)) {
@@ -516,6 +525,9 @@ export class GeneralConfig {
if (!("enableLoadingAnimation" in $$source)) {
this["enableLoadingAnimation"] = false;
}
if (!("enableTabs" in $$source)) {
this["enableTabs"] = false;
}
Object.assign(this, $$source);
}
@@ -1143,8 +1155,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 +1176,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);
@@ -1187,9 +1199,20 @@ export class Theme {
}
/**
* ThemeColorConfig 主题颜色配置
* ThemeColorConfig 主题颜色配置(与前端 ThemeColors 接口保持一致)
*/
export class ThemeColorConfig {
/**
* 主题基本信息
* 主题名称
*/
"name": string;
/**
* 是否为深色主题
*/
"dark": boolean;
/**
* 基础色调
* 主背景色
@@ -1197,7 +1220,7 @@ export class ThemeColorConfig {
"background": string;
/**
* 次要背景色
* 次要背景色(用于代码块交替背景)
*/
"backgroundSecondary": string;
@@ -1207,6 +1230,17 @@ export class ThemeColorConfig {
"surface": string;
/**
* 下拉菜单背景
*/
"dropdownBackground": string;
/**
* 下拉菜单边框
*/
"dropdownBorder": string;
/**
* 文本颜色
* 主文本色
*/
"foreground": string;
@@ -1217,12 +1251,12 @@ export class ThemeColorConfig {
"foregroundSecondary": string;
/**
* 语法高亮
* 注释色
*/
"comment": string;
/**
* 语法高亮色 - 核心
* 关键字
*/
"keyword": string;
@@ -1257,6 +1291,42 @@ export class ThemeColorConfig {
*/
"type": string;
/**
* 语法高亮色 - 扩展
* 常量
*/
"constant": string;
/**
* 存储类型(如 static, const
*/
"storage": string;
/**
* 参数
*/
"parameter": string;
/**
* 类名
*/
"class": string;
/**
* 标题Markdown等
*/
"heading": string;
/**
* 无效内容/错误
*/
"invalid": string;
/**
* 正则表达式
*/
"regexp": string;
/**
* 界面元素
* 光标
@@ -1284,12 +1354,12 @@ export class ThemeColorConfig {
"lineNumber": string;
/**
* 活动行号
* 活动行号颜色
*/
"activeLineNumber": string;
/**
* 边框分割线
* 边框分割线
* 边框色
*/
"borderColor": string;
@@ -1300,7 +1370,7 @@ export class ThemeColorConfig {
"borderLight": string;
/**
* 搜索匹配
* 搜索匹配
* 搜索匹配
*/
"searchMatch": string;
@@ -1312,6 +1382,12 @@ export class ThemeColorConfig {
/** Creates a new ThemeColorConfig instance. */
constructor($$source: Partial<ThemeColorConfig> = {}) {
if (!("name" in $$source)) {
this["name"] = "";
}
if (!("dark" in $$source)) {
this["dark"] = false;
}
if (!("background" in $$source)) {
this["background"] = "";
}
@@ -1321,6 +1397,12 @@ export class ThemeColorConfig {
if (!("surface" in $$source)) {
this["surface"] = "";
}
if (!("dropdownBackground" in $$source)) {
this["dropdownBackground"] = "";
}
if (!("dropdownBorder" in $$source)) {
this["dropdownBorder"] = "";
}
if (!("foreground" in $$source)) {
this["foreground"] = "";
}
@@ -1351,6 +1433,27 @@ export class ThemeColorConfig {
if (!("type" in $$source)) {
this["type"] = "";
}
if (!("constant" in $$source)) {
this["constant"] = "";
}
if (!("storage" in $$source)) {
this["storage"] = "";
}
if (!("parameter" in $$source)) {
this["parameter"] = "";
}
if (!("class" in $$source)) {
this["class"] = "";
}
if (!("heading" in $$source)) {
this["heading"] = "";
}
if (!("invalid" in $$source)) {
this["invalid"] = "";
}
if (!("regexp" in $$source)) {
this["regexp"] = "";
}
if (!("cursor" in $$source)) {
this["cursor"] = "";
}

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

@@ -10,10 +10,6 @@
// @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";
/**
* SelectDirectory 打开目录选择对话框
*/
@@ -29,11 +25,3 @@ export function SelectFile(): Promise<string> & { cancel(): void } {
let $resultPromise = $Call.ByID(37302920) as any;
return $resultPromise;
}
/**
* SetWindow 设置绑定的窗口
*/
export function SetWindow(window: application$0.WebviewWindow | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(968177170, window) as any;
return $resultPromise;
}

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

@@ -32,8 +32,8 @@ export function GetCurrentHotkey(): Promise<models$0.HotkeyCombo | null> & { can
/**
* Initialize 初始化热键服务
*/
export function Initialize(app: application$0.App | null, mainWindow: application$0.WebviewWindow | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3671360458, app, mainWindow) as any;
export function Initialize(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3671360458) as any;
return $resultPromise;
}
@@ -61,6 +61,14 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
return $resultPromise;
}
/**
* ServiceStartup initializes the service when the application starts
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3079990808, options) as any;
return $resultPromise;
}
/**
* UnregisterHotkey 取消注册全局热键
*/

View File

@@ -18,6 +18,7 @@ import * as ThemeService from "./themeservice.js";
import * as TranslationService from "./translationservice.js";
import * as TrayService from "./trayservice.js";
import * as WindowService from "./windowservice.js";
import * as WindowSnapService from "./windowsnapservice.js";
export {
BackupService,
ConfigService,
@@ -35,7 +36,8 @@ export {
ThemeService,
TranslationService,
TrayService,
WindowService
WindowService,
WindowSnapService
};
export * from "./models.js";

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 自我更新结果
*/
@@ -203,7 +239,55 @@ export class SelfUpdateResult {
}
/**
* WindowInfo 窗口信息(简化版)
* 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 窗口信息
*/
export class WindowInfo {
"Window": application$0.WebviewWindow | null;
@@ -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

@@ -34,6 +34,18 @@ 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;
}
/**
* TriggerGC 手动触发垃圾回收
*/
@@ -44,3 +56,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

@@ -17,18 +17,6 @@ import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/applic
// @ts-ignore: Unused imports
import * as models$0 from "../models/models.js";
/**
* CreateTheme 创建新主题
*/
export function CreateTheme(theme: models$0.Theme | null): Promise<models$0.Theme | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(3274757686, theme) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* GetAllThemes 获取所有主题
*/
@@ -42,22 +30,11 @@ export function GetAllThemes(): Promise<(models$0.Theme | null)[]> & { cancel():
}
/**
* GetDefaultThemes 获取默认主题
* GetThemeByID 根据ID或名称获取主题
* 如果 id > 0按ID查询如果 id = 0按名称查询
*/
export function GetDefaultThemes(): Promise<{ [_: string]: models$0.Theme | null }> & { cancel(): void } {
let $resultPromise = $Call.ByID(3801788118) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType3($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* GetThemeByType 根据类型获取默认主题
*/
export function GetThemeByType(themeType: models$0.ThemeType): Promise<models$0.Theme | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(1680465265, themeType) as any;
export function GetThemeByIdOrName(id: number, ...name: string[]): Promise<models$0.Theme | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(127385338, id, name) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
@@ -66,10 +43,10 @@ export function GetThemeByType(themeType: models$0.ThemeType): Promise<models$0.
}
/**
* ResetThemeColors 重置主题颜色为默认值
* ResetTheme 重置主题为预设配置
*/
export function ResetThemeColors(themeType: models$0.ThemeType): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(342461245, themeType) as any;
export function ResetTheme(id: number, ...name: string[]): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1806334457, id, name) as any;
return $resultPromise;
}
@@ -90,10 +67,10 @@ export function ServiceStartup(options: application$0.ServiceOptions): Promise<v
}
/**
* UpdateThemeColors 更新主题颜色
* UpdateTheme 更新主题
*/
export function UpdateThemeColors(themeType: models$0.ThemeType, colors: models$0.ThemeColorConfig): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2750902529, themeType, colors) as any;
export function UpdateTheme(id: number, colors: models$0.ThemeColorConfig): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(70189749, id, colors) as any;
return $resultPromise;
}
@@ -101,4 +78,3 @@ export function UpdateThemeColors(themeType: models$0.ThemeType, colors: models$
const $$createType0 = models$0.Theme.createFrom;
const $$createType1 = $Create.Nullable($$createType0);
const $$createType2 = $Create.Array($$createType1);
const $$createType3 = $Create.Map($Create.Any, $$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

@@ -10,10 +10,6 @@
// @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";
/**
* HandleWindowClose 处理窗口关闭事件
*/
@@ -38,14 +34,6 @@ export function MinimizeButtonClicked(): Promise<void> & { cancel(): void } {
return $resultPromise;
}
/**
* SetAppReferences 设置应用引用
*/
export function SetAppReferences(app: application$0.App | null, mainWindow: application$0.WebviewWindow | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3544515719, app, mainWindow) as any;
return $resultPromise;
}
/**
* ShouldMinimizeToTray 检查是否应该最小化到托盘
*/

View File

@@ -10,10 +10,6 @@
// @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";
@@ -54,14 +50,6 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
return $resultPromise;
}
/**
* SetAppReferences 设置应用和主窗口引用
*/
export function SetAppReferences(app: application$0.App | null, mainWindow: application$0.WebviewWindow | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1120840759, app, mainWindow) as any;
return $resultPromise;
}
/**
* SetWindowSnapService 设置窗口吸附服务引用
*/

View File

@@ -0,0 +1,79 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* WindowSnapService 窗口吸附服务
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
/**
* Cleanup 清理资源
*/
export function Cleanup(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2155505498) as any;
return $resultPromise;
}
/**
* GetCurrentThreshold 获取当前自适应阈值(用于调试或显示)
*/
export function GetCurrentThreshold(): Promise<number> & { cancel(): void } {
let $resultPromise = $Call.ByID(3176419026) as any;
return $resultPromise;
}
/**
* OnWindowSnapConfigChanged 处理窗口吸附配置变更
*/
export function OnWindowSnapConfigChanged(enabled: boolean): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3794787039, enabled) as any;
return $resultPromise;
}
/**
* RegisterWindow 注册需要吸附管理的窗口
*/
export function RegisterWindow(documentID: number, window: application$0.WebviewWindow | null, title: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1000222723, documentID, window, title) as any;
return $resultPromise;
}
/**
* ServiceShutdown 实现服务关闭接口
*/
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1172710495) as any;
return $resultPromise;
}
/**
* ServiceStartup 服务启动时初始化
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2456823262, options) as any;
return $resultPromise;
}
/**
* SetSnapEnabled 设置是否启用窗口吸附
*/
export function SetSnapEnabled(enabled: boolean): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2280126835, enabled) as any;
return $resultPromise;
}
/**
* UnregisterWindow 取消注册窗口
*/
export function UnregisterWindow(documentID: number): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2844230768, documentID) as any;
return $resultPromise;
}

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']

View File

@@ -50,7 +50,11 @@ export default defineConfig([
'.local',
'/bin',
'Dockerfile',
'**/bindings/'
'**/bindings/',
'*.js',
'**/*.js',
'**/*.cjs',
'**/*.mjs',
],
}
]);

File diff suppressed because it is too large Load Diff

View File

@@ -9,49 +9,50 @@
"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/commands": "^6.8.1",
"@codemirror/autocomplete": "^6.19.0",
"@codemirror/commands": "^6.9.0",
"@codemirror/lang-angular": "^0.1.4",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.10",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-less": "^6.0.2",
"@codemirror/lang-lezer": "^6.0.2",
"@codemirror/lang-liquid": "^6.3.0",
"@codemirror/lang-markdown": "^6.3.4",
"@codemirror/lang-markdown": "^6.4.0",
"@codemirror/lang-php": "^6.0.2",
"@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/lint": "^6.8.5",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/lint": "^6.9.0",
"@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.2",
"@codemirror/view": "^6.38.6",
"@cospaia/prettier-plugin-clojure": "^0.0.2",
"@lezer/highlight": "^1.2.1",
"@lezer/highlight": "^1.2.2",
"@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"
"vue-router": "^4.6.3"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
"@eslint/js": "^9.38.0",
"@lezer/generator": "^1.8.0",
"@types/node": "^24.3.1",
"@types/node": "^24.8.1",
"@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.38.0",
"eslint-plugin-vue": "^10.5.1",
"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.46.1",
"unplugin-vue-components": "^29.1.0",
"vite": "^7.1.10",
"vite-plugin-node-polyfills": "^0.24.0",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.6"
"vue-tsc": "^3.1.1"
}
}

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 = {
@@ -61,7 +62,8 @@ export const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
export const APPEARANCE_CONFIG_KEY_MAP: AppearanceConfigKeyMap = {
language: 'appearance.language',
systemTheme: 'appearance.systemTheme'
systemTheme: 'appearance.systemTheme',
currentTheme: 'appearance.currentTheme'
} as const;
export const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = {
@@ -113,11 +115,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,
@@ -126,7 +129,8 @@ export const DEFAULT_CONFIG: AppConfig = {
},
appearance: {
language: LanguageType.LangZhCN,
systemTheme: SystemThemeType.SystemThemeAuto
systemTheme: SystemThemeType.SystemThemeAuto,
currentTheme: 'default-dark'
},
updates: {
version: "1.0.0",

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

@@ -78,7 +78,7 @@ _69: () => {
_70: () => {
return typeof process != "undefined" &&
Object.prototype.toString.call(process) == "[object process]" &&
process.platform == "win32"
process.platform == "win32";
},
_85: s => JSON.stringify(s),
_86: s => printToConsole(s),
@@ -126,7 +126,7 @@ _157: Function.prototype.call.bind(DataView.prototype.setFloat32),
_158: Function.prototype.call.bind(DataView.prototype.getFloat64),
_159: Function.prototype.call.bind(DataView.prototype.setFloat64),
_165: x0 => format = x0,
_166: f => finalizeWrapper(f, function(x0,x1,x2) { return dartInstance.exports._166(f,arguments.length,x0,x1,x2) }),
_166: f => finalizeWrapper(f, function(x0,x1,x2) { return dartInstance.exports._166(f,arguments.length,x0,x1,x2); }),
_184: (c) =>
queueMicrotask(() => dartInstance.exports.$invokeCallback(c)),
_187: (s, m) => {
@@ -337,14 +337,14 @@ _272: (x0,x1) => x0.lastIndex = x1
});
return dartInstance;
}
};
// Call the main function for the instantiated module
// `moduleInstance` is the instantiated dart2wasm module
// `args` are any arguments that should be passed into the main function.
export const invoke = (moduleInstance, ...args) => {
moduleInstance.exports.$invokeMain(args);
}
};
export let format;

View File

@@ -7,7 +7,7 @@ import (
"strings"
"syscall/js"
"github.com/reteps/dockerfmt/lib"
"docker_fmt/lib"
)
func Format(this js.Value, args []js.Value) any {
@@ -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

@@ -2,15 +2,16 @@ module docker_fmt
go 1.25.0
require github.com/reteps/dockerfmt v0.3.7
require (
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/moby/buildkit v0.24.0
mvdan.cc/sh/v3 v3.12.0
)
require (
github.com/containerd/typeurl/v2 v2.2.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/moby/buildkit v0.20.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
google.golang.org/protobuf v1.35.2 // indirect
mvdan.cc/sh/v3 v3.11.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)

View File

@@ -18,6 +18,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/moby/buildkit v0.20.2 h1:qIeR47eQ1tzI1rwz0on3Xx2enRw/1CKjFhoONVcTlMA=
github.com/moby/buildkit v0.20.2/go.mod h1:DhaF82FjwOElTftl0JUAJpH/SUIUx4UvcFncLeOtlDI=
github.com/moby/buildkit v0.24.0 h1:qYfTl7W1SIJzWDIDCcPT8FboHIZCYfi++wvySi3eyFE=
github.com/moby/buildkit v0.24.0/go.mod h1:4qovICAdR2H4C7+EGMRva5zgHW1gyhT4/flHI7F5F9k=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
@@ -59,7 +61,11 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/sh/v3 v3.11.0 h1:q5h+XMDRfUGUedCqFFsjoFjrhwf2Mvtt1rkMvVz0blw=
mvdan.cc/sh/v3 v3.11.0/go.mod h1:LRM+1NjoYCzuq/WZ6y44x14YNAI0NK7FLPeQSaFagGg=
mvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI=
mvdan.cc/sh/v3 v3.12.0/go.mod h1:Se6Cj17eYSn+sNooLZiEUnNNmNxg0imoYlTu4CyaGyg=

View File

@@ -1,5 +1,5 @@
import type { Plugin, SupportLanguage, Parser, Printer, SupportOption } from 'prettier'
import dockerfileInit, { format } from './docker_fmt_vite.js'
import type { Plugin, SupportLanguage, Parser, Printer, SupportOption } from 'prettier';
import dockerfileInit, { format } from './docker_fmt_vite.js';
// Language configuration for Dockerfile
const languages: SupportLanguage[] = [
@@ -11,7 +11,7 @@ const languages: SupportLanguage[] = [
linguistLanguageId: 99,
vscodeLanguageIds: ['dockerfile'],
},
]
];
// Parser configuration
const parsers: Record<string, Parser<any>> = {
@@ -19,69 +19,60 @@ const parsers: Record<string, Parser<any>> = {
parse: (text: string) => {
// For Dockerfile, we don't need complex parsing, just return the text
// The formatting will be handled by the print function
return { type: 'dockerfile', value: text }
return { type: 'dockerfile', value: text };
},
astFormat: 'dockerfile',
locStart: () => 0,
locEnd: () => 0,
},
}
};
// Printer configuration
const printers: Record<string, Printer<any>> = {
dockerfile: {
// @ts-expect-error -- Support async printer like shell plugin
async print(path: any, options: any) {
await ensureInitialized()
const text = path.getValue().value || path.getValue()
await ensureInitialized();
const text = path.getValue().value || path.getValue();
try {
const formatted = format(text, {
indent: options.tabWidth || 2,
trailingNewline: true,
spaceRedirects: options.spaceRedirects !== false,
})
return formatted
});
return formatted;
} catch (error) {
console.warn('Dockerfile formatting error:', error)
return text
console.warn('Dockerfile formatting error:', error);
return text;
}
},
},
}
};
// WASM initialization
let isInitialized = false
let initPromise: Promise<void> | null = null
let isInitialized = false;
let initPromise: Promise<void> | null = null;
async function ensureInitialized(): Promise<void> {
if (isInitialized) {
return Promise.resolve()
return Promise.resolve();
}
if (!initPromise) {
initPromise = (async () => {
try {
await dockerfileInit()
isInitialized = true
await dockerfileInit();
isInitialized = true;
} catch (error) {
console.warn('Failed to initialize Dockerfile WASM module:', error)
initPromise = null
throw error
console.warn('Failed to initialize Dockerfile WASM module:', error);
initPromise = null;
throw error;
}
})()
})();
}
return initPromise
}
// Configuration mapping function
function mapOptionsToConfig(options: any) {
return {
indent: options.tabWidth || 2,
trailingNewline: options.insertFinalNewline !== false,
spaceRedirects: options.spaceRedirects !== false,
}
return initPromise;
}
// Plugin options
@@ -92,7 +83,7 @@ const options: Record<string, SupportOption> = {
default: true,
description: 'Add spaces around redirect operators',
},
}
};
// Plugin definition
const plugin: Plugin = {
@@ -105,7 +96,7 @@ const plugin: Plugin = {
useTabs: false,
spaceRedirects: true,
},
}
};
export default plugin
export { languages, parsers, printers, options }
export default plugin;
export { languages, parsers, printers, options };

View File

@@ -0,0 +1,807 @@
package lib
import (
"bytes"
"fmt"
"io"
"log"
"os"
"regexp"
"slices"
"strings"
"github.com/google/shlex"
"github.com/moby/buildkit/frontend/dockerfile/command"
"github.com/moby/buildkit/frontend/dockerfile/parser"
"mvdan.cc/sh/v3/syntax"
)
type ExtendedNode struct {
*parser.Node
Children []*ExtendedNode
Next *ExtendedNode
OriginalMultiline string
}
type ParseState struct {
CurrentLine int
Output string
// Needed to pull in comments
AllOriginalLines []string
Config *Config
}
type Config struct {
IndentSize uint
TrailingNewline bool
SpaceRedirects bool
}
func FormatNode(ast *ExtendedNode, c *Config) (string, bool) {
nodeName := strings.ToLower(ast.Node.Value)
dispatch := map[string]func(*ExtendedNode, *Config) string{
command.Add: formatSpaceSeparated,
command.Arg: formatBasic,
command.Cmd: formatCmd,
command.Copy: formatSpaceSeparated,
command.Entrypoint: formatEntrypoint,
command.Env: formatEnv,
command.Expose: formatSpaceSeparated,
command.From: formatSpaceSeparated,
command.Healthcheck: formatBasic,
command.Label: formatLabel,
command.Maintainer: formatMaintainer,
command.Onbuild: FormatOnBuild,
command.Run: formatRun,
command.Shell: formatCmd,
command.StopSignal: formatBasic,
command.User: formatBasic,
command.Volume: formatBasic,
command.Workdir: formatSpaceSeparated,
}
fmtFunc := dispatch[nodeName]
if fmtFunc == nil {
return "", false
// log.Fatalf("Unknown command: %s %s\n", nodeName, ast.OriginalMultiline)
}
return fmtFunc(ast, c), true
}
func (df *ParseState) processNode(ast *ExtendedNode) {
// We don't want to process nodes that don't have a start or end line.
if ast.Node.StartLine == 0 || ast.Node.EndLine == 0 {
return
}
// check if we are on the correct line,
// otherwise get the comments we are missing
if df.CurrentLine != ast.StartLine {
df.Output += FormatComments(df.AllOriginalLines[df.CurrentLine : ast.StartLine-1])
df.CurrentLine = ast.StartLine
}
// if df.Output != "" {
// // If the previous line isn't a comment or newline, add a newline
// lastTwoChars := df.Output[len(df.Output)-2 : len(df.Output)]
// lastNonTrailingNewline := strings.LastIndex(strings.TrimRight(df.Output, "\n"), "\n")
// if lastTwoChars != "\n\n" && df.Output[lastNonTrailingNewline+1] != '#' {
// df.Output += "\n"
// }
// }
output, ok := FormatNode(ast, df.Config)
if ok {
df.Output += output
df.CurrentLine = ast.EndLine
}
// fmt.Printf("CurrentLine: %d, %d\n", df.CurrentLine, ast.EndLine)
// fmt.Printf("Unknown command: %s %s\n", nodeName, ast.OriginalMultiline)
for _, child := range ast.Children {
df.processNode(child)
}
// fmt.Printf("CurrentLine2: %d, %d\n", df.CurrentLine, ast.EndLine)
if ast.Node.Next != nil {
df.processNode(ast.Next)
}
}
func FormatOnBuild(n *ExtendedNode, c *Config) string {
if len(n.Node.Next.Children) == 1 {
// fmt.Printf("Onbuild: %s\n", n.Node.Next.Children[0].Value)
output, ok := FormatNode(n.Next.Children[0], c)
if ok {
return strings.ToUpper(n.Node.Value) + " " + output
}
}
return n.OriginalMultiline
}
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, ""))
return "", fmt.Errorf("Error parsing file: %v", err)
}
parseState := &ParseState{
CurrentLine: 0,
Output: "",
AllOriginalLines: fileLines,
}
rootNode := BuildExtendedNode(result.AST, fileLines)
parseState.Config = c
parseState.processNode(rootNode)
// After all directives are processed, we need to check if we have any trailing comments to add.
if parseState.CurrentLine < len(parseState.AllOriginalLines) {
// Add the rest of the file
parseState.Output += FormatComments(parseState.AllOriginalLines[parseState.CurrentLine:])
}
parseState.Output = strings.TrimRight(parseState.Output, "\n")
// Ensure the output ends with a newline if requested
if c.TrailingNewline {
parseState.Output += "\n"
}
return parseState.Output, nil
}
func BuildExtendedNode(n *parser.Node, fileLines []string) *ExtendedNode {
// Build an extended node from the parser node
// This is used to add the original multiline string to the node
// and to add the original line numbers
if n == nil {
return nil
}
// Create the extended node with the current parser node
en := &ExtendedNode{
Node: n,
Next: nil,
Children: nil,
OriginalMultiline: "", // Default to empty string
}
// If we have valid start and end lines, construct the multiline representation
if n.StartLine > 0 && n.EndLine > 0 {
// Subtract 1 from StartLine because fileLines is 0-indexed while StartLine is 1-indexed
for i := n.StartLine - 1; i < n.EndLine; i++ {
en.OriginalMultiline += fileLines[i]
}
}
// Process all children recursively
if len(n.Children) > 0 {
childrenNodes := make([]*ExtendedNode, 0, len(n.Children))
for _, child := range n.Children {
extChild := BuildExtendedNode(child, fileLines)
if extChild != nil {
childrenNodes = append(childrenNodes, extChild)
}
}
// Replace the children with the processed ones
en.Children = childrenNodes
}
// Process the next node recursively
if n.Next != nil {
extNext := BuildExtendedNode(n.Next, fileLines)
if extNext != nil {
en.Next = extNext
}
}
return en
}
func formatEnv(n *ExtendedNode, c *Config) string {
// Only the legacy format will have a empty 3rd child
if n.Next.Next.Next.Value == "" {
return strings.ToUpper(n.Node.Value) + " " + n.Next.Node.Value + "=" + n.Next.Next.Node.Value + "\n"
}
// Otherwise, we have a valid env command
originalTrimmed := strings.TrimLeft(n.OriginalMultiline, " \t")
content := StripWhitespace(regexp.MustCompile(" ").Split(originalTrimmed, 2)[1], true)
// Indent all lines with indentSize spaces
re := regexp.MustCompile("(?m)^ *")
content = strings.Trim(re.ReplaceAllString(content, strings.Repeat(" ", int(c.IndentSize))), " ")
return strings.ToUpper(n.Value) + " " + content
}
func formatShell(content string, hereDoc bool, c *Config) string {
// Semicolons require special handling so we don't break the command
// Improved semicolon support: handle escaped semicolons properly
// Check for unescaped semicolons - if found, try to format them properly
if regexp.MustCompile(`[^\\];`).MatchString(content) {
// Split by unescaped semicolons and format each part separately
parts := regexp.MustCompile(`([^\\]);`).Split(content, -1)
if len(parts) > 1 {
var formattedParts []string
for i, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
// Try to format each part individually
formatted := formatSingleCommand(part, hereDoc, c)
formattedParts = append(formattedParts, formatted)
}
// Add semicolon back except for the last part
if i < len(parts)-1 {
formattedParts[len(formattedParts)-1] += ";"
}
}
return strings.Join(formattedParts, " ")
}
// If splitting didn't work, fall back to original content
return content
}
// Grouped expressions aren't formatted well
// See: https://github.com/mvdan/sh/issues/1148
if strings.Contains(content, "{ \\") {
return content
}
if !hereDoc {
// Here lies some cursed magic. Be careful.
// Replace comments with a subshell evaluation -- they won't be run so we can do this.
content = StripWhitespace(content, true)
lineComment := regexp.MustCompile(`(\n\s*)(#.*)`)
lines := strings.SplitAfter(content, "\n")
for i := range lines {
lineTrim := strings.TrimLeft(lines[i], " \t")
if len(lineTrim) >= 1 && lineTrim[0] == '#' {
lines[i] = strings.ReplaceAll(lines[i], "`", "×")
}
}
content = strings.Join(lines, "")
content = lineComment.ReplaceAllString(content, "$1`$2#`\\")
/*
```
foo \
`#comment#`\
&& bar
```
```
foo && \
`#comment#` \
bar
```
*/
commentContinuation := regexp.MustCompile(`(\\(?:\s*` + "`#.*#`" + `\\){1,}\s*)&&`)
content = commentContinuation.ReplaceAllString(content, "&&$1")
// log.Printf("Content0: %s\n", content)
lines = strings.SplitAfter(content, "\n")
/**
if the next line is not a comment, and we didn't start with a continuation, don't add the `&&`.
*/
inContinuation := false
for i := range lines {
lineTrim := strings.Trim(lines[i], " \t\\\n")
// fmt.Printf("LineTrim: %s\n", lineTrim)
nextLine := ""
isComment := false
nextLineIsComment := false
if i+1 < len(lines) {
nextLine = strings.Trim(lines[i+1], " \t\\\n")
}
if len(nextLine) >= 2 && nextLine[:2] == "`#" {
nextLineIsComment = true
}
if len(lineTrim) >= 2 && lineTrim[:2] == "`#" {
isComment = true
}
// fmt.Printf("isComment: %v, nextLineIsComment: %v, inContinuation: %v\n", isComment, nextLineIsComment, inContinuation)
if isComment && (inContinuation || nextLineIsComment) {
lines[i] = strings.Replace(lines[i], "#`\\", "#`&&\\", 1)
}
if len(lineTrim) >= 2 && !isComment && lineTrim[len(lineTrim)-2:] == "&&" {
inContinuation = true
} else if !isComment {
inContinuation = false
}
}
content = strings.Join(lines, "")
}
// Now that we have a valid bash-style command, we can format it with shfmt
// log.Printf("Content1: %s\n", content)
content = formatBash(content, c)
// log.Printf("Content2: %s\n", content)
if !hereDoc {
reBacktickComment := regexp.MustCompile(`([ \t]*)(?:&& )?` + "`(#.*)#` " + `\\`)
content = reBacktickComment.ReplaceAllString(content, "$1$2")
// Fixup the comment indentation
lines := strings.SplitAfter(content, "\n")
prevIsComment := false
prevCommentSpacing := ""
firstLineIsComment := false
for i := range lines {
lineTrim := strings.TrimLeft(lines[i], " \t")
// fmt.Printf("LineTrim: %s, %v\n", lineTrim, prevIsComment)
if len(lineTrim) >= 1 && lineTrim[0] == '#' {
if i == 0 {
firstLineIsComment = true
lines[i] = strings.Repeat(" ", int(c.IndentSize)) + lineTrim
}
lineParts := strings.SplitN(lines[i], "#", 2)
if prevIsComment {
lines[i] = prevCommentSpacing + "#" + lineParts[1]
} else {
prevCommentSpacing = lineParts[0]
}
prevIsComment = true
} else {
prevIsComment = false
}
}
// TODO: this formatting isn't perfect (see tests/out/run5.dockerfile)
if firstLineIsComment {
lines = slices.Insert(lines, 0, "\\\n")
}
content = strings.Join(lines, "")
content = strings.ReplaceAll(content, "×", "`")
}
return content
}
// formatSingleCommand formats a single shell command (used for semicolon-separated commands)
func formatSingleCommand(content string, hereDoc bool, c *Config) string {
// Grouped expressions aren't formatted well
// See: https://github.com/mvdan/sh/issues/1148
if strings.Contains(content, "{ \\") {
return content
}
if !hereDoc {
// Here lies some cursed magic. Be careful.
// Replace comments with a subshell evaluation -- they won't be run so we can do this.
content = StripWhitespace(content, true)
content = regexp.MustCompile(`#.*`).ReplaceAllString(content, "$(: comment)")
content = strings.ReplaceAll(content, "\\\n", " ")
content = strings.ReplaceAll(content, "\n", " ")
content = regexp.MustCompile(`\s+`).ReplaceAllString(content, " ")
content = strings.TrimSpace(content)
}
return formatBash(content, c)
}
func formatRun(n *ExtendedNode, c *Config) string {
// Get the original RUN command text
hereDoc := false
flags := n.Node.Flags
var content string
if len(n.Node.Heredocs) >= 1 {
content = n.Node.Heredocs[0].Content
hereDoc = true
// Check if heredoc FileDescriptor is 0 (stdin) - this is the standard for RUN commands
if n.Node.Heredocs[0].FileDescriptor != 0 {
log.Printf("Warning: heredoc FileDescriptor is %d, expected 0 for RUN command", n.Node.Heredocs[0].FileDescriptor)
}
} else {
// We split the original multiline string by whitespace
originalText := n.OriginalMultiline
if n.OriginalMultiline == "" {
// If the original multiline string is empty, use the original value
originalText = n.Node.Original
}
originalTrimmed := strings.TrimLeft(originalText, " \t")
parts := regexp.MustCompile("[ \t]").Split(originalTrimmed, 2+len(flags))
content = parts[1+len(flags)]
}
// Try to parse as JSON
jsonItems, err := parseJSONStringArray(content)
if err == nil {
outStr := marshalStringArray(jsonItems)
outStr = strings.ReplaceAll(outStr, "\",\"", "\", \"")
content = outStr + "\n"
} else {
content = formatShell(content, hereDoc, c)
if hereDoc {
n.Node.Heredocs[0].Content = content
content, _ = GetHeredoc(n)
}
}
if len(flags) > 0 {
content = strings.Join(flags, " ") + " " + content
}
return strings.ToUpper(n.Value) + " " + content
}
func GetHeredoc(n *ExtendedNode) (string, bool) {
if len(n.Node.Heredocs) == 0 {
return "", false
}
// printAST(n, 0)
args := []string{}
cur := n.Next
for cur != nil {
if cur.Node.Value != "" {
args = append(args, cur.Node.Value)
}
cur = cur.Next
}
content := strings.Join(args, " ") + "\n" + n.Node.Heredocs[0].Content + n.Node.Heredocs[0].Name + "\n"
return content, true
}
func formatBasic(n *ExtendedNode, c *Config) string {
// Uppercases the command, and indent the following lines
originalTrimmed := strings.TrimLeft(n.OriginalMultiline, " \t")
value, success := GetHeredoc(n)
if !success {
value = regexp.MustCompile(" ").Split(originalTrimmed, 2)[1]
}
return IndentFollowingLines(strings.ToUpper(n.Value)+" "+value, c.IndentSize)
}
// marshalStringArray manually creates a JSON array string from a slice of strings
// This avoids using encoding/json which causes reflection issues in WASM
func marshalStringArray(items []string) string {
if len(items) == 0 {
return "[]"
}
var result strings.Builder
result.WriteString("[")
for i, item := range items {
if i > 0 {
result.WriteString(", ")
}
result.WriteString("\"")
// Escape quotes and backslashes in the string
escaped := strings.ReplaceAll(item, "\\", "\\\\")
escaped = strings.ReplaceAll(escaped, "\"", "\\\"")
result.WriteString(escaped)
result.WriteString("\"")
}
result.WriteString("]")
return result.String()
}
// parseJSONStringArray manually parses a JSON array string into a slice of strings
// This avoids using encoding/json which causes reflection issues in WASM
func parseJSONStringArray(jsonStr string) ([]string, error) {
jsonStr = strings.TrimSpace(jsonStr)
if !strings.HasPrefix(jsonStr, "[") || !strings.HasSuffix(jsonStr, "]") {
return nil, fmt.Errorf("not a JSON array")
}
// Remove brackets
content := strings.TrimSpace(jsonStr[1 : len(jsonStr)-1])
if content == "" {
return []string{}, nil
}
var result []string
var current strings.Builder
inQuotes := false
escaped := false
for i, char := range content {
if escaped {
switch char {
case '"':
current.WriteRune('"')
case '\\':
current.WriteRune('\\')
case 'n':
current.WriteRune('\n')
case 't':
current.WriteRune('\t')
case 'r':
current.WriteRune('\r')
default:
current.WriteRune('\\')
current.WriteRune(char)
}
escaped = false
continue
}
if char == '\\' {
escaped = true
continue
}
if char == '"' {
inQuotes = !inQuotes
continue
}
if !inQuotes && char == ',' {
result = append(result, current.String())
current.Reset()
// Skip whitespace after comma
for i+1 < len(content) && (content[i+1] == ' ' || content[i+1] == '\t') {
i++
}
continue
}
if inQuotes {
current.WriteRune(char)
}
}
// Add the last item
if current.Len() > 0 || len(result) > 0 {
result = append(result, current.String())
}
return result, nil
}
func getCmd(n *ExtendedNode, shouldSplitNode bool) []string {
cmd := []string{}
for node := n; node != nil; node = node.Next {
// Split value by whitespace
rawValue := strings.Trim(node.Node.Value, " \t")
if len(node.Node.Flags) > 0 {
cmd = append(cmd, node.Node.Flags...)
}
// log.Printf("ShouldSplitNode: %v\n", shouldSplitNode)
if shouldSplitNode {
parts, err := shlex.Split(rawValue)
if err != nil {
// 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...)
}
} else {
cmd = append(cmd, rawValue)
}
}
// log.Printf("getCmd: %v\n", cmd)
return cmd
}
func shouldRunInShell(node string) bool {
// https://docs.docker.com/reference/dockerfile/#entrypoint
parts, err := shlex.Split(node)
if err != nil {
// 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
// This is a simplistic check to determine if we need to run in a full shell.
for _, part := range parts {
if part == "&&" || part == ";" || part == "||" {
needsShell = true
break
}
}
return needsShell
}
func formatEntrypoint(n *ExtendedNode, c *Config) string {
// this can technically change behavior. https://docs.docker.com/reference/dockerfile/#understand-how-cmd-and-entrypoint-interact
return formatCmd(n, c)
}
func formatCmd(n *ExtendedNode, c *Config) string {
// printAST(n, 0)
isJSON, ok := n.Node.Attributes["json"]
if !ok {
isJSON = false
}
if !isJSON {
doNotSplit := shouldRunInShell(n.Node.Next.Value)
if doNotSplit {
n.Next.Node.Flags = append(n.Next.Node.Flags, []string{"/bin/sh", "-c"}...)
// Hacky workaround to tell getCmd to not split the command
isJSON = true
}
}
cmd := getCmd(n.Next, !isJSON)
bWithSpace := marshalStringArray(cmd)
bWithSpace = strings.ReplaceAll(bWithSpace, "\",\"", "\", \"")
return strings.ToUpper(n.Node.Value) + " " + bWithSpace + "\n"
}
func formatSpaceSeparated(n *ExtendedNode, c *Config) string {
isJSON, ok := n.Node.Attributes["json"]
if !ok {
isJSON = false
}
cmd, success := GetHeredoc(n)
if !success {
cmd = strings.Join(getCmd(n.Next, isJSON), " ")
if len(n.Node.Flags) > 0 {
cmd = strings.Join(n.Node.Flags, " ") + " " + cmd
}
cmd += "\n"
}
return strings.ToUpper(n.Node.Value) + " " + cmd
}
func formatLabel(n *ExtendedNode, c *Config) string {
// Parse LABEL key-value pairs and sort them alphabetically by key
originalTrimmed := strings.TrimLeft(n.OriginalMultiline, " \t")
content := regexp.MustCompile(" ").Split(originalTrimmed, 2)[1]
// Parse key-value pairs
labels := make(map[string]string)
var keys []string
// Split by whitespace and parse key=value pairs
parts := strings.Fields(content)
for _, part := range parts {
if strings.Contains(part, "=") {
kv := strings.SplitN(part, "=", 2)
if len(kv) == 2 {
key := strings.Trim(kv[0], "\"")
value := strings.Trim(kv[1], "\"")
labels[key] = value
keys = append(keys, key)
}
}
}
// If no key-value pairs found, fall back to basic formatting
if len(keys) == 0 {
return formatBasic(n, c)
}
// Sort keys alphabetically
slices.Sort(keys)
// Build sorted output
var result strings.Builder
result.WriteString(strings.ToUpper(n.Value))
result.WriteString(" ")
for i, key := range keys {
if i > 0 {
result.WriteString(" ")
}
result.WriteString(key)
result.WriteString("=\"")
result.WriteString(labels[key])
result.WriteString("\"")
}
result.WriteString("\n")
return result.String()
}
func formatMaintainer(n *ExtendedNode, c *Config) string {
// Get text between quotes
maintainer := strings.Trim(n.Next.Node.Value, "\"")
return "LABEL org.opencontainers.image.authors=\"" + maintainer + "\"\n"
}
func GetFileLines(fileName string) ([]string, error) {
// Open the file
f, err := os.Open(fileName)
if err != nil {
return []string{}, err
}
defer f.Close()
// Read the file contents
b := new(strings.Builder)
io.Copy(b, f)
fileLines := strings.SplitAfter(b.String(), "\n")
return fileLines, nil
}
func StripWhitespace(lines string, rightOnly bool) string {
// Split the string into lines by newlines
// log.Printf("Lines: .%s.\n", lines)
linesArray := strings.SplitAfter(lines, "\n")
// Create a new slice to hold the stripped lines
var strippedLines string
// Iterate over each line
for _, line := range linesArray {
// Trim leading and trailing whitespace
// log.Printf("Line .%s.\n", line)
hadNewline := len(line) > 0 && line[len(line)-1] == '\n'
if rightOnly {
// Only trim trailing whitespace
line = strings.TrimRight(line, " \t\n")
} else {
// Trim both leading and trailing whitespace
line = strings.Trim(line, " \t\n")
}
// log.Printf("Line2 .%s.", line)
if hadNewline {
line += "\n"
}
strippedLines += line
}
return strippedLines
}
func FormatComments(lines []string) string {
// Adds lines to the output, collapsing multiple newlines into a single newline
// and removing leading / trailing whitespace. We can do this because
// we are adding comments and we don't care about the formatting.
missingContent := StripWhitespace(strings.Join(lines, ""), false)
// Replace multiple newlines with a single newline
re := regexp.MustCompile(`\n{3,}`)
return re.ReplaceAllString(missingContent, "\n")
}
func IndentFollowingLines(lines string, indentSize uint) string {
// Split the input by lines
allLines := strings.SplitAfter(lines, "\n")
// If there's only one line or no lines, return the original
if len(allLines) <= 1 {
return lines
}
// Keep the first line as is
result := allLines[0]
// Indent all subsequent lines
for i := 1; i < len(allLines); i++ {
if allLines[i] != "" { // Skip empty lines
// Remove existing indentation and add new indentation
trimmedLine := strings.TrimLeft(allLines[i], " \t")
allLines[i] = strings.Repeat(" ", int(indentSize)) + trimmedLine
}
// Add to result (with newline except for the last line)
result += allLines[i]
}
return result
}
func formatBash(s string, c *Config) string {
r := strings.NewReader(s)
f, err := syntax.NewParser(syntax.KeepComments(true)).Parse(r, "")
if err != nil {
// On parse failure, return original input to avoid crashing the WASM runtime
return s
}
buf := new(bytes.Buffer)
syntax.NewPrinter(
syntax.Minify(false),
syntax.SingleLine(false),
syntax.SpaceRedirects(c.SpaceRedirects),
syntax.Indent(c.IndentSize),
syntax.BinaryNextLine(true),
).Print(buf, f)
return buf.String()
}

View File

@@ -60,7 +60,7 @@ function initGofmt(): Promise<void> {
// Printer configuration
const goPrinter: Printer<string> = {
// @ts-expect-error -- Support async printer like shell plugin
async print(path, options) {
async print(path, _options) {
try {
// Wait for initialization to complete
await initGofmt();

View File

@@ -39,7 +39,7 @@ const groovyPrinter: Printer<string> = {
return groovyBeautify(path.node, {
width: options.printWidth || 80,
}).trim();
} catch (error) {
} catch (_error) {
return path.node;
}
},

View File

@@ -58,7 +58,7 @@ type ModifierNode = JavaNonTerminal & {
annotation?: AnnotationCstNode[];
};
};
type IsTuple<T> = T extends [] ? true : T extends [infer First, ...infer Remain] ? IsTuple<Remain> : false;
type IsTuple<T> = T extends [] ? true : T extends [infer _First, ...infer Remain] ? IsTuple<Remain> : false;
type IndexProperties<T extends {
length: number;
}> = IsTuple<T> extends true ? Exclude<Partial<T>["length"], T["length"]> : number;

View File

@@ -1,5 +1,5 @@
/* tslint:disable */
/* eslint-disable */
export function format(input: string, filename: string, config?: Config): string;
interface LayoutConfig {

View File

@@ -1,5 +1,5 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const format: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
export const __wbindgen_export_0: (a: number, b: number) => number;

View File

@@ -1,5 +1,5 @@
/* tslint:disable */
/* eslint-disable */
export function format(input: string, path?: string, config?: Config): string;
export interface Config {

View File

@@ -1,5 +1,5 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const format: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
export const __wbindgen_export_0: (a: number, b: number) => number;

View File

@@ -1,5 +1,5 @@
/* tslint:disable */
/* eslint-disable */
export function format(input: string, config?: Config | null): string;
export interface Config {

View File

@@ -1,5 +1,5 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const format: (a: number, b: number, c: number, d: number) => void;
export const __wbindgen_export_0: (a: number, b: number) => number;

View File

@@ -1,7 +1,7 @@
import _fs from 'node:fs'
import _fs from 'node:fs';
declare global {
namespace globalThis {
var fs: typeof _fs
let fs: typeof _fs;
}
}

View File

@@ -1,5 +1,5 @@
/* tslint:disable */
/* eslint-disable */
export function format(input: string, filename: string, config?: Config): string;
interface LayoutConfig {

View File

@@ -1,5 +1,5 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const format: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
export const __wbindgen_export_0: (a: number, b: number) => number;

View File

@@ -23,7 +23,6 @@ import type {
TomlDocument,
TomlExpression,
TomlKeyVal,
TomlComment,
TomlContext
} from './types';
@@ -221,11 +220,11 @@ class TomlBeautifierVisitor extends BaseTomlCstVisitor {
} else {
return this.visit(actualValueNode);
}
} catch (error) {
} catch (_error) {
// 如果getSingle失败尝试直接处理children
if (ctx.children) {
// 处理不同类型的值
for (const [childKey, childNodes] of Object.entries(ctx.children)) {
for (const [_childKey, childNodes] of Object.entries(ctx.children)) {
if (Array.isArray(childNodes) && childNodes.length > 0) {
const firstChild = childNodes[0];
@@ -385,14 +384,14 @@ class TomlBeautifierVisitor extends BaseTomlCstVisitor {
/**
* Visit newline (should not be called)
*/
nl(ctx: any): never {
nl(_ctx: any): never {
throw new Error('Should not get here!');
}
/**
* Visit comment newline (no-op)
*/
commentNewline(ctx: any): void {
commentNewline(_ctx: any): void {
// No operation needed
}
}
@@ -407,7 +406,7 @@ const beautifierVisitor = new TomlBeautifierVisitor();
* @param print - Print function (unused in this implementation)
* @returns Formatted document
*/
export function print(path: AstPath<TomlCstNode>, options?: any, print?: any): Doc {
export function print(path: AstPath<TomlCstNode>, _options?: any, _print?: any): Doc {
const cst = path.node as TomlDocument;
return beautifierVisitor.visit(cst);
}

View File

@@ -4,7 +4,7 @@
* This plugin provides support for formatting multiple web languages using the web_fmt WASM implementation.
* web_fmt is a comprehensive formatter for web development supporting HTML, CSS, JavaScript, and JSON.
*/
import type { Plugin, Parser, Printer, ParserOptions } from 'prettier';
import type { Plugin, Parser, Printer } from 'prettier';
// Import the web_fmt WASM module
import webInit, { format } from './web_fmt_vite.js';

View File

@@ -1,5 +1,5 @@
/* tslint:disable */
/* eslint-disable */
export function format_json(src: string, config?: JsonConfig): string;
export function format_markup(src: string, filename: string, config?: MarkupConfig): string;
export function format_script(src: string, filename: string, config?: ScriptConfig): string;

View File

@@ -1,5 +1,5 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const format_json: (a: number, b: number, c: number, d: number) => void;
export const format_markup: (a: number, b: number, c: number, d: number, e: number, f: number) => void;

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,28 +43,41 @@
</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>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
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();
} catch (error) {
console.error(error)
console.error(error);
}
};
@@ -73,7 +86,7 @@ const toggleMaximize = async () => {
await runtime.Window.ToggleMaximise();
await checkMaximizedState();
} catch (error) {
console.error(error)
console.error(error);
}
};
@@ -81,7 +94,7 @@ const closeWindow = async () => {
try {
await runtime.Window.Close();
} catch (error) {
console.error(error)
console.error(error);
}
};
@@ -89,12 +102,32 @@ const checkMaximizedState = async () => {
try {
isMaximized.value = await runtime.Window.IsMaximised();
} catch (error) {
console.error(error)
console.error(error);
}
};
// 计算标题文本
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',
@@ -157,12 +166,17 @@ export default {
interface: 'Interface Elements',
border: 'Borders & Dividers',
search: 'Search & Matching',
// Base Colors
background: 'Main Background',
backgroundSecondary: 'Secondary Background',
surface: 'Panel Background',
dropdownBackground: 'Dropdown Background',
dropdownBorder: 'Dropdown Border',
// Text Colors
foreground: 'Primary Text',
foregroundSecondary: 'Secondary Text',
comment: 'Comments',
// Syntax Highlighting - Core
keyword: 'Keywords',
string: 'Strings',
function: 'Functions',
@@ -170,14 +184,25 @@ export default {
operator: 'Operators',
variable: 'Variables',
type: 'Types',
// Syntax Highlighting - Extended
constant: 'Constants',
storage: 'Storage Type',
parameter: 'Parameters',
class: 'Class Names',
heading: 'Headings',
invalid: 'Invalid/Error',
regexp: 'Regular Expressions',
// Interface Elements
cursor: 'Cursor',
selection: 'Selection Background',
selectionBlur: 'Unfocused Selection',
activeLine: 'Active Line Highlight',
lineNumber: 'Line Numbers',
activeLineNumber: 'Active Line Number',
// Borders & Dividers
borderColor: 'Border Color',
borderLight: 'Light Border',
// Search & Matching
searchMatch: 'Search Match',
matchingBracket: 'Matching Bracket'
},
@@ -190,6 +215,7 @@ export default {
enableTabIndent: 'Enable Tab Indent',
language: 'Interface Language',
systemTheme: 'System Theme',
presetTheme: 'Preset Theme',
saveOptions: 'Save Options',
autoSaveDelay: 'Auto Save Delay (ms)',
updateSettings: 'Update Settings',

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: '数据存储',
@@ -157,6 +166,7 @@ export default {
enableTabIndent: '启用 Tab 缩进',
language: '界面语言',
systemTheme: '系统主题',
presetTheme: '预设主题',
saveOptions: '保存选项',
autoSaveDelay: '自动保存延迟(毫秒)',
updateSettings: '更新设置',
@@ -197,12 +207,17 @@ export default {
interface: '界面元素',
border: '边框分割线',
search: '搜索匹配',
// 基础色调
background: '主背景色',
backgroundSecondary: '次要背景色',
surface: '面板背景',
dropdownBackground: '下拉菜单背景',
dropdownBorder: '下拉菜单边框',
// 文本颜色
foreground: '主文本色',
foregroundSecondary: '次要文本色',
comment: '注释色',
// 语法高亮 - 核心
keyword: '关键字',
string: '字符串',
function: '函数名',
@@ -210,14 +225,25 @@ export default {
operator: '操作符',
variable: '变量',
type: '类型',
// 语法高亮 - 扩展
constant: '常量',
storage: '存储类型',
parameter: '参数',
class: '类名',
heading: '标题',
invalid: '无效内容',
regexp: '正则表达式',
// 界面元素
cursor: '光标',
selection: '选中背景',
selectionBlur: '失焦选中背景',
activeLine: '当前行高亮',
lineNumber: '行号',
activeLineNumber: '活动行号',
// 边框和分割线
borderColor: '边框色',
borderLight: '浅色边框',
// 搜索和匹配
searchMatch: '搜索匹配',
matchingBracket: '匹配括号'
},

View File

@@ -1,4 +1,4 @@
import {createRouter, createWebHashHistory, createWebHistory, RouteRecordRaw} from 'vue-router';
import {createRouter, createWebHashHistory, RouteRecordRaw} from 'vue-router';
import Editor from '@/views/editor/Editor.vue';
import Settings from '@/views/settings/Settings.vue';
import GeneralPage from '@/views/settings/pages/GeneralPage.vue';

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()
// Computed properties
const isEnabled = computed(() => configStore.config.backup.enabled)
const isConfigured = computed(() => configStore.config.backup.repo_url)
// 清除状态显示
const clearPushStatus = () => {
if (pushStatusTimer !== null) {
window.clearTimeout(pushStatusTimer)
pushStatusTimer = null
}
pushSuccess.value = false
pushError.value = false
// === 业务逻辑方法 ===
/**
* 推送到远程仓库
* 使用现代 async/await 和错误处理
*/
const pushToRemote = async (): Promise<void> => {
// 前置条件检查
if (isPushing.value || !isConfigured.value) {
return;
}
// 清除错误信息和错误图标
const clearError = () => {
if (errorTimer !== null) {
window.clearTimeout(errorTimer)
errorTimer = null
}
error.value = null
pushError.value = false
try {
setBackupStatus(BackupStatus.PUSHING);
await BackupService.PushToRemote();
setBackupStatus(BackupStatus.SUCCESS, 'Backup completed successfully', 3000);
} catch (error) {
handleError(error);
}
};
// 设置错误信息和错误图标并自动清除
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)
/**
* 重试备份操作
*/
const retryBackup = async (): Promise<void> => {
if (isError.value) {
await pushToRemote();
}
};
// Push to remote repository
const pushToRemote = async () => {
if (isPushing.value || !isConfigured.value) return
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
}
// === 响应式副作用 ===
// 监听配置变化,自动清除错误状态
watchEffect(() => {
if (isEnabled.value && isConfigured.value && isError.value) {
// 配置修复后清除错误状态
clearStatus();
}
});
// 初始化备份服务
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')
}
}
// === 返回的 API ===
return {
// 只读状态
config: readonly(config),
backupResult: readonly(backupResult),
// 计算属性
isEnabled,
isConfigured,
isPushing,
isSuccess,
isError,
errorMessage,
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,46 +4,33 @@ 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, t} = useI18n();
const {locale} = useI18n();
// 响应式状态
const state = reactive({
@@ -51,7 +38,7 @@ export const useConfigStore = defineStore('config', () => {
isLoading: false,
configLoaded: false
});
// Font options (no longer localized)
const fontOptions = computed(() => FONT_OPTIONS);
@@ -193,7 +180,7 @@ export const useConfigStore = defineStore('config', () => {
state.isLoading = true;
try {
await ConfigService.ResetConfig()
await ConfigService.ResetConfig();
const appConfig = await ConfigService.GetConfig();
if (appConfig) {
state.config = JSON.parse(JSON.stringify(appConfig)) as AppConfig;
@@ -217,6 +204,10 @@ export const useConfigStore = defineStore('config', () => {
await updateAppearanceConfig('systemTheme', systemTheme);
};
// 当前主题设置方法
const setCurrentTheme = async (themeName: string): Promise<void> => {
await updateAppearanceConfig('currentTheme', themeName);
};
// 初始化语言设置
@@ -230,8 +221,8 @@ export const useConfigStore = defineStore('config', () => {
// 同步前端语言设置
const frontendLocale = ConfigUtils.backendLanguageToFrontend(state.config.appearance.language);
locale.value = frontendLocale as any;
} catch (error) {
const browserLang = getBrowserLanguage();
} catch (_error) {
const browserLang = SUPPORTED_LOCALES[0].code;
locale.value = browserLang as any;
}
};
@@ -268,7 +259,7 @@ export const useConfigStore = defineStore('config', () => {
configLoaded: computed(() => state.configLoaded),
isLoading: computed(() => state.isLoading),
fontOptions,
// 限制常量
...limits,
@@ -282,6 +273,7 @@ export const useConfigStore = defineStore('config', () => {
// 主题相关方法
setSystemTheme,
setCurrentTheme,
// 字体大小操作
...adjusters.fontSize,
@@ -330,19 +322,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,43 +130,8 @@ 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, newPath?: string): Promise<boolean> => {
const updateDocumentMetadata = async (docId: number, title: string): Promise<boolean> => {
try {
await DocumentService.UpdateDocumentTitle(docId, title);
@@ -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');
}
@@ -164,9 +125,7 @@ export const useEditorStore = defineStore('editor', () => {
const basicExtensions = createBasicSetup();
// 获取主题扩展
const themeExtension = createThemeExtension(
configStore.config.appearance.systemTheme || SystemThemeType.SystemThemeAuto
);
const themeExtension = createThemeExtension();
// Tab相关扩展
const tabExtensions = getTabExtensions(
@@ -196,7 +155,7 @@ export const useEditorStore = defineStore('editor', () => {
});
// 再次检查操作有效性
if (!isOperationValid(operationId, documentId)) {
if (!operationManager.isOperationValid(operationId, documentId)) {
throw new Error('Operation cancelled');
}
@@ -204,7 +163,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 +171,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 +211,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 +243,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 +257,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 +270,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 +280,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 +311,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 +337,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 +346,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 +356,7 @@ export const useEditorStore = defineStore('editor', () => {
// 设置编辑器容器
const setEditorContainer = (container: HTMLElement | null) => {
editorCache.value.containerElement = container;
containerElement.value = container;
// 如果设置容器时已有当前文档,立即加载编辑器
if (container && documentStore.currentDocument) {
@@ -439,9 +368,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 +378,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 +385,7 @@ export const useEditorStore = defineStore('editor', () => {
await saveEditorContent(currentDocId);
// 检查操作是否仍然有效
if (!isOperationValid(operationId, documentId)) {
if (!operationManager.isOperationValid(operationId, documentId)) {
return;
}
}
@@ -471,12 +395,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 +419,7 @@ export const useEditorStore = defineStore('editor', () => {
}
// 最终检查操作有效性
if (!isOperationValid(operationId, documentId)) {
if (!operationManager.isOperationValid(operationId, documentId)) {
return;
}
@@ -509,35 +433,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 +474,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 +489,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,16 +501,14 @@ export const useEditorStore = defineStore('editor', () => {
// 应用主题设置
const applyThemeSettings = () => {
Object.values(editorCache.value.instances).forEach(instance => {
updateEditorTheme(instance.view,
themeStore.currentTheme || SystemThemeType.SystemThemeAuto
);
editorCache.values().forEach(instance => {
updateEditorTheme(instance.view);
});
};
// 应用Tab设置
const applyTabSettings = () => {
Object.values(editorCache.value.instances).forEach(instance => {
editorCache.values().forEach(instance => {
updateTabConfig(
instance.view,
configStore.config.editing.tabSize,
@@ -611,7 +522,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 +531,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 +548,6 @@ export const useEditorStore = defineStore('editor', () => {
instance.view.destroy();
});
editorCache.value.instances = {};
editorCache.value.lru = [];
currentEditor.value = null;
};
@@ -665,30 +571,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,40 +1,40 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { Extension, ExtensionID } from '@/../bindings/voidraft/internal/models/models'
import { ExtensionService } from '@/../bindings/voidraft/internal/services'
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { Extension, ExtensionID } from '@/../bindings/voidraft/internal/models/models';
import { ExtensionService } from '@/../bindings/voidraft/internal/services';
export const useExtensionStore = defineStore('extension', () => {
// 扩展配置数据
const extensions = ref<Extension[]>([])
const extensions = ref<Extension[]>([]);
// 获取启用的扩展
const enabledExtensions = computed(() =>
extensions.value.filter(ext => ext.enabled)
)
);
// 获取启用的扩展ID列表
const enabledExtensionIds = computed(() =>
enabledExtensions.value.map(ext => ext.id)
)
);
/**
* 从后端加载扩展配置
*/
const loadExtensions = async (): Promise<void> => {
try {
extensions.value = await ExtensionService.GetAllExtensions()
extensions.value = await ExtensionService.GetAllExtensions();
} catch (err) {
console.error('[ExtensionStore] Failed to load extensions:', err)
console.error('[ExtensionStore] Failed to load extensions:', err);
}
}
};
/**
* 获取扩展配置
*/
const getExtensionConfig = (id: ExtensionID): any => {
const extension = extensions.value.find(ext => ext.id === id)
return extension?.config ?? {}
}
const extension = extensions.value.find(ext => ext.id === id);
return extension?.config ?? {};
};
return {
// 状态
@@ -45,5 +45,5 @@ export const useExtensionStore = defineStore('extension', () => {
// 方法
loadExtensions,
getExtensionConfig,
}
})
};
});

View File

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

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,8 +44,25 @@ export const useSystemStore = defineStore('system', () => {
isLoading.value = true;
try {
environment.value = await runtime.System.Environment();
} catch (err) {
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 {
isLoading.value = false;
@@ -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,159 +1,195 @@
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, ref } from 'vue';
import {SystemThemeType, ThemeType, Theme, ThemeColorConfig} from '@/../bindings/voidraft/internal/models/models';
import { ThemeService } from '@/../bindings/voidraft/internal/services';
import { useConfigStore } from './configStore';
import { useEditorStore } from './editorStore';
import type { ThemeColors } from '@/views/editor/theme/types';
/**
* 主题管理 Store
* 职责:管理主题状态颜色配置
* 职责:管理主题状态颜色配置和预设主题列表
*/
export const useThemeStore = defineStore('theme', () => {
const configStore = useConfigStore();
// 响应式状态 - 存储当前使用的主题颜色
const themeColors = reactive({
darkTheme: { ...defaultDarkColors },
lightTheme: { ...defaultLightColors }
});
// 所有主题列表
const allThemes = ref<Theme[]>([]);
// 计算属性 - 当前选择的主题类型
// 当前主题的颜色配置
const currentColors = ref<ThemeColors | null>(null);
// 计算属性:当前系统主题模式
const currentTheme = computed(() =>
configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto
);
// 初始化主题颜色 - 从数据库加载
const initializeThemeColors = async () => {
try {
const themes = await ThemeService.GetDefaultThemes();
if (!themes) {
Object.assign(themeColors.darkTheme, defaultDarkColors);
Object.assign(themeColors.lightTheme, defaultLightColors);
}
if (themes[ThemeType.ThemeTypeDark]) {
Object.assign(themeColors.darkTheme, themes[ThemeType.ThemeTypeDark].colors);
}
if (themes[ThemeType.ThemeTypeLight]) {
Object.assign(themeColors.lightTheme, themes[ThemeType.ThemeTypeLight].colors);
}
} catch (error) {
console.warn('Failed to load themes from database, using defaults:', error);
// 如果数据库加载失败,使用默认主题
Object.assign(themeColors.darkTheme, defaultDarkColors);
Object.assign(themeColors.lightTheme, defaultLightColors);
}
};
// 计算属性:当前是否为深色模式
const isDarkMode = computed(() =>
currentTheme.value === SystemThemeType.SystemThemeDark ||
(currentTheme.value === SystemThemeType.SystemThemeAuto &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
);
// 计算属性:根据类型获取主题列表
const darkThemes = computed(() =>
allThemes.value.filter(t => t.type === ThemeType.ThemeTypeDark)
);
const lightThemes = computed(() =>
allThemes.value.filter(t => t.type === ThemeType.ThemeTypeLight)
);
// 计算属性:当前可用的主题列表
const availableThemes = computed(() =>
isDarkMode.value ? darkThemes.value : lightThemes.value
);
// 应用主题到 DOM
const applyThemeToDOM = (theme: SystemThemeType) => {
document.documentElement.setAttribute('data-theme',
theme === SystemThemeType.SystemThemeAuto ? 'auto' :
theme === SystemThemeType.SystemThemeDark ? 'dark' : 'light'
);
const themeMap = {
[SystemThemeType.SystemThemeAuto]: 'auto',
[SystemThemeType.SystemThemeDark]: 'dark',
[SystemThemeType.SystemThemeLight]: 'light'
};
document.documentElement.setAttribute('data-theme', themeMap[theme]);
};
// 从数据库加载所有主题
const loadAllThemes = async () => {
try {
const themes = await ThemeService.GetAllThemes();
allThemes.value = (themes || []).filter((t): t is Theme => t !== null);
return allThemes.value;
} catch (error) {
console.error('Failed to load themes from database:', error);
allThemes.value = [];
return [];
}
};
// 初始化主题颜色
const initializeThemeColors = async () => {
// 加载所有主题
await loadAllThemes();
// 从配置获取当前主题名称并加载
const currentThemeName = configStore.config?.appearance?.currentTheme || 'default-dark';
const theme = allThemes.value.find(t => t.name === currentThemeName);
if (!theme) {
console.error(`Theme not found: ${currentThemeName}`);
return;
}
// 直接设置当前主题颜色
currentColors.value = theme.colors as ThemeColors;
};
// 初始化主题
const initializeTheme = async () => {
const theme = configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto;
const theme = currentTheme.value;
applyThemeToDOM(theme);
await initializeThemeColors();
};
// 设置主题
// 设置系统主题模式(深色/浅色/自动)
const setTheme = async (theme: SystemThemeType) => {
await configStore.setSystemTheme(theme);
applyThemeToDOM(theme);
refreshEditorTheme();
};
// 更新主题颜色
const updateThemeColors = (darkColors: any = null, lightColors: any = null): boolean => {
let hasChanges = false;
if (darkColors) {
Object.entries(darkColors).forEach(([key, value]) => {
if (value !== undefined && themeColors.darkTheme[key] !== value) {
themeColors.darkTheme[key] = value;
hasChanges = true;
}
});
}
if (lightColors) {
Object.entries(lightColors).forEach(([key, value]) => {
if (value !== undefined && themeColors.lightTheme[key] !== value) {
themeColors.lightTheme[key] = value;
hasChanges = true;
}
});
}
return hasChanges;
};
// 保存主题颜色到数据库
const saveThemeColors = async () => {
try {
const darkColors = ThemeColorConfig.createFrom(themeColors.darkTheme);
const lightColors = ThemeColorConfig.createFrom(themeColors.lightTheme);
await ThemeService.UpdateThemeColors(ThemeType.ThemeTypeDark, darkColors);
await ThemeService.UpdateThemeColors(ThemeType.ThemeTypeLight, lightColors);
} catch (error) {
console.error('Failed to save theme colors:', error);
throw error;
}
};
// 重置主题颜色
const resetThemeColors = async (themeType: 'darkTheme' | 'lightTheme') => {
try {
const dbThemeType = themeType === 'darkTheme' ? ThemeType.ThemeTypeDark : ThemeType.ThemeTypeLight;
// 1. 调用后端重置服务
await ThemeService.ResetThemeColors(dbThemeType);
// 2. 更新内存中的颜色状态
if (themeType === 'darkTheme') {
Object.assign(themeColors.darkTheme, defaultDarkColors);
} else {
Object.assign(themeColors.lightTheme, defaultLightColors);
}
// 3. 刷新编辑器主题
refreshEditorTheme();
return true;
} catch (error) {
console.error('Failed to reset theme colors:', error);
// 切换到指定的预设主题
const switchToTheme = async (themeName: string) => {
const theme = allThemes.value.find(t => t.name === themeName);
if (!theme) {
console.error('Theme not found:', themeName);
return false;
}
// 直接设置当前主题颜色
currentColors.value = theme.colors as ThemeColors;
// 持久化到配置
await configStore.setCurrentTheme(themeName);
// 刷新编辑器
refreshEditorTheme();
return true;
};
// 刷新编辑器主题(在主题颜色更改后调用)
// 更新当前主题颜色配置
const updateCurrentColors = (colors: Partial<ThemeColors>) => {
if (!currentColors.value) return;
Object.assign(currentColors.value, colors);
};
// 保存当前主题颜色到数据库
const saveCurrentTheme = async () => {
if (!currentColors.value) {
throw new Error('No theme selected');
}
const theme = allThemes.value.find(t => t.name === currentColors.value!.name);
if (!theme) {
throw new Error('Theme not found');
}
await ThemeService.UpdateTheme(theme.id, currentColors.value as ThemeColorConfig);
return true;
};
// 重置当前主题为预设配置
const resetCurrentTheme = async () => {
if (!currentColors.value) {
throw new Error('No theme selected');
}
// 调用后端重置
await ThemeService.ResetTheme(0, currentColors.value.name);
// 重新加载所有主题
await loadAllThemes();
const updatedTheme = allThemes.value.find(t => t.name === currentColors.value!.name);
if (updatedTheme) {
currentColors.value = updatedTheme.colors as ThemeColors;
}
refreshEditorTheme();
return true;
};
// 刷新编辑器主题
const refreshEditorTheme = () => {
// 使用当前主题重新应用DOM主题
const theme = currentTheme.value;
applyThemeToDOM(theme);
applyThemeToDOM(currentTheme.value);
const editorStore = useEditorStore();
if (editorStore) {
editorStore.applyThemeSettings();
}
editorStore?.applyThemeSettings();
};
return {
// 状态
allThemes,
darkThemes,
lightThemes,
availableThemes,
currentTheme,
themeColors,
currentColors,
isDarkMode,
// 方法
setTheme,
switchToTheme,
initializeTheme,
loadAllThemes,
updateCurrentColors,
saveCurrentTheme,
resetCurrentTheme,
refreshEditorTheme,
applyThemeToDOM,
updateThemeColors,
saveThemeColors,
resetThemeColors,
refreshEditorTheme
};
});

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);
}
}
});
// 等待所有语言映射加载完成
await Promise.all(loadPromises);
} catch (err) {
error.value = 'no available translators';
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 * as runtime from "@wailsio/runtime"
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,8 +1,6 @@
import { Extension, Compartment } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { SystemThemeType } from '@/../bindings/voidraft/internal/models/models';
import { createDarkTheme } from '@/views/editor/theme/dark';
import { createLightTheme } from '@/views/editor/theme/light';
import { createThemeByColors } from '@/views/editor/theme';
import { useThemeStore } from '@/stores/themeStore';
// 主题区间 - 用于动态切换主题
@@ -11,43 +9,50 @@ export const themeCompartment = new Compartment();
/**
* 根据主题类型获取主题扩展
*/
const getThemeExtension = (themeType: SystemThemeType): Extension => {
const getThemeExtension = (): Extension | null => {
const themeStore = useThemeStore();
// 处理 auto 主题类型
let actualTheme: SystemThemeType = themeType;
if (themeType === SystemThemeType.SystemThemeAuto) {
actualTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? SystemThemeType.SystemThemeDark
: SystemThemeType.SystemThemeLight;
// 直接获取当前主题颜色配置
const colors = themeStore.currentColors;
if (!colors) {
return null;
}
// 根据主题类型创建主题
if (actualTheme === SystemThemeType.SystemThemeLight) {
return createLightTheme(themeStore.themeColors.lightTheme);
} else {
return createDarkTheme(themeStore.themeColors.darkTheme);
}
// 使用颜色配置创建主题
return createThemeByColors(colors);
};
/**
* 创建主题扩展(用于编辑器初始化)
*/
export const createThemeExtension = (themeType: SystemThemeType = SystemThemeType.SystemThemeDark): Extension => {
const extension = getThemeExtension(themeType);
export const createThemeExtension = (): Extension => {
const extension = getThemeExtension();
// 如果主题未加载,返回空扩展
if (!extension) {
return themeCompartment.of([]);
}
return themeCompartment.of(extension);
};
/**
* 更新编辑器主题
*/
export const updateEditorTheme = (view: EditorView, themeType: SystemThemeType): void => {
export const updateEditorTheme = (view: EditorView): void => {
if (!view?.dispatch) {
return;
}
try {
const extension = getThemeExtension(themeType);
const extension = getThemeExtension();
// 如果主题未加载,不更新
if (!extension) {
return;
}
view.dispatch({
effects: themeCompartment.reconfigure(extension)
});

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;
// 子菜单缓存池
let 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";
@@ -92,14 +87,6 @@ function formatKeyBinding(keyBinding: string): string {
.replace(/-/g, " + ");
}
/**
* 从命令注册表获取命令处理程序和翻译键
* @param command 命令ID
* @returns 命令处理程序和翻译键
*/
function getCommandInfo(command: KeyBindingCommand): { handler: (view: EditorView) => boolean, descriptionKey: string } | undefined {
return commandRegistry[command];
}
/**
* 创建编辑菜单项
@@ -142,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)
}
];
}
/**
* 创建主菜单项
@@ -194,11 +143,7 @@ function createMainMenuItems(): MenuItem[] {
// 构建主菜单
return [
...basicItems,
...historyItems,
{
label: t("extensions.codeblock.name"),
submenu: createCodeBlockItems()
}
...historyItems
];
}

View File

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

View File

@@ -193,8 +193,8 @@ function setSel(state: any, selection: EditorSelection) {
}
function extendSel(state: any, dispatch: any, how: (range: any) => any) {
let selection = updateSel(state.selection, range => {
let head = how(range);
const selection = updateSel(state.selection, range => {
const head = how(range);
return EditorSelection.range(range.anchor, head.head, head.goalColumn, head.bidiLevel || undefined);
});
if (selection.eq(state.selection)) return false;
@@ -203,7 +203,7 @@ function extendSel(state: any, dispatch: any, how: (range: any) => any) {
}
function moveSel(state: any, dispatch: any, how: (range: any) => any) {
let selection = updateSel(state.selection, how);
const selection = updateSel(state.selection, how);
if (selection.eq(state.selection)) return false;
dispatch(setSel(state, selection));
return true;
@@ -268,7 +268,7 @@ export function selectPreviousBlock({ state, dispatch }: any) {
/**
* 删除块
*/
export const deleteBlock = (options: EditorOptions): Command => ({ state, dispatch }) => {
export const deleteBlock = (_options: EditorOptions): Command => ({ state, dispatch }) => {
if (state.readOnly) return false;
const block = getActiveNoteBlock(state);
@@ -380,4 +380,4 @@ function moveCurrentBlock(state: any, dispatch: any, up: boolean) {
*/
export const formatCurrentBlock: Command = (view) => {
return formatBlockContent(view);
}
};

View File

@@ -17,11 +17,11 @@ const blockSeparatorRegex = new RegExp(`\\n∞∞∞(${languageTokensMatcher})(-
/**
* 获取被复制的范围和内容
*/
function copiedRange(state: EditorState) {
let content: string[] = [];
let ranges: any[] = [];
function copiedRange(state: EditorState, forCut: boolean = false) {
const content: string[] = [];
const ranges: any[] = [];
for (let range of state.selection.ranges) {
for (const range of state.selection.ranges) {
if (!range.empty) {
content.push(state.sliceDoc(range.from, range.to));
ranges.push(range);
@@ -31,13 +31,19 @@ function copiedRange(state: EditorState) {
if (ranges.length === 0) {
// 如果所有范围都是空的,我们想要复制每个选择的整行(唯一的)
const copiedLines: number[] = [];
for (let range of state.selection.ranges) {
for (const range of state.selection.ranges) {
if (range.empty) {
const line = state.doc.lineAt(range.head);
const lineContent = state.sliceDoc(line.from, line.to);
if (!copiedLines.includes(line.from)) {
content.push(lineContent);
ranges.push(range);
// 对于剪切操作,需要包含整行范围(包括换行符)
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);
}
}
@@ -55,7 +61,7 @@ function copiedRange(state: EditorState) {
*/
export const codeBlockCopyCut = EditorView.domEventHandlers({
copy(event, view) {
let { text, ranges } = copiedRange(view.state);
let { text } = copiedRange(view.state);
// 将块分隔符替换为双换行符
text = text.replaceAll(blockSeparatorRegex, "\n\n");
@@ -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

@@ -19,7 +19,7 @@ class NoteBlockStart extends WidgetType {
}
toDOM() {
let wrap = document.createElement("div");
const wrap = document.createElement("div");
wrap.className = "code-block-start" + (this.isFirst ? " first" : "");
return wrap;
}
@@ -37,8 +37,8 @@ const noteBlockWidget = () => {
const builder = new RangeSetBuilder<Decoration>();
state.field(blockState).forEach((block: any) => {
let delimiter = block.delimiter;
let deco = Decoration.replace({
const delimiter = block.delimiter;
const deco = Decoration.replace({
widget: new NoteBlockStart(delimiter.from === 0),
inclusive: true,
block: true,
@@ -80,7 +80,7 @@ const noteBlockWidget = () => {
* 原子范围,防止在分隔符内编辑
*/
function atomicRanges(view: EditorView) {
let builder = new RangeSetBuilder();
const builder = new RangeSetBuilder();
view.state.field(blockState).forEach((block: any) => {
builder.add(
block.delimiter.from,
@@ -167,7 +167,7 @@ const blockLayer = layer({
return markers;
},
update(update: any, dom: any) {
update(update: any, _dom: any) {
return update.docChanged || update.viewportChanged;
},

View File

@@ -24,11 +24,11 @@ function updateSel(sel: EditorSelection, by: (range: SelectionRange) => Selectio
* 获取选中的行块
*/
function selectedLineBlocks(state: any): LineBlock[] {
let blocks: LineBlock[] = [];
const blocks: LineBlock[] = [];
let upto = -1;
for (let range of state.selection.ranges) {
let startLine = state.doc.lineAt(range.from);
for (const range of state.selection.ranges) {
const startLine = state.doc.lineAt(range.from);
let endLine = state.doc.lineAt(range.to);
if (!range.empty && range.to == endLine.from) {
@@ -36,7 +36,7 @@ function selectedLineBlocks(state: any): LineBlock[] {
}
if (upto >= startLine.number) {
let prev = blocks[blocks.length - 1];
const prev = blocks[blocks.length - 1];
prev.to = endLine.to;
prev.ranges.push(range);
} else {

View File

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

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

@@ -49,8 +49,8 @@ export const CodeBlockLanguage = LRLanguage.define({
* 创建代码块语言支持
*/
export function codeBlockLang() {
let wrap = configureNesting();
let lang = CodeBlockLanguage.configure({ dialect: "", wrap: wrap });
const wrap = configureNesting();
const lang = CodeBlockLanguage.configure({ dialect: "", wrap: wrap });
return [
new LanguageSupport(lang, [json().support]),

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 {

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