Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 852424356a | |||
| b704dd2438 | |||
| aa8139884b | |||
| 9a15df01ee | |||
| 03780b5bc7 | |||
| b5d90cc59a | |||
| d49ffc20df | |||
| c22e349181 | |||
| 45968cd353 | |||
| 2d02bf7f1f | |||
| 1216b0b67c | |||
| cf8bf688bf | |||
| 4d6a4ff79f | |||
| 3077d5a7c5 | |||
| bc0569af93 | |||
| 0188b618f2 | |||
| 08860e9a5c | |||
| a56d4ef379 | |||
| f5bfff80b7 | |||
| 1462d8a753 |
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export * from "./models.js";
|
||||
@@ -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>);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export * from "./models.js";
|
||||
@@ -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;
|
||||
@@ -68,4 +68,9 @@ export enum TranslatorType {
|
||||
* DeeplTranslatorType DeepL翻译器
|
||||
*/
|
||||
DeeplTranslatorType = "deepl",
|
||||
|
||||
/**
|
||||
* TartuNLPTranslatorType TartuNLP翻译器
|
||||
*/
|
||||
TartuNLPTranslatorType = "tartunlp",
|
||||
};
|
||||
|
||||
@@ -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"] = "";
|
||||
}
|
||||
|
||||
@@ -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 启动自动备份定时器
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 取消注册全局热键
|
||||
*/
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 检查是否应该最小化到托盘
|
||||
*/
|
||||
|
||||
@@ -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 设置窗口吸附服务引用
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
3
frontend/components.d.ts
vendored
3
frontend/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -50,7 +50,11 @@ export default defineConfig([
|
||||
'.local',
|
||||
'/bin',
|
||||
'Dockerfile',
|
||||
'**/bindings/'
|
||||
'**/bindings/',
|
||||
'*.js',
|
||||
'**/*.js',
|
||||
'**/*.cjs',
|
||||
'**/*.mjs',
|
||||
],
|
||||
}
|
||||
]);
|
||||
783
frontend/package-lock.json
generated
783
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
13
frontend/src/common/constant/editor.ts
Normal file
13
frontend/src/common/constant/editor.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 编辑器相关常量配置
|
||||
*/
|
||||
|
||||
// 编辑器实例管理
|
||||
export const EDITOR_CONFIG = {
|
||||
/** 最多缓存的编辑器实例数量 */
|
||||
MAX_INSTANCES: 5,
|
||||
/** 语法树缓存过期时间(毫秒) */
|
||||
SYNTAX_TREE_CACHE_TIMEOUT: 30000,
|
||||
/** 加载状态延迟时间(毫秒) */
|
||||
LOADING_DELAY: 500,
|
||||
} as const;
|
||||
@@ -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;
|
||||
49
frontend/src/common/constant/translation.ts
Normal file
49
frontend/src/common/constant/translation.ts
Normal 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>`;
|
||||
@@ -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;
|
||||
Binary file not shown.
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 };
|
||||
807
frontend/src/common/prettier/plugins/docker/lib/format.go
Normal file
807
frontend/src/common/prettier/plugins/docker/lib/format.go
Normal 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()
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -39,7 +39,7 @@ const groovyPrinter: Printer<string> = {
|
||||
return groovyBeautify(path.node, {
|
||||
width: options.printWidth || 80,
|
||||
}).trim();
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
return path.node;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
export function format(input: string, filename: string, config?: Config): string;
|
||||
|
||||
interface LayoutConfig {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
export function format(input: string, path?: string, config?: Config): string;
|
||||
|
||||
export interface Config {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
export function format(input: string, config?: Config | null): string;
|
||||
|
||||
export interface Config {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
export function format(input: string, filename: string, config?: Config): string;
|
||||
|
||||
interface LayoutConfig {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
265
frontend/src/common/utils/asyncManager.ts
Normal file
265
frontend/src/common/utils/asyncManager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
111
frontend/src/common/utils/debounce.ts
Normal file
111
frontend/src/common/utils/debounce.ts
Normal 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;
|
||||
}
|
||||
280
frontend/src/common/utils/doublyLinkedList.ts
Normal file
280
frontend/src/common/utils/doublyLinkedList.ts
Normal 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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
92
frontend/src/common/utils/hashUtils.ts
Normal file
92
frontend/src/common/utils/hashUtils.ts
Normal 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;
|
||||
}
|
||||
157
frontend/src/common/utils/lruCache.ts
Normal file
157
frontend/src/common/utils/lruCache.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
162
frontend/src/common/utils/timerUtils.ts
Normal file
162
frontend/src/common/utils/timerUtils.ts
Normal 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 };
|
||||
};
|
||||
@@ -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>
|
||||
242
frontend/src/components/tabs/TabContainer.vue
Normal file
242
frontend/src/components/tabs/TabContainer.vue
Normal 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>
|
||||
181
frontend/src/components/tabs/TabContextMenu.vue
Normal file
181
frontend/src/components/tabs/TabContextMenu.vue
Normal 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>
|
||||
294
frontend/src/components/tabs/TabItem.vue
Normal file
294
frontend/src/components/tabs/TabItem.vue
Normal 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>
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? '' : '');
|
||||
|
||||
// 判断是否在设置页面
|
||||
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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '匹配括号'
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
// 状态
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
@@ -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']
|
||||
}
|
||||
});
|
||||
});
|
||||
259
frontend/src/stores/tabStore.ts
Normal file
259
frontend/src/stores/tabStore.ts
Normal 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
|
||||
};
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
@@ -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']
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,表示命令已开始执行
|
||||
};
|
||||
@@ -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 [
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user