Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5688304817 | |||
| 4380ad010c | |||
| 4fa6bb42e3 | |||
| 7aa3a7e37f | |||
| 94306497a9 | |||
| 93c85b800b | |||
| 8ac78e39f1 | |||
| 61a23fe7f2 | |||
| 87fea58102 | |||
|
|
edeac01bee | ||
| 852424356a | |||
| b704dd2438 | |||
| aa8139884b | |||
| 9a15df01ee | |||
| 03780b5bc7 | |||
| b5d90cc59a | |||
| d49ffc20df | |||
| c22e349181 | |||
| 45968cd353 | |||
| 2d02bf7f1f | |||
| 1216b0b67c | |||
| cf8bf688bf | |||
| 4d6a4ff79f | |||
| 3077d5a7c5 | |||
| bc0569af93 | |||
| 0188b618f2 | |||
| 08860e9a5c | |||
| a56d4ef379 |
@@ -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,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>);
|
||||
}
|
||||
}
|
||||
14
frontend/bindings/net/http/models.ts
Normal file
14
frontend/bindings/net/http/models.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// 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 Header represents the key-value pairs in an HTTP header.
|
||||
*
|
||||
* The keys should be in canonical form, as returned by
|
||||
* [CanonicalHeaderKey].
|
||||
*/
|
||||
export type Header = { [_: string]: string[] };
|
||||
@@ -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 取消注册全局热键
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* HttpClientService HTTP客户端服务
|
||||
* @module
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
/**
|
||||
* ExecuteRequest 执行HTTP请求
|
||||
*/
|
||||
export function ExecuteRequest(request: $models.HttpRequest | null): Promise<$models.HttpResponse | null> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3143343977, request) as any;
|
||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||
return $$createType1($result);
|
||||
}) as any;
|
||||
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
|
||||
return $typingPromise;
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = $models.HttpResponse.createFrom;
|
||||
const $$createType1 = $Create.Nullable($$createType0);
|
||||
@@ -8,6 +8,7 @@ import * as DialogService from "./dialogservice.js";
|
||||
import * as DocumentService from "./documentservice.js";
|
||||
import * as ExtensionService from "./extensionservice.js";
|
||||
import * as HotkeyService from "./hotkeyservice.js";
|
||||
import * as HttpClientService from "./httpclientservice.js";
|
||||
import * as KeyBindingService from "./keybindingservice.js";
|
||||
import * as MigrationService from "./migrationservice.js";
|
||||
import * as SelfUpdateService from "./selfupdateservice.js";
|
||||
@@ -26,6 +27,7 @@ export {
|
||||
DocumentService,
|
||||
ExtensionService,
|
||||
HotkeyService,
|
||||
HttpClientService,
|
||||
KeyBindingService,
|
||||
MigrationService,
|
||||
SelfUpdateService,
|
||||
|
||||
@@ -8,6 +8,114 @@ import {Create as $Create} from "@wailsio/runtime";
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as http$0 from "../../../net/http/models.js";
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as time$0 from "../../../time/models.js";
|
||||
|
||||
/**
|
||||
* HttpRequest HTTP请求结构
|
||||
*/
|
||||
export class HttpRequest {
|
||||
"method": string;
|
||||
"url": string;
|
||||
"headers": { [_: string]: string };
|
||||
|
||||
/**
|
||||
* json, formdata, urlencoded, text
|
||||
*/
|
||||
"bodyType"?: string;
|
||||
"body"?: any;
|
||||
|
||||
/** Creates a new HttpRequest instance. */
|
||||
constructor($$source: Partial<HttpRequest> = {}) {
|
||||
if (!("method" in $$source)) {
|
||||
this["method"] = "";
|
||||
}
|
||||
if (!("url" in $$source)) {
|
||||
this["url"] = "";
|
||||
}
|
||||
if (!("headers" in $$source)) {
|
||||
this["headers"] = {};
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new HttpRequest instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): HttpRequest {
|
||||
const $$createField2_0 = $$createType0;
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
if ("headers" in $$parsedSource) {
|
||||
$$parsedSource["headers"] = $$createField2_0($$parsedSource["headers"]);
|
||||
}
|
||||
return new HttpRequest($$parsedSource as Partial<HttpRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HttpResponse HTTP响应结构
|
||||
*/
|
||||
export class HttpResponse {
|
||||
/**
|
||||
* 使用resp.Status()返回完整状态如"200 OK"
|
||||
*/
|
||||
"status": string;
|
||||
|
||||
/**
|
||||
* 响应时间(毫秒)
|
||||
*/
|
||||
"time": number;
|
||||
|
||||
/**
|
||||
* 请求大小
|
||||
*/
|
||||
"requestSize": string;
|
||||
"body": any;
|
||||
"headers": http$0.Header;
|
||||
"timestamp": time$0.Time;
|
||||
"error"?: any;
|
||||
|
||||
/** Creates a new HttpResponse instance. */
|
||||
constructor($$source: Partial<HttpResponse> = {}) {
|
||||
if (!("status" in $$source)) {
|
||||
this["status"] = "";
|
||||
}
|
||||
if (!("time" in $$source)) {
|
||||
this["time"] = 0;
|
||||
}
|
||||
if (!("requestSize" in $$source)) {
|
||||
this["requestSize"] = "";
|
||||
}
|
||||
if (!("body" in $$source)) {
|
||||
this["body"] = null;
|
||||
}
|
||||
if (!("headers" in $$source)) {
|
||||
this["headers"] = ({} as http$0.Header);
|
||||
}
|
||||
if (!("timestamp" in $$source)) {
|
||||
this["timestamp"] = null;
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new HttpResponse instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): HttpResponse {
|
||||
const $$createField4_0 = $$createType1;
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
if ("headers" in $$parsedSource) {
|
||||
$$parsedSource["headers"] = $$createField4_0($$parsedSource["headers"]);
|
||||
}
|
||||
return new HttpResponse($$parsedSource as Partial<HttpResponse>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MemoryStats 内存统计信息
|
||||
@@ -119,6 +227,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 +347,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 = $$createType5;
|
||||
const $$createField4_0 = $$createType6;
|
||||
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 +421,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 = $$createType8;
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
if ("Window" in $$parsedSource) {
|
||||
$$parsedSource["Window"] = $$createField0_0($$parsedSource["Window"]);
|
||||
@@ -259,5 +451,17 @@ export class WindowSnapService {
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = application$0.WebviewWindow.createFrom;
|
||||
const $$createType1 = $Create.Nullable($$createType0);
|
||||
const $$createType0 = $Create.Map($Create.Any, $Create.Any);
|
||||
var $$createType1 = (function $$initCreateType1(...args): any {
|
||||
if ($$createType1 === $$initCreateType1) {
|
||||
$$createType1 = $$createType3;
|
||||
}
|
||||
return $$createType1(...args);
|
||||
});
|
||||
const $$createType2 = $Create.Array($Create.Any);
|
||||
const $$createType3 = $Create.Map($Create.Any, $$createType2);
|
||||
const $$createType4 = OSInfo.createFrom;
|
||||
const $$createType5 = $Create.Nullable($$createType4);
|
||||
const $$createType6 = $Create.Map($Create.Any, $Create.Any);
|
||||
const $$createType7 = application$0.WebviewWindow.createFrom;
|
||||
const $$createType8 = $Create.Nullable($$createType7);
|
||||
|
||||
@@ -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 设置窗口吸附服务引用
|
||||
*/
|
||||
|
||||
8
frontend/components.d.ts
vendored
8
frontend/components.d.ts
vendored
@@ -1,8 +1,11 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
@@ -16,6 +19,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']
|
||||
|
||||
1251
frontend/package-lock.json
generated
1251
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,82 +9,85 @@
|
||||
"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",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.7",
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/autocomplete": "^6.19.1",
|
||||
"@codemirror/commands": "^6.10.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.5.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/language-data": "^6.5.2",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/lint": "^6.9.1",
|
||||
"@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.3",
|
||||
"@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",
|
||||
"jsox": "^1.2.123",
|
||||
"linguist-languages": "^9.0.0",
|
||||
"linguist-languages": "^9.1.0",
|
||||
"php-parser": "^3.2.5",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.5.0",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"prettier": "^3.6.2",
|
||||
"remarkable": "^2.0.1",
|
||||
"sass": "^1.92.1",
|
||||
"vue": "^3.5.21",
|
||||
"sass": "^1.93.3",
|
||||
"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.39.0",
|
||||
"@lezer/generator": "^1.8.0",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/node": "^24.9.2",
|
||||
"@types/remarkable": "^2.0.8",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@wailsio/runtime": "latest",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-plugin-vue": "^10.4.0",
|
||||
"globals": "^16.4.0",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.43.0",
|
||||
"unplugin-vue-components": "^29.0.0",
|
||||
"vite": "^7.1.5",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.0",
|
||||
"eslint-plugin-vue": "^10.5.1",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "^7.1.12",
|
||||
"vite-plugin-node-polyfills": "^0.24.0",
|
||||
"vitest": "^4.0.6",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^3.0.6"
|
||||
"vue-tsc": "^3.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 +25,8 @@ 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>`;
|
||||
Binary file not shown.
@@ -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}
|
||||
}
|
||||
|
||||
@@ -121,11 +121,11 @@ func FormatOnBuild(n *ExtendedNode, c *Config) string {
|
||||
return n.OriginalMultiline
|
||||
}
|
||||
|
||||
func FormatFileLines(fileLines []string, c *Config) string {
|
||||
func FormatFileLines(fileLines []string, c *Config) (string, error) {
|
||||
result, err := parser.Parse(strings.NewReader(strings.Join(fileLines, "")))
|
||||
if err != nil {
|
||||
log.Printf("%s\n", strings.Join(fileLines, ""))
|
||||
log.Fatalf("Error parsing file: %v", err)
|
||||
return "", fmt.Errorf("Error parsing file: %v", err)
|
||||
}
|
||||
|
||||
parseState := &ParseState{
|
||||
@@ -148,7 +148,7 @@ func FormatFileLines(fileLines []string, c *Config) string {
|
||||
if c.TrailingNewline {
|
||||
parseState.Output += "\n"
|
||||
}
|
||||
return parseState.Output
|
||||
return parseState.Output, nil
|
||||
}
|
||||
|
||||
func BuildExtendedNode(n *parser.Node, fileLines []string) *ExtendedNode {
|
||||
@@ -572,9 +572,12 @@ func getCmd(n *ExtendedNode, shouldSplitNode bool) []string {
|
||||
if shouldSplitNode {
|
||||
parts, err := shlex.Split(rawValue)
|
||||
if err != nil {
|
||||
log.Fatalf("Error splitting: %s\n", node.Node.Value)
|
||||
// Fallback: if splitting fails, use raw value as a single token to avoid exiting
|
||||
log.Printf("Error splitting: %s: %v\n", node.Node.Value, err)
|
||||
cmd = append(cmd, rawValue)
|
||||
} else {
|
||||
cmd = append(cmd, parts...)
|
||||
}
|
||||
cmd = append(cmd, parts...)
|
||||
} else {
|
||||
cmd = append(cmd, rawValue)
|
||||
}
|
||||
@@ -587,7 +590,11 @@ func shouldRunInShell(node string) bool {
|
||||
// https://docs.docker.com/reference/dockerfile/#entrypoint
|
||||
parts, err := shlex.Split(node)
|
||||
if err != nil {
|
||||
log.Fatalf("Error splitting: %s\n", node)
|
||||
// If we cannot reliably split, heuristically decide based on common shell operators
|
||||
if strings.Contains(node, "&&") || strings.Contains(node, ";") || strings.Contains(node, "||") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
needsShell := false
|
||||
@@ -785,8 +792,8 @@ func formatBash(s string, c *Config) string {
|
||||
r := strings.NewReader(s)
|
||||
f, err := syntax.NewParser(syntax.KeepComments(true)).Parse(r, "")
|
||||
if err != nil {
|
||||
fmt.Printf("Error parsing: %s\n", s)
|
||||
panic(err)
|
||||
// On parse failure, return original input to avoid crashing the WASM runtime
|
||||
return s
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
syntax.NewPrinter(
|
||||
|
||||
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,106 +22,74 @@ 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 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 canvas = canvasRef.value;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
// 绘制网格背景
|
||||
const drawGrid = (ctx: CanvasRenderingContext2D, width: number, height: number, colors: ReturnType<typeof getThemeColors>): void => {
|
||||
ctx.strokeStyle = colors.grid;
|
||||
ctx.lineWidth = 0.5;
|
||||
|
||||
// 设置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)';
|
||||
|
||||
// 绘制背景网格 - 更加柔和
|
||||
// 水平网格线
|
||||
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);
|
||||
@@ -130,13 +99,79 @@ const drawChart = () => {
|
||||
// 垂直网格线
|
||||
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;
|
||||
|
||||
@@ -145,80 +180,25 @@ const drawChart = () => {
|
||||
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);
|
||||
@@ -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>
|
||||
@@ -307,9 +247,9 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="chart-area">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="memory-chart"
|
||||
:class="{ 'loading': isLoading }"
|
||||
ref="canvasRef"
|
||||
class="memory-chart"
|
||||
:class="{ 'loading': isLoading }"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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,8 +43,13 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="titlebar-content" @dblclick="toggleMaximize" @contextmenu.prevent>
|
||||
<div class="titlebar-title">{{ titleText }}</div>
|
||||
<!-- 标签页容器区域 -->
|
||||
<div class="titlebar-tabs" v-if="tabStore.isTabsEnabled && !isInSettings" style="--wails-draggable:drag">
|
||||
<TabContainer />
|
||||
</div>
|
||||
|
||||
<div class="titlebar-content" @dblclick="toggleMaximize" @contextmenu.prevent v-if="!tabStore.isTabsEnabled || isInSettings">
|
||||
<div class="titlebar-title" :title="fullTitleText">{{ titleText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -52,14 +57,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import * as runtime from '@wailsio/runtime';
|
||||
import { useDocumentStore } from '@/stores/documentStore';
|
||||
import TabContainer from '@/components/tabs/TabContainer.vue';
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
|
||||
const tabStore = useTabStore();
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const isMaximized = ref(false);
|
||||
const showControlIcons = ref(false);
|
||||
const documentStore = useDocumentStore();
|
||||
|
||||
// 判断是否在设置页面
|
||||
const isInSettings = computed(() => route.path.startsWith('/settings'));
|
||||
|
||||
const minimizeWindow = async () => {
|
||||
try {
|
||||
await runtime.Window.Minimise();
|
||||
@@ -95,6 +108,26 @@ const checkMaximizedState = async () => {
|
||||
|
||||
// 计算标题文本
|
||||
const titleText = computed(() => {
|
||||
if (isInSettings.value) {
|
||||
return `voidraft - ` + t('settings.title');
|
||||
}
|
||||
const currentDoc = documentStore.currentDocument;
|
||||
if (currentDoc) {
|
||||
// 限制文档标题长度,避免标题栏换行
|
||||
const maxTitleLength = 30;
|
||||
const truncatedTitle = currentDoc.title.length > maxTitleLength
|
||||
? currentDoc.title.substring(0, maxTitleLength) + '...'
|
||||
: currentDoc.title;
|
||||
return `voidraft - ${truncatedTitle}`;
|
||||
}
|
||||
return 'voidraft';
|
||||
});
|
||||
|
||||
// 计算完整标题文本(用于tooltip)
|
||||
const fullTitleText = computed(() => {
|
||||
if (isInSettings.value) {
|
||||
return `voidraft - ` + t('settings.title');
|
||||
}
|
||||
const currentDoc = documentStore.currentDocument;
|
||||
return currentDoc ? `voidraft - ${currentDoc.title}` : 'voidraft';
|
||||
});
|
||||
@@ -134,6 +167,7 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
padding-left: 8px;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
-webkit-context-menu: none;
|
||||
-moz-context-menu: none;
|
||||
@@ -212,12 +246,67 @@ onMounted(async () => {
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
cursor: default;
|
||||
min-width: 0;
|
||||
|
||||
-webkit-context-menu: none;
|
||||
-moz-context-menu: none;
|
||||
context-menu: none;
|
||||
}
|
||||
|
||||
.titlebar-tabs {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
min-width: 0;
|
||||
overflow: visible; /* 允许TabContainer内部处理滚动 */
|
||||
|
||||
/* 确保TabContainer能够正确处理滚动 */
|
||||
:deep(.tab-container) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.tab-bar) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.tab-scroll-wrapper) {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 确保底部线条能够正确显示 */
|
||||
:deep(.tab-item) {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: var(--tab-active-line, var(--accent-color, #007acc));
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&.active::after {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.titlebar-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -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');
|
||||
const currentLang = languageDisplayInfo.value.language;
|
||||
const activeOption = document.querySelector(`.language-option[data-language="${currentLang}"]`) as HTMLElement;
|
||||
|
||||
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'
|
||||
});
|
||||
}
|
||||
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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
const hasOpen = await windowStore.isDocumentWindowOpen(doc.id);
|
||||
if (hasOpen) {
|
||||
documentStore.setError(doc.id, t('toolbar.alreadyOpenInNewWindow'));
|
||||
deleteConfirmId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除之前的定时器(如果存在)
|
||||
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);
|
||||
|
||||
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,33 +1,36 @@
|
||||
<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;
|
||||
});
|
||||
|
||||
// 切换窗口置顶状态
|
||||
@@ -36,7 +39,7 @@ const toggleAlwaysOnTop = async () => {
|
||||
|
||||
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);
|
||||
|
||||
// 检查块和语言格式化支持
|
||||
canFormatCurrentBlock.value = !!(
|
||||
activeBlock &&
|
||||
getLanguage(activeBlock.language.name as any)?.prettier
|
||||
);
|
||||
// 提前返回,减少不必要的计算
|
||||
if (!activeBlock) {
|
||||
canFormatCurrentBlock.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
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,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);
|
||||
|
||||
// Backup result states
|
||||
const pushSuccess = ref(false);
|
||||
const pushError = ref(false);
|
||||
// 统一的备份结果状态
|
||||
const backupResult = ref<BackupResult>({
|
||||
status: BackupStatus.IDLE
|
||||
});
|
||||
|
||||
// Timers for auto-hiding status icons and error messages
|
||||
let pushStatusTimer: number | null = null;
|
||||
let errorTimer: number | null = null;
|
||||
// === 定时器管理 ===
|
||||
const statusTimer = createTimerManager();
|
||||
|
||||
// 获取configStore
|
||||
const configStore = useConfigStore();
|
||||
// 组件卸载时清理定时器
|
||||
onScopeDispose(() => {
|
||||
statusTimer.clear();
|
||||
});
|
||||
|
||||
// Computed properties
|
||||
const isEnabled = computed(() => configStore.config.backup.enabled);
|
||||
const isConfigured = computed(() => configStore.config.backup.repo_url);
|
||||
// === 外部依赖 ===
|
||||
const configStore = useConfigStore();
|
||||
|
||||
// 清除状态显示
|
||||
const clearPushStatus = () => {
|
||||
if (pushStatusTimer !== null) {
|
||||
window.clearTimeout(pushStatusTimer);
|
||||
pushStatusTimer = null;
|
||||
// === 计算属性 ===
|
||||
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();
|
||||
|
||||
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 };
|
||||
}
|
||||
pushSuccess.value = false;
|
||||
pushError.value = false;
|
||||
};
|
||||
}, autoHide);
|
||||
}
|
||||
};
|
||||
|
||||
// 清除错误信息和错误图标
|
||||
const clearError = () => {
|
||||
if (errorTimer !== null) {
|
||||
window.clearTimeout(errorTimer);
|
||||
errorTimer = null;
|
||||
}
|
||||
error.value = null;
|
||||
pushError.value = false;
|
||||
};
|
||||
/**
|
||||
* 清除当前状态
|
||||
*/
|
||||
const clearStatus = (): void => {
|
||||
statusTimer.clear();
|
||||
backupResult.value = { status: BackupStatus.IDLE };
|
||||
};
|
||||
|
||||
// 设置错误信息和错误图标并自动清除
|
||||
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 handleError = (error: unknown): void => {
|
||||
const message: ErrorMessage<typeof error> = isBackupError(error)
|
||||
? error.message
|
||||
: 'Backup operation failed';
|
||||
|
||||
// Push to remote repository
|
||||
const pushToRemote = async () => {
|
||||
if (isPushing.value || !isConfigured.value) return;
|
||||
setBackupStatus(BackupStatus.ERROR, message, 5000);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 推送到远程仓库
|
||||
* 使用现代 async/await 和错误处理
|
||||
*/
|
||||
const pushToRemote = async (): Promise<void> => {
|
||||
// 前置条件检查
|
||||
if (isPushing.value || !isConfigured.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化备份服务
|
||||
const initialize = async () => {
|
||||
if (!isEnabled.value) return;
|
||||
try {
|
||||
setBackupStatus(BackupStatus.PUSHING);
|
||||
|
||||
// 避免重复初始化
|
||||
if (isInitialized.value) return;
|
||||
await BackupService.PushToRemote();
|
||||
|
||||
clearError(); // 清除之前的错误信息
|
||||
try {
|
||||
await BackupService.Initialize();
|
||||
isInitialized.value = true;
|
||||
} catch (err: any) {
|
||||
setErrorWithAutoHide(err?.message || 'Failed to initialize backup service');
|
||||
}
|
||||
};
|
||||
setBackupStatus(BackupStatus.SUCCESS, 'Backup completed successfully', 3000);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 重试备份操作
|
||||
*/
|
||||
const retryBackup = async (): Promise<void> => {
|
||||
if (isError.value) {
|
||||
await pushToRemote();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
config: readonly(config),
|
||||
isPushing: readonly(isPushing),
|
||||
error: readonly(error),
|
||||
isInitialized: readonly(isInitialized),
|
||||
pushSuccess: readonly(pushSuccess),
|
||||
pushError: readonly(pushError),
|
||||
// === 响应式副作用 ===
|
||||
|
||||
// Computed
|
||||
isEnabled,
|
||||
isConfigured,
|
||||
// 监听配置变化,自动清除错误状态
|
||||
watchEffect(() => {
|
||||
if (isEnabled.value && isConfigured.value && isError.value) {
|
||||
// 配置修复后清除错误状态
|
||||
clearStatus();
|
||||
}
|
||||
});
|
||||
|
||||
// Methods
|
||||
pushToRemote,
|
||||
initialize,
|
||||
clearError
|
||||
};
|
||||
// === 返回的 API ===
|
||||
return {
|
||||
// 只读状态
|
||||
config: readonly(config),
|
||||
backupResult: readonly(backupResult),
|
||||
|
||||
// 计算属性
|
||||
isEnabled,
|
||||
isConfigured,
|
||||
isPushing,
|
||||
isSuccess,
|
||||
isError,
|
||||
errorMessage,
|
||||
|
||||
// 方法
|
||||
pushToRemote,
|
||||
retryBackup,
|
||||
clearStatus
|
||||
} as const;
|
||||
});
|
||||
@@ -4,44 +4,31 @@ import {ConfigService, StartupService} from '@/../bindings/voidraft/internal/ser
|
||||
import {
|
||||
AppConfig,
|
||||
AppearanceConfig,
|
||||
AuthMethod,
|
||||
EditingConfig,
|
||||
GeneralConfig,
|
||||
GitBackupConfig,
|
||||
LanguageType,
|
||||
SystemThemeType,
|
||||
TabType,
|
||||
UpdatesConfig,
|
||||
GitBackupConfig,
|
||||
AuthMethod
|
||||
UpdatesConfig
|
||||
} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {ConfigUtils} from '@/common/utils/configUtils';
|
||||
import {FONT_OPTIONS} from '@/common/constant/fonts';
|
||||
import {SupportedLocaleType, SUPPORTED_LOCALES} from '@/common/constant/locales';
|
||||
import {SUPPORTED_LOCALES} from '@/common/constant/locales';
|
||||
import {
|
||||
NumberConfigKey,
|
||||
GENERAL_CONFIG_KEY_MAP,
|
||||
EDITING_CONFIG_KEY_MAP,
|
||||
APPEARANCE_CONFIG_KEY_MAP,
|
||||
UPDATES_CONFIG_KEY_MAP,
|
||||
BACKUP_CONFIG_KEY_MAP,
|
||||
CONFIG_LIMITS,
|
||||
DEFAULT_CONFIG
|
||||
DEFAULT_CONFIG,
|
||||
EDITING_CONFIG_KEY_MAP,
|
||||
GENERAL_CONFIG_KEY_MAP,
|
||||
NumberConfigKey,
|
||||
UPDATES_CONFIG_KEY_MAP
|
||||
} from '@/common/constant/config';
|
||||
import * as runtime from '@wailsio/runtime';
|
||||
|
||||
// 获取浏览器的默认语言
|
||||
const getBrowserLanguage = (): SupportedLocaleType => {
|
||||
const browserLang = navigator.language;
|
||||
const langCode = browserLang.split('-')[0];
|
||||
|
||||
// 检查是否支持此语言
|
||||
const supportedLang = SUPPORTED_LOCALES.find(locale =>
|
||||
locale.code.startsWith(langCode) || locale.code.split('-')[0] === langCode
|
||||
);
|
||||
|
||||
return supportedLang?.code || 'zh-CN';
|
||||
};
|
||||
|
||||
export const useConfigStore = defineStore('config', () => {
|
||||
const {locale} = useI18n();
|
||||
|
||||
@@ -217,6 +204,10 @@ export const useConfigStore = defineStore('config', () => {
|
||||
await updateAppearanceConfig('systemTheme', systemTheme);
|
||||
};
|
||||
|
||||
// 当前主题设置方法
|
||||
const setCurrentTheme = async (themeName: string): Promise<void> => {
|
||||
await updateAppearanceConfig('currentTheme', themeName);
|
||||
};
|
||||
|
||||
|
||||
// 初始化语言设置
|
||||
@@ -231,7 +222,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const frontendLocale = ConfigUtils.backendLanguageToFrontend(state.config.appearance.language);
|
||||
locale.value = frontendLocale as any;
|
||||
} catch (_error) {
|
||||
const browserLang = getBrowserLanguage();
|
||||
const browserLang = SUPPORTED_LOCALES[0].code;
|
||||
locale.value = browserLang as any;
|
||||
}
|
||||
};
|
||||
@@ -282,6 +273,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||
|
||||
// 主题相关方法
|
||||
setSystemTheme,
|
||||
setCurrentTheme,
|
||||
|
||||
// 字体大小操作
|
||||
...adjusters.fontSize,
|
||||
@@ -337,12 +329,19 @@ export const useConfigStore = defineStore('config', () => {
|
||||
// 加载动画配置相关方法
|
||||
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),
|
||||
|
||||
@@ -3,46 +3,29 @@ import {computed, ref} from 'vue';
|
||||
import {DocumentService} from '@/../bindings/voidraft/internal/services';
|
||||
import {OpenDocumentWindow} from '@/../bindings/voidraft/internal/services/windowservice';
|
||||
import {Document} from '@/../bindings/voidraft/internal/models/models';
|
||||
|
||||
import {useTabStore} from "@/stores/tabStore";
|
||||
|
||||
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,12 +33,51 @@ 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> => {
|
||||
try {
|
||||
await OpenDocumentWindow(docId);
|
||||
const tabStore = useTabStore();
|
||||
if (tabStore.isTabsEnabled && tabStore.hasTab(docId)) {
|
||||
tabStore.closeTab(docId);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to open document in new window:', error);
|
||||
@@ -63,22 +85,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 +128,6 @@ export const useDocumentStore = defineStore('document', () => {
|
||||
|
||||
currentDocumentId.value = docId;
|
||||
currentDocument.value = doc;
|
||||
addRecentDocument(docId);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -97,41 +136,6 @@ export const useDocumentStore = defineStore('document', () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 创建新文档
|
||||
const createNewDocument = async (title: string): Promise<Document | null> => {
|
||||
try {
|
||||
const newDoc = await DocumentService.CreateDocument(title);
|
||||
if (!newDoc) {
|
||||
throw new Error('Failed to create document');
|
||||
}
|
||||
|
||||
// 更新文档列表
|
||||
documents.value[newDoc.id] = newDoc;
|
||||
|
||||
return newDoc;
|
||||
} catch (error) {
|
||||
console.error('Failed to create document:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 保存新文档
|
||||
const saveNewDocument = async (title: string, content: string): Promise<boolean> => {
|
||||
try {
|
||||
const newDoc = await createNewDocument(title);
|
||||
if (!newDoc) return false;
|
||||
|
||||
// 更新内容
|
||||
await DocumentService.UpdateDocumentContent(newDoc.id, content);
|
||||
newDoc.content = content;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to save new document:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 更新文档元数据
|
||||
const updateDocumentMetadata = async (docId: number, title: string): Promise<boolean> => {
|
||||
try {
|
||||
@@ -141,12 +145,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 +172,6 @@ export const useDocumentStore = defineStore('document', () => {
|
||||
|
||||
// 更新本地状态
|
||||
delete documents.value[docId];
|
||||
recentDocumentIds.value = recentDocumentIds.value.filter(id => id !== docId);
|
||||
|
||||
// 如果删除的是当前文档,切换到第一个可用文档
|
||||
if (currentDocumentId.value === docId) {
|
||||
@@ -188,20 +191,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 +203,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 +216,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,12 @@ 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';
|
||||
import {createHttpClientExtension} from "@/views/editor/extensions/httpclient";
|
||||
|
||||
export interface DocumentStats {
|
||||
lines: number;
|
||||
@@ -33,7 +37,7 @@ interface EditorInstance {
|
||||
content: string;
|
||||
isDirty: boolean;
|
||||
lastModified: Date;
|
||||
autoSaveTimer: number | null;
|
||||
autoSaveTimer: TimerManager;
|
||||
syntaxTreeCache: {
|
||||
lastDocLength: number;
|
||||
lastContentHash: string;
|
||||
@@ -49,15 +53,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 +66,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 +89,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 +113,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 +126,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(
|
||||
@@ -195,8 +155,10 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
enableAutoDetection: true
|
||||
});
|
||||
|
||||
const httpExtension = createHttpClientExtension();
|
||||
|
||||
// 再次检查操作有效性
|
||||
if (!isOperationValid(operationId, documentId)) {
|
||||
if (!operationManager.isOperationValid(operationId, documentId)) {
|
||||
throw new Error('Operation cancelled');
|
||||
}
|
||||
|
||||
@@ -204,7 +166,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 +174,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');
|
||||
}
|
||||
|
||||
@@ -226,7 +188,8 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
statsExtension,
|
||||
contentChangeExtension,
|
||||
codeBlockExtension,
|
||||
...dynamicExtensions
|
||||
...dynamicExtensions,
|
||||
...httpExtension
|
||||
];
|
||||
|
||||
// 创建编辑器状态
|
||||
@@ -252,52 +215,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 +247,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 +261,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 +274,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 +284,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 +315,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 +341,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 +350,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 +360,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
|
||||
// 设置编辑器容器
|
||||
const setEditorContainer = (container: HTMLElement | null) => {
|
||||
editorCache.value.containerElement = container;
|
||||
containerElement.value = container;
|
||||
|
||||
// 如果设置容器时已有当前文档,立即加载编辑器
|
||||
if (container && documentStore.currentDocument) {
|
||||
@@ -439,9 +372,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 +382,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 +389,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
await saveEditorContent(currentDocId);
|
||||
|
||||
// 检查操作是否仍然有效
|
||||
if (!isOperationValid(operationId, documentId)) {
|
||||
if (!operationManager.isOperationValid(operationId, documentId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -471,12 +399,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 +423,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
}
|
||||
|
||||
// 最终检查操作有效性
|
||||
if (!isOperationValid(operationId, documentId)) {
|
||||
if (!operationManager.isOperationValid(operationId, documentId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -509,35 +437,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 +478,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 +493,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 +505,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 +526,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
const applyKeymapSettings = async () => {
|
||||
// 确保所有编辑器实例的快捷键都更新
|
||||
await Promise.all(
|
||||
Object.values(editorCache.value.instances).map(instance =>
|
||||
editorCache.values().map(instance =>
|
||||
updateKeymapExtension(instance.view)
|
||||
)
|
||||
);
|
||||
@@ -620,14 +535,11 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
// 清空所有编辑器
|
||||
const clearAllEditors = () => {
|
||||
// 取消所有挂起的操作
|
||||
cancelPreviousOperations();
|
||||
currentLoadingDocumentId.value = null;
|
||||
operationManager.cancelAllOperations();
|
||||
|
||||
Object.values(editorCache.value.instances).forEach(instance => {
|
||||
editorCache.clear((_documentId, instance) => {
|
||||
// 清除自动保存定时器
|
||||
if (instance.autoSaveTimer) {
|
||||
clearTimeout(instance.autoSaveTimer);
|
||||
}
|
||||
instance.autoSaveTimer.clear();
|
||||
|
||||
// 从扩展管理器移除
|
||||
removeExtensionManagerView(instance.documentId);
|
||||
@@ -640,8 +552,6 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
instance.view.destroy();
|
||||
});
|
||||
|
||||
editorCache.value.instances = {};
|
||||
editorCache.value.lru = [];
|
||||
currentEditor.value = null;
|
||||
};
|
||||
|
||||
@@ -665,30 +575,36 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
// 重新加载扩展配置
|
||||
await extensionStore.loadExtensions();
|
||||
|
||||
// 不再需要单独更新当前编辑器的快捷键映射,因为扩展管理器会更新所有实例
|
||||
// 但我们仍需要确保快捷键配置在所有编辑器上更新
|
||||
await applyKeymapSettings();
|
||||
};
|
||||
|
||||
// 监听文档切换
|
||||
watch(() => documentStore.currentDocument, (newDoc) => {
|
||||
if (newDoc && editorCache.value.containerElement) {
|
||||
watch(() => documentStore.currentDocument, async (newDoc) => {
|
||||
if (newDoc && containerElement.value) {
|
||||
// 使用 nextTick 确保DOM更新完成后再加载编辑器
|
||||
nextTick(() => {
|
||||
await nextTick(() => {
|
||||
loadEditor(newDoc.id, newDoc.content);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 监听配置变化
|
||||
watch(() => configStore.config.editing.fontSize, applyFontSettings);
|
||||
watch(() => configStore.config.editing.fontFamily, applyFontSettings);
|
||||
watch(() => configStore.config.editing.lineHeight, applyFontSettings);
|
||||
watch(() => configStore.config.editing.fontWeight, applyFontSettings);
|
||||
watch(() => configStore.config.editing.tabSize, applyTabSettings);
|
||||
watch(() => configStore.config.editing.enableTabIndent, applyTabSettings);
|
||||
watch(() => configStore.config.editing.tabType, applyTabSettings);
|
||||
watch(() => themeStore.currentTheme, applyThemeSettings);
|
||||
// 创建字体配置的计算属性
|
||||
const fontConfig = computed(() => ({
|
||||
fontSize: configStore.config.editing.fontSize,
|
||||
fontFamily: configStore.config.editing.fontFamily,
|
||||
lineHeight: configStore.config.editing.lineHeight,
|
||||
fontWeight: configStore.config.editing.fontWeight
|
||||
}));
|
||||
// 创建Tab配置的计算属性
|
||||
const tabConfig = computed(() => ({
|
||||
tabSize: configStore.config.editing.tabSize,
|
||||
enableTabIndent: configStore.config.editing.enableTabIndent,
|
||||
tabType: configStore.config.editing.tabType
|
||||
}));
|
||||
// 监听字体配置变化
|
||||
watch(fontConfig, applyFontSettings, { deep: true });
|
||||
// 监听Tab配置变化
|
||||
watch(tabConfig, applyTabSettings, { deep: true });
|
||||
|
||||
return {
|
||||
// 状态
|
||||
|
||||
@@ -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 {
|
||||
@@ -42,7 +44,24 @@ export const useSystemStore = defineStore('system', () => {
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
environment.value = await runtime.System.Environment();
|
||||
const systemInfo: SystemInfo | null = await GetSystemInfo();
|
||||
|
||||
if (systemInfo) {
|
||||
environment.value = {
|
||||
OS: systemInfo.os,
|
||||
Arch: systemInfo.arch,
|
||||
Debug: systemInfo.debug,
|
||||
OSInfo: {
|
||||
Name: systemInfo.osInfo?.name || '',
|
||||
Branding: systemInfo.osInfo?.branding || '',
|
||||
Version: systemInfo.osInfo?.version || '',
|
||||
ID: systemInfo.osInfo?.id || '',
|
||||
},
|
||||
PlatformInfo: systemInfo.platformInfo || {},
|
||||
};
|
||||
} else {
|
||||
environment.value = null;
|
||||
}
|
||||
} catch (_err) {
|
||||
environment.value = null;
|
||||
} finally {
|
||||
|
||||
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;
|
||||
translators.value = await TranslationService.GetTranslators();
|
||||
|
||||
// 如果默认翻译器不在可用列表中,则使用第一个可用的翻译器
|
||||
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;
|
||||
const loadPromises = translators.value.map(async (translatorType) => {
|
||||
try {
|
||||
const languages = await TranslationService.GetTranslatorLanguages(translatorType as any);
|
||||
if (languages) {
|
||||
translatorLanguages.value[translatorType] = languages;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to preload languages for ${translatorType}:`, err);
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
error.value = 'no available translators';
|
||||
});
|
||||
|
||||
// 等待所有语言映射加载完成
|
||||
await Promise.all(loadPromises);
|
||||
} catch (err) {
|
||||
console.error('Failed to load available translators:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载指定翻译器的语言列表
|
||||
* @param translatorType 翻译器类型
|
||||
*/
|
||||
const loadTranslatorLanguages = async (translatorType: string): Promise<void> => {
|
||||
try {
|
||||
const languages = await TranslationService.GetTranslatorLanguages(translatorType as any);
|
||||
|
||||
if (languages) {
|
||||
languageMaps.value[translatorType] = languages;
|
||||
translatorLanguages.value[translatorType] = languages;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to load languages for ${translatorType}:`, err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检测文本语言
|
||||
* @param text 待检测的文本
|
||||
* @returns 检测到的语言代码,如未检测到则返回空字符串
|
||||
*/
|
||||
const detectLanguage = (text: string): string => {
|
||||
if (!text || text.trim().length < 10) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
// franc返回ISO 639-3代码
|
||||
const detectedIso639_3 = franc(text);
|
||||
|
||||
// 如果是未知语言,返回空字符串
|
||||
if (detectedIso639_3 === 'und') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 转换为常用语言代码
|
||||
return ISO_LANGUAGE_MAP[detectedIso639_3] || '';
|
||||
} catch (err) {
|
||||
console.error('语言检测失败:', err);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 在翻译器语言列表中查找相似的语言代码
|
||||
* @param langCode 待查找的语言代码
|
||||
* @param translatorType 翻译器类型
|
||||
* @returns 找到的语言代码或空字符串
|
||||
*/
|
||||
const findSimilarLanguage = (langCode: string, translatorType: string): string => {
|
||||
if (!langCode) return '';
|
||||
|
||||
const languageMap = languageMaps.value[translatorType] || {};
|
||||
const langCodeLower = langCode.toLowerCase();
|
||||
|
||||
// 1. 尝试精确匹配
|
||||
if (languageMap[langCode]) {
|
||||
return langCode;
|
||||
}
|
||||
|
||||
// 2. 检查通用别名映射
|
||||
const possibleAliases = Object.entries(COMMON_LANGUAGE_ALIASES).find(
|
||||
([code, aliases]) => code === langCodeLower || aliases.includes(langCodeLower)
|
||||
);
|
||||
|
||||
if (possibleAliases) {
|
||||
// 检查主代码是否可用
|
||||
const [mainCode, aliases] = possibleAliases;
|
||||
if (languageMap[mainCode]) {
|
||||
return mainCode;
|
||||
}
|
||||
|
||||
// 检查别名是否可用
|
||||
for (const alias of aliases) {
|
||||
if (languageMap[alias]) {
|
||||
return alias;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 尝试正则表达式匹配
|
||||
// 创建一个基于语言代码的正则表达式:例如 'en' 会匹配 'en-US', 'en_GB' 等
|
||||
const codePattern = new RegExp(`^${langCodeLower}[-_]?`, 'i');
|
||||
|
||||
// 在语言列表中查找匹配的语言代码
|
||||
const availableCodes = Object.keys(languageMap);
|
||||
const matchedCode = availableCodes.find(code =>
|
||||
codePattern.test(code.toLowerCase())
|
||||
);
|
||||
|
||||
if (matchedCode) {
|
||||
return matchedCode;
|
||||
}
|
||||
|
||||
// 4. 反向匹配,例如 'zh-CN' 应该能匹配到 'zh'
|
||||
if (langCodeLower.includes('-') || langCodeLower.includes('_')) {
|
||||
const baseCode = langCodeLower.split(/[-_]/)[0];
|
||||
if (languageMap[baseCode]) {
|
||||
return baseCode;
|
||||
}
|
||||
|
||||
// 通过基础代码查找别名
|
||||
const baseCodeAliases = Object.entries(COMMON_LANGUAGE_ALIASES).find(
|
||||
([code, aliases]) => code === baseCode || aliases.includes(baseCode)
|
||||
);
|
||||
|
||||
if (baseCodeAliases) {
|
||||
const [mainCode, aliases] = baseCodeAliases;
|
||||
if (languageMap[mainCode]) {
|
||||
return mainCode;
|
||||
}
|
||||
|
||||
for (const alias of aliases) {
|
||||
if (languageMap[alias]) {
|
||||
return alias;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 最后尝试查找与部分代码匹配的任何语言
|
||||
const partialMatch = availableCodes.find(code =>
|
||||
code.toLowerCase().includes(langCodeLower) ||
|
||||
langCodeLower.includes(code.toLowerCase())
|
||||
);
|
||||
|
||||
if (partialMatch) {
|
||||
return partialMatch;
|
||||
}
|
||||
|
||||
// 如果所有匹配都失败,返回英语作为默认值
|
||||
return 'en';
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证语言代码是否受当前翻译器支持
|
||||
* @param langCode 语言代码
|
||||
* @param translatorType 翻译器类型(可选,默认使用当前翻译器)
|
||||
* @returns 验证后的语言代码
|
||||
*/
|
||||
const validateLanguage = (langCode: string, translatorType?: string): string => {
|
||||
// 如果语言代码为空,返回auto作为API调用的默认值
|
||||
if (!langCode) return 'auto';
|
||||
|
||||
const currentType = translatorType || defaultTranslator.value;
|
||||
|
||||
// 尝试在指定翻译器的语言列表中查找相似的语言代码
|
||||
return findSimilarLanguage(langCode, currentType) || 'auto';
|
||||
};
|
||||
|
||||
/**
|
||||
* 增加语言使用次数并添加到最近使用列表
|
||||
* @param langCode 语言代码
|
||||
* @param weight 权重,默认为1
|
||||
*/
|
||||
const incrementLanguageUsage = (langCode: string, weight: number = 1): void => {
|
||||
if (!langCode || langCode === 'auto') return;
|
||||
|
||||
// 转换为小写,确保一致性
|
||||
const normalizedCode = langCode.toLowerCase();
|
||||
|
||||
// 更新使用次数,乘以权重
|
||||
const currentCount = languageUsageCount.value[normalizedCode] || 0;
|
||||
languageUsageCount.value[normalizedCode] = currentCount + weight;
|
||||
|
||||
// 更新最近使用的语言列表
|
||||
updateRecentLanguages(normalizedCode);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新最近使用的语言列表
|
||||
* @param langCode 语言代码
|
||||
*/
|
||||
const updateRecentLanguages = (langCode: string): void => {
|
||||
if (!langCode) return;
|
||||
|
||||
// 如果已经在列表中,先移除它
|
||||
const index = recentLanguages.value.indexOf(langCode);
|
||||
if (index !== -1) {
|
||||
recentLanguages.value.splice(index, 1);
|
||||
}
|
||||
|
||||
// 添加到列表开头
|
||||
recentLanguages.value.unshift(langCode);
|
||||
|
||||
// 保持列表最多10个元素
|
||||
if (recentLanguages.value.length > 10) {
|
||||
recentLanguages.value = recentLanguages.value.slice(0, 10);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取按使用频率排序的语言列表
|
||||
* @param translatorType 翻译器类型
|
||||
* @param grouped 是否分组返回(常用/其他)
|
||||
* @returns 排序后的语言列表或分组后的语言列表
|
||||
*/
|
||||
const getSortedLanguages = (translatorType: string, grouped: boolean = false): [string, any][] | {frequent: [string, any][], others: [string, any][]} => {
|
||||
const languageMap = languageMaps.value[translatorType] || {};
|
||||
|
||||
// 获取语言列表
|
||||
const languages = Object.entries(languageMap);
|
||||
|
||||
// 按使用频率排序
|
||||
const sortedLanguages = languages.sort(([codeA, infoA], [codeB, infoB]) => {
|
||||
// 获取使用次数,默认为0
|
||||
const countA = languageUsageCount.value[codeA.toLowerCase()] || 0;
|
||||
const countB = languageUsageCount.value[codeB.toLowerCase()] || 0;
|
||||
|
||||
// 首先按使用频率降序排序
|
||||
if (countB !== countA) {
|
||||
return countB - countA;
|
||||
}
|
||||
|
||||
// 其次按最近使用情况排序
|
||||
const recentIndexA = recentLanguages.value.indexOf(codeA.toLowerCase());
|
||||
const recentIndexB = recentLanguages.value.indexOf(codeB.toLowerCase());
|
||||
|
||||
if (recentIndexA !== -1 && recentIndexB !== -1) {
|
||||
return recentIndexA - recentIndexB;
|
||||
} else if (recentIndexA !== -1) {
|
||||
return -1;
|
||||
} else if (recentIndexB !== -1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 如果使用频率和最近使用情况都相同,按名称排序
|
||||
const nameA = infoA.Name || infoA.name || codeA;
|
||||
const nameB = infoB.Name || infoB.name || codeB;
|
||||
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
// 如果不需要分组,直接返回排序后的列表
|
||||
if (!grouped) {
|
||||
return sortedLanguages;
|
||||
}
|
||||
|
||||
// 分组:将有使用记录的语言归为常用组,其他归为其他组
|
||||
const frequentLanguages: [string, any][] = [];
|
||||
const otherLanguages: [string, any][] = [];
|
||||
|
||||
sortedLanguages.forEach(lang => {
|
||||
const [code] = lang;
|
||||
const usageCount = languageUsageCount.value[code.toLowerCase()] || 0;
|
||||
const isInRecent = recentLanguages.value.includes(code.toLowerCase());
|
||||
|
||||
if (usageCount > 0 || isInRecent) {
|
||||
frequentLanguages.push(lang);
|
||||
} else {
|
||||
otherLanguages.push(lang);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
frequent: frequentLanguages,
|
||||
others: otherLanguages
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 翻译文本
|
||||
* @param text 待翻译文本
|
||||
* @param to 目标语言代码
|
||||
* @param translatorType 翻译器类型
|
||||
* @returns 翻译结果
|
||||
*/
|
||||
const translateText = async (
|
||||
text: string,
|
||||
to?: string,
|
||||
translatorType?: string
|
||||
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
|
||||
const isYoudaoTranslator = translatorType === 'youdao';
|
||||
const bothAuto = sourceLang === 'auto' && targetLang === 'auto';
|
||||
|
||||
// 使用检测到的语言或回退到auto
|
||||
let actualSourceLang = detectedSourceLang.value || '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
|
||||
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 { 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;
|
||||
// 方法
|
||||
checkForUpdates,
|
||||
applyUpdate,
|
||||
restartApplication,
|
||||
checkOnStartup,
|
||||
openReleaseURL,
|
||||
clearStatus,
|
||||
|
||||
// 重置错误信息
|
||||
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
|
||||
};
|
||||
// 内部状态管理
|
||||
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,
|
||||
@@ -33,6 +37,8 @@ onMounted(async () => {
|
||||
// 设置编辑器容器
|
||||
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;
|
||||
// 子菜单缓存池
|
||||
const submenuPool: Map<string, HTMLElement> = new Map();
|
||||
|
||||
/**
|
||||
* 获取或创建菜单DOM元素
|
||||
* 菜单项元素池,用于复用DOM元素
|
||||
*/
|
||||
function getOrCreateMenuElement(): HTMLElement {
|
||||
if (!menuElement) {
|
||||
menuElement = document.createElement("div");
|
||||
menuElement.className = "cm-context-menu";
|
||||
menuElement.style.display = "none";
|
||||
document.body.appendChild(menuElement);
|
||||
class MenuItemPool {
|
||||
private pool: HTMLElement[] = [];
|
||||
private maxPoolSize = 50; // 最大池大小
|
||||
|
||||
// 阻止菜单内右键点击冒泡
|
||||
menuElement.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
/**
|
||||
* 获取或创建菜单项元素
|
||||
*/
|
||||
get(): HTMLElement {
|
||||
if (this.pool.length > 0) {
|
||||
return this.pool.pop()!;
|
||||
}
|
||||
|
||||
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);
|
||||
class ContextMenuManager {
|
||||
private static instance: ContextMenuManager;
|
||||
|
||||
// 阻止子菜单点击事件冒泡
|
||||
submenu.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
return submenuPool.get(id)!;
|
||||
}
|
||||
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[] = [];
|
||||
|
||||
/**
|
||||
* 创建菜单项DOM元素
|
||||
*/
|
||||
function createMenuItemElement(item: MenuItem, view: EditorView): HTMLElement {
|
||||
// 创建菜单项容器
|
||||
const menuItem = document.createElement("div");
|
||||
menuItem.className = "cm-context-menu-item";
|
||||
// 事件委托处理器
|
||||
private menuClickHandler: ((e: MouseEvent) => void) | null = null;
|
||||
private menuMouseHandler: ((e: MouseEvent) => void) | null = null;
|
||||
|
||||
// 如果有子菜单,添加相应类
|
||||
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';
|
||||
}
|
||||
|
||||
// 当鼠标悬停在菜单项上时,显示子菜单
|
||||
menuItem.addEventListener('mouseenter', () => {
|
||||
const rect = menuItem.getBoundingClientRect();
|
||||
/**
|
||||
* 命令注册和管理
|
||||
*/
|
||||
private commands: Map<string, (view: EditorView) => void> = new Map();
|
||||
private commandCounter = 0;
|
||||
|
||||
// 计算子菜单位置
|
||||
submenu.style.left = `${rect.right}px`;
|
||||
submenu.style.top = `${rect.top}px`;
|
||||
private registerCommand(command: (view: EditorView) => void): string {
|
||||
const commandId = `cmd_${this.commandCounter++}`;
|
||||
this.commands.set(commandId, command);
|
||||
return commandId;
|
||||
}
|
||||
|
||||
// 检查子菜单是否会超出屏幕右侧
|
||||
setTimeout(() => {
|
||||
const submenuRect = submenu.getBoundingClientRect();
|
||||
if (submenuRect.right > window.innerWidth) {
|
||||
// 如果会超出右侧,则显示在左侧
|
||||
submenu.style.left = `${rect.left - submenuRect.width}px`;
|
||||
}
|
||||
private getCommandByName(commandId: string): ((view: EditorView) => void) | undefined {
|
||||
return this.commands.get(commandId);
|
||||
}
|
||||
|
||||
// 检查子菜单是否会超出屏幕底部
|
||||
if (submenuRect.bottom > window.innerHeight) {
|
||||
// 如果会超出底部,则向上调整
|
||||
const newTop = rect.top - (submenuRect.bottom - window.innerHeight);
|
||||
submenu.style.top = `${Math.max(0, newTop)}px`;
|
||||
}
|
||||
}, 0);
|
||||
/**
|
||||
* 处理菜单项鼠标进入事件
|
||||
*/
|
||||
private handleMenuItemMouseEnter(menuItem: HTMLElement): void {
|
||||
const submenuId = menuItem.dataset.submenuId;
|
||||
if (!submenuId) return;
|
||||
|
||||
// 显示子菜单
|
||||
submenu.style.opacity = '1';
|
||||
submenu.style.pointerEvents = 'auto';
|
||||
submenu.style.visibility = 'visible';
|
||||
submenu.style.transform = 'translateX(0)';
|
||||
});
|
||||
const submenu = this.submenuPool.get(submenuId);
|
||||
if (!submenu) return;
|
||||
|
||||
// 当鼠标离开菜单项时,隐藏子菜单
|
||||
menuItem.addEventListener('mouseleave', (e) => {
|
||||
// 检查是否移动到子菜单上
|
||||
const toElement = e.relatedTarget as HTMLElement;
|
||||
if (submenu.contains(toElement)) {
|
||||
return; // 如果移动到子菜单上,不隐藏
|
||||
const rect = menuItem.getBoundingClientRect();
|
||||
|
||||
// 计算子菜单位置
|
||||
submenu.style.left = `${rect.right}px`;
|
||||
submenu.style.top = `${rect.top}px`;
|
||||
|
||||
// 检查子菜单是否会超出屏幕
|
||||
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();
|
||||
/**
|
||||
* 隐藏子菜单
|
||||
*/
|
||||
private hideSubmenu(submenu: HTMLElement): void {
|
||||
submenu.style.opacity = '0';
|
||||
submenu.style.pointerEvents = 'none';
|
||||
submenu.style.transform = 'translateX(10px)';
|
||||
|
||||
// 添加点击动画效果
|
||||
const ripple = document.createElement("div");
|
||||
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;
|
||||
// 计算相对位置
|
||||
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.left = (x - 50) + "px";
|
||||
ripple.style.top = (y - 50) + "px";
|
||||
ripple.style.transform = "scale(0)";
|
||||
ripple.style.opacity = "1";
|
||||
|
||||
menuItem.appendChild(ripple);
|
||||
menuItem.appendChild(ripple);
|
||||
|
||||
// 执行动画
|
||||
requestAnimationFrame(() => {
|
||||
ripple.style.transform = "scale(1)";
|
||||
ripple.style.opacity = "0";
|
||||
|
||||
// 执行点击动画
|
||||
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;
|
||||
}
|
||||
/**
|
||||
* 检查点击是否在菜单内
|
||||
*/
|
||||
private isClickInsideMenu(target: Node): boolean {
|
||||
if (this.menuElement && this.menuElement.contains(target)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建分隔线
|
||||
*/
|
||||
function createDivider(): HTMLElement {
|
||||
const divider = document.createElement("div");
|
||||
divider.className = "cm-context-menu-divider";
|
||||
return divider;
|
||||
}
|
||||
// 检查是否在子菜单内
|
||||
for (const submenu of this.activeSubmenus) {
|
||||
if (submenu.contains(target)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加菜单组
|
||||
* @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);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 添加菜单项
|
||||
items.forEach(item => {
|
||||
const menuItemElement = createMenuItemElement(item, view);
|
||||
menuElement.appendChild(menuItemElement);
|
||||
});
|
||||
/**
|
||||
* 定位菜单元素
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单例实例
|
||||
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";
|
||||
|
||||
@@ -134,44 +129,6 @@ function createHistoryItems(): MenuItem[] {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建代码块相关菜单项
|
||||
*/
|
||||
function createCodeBlockItems(): MenuItem[] {
|
||||
const defaultOptions = { defaultBlockToken: 'text', defaultBlockAutoDetect: true };
|
||||
return [
|
||||
// 格式化
|
||||
{
|
||||
label: t("keybindings.commands.blockFormat"),
|
||||
command: formatCurrentBlock,
|
||||
shortcut: getShortcutText(KeyBindingCommand.BlockFormatCommand)
|
||||
},
|
||||
// 删除
|
||||
{
|
||||
label: t("keybindings.commands.blockDelete"),
|
||||
command: deleteBlock(defaultOptions),
|
||||
shortcut: getShortcutText(KeyBindingCommand.BlockDeleteCommand)
|
||||
},
|
||||
// 在当前块后添加新块
|
||||
{
|
||||
label: t("keybindings.commands.blockAddAfterCurrent"),
|
||||
command: addNewBlockAfterCurrent(defaultOptions),
|
||||
shortcut: getShortcutText(KeyBindingCommand.BlockAddAfterCurrentCommand)
|
||||
},
|
||||
// 在当前块前添加新块
|
||||
{
|
||||
label: t("keybindings.commands.blockAddBeforeCurrent"),
|
||||
command: addNewBlockBeforeCurrent(defaultOptions),
|
||||
shortcut: getShortcutText(KeyBindingCommand.BlockAddBeforeCurrentCommand)
|
||||
},
|
||||
// 在最后添加新块
|
||||
{
|
||||
label: t("keybindings.commands.blockAddAfterLast"),
|
||||
command: addNewBlockAfterLast(defaultOptions),
|
||||
shortcut: getShortcutText(KeyBindingCommand.BlockAddAfterLastCommand)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建主菜单项
|
||||
@@ -186,11 +143,7 @@ function createMainMenuItems(): MenuItem[] {
|
||||
// 构建主菜单
|
||||
return [
|
||||
...basicItems,
|
||||
...historyItems,
|
||||
{
|
||||
label: t("extensions.codeblock.name"),
|
||||
submenu: createCodeBlockItems()
|
||||
}
|
||||
...historyItems
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const blockSeparatorRegex = new RegExp(`\\n∞∞∞(${languageTokensMatcher})(-
|
||||
/**
|
||||
* 获取被复制的范围和内容
|
||||
*/
|
||||
function copiedRange(state: EditorState) {
|
||||
function copiedRange(state: EditorState, forCut: boolean = false) {
|
||||
const content: string[] = [];
|
||||
const ranges: any[] = [];
|
||||
|
||||
@@ -37,7 +37,13 @@ function copiedRange(state: EditorState) {
|
||||
const lineContent = state.sliceDoc(line.from, line.to);
|
||||
if (!copiedLines.includes(line.from)) {
|
||||
content.push(lineContent);
|
||||
ranges.push(range);
|
||||
// 对于剪切操作,需要包含整行范围(包括换行符)
|
||||
if (forCut) {
|
||||
const lineEnd = line.to < state.doc.length ? line.to + 1 : line.to;
|
||||
ranges.push({ from: line.from, to: lineEnd });
|
||||
} else {
|
||||
ranges.push(range);
|
||||
}
|
||||
copiedLines.push(line.from);
|
||||
}
|
||||
}
|
||||
@@ -68,7 +74,7 @@ export const codeBlockCopyCut = EditorView.domEventHandlers({
|
||||
},
|
||||
|
||||
cut(event, view) {
|
||||
let { text, ranges } = copiedRange(view.state);
|
||||
let { text, ranges } = copiedRange(view.state, true);
|
||||
// 将块分隔符替换为双换行符
|
||||
text = text.replaceAll(blockSeparatorRegex, "\n\n");
|
||||
|
||||
@@ -93,7 +99,7 @@ export const codeBlockCopyCut = EditorView.domEventHandlers({
|
||||
* 复制和剪切的通用函数
|
||||
*/
|
||||
const copyCut = (view: EditorView, cut: boolean): boolean => {
|
||||
let { text, ranges } = copiedRange(view.state);
|
||||
let { text, ranges } = copiedRange(view.state, cut);
|
||||
// 将块分隔符替换为双换行符
|
||||
text = text.replaceAll(blockSeparatorRegex, "\n\n");
|
||||
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -26,20 +26,20 @@ try {
|
||||
|
||||
// 运行 lezer-generator
|
||||
console.log('⚙️ building parser...');
|
||||
execSync('npx lezer-generator codeblock.grammar -o parser.js', {
|
||||
execSync('npx lezer-generator codeblock.grammar -o parser.ts --typeScript', {
|
||||
cwd: __dirname,
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
// 检查生成的文件
|
||||
const parserFile = path.join(__dirname, 'parser.js');
|
||||
const termsFile = path.join(__dirname, 'parser.terms.js');
|
||||
const parserFile = path.join(__dirname, 'parser.ts');
|
||||
const termsFile = path.join(__dirname, 'parser.terms.ts');
|
||||
|
||||
if (fs.existsSync(parserFile) && fs.existsSync(termsFile)) {
|
||||
console.log('✅ parser file successfully generated!');
|
||||
console.log('📦 parser files:');
|
||||
console.log(' - parser.js');
|
||||
console.log(' - parser.terms.js');
|
||||
console.log(' - parser.ts');
|
||||
console.log(' - parser.terms.ts');
|
||||
} else {
|
||||
throw new Error('failed to generate parser');
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 提供多语言代码块支持
|
||||
*/
|
||||
|
||||
import { parser } from "./parser.js";
|
||||
import { parser } from "./parser";
|
||||
import { configureNesting } from "./nested-parser";
|
||||
|
||||
import {
|
||||
|
||||
@@ -16,7 +16,9 @@ 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" |
|
||||
"http"
|
||||
}
|
||||
|
||||
@tokens {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { ExternalTokenizer } from "@lezer/lr";
|
||||
import { BlockContent } from "./parser.terms.js";
|
||||
import { BlockContent } from "./parser.terms";
|
||||
import { LANGUAGES } from "./languages";
|
||||
|
||||
const EOF = -1;
|
||||
|
||||
@@ -24,7 +24,7 @@ export {
|
||||
} from './nested-parser';
|
||||
|
||||
// 解析器术语
|
||||
export * from './parser.terms.js';
|
||||
export * from './parser.terms';
|
||||
|
||||
// 外部标记器
|
||||
export {
|
||||
@@ -34,4 +34,4 @@ export {
|
||||
// 解析器
|
||||
export {
|
||||
parser
|
||||
} from './parser.js';
|
||||
} from './parser';
|
||||
@@ -15,6 +15,15 @@ import {cppLanguage} from "@codemirror/lang-cpp";
|
||||
import {xmlLanguage} from "@codemirror/lang-xml";
|
||||
import {rustLanguage} from "@codemirror/lang-rust";
|
||||
import {yamlLanguage} from "@codemirror/lang-yaml";
|
||||
import {vueLanguage} from "@codemirror/lang-vue";
|
||||
import {lezerLanguage} from "@codemirror/lang-lezer";
|
||||
import {liquidLanguage} from "@codemirror/lang-liquid";
|
||||
import {wastLanguage} from "@codemirror/lang-wast";
|
||||
import {sassLanguage} from "@codemirror/lang-sass";
|
||||
import {lessLanguage} from "@codemirror/lang-less";
|
||||
import {angularLanguage} from "@codemirror/lang-angular";
|
||||
import { svelteLanguage } from "@replit/codemirror-lang-svelte";
|
||||
import { httpLanguage } from "@/views/editor/extensions/httpclient/language/http-language";
|
||||
|
||||
import {StreamLanguage} from "@codemirror/language";
|
||||
import {ruby} from "@codemirror/legacy-modes/mode/ruby";
|
||||
@@ -53,6 +62,7 @@ import clangPrettierPlugin from "@/common/prettier/plugins/clang";
|
||||
import pythonPrettierPlugin from "@/common/prettier/plugins/python";
|
||||
import dartPrettierPlugin from "@/common/prettier/plugins/dart";
|
||||
import luaPrettierPlugin from "@/common/prettier/plugins/lua";
|
||||
import webPrettierPlugin from "@/common/prettier/plugins/web";
|
||||
import * as prettierPluginEstree from "prettier/plugins/estree";
|
||||
|
||||
/**
|
||||
@@ -73,10 +83,10 @@ export class LanguageInfo {
|
||||
}
|
||||
|
||||
/**
|
||||
* 支持的语言列表(与 Worker 中的 LANGUAGES 对应)
|
||||
* 支持的语言列表
|
||||
*/
|
||||
export const LANGUAGES: LanguageInfo[] = [
|
||||
new LanguageInfo("text", "Plain Text", null),
|
||||
new LanguageInfo("text", "Text", null),
|
||||
new LanguageInfo("json", "JSON", jsonLanguage.parser, ["json"], {
|
||||
parser: "json",
|
||||
plugins: [babelPrettierPlugin, prettierPluginEstree]
|
||||
@@ -89,6 +99,13 @@ export const LANGUAGES: LanguageInfo[] = [
|
||||
parser: "html",
|
||||
plugins: [htmlPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("vue", "Vue", vueLanguage.parser, ["vue"], {
|
||||
parser: "web-fmt",
|
||||
plugins: [webPrettierPlugin],
|
||||
options: {
|
||||
filename: "index.vue"
|
||||
}
|
||||
}),
|
||||
new LanguageInfo("sql", "SQL", StandardSQL.language.parser, ["sql"], {
|
||||
parser: "sql",
|
||||
plugins: [sqlPrettierPlugin]
|
||||
@@ -124,7 +141,7 @@ export const LANGUAGES: LanguageInfo[] = [
|
||||
parser: "rust",
|
||||
plugins: [rustPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("cs", "C#", StreamLanguage.define(csharp).parser, ["cs"],{
|
||||
new LanguageInfo("cs", "C#", StreamLanguage.define(csharp).parser, ["cs"], {
|
||||
parser: "clang-format",
|
||||
plugins: [clangPrettierPlugin],
|
||||
options: {
|
||||
@@ -183,6 +200,33 @@ export const LANGUAGES: LanguageInfo[] = [
|
||||
plugins: [luaPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("math", "Math", null, ["math"]),
|
||||
new LanguageInfo("lezer", "Lezer", lezerLanguage.parser, ["lezer"]),
|
||||
new LanguageInfo("liquid", "Liquid", liquidLanguage.parser, ["liquid"]),
|
||||
new LanguageInfo("wast", "WebAssembly", wastLanguage.parser, ["wast"]),
|
||||
new LanguageInfo("sass", "Sass", sassLanguage.parser, ["sass"], {
|
||||
parser: "web-fmt",
|
||||
plugins: [webPrettierPlugin],
|
||||
options: {
|
||||
filename: "index.sass"
|
||||
}
|
||||
}),
|
||||
new LanguageInfo("less", "Less", lessLanguage.parser, ["less"], {
|
||||
parser: "web-fmt",
|
||||
plugins: [webPrettierPlugin],
|
||||
options: {
|
||||
filename: "index.less"
|
||||
}
|
||||
}),
|
||||
new LanguageInfo("angular", "Angular", angularLanguage.parser, ["angular"]),
|
||||
new LanguageInfo("svelte", "Svelte", svelteLanguage.parser, ["svelte"], {
|
||||
parser: "web-fmt",
|
||||
plugins: [webPrettierPlugin],
|
||||
options: {
|
||||
filename: "index.svelte"
|
||||
}
|
||||
}),
|
||||
new LanguageInfo("http", "Http", httpLanguage.parser, ["http"]),
|
||||
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { parseMixed } from "@lezer/common";
|
||||
import { BlockContent, BlockLanguage } from "./parser.terms.js";
|
||||
import { BlockContent, BlockLanguage } from "./parser.terms";
|
||||
import { languageMapping } from "./languages";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {blockContent} from "./external-tokens.js"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "!jQQOQOOOVOQO'#C`O#]OPO'#C_OOOO'#Cc'#CcQQOQOOOOOO'#Ca'#CaO#bOSO,58zOOOO,58y,58yOOOO-E6a-E6aOOOP1G.f1G.fO#jOSO1G.fOOOP7+$Q7+$Q",
|
||||
stateData: "#o~OXPO~OYTOZTO[TO]TO^TO_TO`TOaTObTOcTOdTOeTOfTOgTOhTOiTOjTOkTOlTOmTOnTOoTOpTOqTOrTOsTOtTOuTOvTOwTOxTOyTO~OPVO~OUYOzXO~OzZO~O",
|
||||
goto: "jWPPPX]aPdTROSTQOSRUPQSORWS",
|
||||
nodeNames: "⚠ BlockContent Document Block BlockDelimiter BlockLanguage Auto",
|
||||
maxTerm: 42,
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 1,
|
||||
tokenData: ".w~RaYZ!W}!O!q#V#W!|#W#X#}#X#Y%p#Z#[&Z#[#]'W#^#_'o#_#`(n#`#a(y#a#b)[#d#e){#f#g*t#g#h+X#h#i,x#l#m-}#m#n.`R!]PzQ%&x%&y!`P!cP%&x%&y!fP!iP%&x%&y!lP!qOXP~!tP#T#U!w~!|OU~~#PR#`#a#Y#d#e#e#g#h#p~#]P#^#_#`~#eOl~~#hP#d#e#k~#pOd~~#uPf~#g#h#x~#}Ob~~$QQ#T#U$W#c#d$i~$ZP#f#g$^~$aP#h#i$d~$iOu~~$lP#V#W$o~$rP#_#`$u~$xP#X#Y${~%OP#f#g%R~%UP#Y#Z%X~%[P#]#^%_~%bP#`#a%e~%hP#X#Y%k~%pOx~~%sQ#f#g%y#l#m&U~%|P#`#a&P~&UOn~~&ZOm~~&^Q#c#d&d#f#g&i~&iOk~~&lP#c#d&o~&rP#c#d&u~&xP#j#k&{~'OP#m#n'R~'WOs~~'ZP#h#i'^~'aP#a#b'd~'gP#`#a'j~'oO]~~'rQ#T#U'x#g#h(Z~'{P#j#k(O~(RP#T#U(U~(ZO`~~(`Po~#c#d(c~(fP#b#c(i~(nOZ~~(qP#h#i(t~(yOr~~(|P#i#j)P~)SP#T#U)V~)[Oy~~)_Q#T#U)e#W#X)v~)hP#h#i)k~)nP#[#])q~)vOw~~){O_~~*OR#[#]*X#g#h*d#m#n*o~*[P#d#e*_~*dOa~~*gP!R!S*j~*oOt~~*tO[~~*wQ#U#V*}#g#h+S~+SOg~~+XOe~~+[S#V#W+h#[#],P#e#f,U#k#l,a~+kP#T#U+n~+qP#`#a+t~+wP#T#U+z~,POv~~,UOh~~,XP#`#a,[~,aO^~~,dP#]#^,g~,jP#Y#Z,m~,pP#h#i,s~,xOq~~,{R#X#Y-U#c#d-g#g#h-x~-XP#l#m-[~-_P#h#i-b~-gOY~~-jP#a#b-m~-pP#`#a-s~-xOj~~-}Op~~.QP#a#b.T~.WP#`#a.Z~.`Oc~~.cP#T#U.f~.iP#a#b.l~.oP#`#a.r~.wOi~",
|
||||
tokenizers: [blockContent, 0, 1],
|
||||
topRules: {"Document":[0,2]},
|
||||
tokenPrec: 0
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {blockContent} from "./external-tokens.js"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "!jQQOQOOOVOQO'#C`O#xOPO'#C_OOOO'#Cc'#CcQQOQOOOOOO'#Ca'#CaO#}OSO,58zOOOO,58y,58yOOOO-E6a-E6aOOOP1G.f1G.fO$VOSO1G.fOOOP7+$Q7+$Q",
|
||||
stateData: "$[~OXPO~OYTOZTO[TO]TO^TO_TO`TOaTObTOcTOdTOeTOfTOgTOhTOiTOjTOkTOlTOmTOnTOoTOpTOqTOrTOsTOtTOuTOvTOwTOxTOyTOzTO{TO|TO}TO!OTO!PTO!QTO!RTO!STO~OPVO~OUYO!TXO~O!TZO~O",
|
||||
goto: "jWPPPX]aPdTROSTQOSRUPQSORWS",
|
||||
nodeNames: "⚠ BlockContent Document Block BlockDelimiter BlockLanguage Auto",
|
||||
maxTerm: 51,
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 1,
|
||||
tokenData: "3u~RdYZ!a}!O!z#T#U#V#V#W$Q#W#X%R#X#Y&t#Z#['_#[#]([#^#_)R#_#`*Q#`#a*]#a#b,Y#d#e,y#f#g-r#g#h.V#h#i0|#j#k2R#k#l2d#l#m2{#m#n3^R!fP!TQ%&x%&y!iP!lP%&x%&y!oP!rP%&x%&y!uP!zOXP~!}P#T#U#Q~#VOU~~#YP#b#c#]~#`P#Z#[#c~#fP#i#j#i~#lP#`#a#o~#rP#T#U#u~#xP#f#g#{~$QO!Q~~$TR#`#a$^#d#e$i#g#h$t~$aP#^#_$d~$iOl~~$lP#d#e$o~$tOd~~$yPf~#g#h$|~%ROb~~%UQ#T#U%[#c#d%m~%_P#f#g%b~%eP#h#i%h~%mOu~~%pP#V#W%s~%vP#_#`%y~%|P#X#Y&P~&SP#f#g&V~&YP#Y#Z&]~&`P#]#^&c~&fP#`#a&i~&lP#X#Y&o~&tOx~~&wQ#f#g&}#l#m'Y~'QP#`#a'T~'YOn~~'_Om~~'bQ#c#d'h#f#g'm~'mOk~~'pP#c#d's~'vP#c#d'y~'|P#j#k(P~(SP#m#n(V~([Os~~(_P#h#i(b~(eQ#a#b(k#h#i(v~(nP#`#a(q~(vO]~~(yP#d#e(|~)RO!S~~)UQ#T#U)[#g#h)m~)_P#j#k)b~)eP#T#U)h~)mO`~~)rPo~#c#d)u~)xP#b#c){~*QOZ~~*TP#h#i*W~*]Or~~*`R#X#Y*i#]#^+`#i#j+}~*lQ#g#h*r#n#o*}~*uP#g#h*x~*}O!P~~+QP#X#Y+T~+WP#f#g+Z~+`O{~~+cP#e#f+f~+iP#i#j+l~+oP#]#^+r~+uP#W#X+x~+}O|~~,QP#T#U,T~,YOy~~,]Q#T#U,c#W#X,t~,fP#h#i,i~,lP#[#],o~,tOw~~,yO_~~,|R#[#]-V#g#h-b#m#n-m~-YP#d#e-]~-bOa~~-eP!R!S-h~-mOt~~-rO[~~-uQ#U#V-{#g#h.Q~.QOg~~.VOe~~.YU#T#U.l#V#W.}#[#]/f#e#f/k#j#k/v#k#l0e~.oP#g#h.r~.uP#g#h.x~.}O!O~~/QP#T#U/T~/WP#`#a/Z~/^P#T#U/a~/fOv~~/kOh~~/nP#`#a/q~/vO^~~/yP#X#Y/|~0PP#`#a0S~0VP#h#i0Y~0]P#X#Y0`~0eO!R~~0hP#]#^0k~0nP#Y#Z0q~0tP#h#i0w~0|Oq~~1PR#X#Y1Y#c#d1k#g#h1|~1]P#l#m1`~1cP#h#i1f~1kOY~~1nP#a#b1q~1tP#`#a1w~1|Oj~~2ROp~~2UP#i#j2X~2[P#X#Y2_~2dOz~~2gP#T#U2j~2mP#g#h2p~2sP#h#i2v~2{O}~~3OP#a#b3R~3UP#`#a3X~3^Oc~~3aP#T#U3d~3gP#a#b3j~3mP#`#a3p~3uOi~",
|
||||
tokenizers: [blockContent, 0, 1],
|
||||
topRules: {"Document":[0,2]},
|
||||
tokenPrec: 0
|
||||
})
|
||||
@@ -27,6 +27,11 @@ export const blockState = StateField.define<Block[]>({
|
||||
* 获取当前活动的块
|
||||
*/
|
||||
export function getActiveNoteBlock(state: EditorState): Block | undefined {
|
||||
// 检查 blockState 字段是否存在
|
||||
if (!state.field(blockState, false)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 找到光标所在的块
|
||||
const range = state.selection.asSingle().ranges[0];
|
||||
return state.field(blockState).find(block =>
|
||||
@@ -38,6 +43,9 @@ export function getActiveNoteBlock(state: EditorState): Block | undefined {
|
||||
* 获取第一个块
|
||||
*/
|
||||
export function getFirstNoteBlock(state: EditorState): Block | undefined {
|
||||
if (!state.field(blockState, false)) {
|
||||
return undefined;
|
||||
}
|
||||
return state.field(blockState)[0];
|
||||
}
|
||||
|
||||
@@ -45,6 +53,9 @@ export function getFirstNoteBlock(state: EditorState): Block | undefined {
|
||||
* 获取最后一个块
|
||||
*/
|
||||
export function getLastNoteBlock(state: EditorState): Block | undefined {
|
||||
if (!state.field(blockState, false)) {
|
||||
return undefined;
|
||||
}
|
||||
const blocks = state.field(blockState);
|
||||
return blocks[blocks.length - 1];
|
||||
}
|
||||
@@ -53,6 +64,9 @@ export function getLastNoteBlock(state: EditorState): Block | undefined {
|
||||
* 根据位置获取块
|
||||
*/
|
||||
export function getNoteBlockFromPos(state: EditorState, pos: number): Block | undefined {
|
||||
if (!state.field(blockState, false)) {
|
||||
return undefined;
|
||||
}
|
||||
return state.field(blockState).find(block =>
|
||||
block.range.from <= pos && block.range.to >= pos
|
||||
);
|
||||
|
||||
@@ -57,6 +57,15 @@ export type SupportedLanguage =
|
||||
| 'dockerfile'
|
||||
| 'lua'
|
||||
| 'math'
|
||||
| 'vue'
|
||||
| 'lezer'
|
||||
| 'liquid'
|
||||
| 'wast'
|
||||
| 'sass'
|
||||
| 'less'
|
||||
| 'angular'
|
||||
| 'svelte'
|
||||
| 'http' // HTTP Client
|
||||
|
||||
/**
|
||||
* 创建块的选项
|
||||
@@ -75,56 +84,10 @@ export interface EditorOptions {
|
||||
defaultBlockAutoDetect: boolean;
|
||||
}
|
||||
|
||||
// 语言信息接口
|
||||
export interface LanguageInfo {
|
||||
name: SupportedLanguage;
|
||||
auto: boolean; // 是否自动检测语言
|
||||
}
|
||||
|
||||
// 位置范围接口
|
||||
export interface Range {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
// 代码块核心接口
|
||||
export interface CodeBlock {
|
||||
language: LanguageInfo;
|
||||
content: Range; // 内容区域
|
||||
delimiter: Range; // 分隔符区域
|
||||
range: Range; // 整个块区域(包括分隔符和内容)
|
||||
}
|
||||
|
||||
// 代码块解析选项
|
||||
export interface ParseOptions {
|
||||
fallbackLanguage?: SupportedLanguage;
|
||||
enableAutoDetection?: boolean;
|
||||
}
|
||||
|
||||
// 分隔符格式常量
|
||||
export const DELIMITER_REGEX = /^\n∞∞∞([a-zA-Z0-9_-]+)(-a)?\n/gm;
|
||||
export const DELIMITER_PREFIX = '\n∞∞∞';
|
||||
export const DELIMITER_SUFFIX = '\n';
|
||||
export const AUTO_DETECT_SUFFIX = '-a';
|
||||
|
||||
// 代码块操作类型
|
||||
export type BlockOperation =
|
||||
| 'insert-after'
|
||||
| 'insert-before'
|
||||
| 'delete'
|
||||
| 'move-up'
|
||||
| 'move-down'
|
||||
| 'change-language';
|
||||
|
||||
// 代码块状态更新事件
|
||||
export interface BlockStateUpdate {
|
||||
blocks: CodeBlock[];
|
||||
activeBlockIndex: number;
|
||||
operation?: BlockOperation;
|
||||
}
|
||||
|
||||
// 语言检测结果
|
||||
export interface LanguageDetectionResult {
|
||||
language: SupportedLanguage;
|
||||
confidence: number;
|
||||
}
|
||||
20
frontend/src/views/editor/extensions/httpclient/index.ts
Normal file
20
frontend/src/views/editor/extensions/httpclient/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* HTTP Client 扩展
|
||||
*/
|
||||
|
||||
import {Extension} from '@codemirror/state';
|
||||
|
||||
import {httpRequestsField, httpRunButtonGutter, httpRunButtonTheme} from './widgets/run-gutter';
|
||||
|
||||
/**
|
||||
* 创建 HTTP Client 扩展
|
||||
*/
|
||||
export function createHttpClientExtension(): Extension[] {
|
||||
return [
|
||||
httpRequestsField,
|
||||
httpRunButtonGutter,
|
||||
httpRunButtonTheme,
|
||||
] as Extension[];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* HTTP Grammar Parser Builder
|
||||
* 编译 Lezer grammar 文件为 TypeScript parser
|
||||
* 使用 --typeScript 选项生成 .ts 文件
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
console.log('🚀 开始编译 HTTP grammar parser (TypeScript)...');
|
||||
|
||||
try {
|
||||
// 检查语法文件是否存在
|
||||
const grammarFile = path.join(__dirname, 'http.grammar');
|
||||
if (!fs.existsSync(grammarFile)) {
|
||||
throw new Error('语法文件 http.grammar 未找到');
|
||||
}
|
||||
|
||||
console.log('📄 语法文件:', grammarFile);
|
||||
|
||||
// 运行 lezer-generator with TypeScript output
|
||||
console.log('⚙️ 编译 parser (生成 TypeScript)...');
|
||||
execSync('npx lezer-generator http.grammar -o http.parser.ts --typeScript', {
|
||||
cwd: __dirname,
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
// 检查生成的文件
|
||||
const parserFile = path.join(__dirname, 'http.parser.ts');
|
||||
const termsFile = path.join(__dirname, 'http.parser.terms.ts');
|
||||
|
||||
if (fs.existsSync(parserFile) && fs.existsSync(termsFile)) {
|
||||
console.log('✅ Parser 文件成功生成!');
|
||||
console.log('📦 生成的文件:');
|
||||
console.log(' - http.parser.ts');
|
||||
console.log(' - http.parser.terms.ts');
|
||||
} else {
|
||||
throw new Error('Parser 生成失败');
|
||||
}
|
||||
|
||||
console.log('🎉 编译成功!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 编译失败:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { LRLanguage, LanguageSupport, foldNodeProp, foldInside, indentNodeProp } from '@codemirror/language';
|
||||
import { parser } from './http.parser';
|
||||
import { httpHighlighting } from './http.highlight';
|
||||
|
||||
/**
|
||||
* HTTP Client 语言定义
|
||||
*/
|
||||
|
||||
// 配置折叠规则和高亮
|
||||
const httpParserWithMetadata = parser.configure({
|
||||
props: [
|
||||
// 应用语法高亮
|
||||
httpHighlighting,
|
||||
|
||||
// 折叠规则:允许折叠块结构
|
||||
foldNodeProp.add({
|
||||
RequestStatement: foldInside,
|
||||
Block: foldInside,
|
||||
AtRule: foldInside,
|
||||
Document: foldInside,
|
||||
}),
|
||||
|
||||
// 缩进规则
|
||||
indentNodeProp.add({
|
||||
Block: () => 2,
|
||||
Declaration: () => 0,
|
||||
AtRule: () => 0,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// 创建 LR 语言实例
|
||||
export const httpLanguage = LRLanguage.define({
|
||||
parser: httpParserWithMetadata,
|
||||
languageData: {
|
||||
//自动闭合括号
|
||||
closeBrackets: { brackets: ['(', '[', '{', '"', "'"] },
|
||||
// 单词字符定义
|
||||
wordChars: '-_',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* HTTP Client 语言支持
|
||||
*/
|
||||
export function http() {
|
||||
return new LanguageSupport(httpLanguage);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// HTTP Client Grammar
|
||||
//
|
||||
// 语法规则:
|
||||
// 1. HTTP 头部属性:逗号可选
|
||||
// host: "example.com"
|
||||
// content-type: "application/json"
|
||||
//
|
||||
// 2. 请求体格式:
|
||||
// @json - JSON 格式(属性必须用逗号分隔)
|
||||
// @formdata - 表单数据(属性必须用逗号分隔)
|
||||
// @urlencoded - URL 编码格式(属性必须用逗号分隔)
|
||||
// @text - 纯文本内容
|
||||
//
|
||||
// 3. 变量定义:
|
||||
// @var {
|
||||
// baseUrl: "https://api.example.com",
|
||||
// version: "v1",
|
||||
// timeout: 30000
|
||||
// }
|
||||
//
|
||||
// 4. 变量引用:
|
||||
// {{variableName}} - 简单引用
|
||||
// {{variableName:default}} - 带默认值引用
|
||||
//
|
||||
// 5. 响应数据:
|
||||
// 使用独立的 JSON 块
|
||||
// # Response 200 OK 234ms
|
||||
// { "code": 200, "message": "success" }
|
||||
//
|
||||
// 6. 注释:
|
||||
// # 单行注释
|
||||
//
|
||||
// 示例 1 - JSON 请求:
|
||||
// POST "http://api.example.com/users" {
|
||||
// content-type: "application/json"
|
||||
//
|
||||
// @json {
|
||||
// name: "张三",
|
||||
// age: 25,
|
||||
// email: "zhangsan@example.com"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// 示例 2 - FormData 请求:
|
||||
// POST "http://api.example.com/upload" {
|
||||
// content-type: "multipart/form-data"
|
||||
//
|
||||
// @formdata {
|
||||
// file: "avatar.png",
|
||||
// username: "zhangsan"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// 示例 3 - URLEncoded 请求:
|
||||
// POST "http://api.example.com/login" {
|
||||
// content-type: "application/x-www-form-urlencoded"
|
||||
//
|
||||
// @urlencoded {
|
||||
// username: "admin",
|
||||
// password: "123456"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// 示例 4 - 纯文本请求:
|
||||
// POST "http://api.example.com/webhook" {
|
||||
// content-type: "text/plain"
|
||||
//
|
||||
// @text {
|
||||
// content: "纯文本内容"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// 示例 5 - 带响应数据:
|
||||
// POST "http://api.example.com/login" {
|
||||
// @json {
|
||||
// username: "admin",
|
||||
// password: "123456"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// # Response 200 OK 234ms 2025-10-11 10:30:25
|
||||
// {
|
||||
// "code": 200,
|
||||
// "message": "登录成功",
|
||||
// "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
// "data": {
|
||||
// "userId": 1001,
|
||||
// "username": "admin"
|
||||
// }
|
||||
// }
|
||||
|
||||
@skip { whitespace | LineComment }
|
||||
|
||||
@top Document { item* }
|
||||
|
||||
item {
|
||||
VarDeclaration |
|
||||
RequestStatement |
|
||||
ResponseDeclaration |
|
||||
AtRule |
|
||||
JsonObject |
|
||||
JsonArray
|
||||
}
|
||||
|
||||
// 变量声明
|
||||
VarDeclaration {
|
||||
@specialize[@name=VarKeyword]<AtKeyword, "@var">
|
||||
JsonBlock
|
||||
}
|
||||
|
||||
// 响应声明
|
||||
// 格式:@response <status> <time>ms <timestamp> { <json> }
|
||||
// 示例:@response 200 123ms 2025-10-31T10:30:31 { "data": "..." }
|
||||
// 错误:@response error 0ms 2025-10-31T10:30:31 { "error": "..." }
|
||||
ResponseDeclaration {
|
||||
@specialize[@name=ResponseKeyword]<AtKeyword, "@response">
|
||||
ResponseStatus
|
||||
ResponseTime
|
||||
ResponseTimestamp
|
||||
ResponseBlock
|
||||
}
|
||||
|
||||
// 响应状态:状态码(200 或 200-OK)或 "error" 关键字
|
||||
// 数字开头的状态码作为一个整体 token
|
||||
ResponseStatus {
|
||||
StatusCode |
|
||||
@specialize[@name=ErrorStatus]<identifier, "error">
|
||||
}
|
||||
|
||||
// 响应时间:数字 + "ms" 作为一个整体 token
|
||||
ResponseTime {
|
||||
TimeValue
|
||||
}
|
||||
|
||||
// 响应时间戳:ISO 8601 格式字符串
|
||||
// 格式:2025-10-31T10:30:31
|
||||
ResponseTimestamp {
|
||||
Timestamp
|
||||
}
|
||||
|
||||
// 响应块:标准 JSON 对象或数组(支持带引号的 key)
|
||||
ResponseBlock {
|
||||
JsonObject | JsonArray
|
||||
}
|
||||
|
||||
// HTTP 请求 - URL 必须是字符串
|
||||
RequestStatement {
|
||||
Method Url Block
|
||||
}
|
||||
|
||||
Method {
|
||||
@specialize[@name=GET]<identifier, "GET"> |
|
||||
@specialize[@name=POST]<identifier, "POST"> |
|
||||
@specialize[@name=PUT]<identifier, "PUT"> |
|
||||
@specialize[@name=DELETE]<identifier, "DELETE"> |
|
||||
@specialize[@name=PATCH]<identifier, "PATCH"> |
|
||||
@specialize[@name=HEAD]<identifier, "HEAD"> |
|
||||
@specialize[@name=OPTIONS]<identifier, "OPTIONS"> |
|
||||
@specialize[@name=CONNECT]<identifier, "CONNECT"> |
|
||||
@specialize[@name=TRACE]<identifier, "TRACE">
|
||||
}
|
||||
|
||||
// URL 必须是字符串
|
||||
Url { StringLiteral }
|
||||
|
||||
// @ 规则(支持多种请求体格式,后面可选逗号)
|
||||
AtRule {
|
||||
(JsonRule |
|
||||
FormDataRule |
|
||||
UrlEncodedRule |
|
||||
TextRule) ","?
|
||||
}
|
||||
|
||||
// @json 块:JSON 格式请求体(属性必须用逗号分隔)
|
||||
JsonRule {
|
||||
@specialize[@name=JsonKeyword]<AtKeyword, "@json">
|
||||
JsonBlock
|
||||
}
|
||||
|
||||
// @formdata 块:表单数据格式(属性必须用逗号分隔)
|
||||
FormDataRule {
|
||||
@specialize[@name=FormDataKeyword]<AtKeyword, "@formdata">
|
||||
JsonBlock
|
||||
}
|
||||
|
||||
// @urlencoded 块:URL 编码格式(属性必须用逗号分隔)
|
||||
UrlEncodedRule {
|
||||
@specialize[@name=UrlEncodedKeyword]<AtKeyword, "@urlencoded">
|
||||
JsonBlock
|
||||
}
|
||||
|
||||
// @text 块:纯文本请求体(使用 content 字段)
|
||||
TextRule {
|
||||
@specialize[@name=TextKeyword]<AtKeyword, "@text">
|
||||
JsonBlock
|
||||
}
|
||||
|
||||
// 普通块结构(属性逗号可选,最多一个请求体)
|
||||
Block {
|
||||
"{" blockContent? "}"
|
||||
}
|
||||
|
||||
// 块内容:
|
||||
// - 选项1: 只有属性
|
||||
// - 选项2: 属性 + 请求体
|
||||
// - 选项3: 属性 + 请求体 + 属性
|
||||
blockContent {
|
||||
Property+ | Property* AtRule Property*
|
||||
}
|
||||
|
||||
// HTTP 属性(逗号可选)
|
||||
Property {
|
||||
PropertyName { identifier }
|
||||
":" value ","?
|
||||
}
|
||||
|
||||
// JSON 块结构(属性必须用逗号分隔)
|
||||
JsonBlock {
|
||||
"{" jsonBlockContent? "}"
|
||||
}
|
||||
|
||||
jsonBlockContent {
|
||||
JsonProperty ("," JsonProperty)* ","?
|
||||
}
|
||||
|
||||
// JSON 属性
|
||||
JsonProperty {
|
||||
PropertyName { identifier }
|
||||
":" jsonValue
|
||||
}
|
||||
|
||||
// 值
|
||||
NumberLiteral {
|
||||
numberLiteralInner Unit?
|
||||
}
|
||||
|
||||
// HTTP 属性值(支持块嵌套和变量引用)
|
||||
value {
|
||||
StringLiteral |
|
||||
NumberLiteral |
|
||||
VariableRef |
|
||||
Block |
|
||||
identifier
|
||||
}
|
||||
|
||||
// JSON 属性值(严格的 JSON 语法:字符串必须用引号,支持变量引用)
|
||||
jsonValue {
|
||||
StringLiteral |
|
||||
NumberLiteral |
|
||||
VariableRef |
|
||||
JsonBlock |
|
||||
JsonTrue |
|
||||
JsonFalse |
|
||||
JsonNull
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// 独立 JSON 语法(用于响应数据)
|
||||
// ===============================
|
||||
|
||||
// JSON 对象(独立的 JSON 块,不需要 @ 前缀)
|
||||
JsonObject {
|
||||
"{" jsonObjectContent? "}"
|
||||
}
|
||||
|
||||
jsonObjectContent {
|
||||
JsonMember ("," JsonMember)* ","?
|
||||
}
|
||||
|
||||
// JSON 成员(支持字符串键名和标识符键名)
|
||||
JsonMember {
|
||||
(StringLiteral | identifier) ":" JsonValue
|
||||
}
|
||||
|
||||
// JSON 数组
|
||||
JsonArray {
|
||||
"[" jsonArrayContent? "]"
|
||||
}
|
||||
|
||||
jsonArrayContent {
|
||||
JsonValue ("," JsonValue)* ","?
|
||||
}
|
||||
|
||||
// JSON 值(完整的 JSON 值类型,支持变量引用)
|
||||
JsonValue {
|
||||
StringLiteral |
|
||||
NumberLiteral |
|
||||
VariableRef |
|
||||
JsonObject |
|
||||
JsonArray |
|
||||
JsonTrue |
|
||||
JsonFalse |
|
||||
JsonNull
|
||||
}
|
||||
|
||||
// JSON 字面量
|
||||
JsonTrue { @specialize[@name=True]<identifier, "true"> }
|
||||
JsonFalse { @specialize[@name=False]<identifier, "false"> }
|
||||
JsonNull { @specialize[@name=Null]<identifier, "null"> }
|
||||
|
||||
// Tokens
|
||||
@tokens {
|
||||
// 单行注释(# 开头到行尾)
|
||||
LineComment { "#" ![\n]* }
|
||||
|
||||
AtKeyword { "@" "-"? @asciiLetter (@asciiLetter | @digit | "-")* }
|
||||
|
||||
// 变量引用: {{variableName}} 或 {{variableName:default}} 或 {{obj.nested.path}}
|
||||
VariableRef[isolate] {
|
||||
"{{"
|
||||
(@asciiLetter | $[_$]) (@asciiLetter | @digit | $[-_$] | ".")*
|
||||
(":" (![\n}] | "}" ![}])*)?
|
||||
"}}"
|
||||
}
|
||||
|
||||
// 标识符(属性名,支持连字符)
|
||||
identifier {
|
||||
(@asciiLetter | $[_$])
|
||||
(@asciiLetter | @digit | $[-_$])*
|
||||
}
|
||||
|
||||
// 单位(必须跟在数字后面,所以不单独匹配)
|
||||
Unit { @asciiLetter+ }
|
||||
|
||||
// 时间戳:ISO 8601 格式(YYYY-MM-DDTHH:MM:SS)
|
||||
Timestamp[isolate] {
|
||||
@digit @digit @digit @digit "-" @digit @digit "-" @digit @digit
|
||||
"T" @digit @digit ":" @digit @digit ":" @digit @digit
|
||||
}
|
||||
|
||||
// 状态码:纯数字或数字-字母组合(200, 200-OK, 404-Not-Found)
|
||||
StatusCode {
|
||||
@digit+ ("-" @asciiLetter (@asciiLetter | "-")*)?
|
||||
}
|
||||
|
||||
// 时间值:数字 + ms(123ms)
|
||||
TimeValue {
|
||||
@digit+ "ms"
|
||||
}
|
||||
|
||||
whitespace { @whitespace+ }
|
||||
|
||||
@precedence { Timestamp, TimeValue, StatusCode, numberLiteralInner, VariableRef, identifier, Unit }
|
||||
|
||||
numberLiteralInner {
|
||||
("+" | "-")? (@digit+ ("." @digit*)? | "." @digit+)
|
||||
(("e" | "E") ("+" | "-")? @digit+)?
|
||||
}
|
||||
|
||||
StringLiteral[isolate] {
|
||||
"\"" (!["\n\\] | "\\" _)* "\"" |
|
||||
"'" (!['\n\\] | "\\" _)* "'"
|
||||
}
|
||||
|
||||
":" ","
|
||||
|
||||
"{" "}"
|
||||
|
||||
"[" "]"
|
||||
}
|
||||
|
||||
@external propSource httpHighlighting from "./http.highlight"
|
||||
|
||||
@detectDelim
|
||||
@@ -0,0 +1,263 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { httpLanguage } from './index';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
|
||||
/**
|
||||
* 创建测试用的 EditorState
|
||||
*/
|
||||
function createTestState(content: string): EditorState {
|
||||
return EditorState.create({
|
||||
doc: content,
|
||||
extensions: [httpLanguage]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查节点是否存在
|
||||
*/
|
||||
function hasNode(state: EditorState, nodeName: string): boolean {
|
||||
const tree = syntaxTree(state);
|
||||
let found = false;
|
||||
tree.iterate({
|
||||
enter: (node) => {
|
||||
if (node.name === nodeName) {
|
||||
found = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点文本
|
||||
*/
|
||||
function getNodeText(state: EditorState, nodeName: string): string | null {
|
||||
const tree = syntaxTree(state);
|
||||
let text: string | null = null;
|
||||
tree.iterate({
|
||||
enter: (node) => {
|
||||
if (node.name === nodeName) {
|
||||
text = state.doc.sliceString(node.from, node.to);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
describe('HTTP Grammar - @response 响应语法', () => {
|
||||
|
||||
it('✅ 成功响应 - 完整格式', () => {
|
||||
const content = `@response 200 123ms 2025-10-31T10:30:31 {
|
||||
"message": "success",
|
||||
"data": [1, 2, 3]
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
|
||||
expect(hasNode(state, 'ResponseDeclaration')).toBe(true);
|
||||
expect(hasNode(state, 'ResponseStatus')).toBe(true);
|
||||
expect(hasNode(state, 'ResponseTime')).toBe(true);
|
||||
expect(hasNode(state, 'ResponseTimestamp')).toBe(true);
|
||||
expect(hasNode(state, 'ResponseBlock')).toBe(true);
|
||||
});
|
||||
|
||||
it('✅ 错误响应 - error 关键字', () => {
|
||||
const content = `@response error 0ms 2025-10-31T10:30:31 {
|
||||
"error": "Network timeout"
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
|
||||
expect(hasNode(state, 'ResponseDeclaration')).toBe(true);
|
||||
expect(hasNode(state, 'ErrorStatus')).toBe(true);
|
||||
expect(hasNode(state, 'TimeUnit')).toBe(true);
|
||||
});
|
||||
|
||||
it('✅ 响应与请求结合', () => {
|
||||
const content = `GET "https://api.example.com/users" {}
|
||||
|
||||
@response 200 123ms 2025-10-31T10:30:31 {
|
||||
"users": [
|
||||
{ "id": 1, "name": "Alice" },
|
||||
{ "id": 2, "name": "Bob" }
|
||||
]
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
|
||||
expect(hasNode(state, 'RequestStatement')).toBe(true);
|
||||
expect(hasNode(state, 'ResponseDeclaration')).toBe(true);
|
||||
});
|
||||
|
||||
it('✅ 多个请求和响应', () => {
|
||||
const content = `GET "https://api.example.com/users" {}
|
||||
@response 200 100ms 2025-10-31T10:30:31 {
|
||||
"users": []
|
||||
}
|
||||
|
||||
POST "https://api.example.com/users" {
|
||||
@json { "name": "Alice" }
|
||||
}
|
||||
@response 201 50ms 2025-10-31T10:30:32 {
|
||||
"id": 1,
|
||||
"name": "Alice"
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
const tree = syntaxTree(state);
|
||||
|
||||
// 统计 ResponseDeclaration 数量
|
||||
let responseCount = 0;
|
||||
tree.iterate({
|
||||
enter: (node) => {
|
||||
if (node.name === 'ResponseDeclaration') {
|
||||
responseCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(responseCount).toBe(2);
|
||||
});
|
||||
|
||||
it('✅ 响应状态码类型', () => {
|
||||
const testCases = [
|
||||
{ status: '200', shouldParse: true },
|
||||
{ status: '201', shouldParse: true },
|
||||
{ status: '404', shouldParse: true },
|
||||
{ status: '500', shouldParse: true },
|
||||
{ status: 'error', shouldParse: true }
|
||||
];
|
||||
|
||||
testCases.forEach(({ status, shouldParse }) => {
|
||||
const content = `@response ${status} 0ms 2025-10-31T10:30:31 { "data": null }`;
|
||||
const state = createTestState(content);
|
||||
expect(hasNode(state, 'ResponseDeclaration')).toBe(shouldParse);
|
||||
});
|
||||
});
|
||||
|
||||
it('✅ 响应时间单位', () => {
|
||||
const content = `@response 200 12345ms 2025-10-31T10:30:31 {
|
||||
"data": "test"
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
|
||||
expect(hasNode(state, 'TimeUnit')).toBe(true);
|
||||
expect(getNodeText(state, 'TimeUnit')).toBe('ms');
|
||||
});
|
||||
|
||||
it('✅ 响应块包含复杂 JSON', () => {
|
||||
const content = `@response 200 150ms 2025-10-31T10:30:31 {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"users": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Alice",
|
||||
"email": "alice@example.com",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Bob",
|
||||
"email": "bob@example.com",
|
||||
"active": false
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"pageSize": 10,
|
||||
"total": 100
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
|
||||
expect(hasNode(state, 'ResponseDeclaration')).toBe(true);
|
||||
expect(hasNode(state, 'JsonObject')).toBe(true);
|
||||
expect(hasNode(state, 'JsonArray')).toBe(true);
|
||||
});
|
||||
|
||||
it('✅ 空响应体', () => {
|
||||
const content = `@response 204 50ms 2025-10-31T10:30:31 {}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
|
||||
expect(hasNode(state, 'ResponseDeclaration')).toBe(true);
|
||||
expect(hasNode(state, 'ResponseBlock')).toBe(true);
|
||||
});
|
||||
|
||||
it('✅ 响应体为数组', () => {
|
||||
const content = `@response 200 80ms 2025-10-31T10:30:31 [
|
||||
{ "id": 1, "name": "Alice" },
|
||||
{ "id": 2, "name": "Bob" }
|
||||
]`;
|
||||
|
||||
const state = createTestState(content);
|
||||
|
||||
expect(hasNode(state, 'ResponseDeclaration')).toBe(true);
|
||||
expect(hasNode(state, 'JsonArray')).toBe(true);
|
||||
});
|
||||
|
||||
it('✅ 时间戳格式', () => {
|
||||
const testCases = [
|
||||
'2025-10-31T10:30:31',
|
||||
'2025-01-01T00:00:00',
|
||||
'2025-12-31T23:59:59'
|
||||
];
|
||||
|
||||
testCases.forEach(timestamp => {
|
||||
const content = `@response 200 100ms ${timestamp} { "data": null }`;
|
||||
const state = createTestState(content);
|
||||
expect(hasNode(state, 'ResponseTimestamp')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('❌ 缺少必填字段应该有错误', () => {
|
||||
const invalidCases = [
|
||||
'@response 200 { "data": null }', // 缺少时间和时间戳
|
||||
'@response 200 100ms { "data": null }', // 缺少时间戳
|
||||
];
|
||||
|
||||
invalidCases.forEach(content => {
|
||||
const state = createTestState(content);
|
||||
const tree = syntaxTree(state);
|
||||
|
||||
// 检查是否有错误节点
|
||||
let hasError = false;
|
||||
tree.iterate({
|
||||
enter: (node) => {
|
||||
if (node.name === '⚠') {
|
||||
hasError = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(hasError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('✅ 与变量结合', () => {
|
||||
const content = `@var {
|
||||
apiUrl: "https://api.example.com"
|
||||
}
|
||||
|
||||
GET "https://api.example.com/users" {}
|
||||
|
||||
@response 200 123ms 2025-10-31T10:30:31 {
|
||||
"users": []
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
|
||||
expect(hasNode(state, 'VarDeclaration')).toBe(true);
|
||||
expect(hasNode(state, 'RequestStatement')).toBe(true);
|
||||
expect(hasNode(state, 'ResponseDeclaration')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,725 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parser } from './http.parser';
|
||||
|
||||
/**
|
||||
* HTTP Grammar 测试
|
||||
*
|
||||
* 测试目标:验证标准的 HTTP 请求语法是否能正确解析,不应该出现错误节点(⚠)
|
||||
*/
|
||||
describe('HTTP Grammar 解析测试', () => {
|
||||
|
||||
/**
|
||||
* 辅助函数:解析代码并返回语法树
|
||||
*/
|
||||
function parseCode(code: string) {
|
||||
const tree = parser.parse(code);
|
||||
return tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:检查语法树中是否有错误节点
|
||||
*/
|
||||
function hasErrorNodes(tree: any): { hasError: boolean; errors: Array<{ name: string; from: number; to: number; text: string }> } {
|
||||
const errors: Array<{ name: string; from: number; to: number; text: string }> = [];
|
||||
|
||||
tree.iterate({
|
||||
enter: (node: any) => {
|
||||
if (node.name === '⚠') {
|
||||
errors.push({
|
||||
name: node.name,
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
text: tree.toString().substring(node.from, node.to)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
hasError: errors.length > 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:打印语法树结构(用于调试)
|
||||
*/
|
||||
function printTree(tree: any, code: string, maxDepth = 5) {
|
||||
const lines: string[] = [];
|
||||
|
||||
tree.iterate({
|
||||
enter: (node: any) => {
|
||||
const depth = getNodeDepth(tree, node);
|
||||
if (depth > maxDepth) return false; // 限制深度
|
||||
|
||||
const indent = ' '.repeat(depth);
|
||||
const text = code.substring(node.from, Math.min(node.to, node.from + 30));
|
||||
const displayText = text.length === 30 ? text + '...' : text;
|
||||
|
||||
lines.push(`${indent}${node.name} [${node.from}-${node.to}]: "${displayText.replace(/\n/g, '\\n')}"`);
|
||||
}
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点深度
|
||||
*/
|
||||
function getNodeDepth(tree: any, targetNode: any): number {
|
||||
let depth = 0;
|
||||
let cursor = tree.cursor();
|
||||
|
||||
function traverse(node: any, currentDepth: number): boolean {
|
||||
if (node.from === targetNode.from && node.to === targetNode.to && node.name === targetNode.name) {
|
||||
depth = currentDepth;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 简化:假设深度就是节点的层级
|
||||
let current = targetNode;
|
||||
while (current.parent) {
|
||||
depth++;
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
return depth;
|
||||
}
|
||||
|
||||
it('应该正确解析标准的 GET 请求(包含 @json 和 @res)', () => {
|
||||
const code = `GET "http://127.0.0.1:80/api/create" {
|
||||
host: "https://api.example.com",
|
||||
content-type: "application/json",
|
||||
user-agent: 'Mozilla/5.0',
|
||||
|
||||
@json {
|
||||
name : "xxx",
|
||||
test: "xx"
|
||||
}
|
||||
|
||||
@res {
|
||||
code: 200,
|
||||
status: "ok",
|
||||
size: "20kb",
|
||||
time: "2025-10-31 10:30:26",
|
||||
data: {
|
||||
xxx:"xxx"
|
||||
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
// 如果有错误,打印详细信息
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 发现错误节点:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
console.log('\n完整语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('应该正确解析简单的 POST 请求', () => {
|
||||
const code = `POST "http://127.0.0.1/api" {
|
||||
host: "example.com",
|
||||
content-type: "application/json"
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 发现错误节点:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
console.log('\n完整语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带嵌套块的请求', () => {
|
||||
const code = `POST "http://test.com" {
|
||||
@json {
|
||||
user: {
|
||||
name: "test",
|
||||
age: 25
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 发现错误节点:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
console.log('\n完整语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确识别 RequestStatement 节点', () => {
|
||||
const code = `GET "http://test.com" {
|
||||
host: "test.com"
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
let hasRequestStatement = false;
|
||||
let hasMethod = false;
|
||||
let hasUrl = false;
|
||||
let hasBlock = false;
|
||||
|
||||
tree.iterate({
|
||||
enter: (node: any) => {
|
||||
if (node.name === 'RequestStatement') hasRequestStatement = true;
|
||||
if (node.name === 'Method' || node.name === 'GET') hasMethod = true;
|
||||
if (node.name === 'Url') hasUrl = true;
|
||||
if (node.name === 'Block') hasBlock = true;
|
||||
}
|
||||
});
|
||||
|
||||
expect(hasRequestStatement).toBe(true);
|
||||
expect(hasMethod).toBe(true);
|
||||
expect(hasUrl).toBe(true);
|
||||
expect(hasBlock).toBe(true);
|
||||
});
|
||||
|
||||
it('应该正确解析多个连续的请求', () => {
|
||||
const code = `GET "http://test1.com" {
|
||||
host: "test1.com"
|
||||
}
|
||||
|
||||
POST "http://test2.com" {
|
||||
host: "test2.com"
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 发现错误节点:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
|
||||
// 统计 RequestStatement 数量
|
||||
let requestCount = 0;
|
||||
tree.iterate({
|
||||
enter: (node: any) => {
|
||||
if (node.name === 'RequestStatement') requestCount++;
|
||||
}
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
});
|
||||
|
||||
it('错误语法:方法名拼写错误(应该产生错误)', () => {
|
||||
const code = `Gef "http://test.com" {
|
||||
host: "test.com"
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
// 这个应该有错误
|
||||
expect(result.hasError).toBe(true);
|
||||
});
|
||||
|
||||
it('错误语法:花括号不匹配(应该产生错误)', () => {
|
||||
const code = `GET "http://test.com" {
|
||||
host: "test.com"`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
// 这个应该有错误
|
||||
expect(result.hasError).toBe(true);
|
||||
});
|
||||
|
||||
it('应该支持属性后面不加逗号', () => {
|
||||
const code = `GET "http://test.com" {
|
||||
host: "test.com"
|
||||
content-type: "application/json"
|
||||
user-agent: "Mozilla/5.0"
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 发现错误节点:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
console.log('\n完整语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该支持 @json/@res 块后面不加逗号(JSON块内部必须用逗号)', () => {
|
||||
const code = `POST "http://test.com" {
|
||||
host: "test.com"
|
||||
|
||||
@json {
|
||||
name: "xxx",
|
||||
test: "xx"
|
||||
}
|
||||
|
||||
@res {
|
||||
code: 200,
|
||||
status: "ok"
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 发现错误节点:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
console.log('\n完整语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该支持混合使用逗号(有些有逗号,有些没有)', () => {
|
||||
const code = `POST "http://test.com" {
|
||||
host: "test.com",
|
||||
content-type: "application/json"
|
||||
user-agent: "Mozilla/5.0",
|
||||
|
||||
@json {
|
||||
name: "xxx",
|
||||
test: "xx"
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 发现错误节点:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('用户提供的真实示例(HTTP 属性不用逗号,JSON 块内必须用逗号)', () => {
|
||||
const code = `GET "http://127.0.0.1:80/api/create" {
|
||||
host: "https://api.example.com"
|
||||
content-type: "application/json"
|
||||
user-agent: 'Mozilla/5.0'
|
||||
|
||||
@json {
|
||||
name: "xxx",
|
||||
test: "xx"
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 发现错误节点:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
console.log('\n完整语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('JSON 块内缺少逗号应该报错', () => {
|
||||
const code = `POST "http://test.com" {
|
||||
@json {
|
||||
name: "xxx"
|
||||
test: "xx"
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
// JSON 块内缺少逗号,应该有错误
|
||||
expect(result.hasError).toBe(true);
|
||||
});
|
||||
|
||||
it('支持 @formdata 块(必须使用逗号)', () => {
|
||||
const code = `POST "http://test.com" {
|
||||
@formdata {
|
||||
file: "test.png",
|
||||
description: "test file"
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 发现错误节点:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('支持 JSON 嵌套对象', () => {
|
||||
const code = `POST "http://test.com" {
|
||||
@json {
|
||||
user: {
|
||||
name: "test",
|
||||
age: 25
|
||||
},
|
||||
settings: {
|
||||
theme: "dark"
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 发现错误节点:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP 请求体格式测试', () => {
|
||||
|
||||
/**
|
||||
* 辅助函数:解析代码并返回语法树
|
||||
*/
|
||||
function parseCode(code: string) {
|
||||
const tree = parser.parse(code);
|
||||
return tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:检查语法树中是否有错误节点
|
||||
*/
|
||||
function hasErrorNodes(tree: any): { hasError: boolean; errors: Array<{ name: string; from: number; to: number; text: string }> } {
|
||||
const errors: Array<{ name: string; from: number; to: number; text: string }> = [];
|
||||
|
||||
tree.iterate({
|
||||
enter: (node: any) => {
|
||||
if (node.name === '⚠') {
|
||||
errors.push({
|
||||
name: node.name,
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
text: tree.toString().substring(node.from, node.to)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
hasError: errors.length > 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
it('✅ @json - JSON 格式请求体', () => {
|
||||
const code = `POST "http://api.example.com/users" {
|
||||
content-type: "application/json"
|
||||
authorization: "Bearer token123"
|
||||
|
||||
@json {
|
||||
name: "张三",
|
||||
age: 25,
|
||||
email: "zhangsan@example.com",
|
||||
address: {
|
||||
city: "北京",
|
||||
street: "长安街"
|
||||
},
|
||||
tags: {
|
||||
skill: "TypeScript",
|
||||
level: "advanced"
|
||||
}
|
||||
}
|
||||
|
||||
@res {
|
||||
code: 200,
|
||||
message: "success",
|
||||
data: {
|
||||
id: 12345
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ @json 格式错误:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('✅ @formdata - 表单数据格式', () => {
|
||||
const code = `POST "http://api.example.com/upload" {
|
||||
content-type: "multipart/form-data"
|
||||
|
||||
@formdata {
|
||||
file: "avatar.png",
|
||||
username: "zhangsan",
|
||||
email: "zhangsan@example.com",
|
||||
age: 25,
|
||||
description: "用户头像上传"
|
||||
}
|
||||
|
||||
@res {
|
||||
code: 200,
|
||||
message: "上传成功",
|
||||
url: "https://cdn.example.com/avatar.png"
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ @formdata 格式错误:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('✅ @urlencoded - URL 编码格式', () => {
|
||||
const code = `POST "http://api.example.com/login" {
|
||||
content-type: "application/x-www-form-urlencoded"
|
||||
|
||||
@urlencoded {
|
||||
username: "admin",
|
||||
password: "123456",
|
||||
remember: true
|
||||
}
|
||||
|
||||
@res {
|
||||
code: 200,
|
||||
message: "登录成功",
|
||||
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ @urlencoded 格式错误:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('✅ @text - 纯文本请求体', () => {
|
||||
const code = `POST "http://api.example.com/webhook" {
|
||||
content-type: "text/plain"
|
||||
|
||||
@text {
|
||||
content: "这是一段纯文本内容,可以包含多行\\n支持中文和特殊字符!@#$%"
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ @text 格式错误:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('✅ # 单行注释', () => {
|
||||
const code = `# 这是一个用户登录接口
|
||||
POST "http://api.example.com/login" {
|
||||
# 添加认证头
|
||||
content-type: "application/json"
|
||||
|
||||
# 登录参数
|
||||
@json {
|
||||
username: "admin",
|
||||
password: "123456"
|
||||
}
|
||||
|
||||
# 期望的响应
|
||||
@res {
|
||||
code: 200,
|
||||
# 用户token
|
||||
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 单行注释格式错误:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('✅ 混合多种格式 - JSON + 响应', () => {
|
||||
const code = `POST "http://api.example.com/login" {
|
||||
content-type: "application/json"
|
||||
user-agent: "Mozilla/5.0"
|
||||
|
||||
@json {
|
||||
username: "admin",
|
||||
password: "123456"
|
||||
}
|
||||
|
||||
@res {
|
||||
code: 200,
|
||||
message: "登录成功",
|
||||
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
user: {
|
||||
id: 1,
|
||||
name: "管理员"
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 混合格式错误:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('✅ 复杂嵌套 JSON', () => {
|
||||
const code = `POST "http://api.example.com/complex" {
|
||||
@json {
|
||||
user: {
|
||||
profile: {
|
||||
name: "张三",
|
||||
contact: {
|
||||
email: "zhangsan@example.com",
|
||||
phone: "13800138000"
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
theme: "dark",
|
||||
language: "zh-CN"
|
||||
}
|
||||
},
|
||||
metadata: {
|
||||
version: "1.0",
|
||||
timestamp: 1234567890
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 复杂嵌套格式错误:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('✅ 多个请求(不同格式 + 注释)', () => {
|
||||
const code = `# JSON 请求
|
||||
POST "http://api.example.com/json" {
|
||||
@json {
|
||||
name: "test"
|
||||
}
|
||||
}
|
||||
|
||||
# FormData 请求
|
||||
POST "http://api.example.com/form" {
|
||||
@formdata {
|
||||
file: "test.txt",
|
||||
description: "test"
|
||||
}
|
||||
}
|
||||
|
||||
# URLEncoded 请求
|
||||
POST "http://api.example.com/login" {
|
||||
@urlencoded {
|
||||
username: "admin",
|
||||
password: "123456"
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 多请求格式错误:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parser } from './http.parser';
|
||||
|
||||
/**
|
||||
* HTTP 变量功能测试
|
||||
*
|
||||
* 测试变量定义 @var 和变量引用 {{variableName}} 语法
|
||||
*/
|
||||
describe('HTTP 变量功能测试', () => {
|
||||
|
||||
/**
|
||||
* 辅助函数:解析代码并返回语法树
|
||||
*/
|
||||
function parseCode(code: string) {
|
||||
const tree = parser.parse(code);
|
||||
return tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:检查语法树中是否有错误节点
|
||||
*/
|
||||
function hasErrorNodes(tree: any): { hasError: boolean; errors: Array<{ name: string; from: number; to: number; text: string }> } {
|
||||
const errors: Array<{ name: string; from: number; to: number; text: string }> = [];
|
||||
|
||||
tree.iterate({
|
||||
enter: (node: any) => {
|
||||
if (node.name === '⚠') {
|
||||
errors.push({
|
||||
name: node.name,
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
text: tree.toString().substring(node.from, node.to)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
hasError: errors.length > 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:打印语法树结构(用于调试)
|
||||
*/
|
||||
function printTree(tree: any, code: string, maxDepth = 5) {
|
||||
const lines: string[] = [];
|
||||
|
||||
tree.iterate({
|
||||
enter: (node: any) => {
|
||||
const depth = getNodeDepth(tree, node);
|
||||
if (depth > maxDepth) return false;
|
||||
|
||||
const indent = ' '.repeat(depth);
|
||||
const text = code.substring(node.from, Math.min(node.to, node.from + 30));
|
||||
const displayText = text.length === 30 ? text + '...' : text;
|
||||
|
||||
lines.push(`${indent}${node.name} [${node.from}-${node.to}]: "${displayText.replace(/\n/g, '\\n')}"`);
|
||||
}
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getNodeDepth(tree: any, targetNode: any): number {
|
||||
let depth = 0;
|
||||
let current = targetNode;
|
||||
while (current.parent) {
|
||||
depth++;
|
||||
current = current.parent;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
it('✅ @var - 变量声明', () => {
|
||||
const code = `@var {
|
||||
baseUrl: "https://api.example.com",
|
||||
version: "v1",
|
||||
timeout: 30000
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ @var 变量声明格式错误:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
console.log('\n完整语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
|
||||
// 验证是否有 VarDeclaration 节点
|
||||
let hasVarDeclaration = false;
|
||||
let hasVarKeyword = false;
|
||||
tree.iterate({
|
||||
enter: (node: any) => {
|
||||
if (node.name === 'VarDeclaration') hasVarDeclaration = true;
|
||||
if (node.name === 'VarKeyword') hasVarKeyword = true;
|
||||
}
|
||||
});
|
||||
|
||||
expect(hasVarDeclaration).toBe(true);
|
||||
expect(hasVarKeyword).toBe(true);
|
||||
});
|
||||
|
||||
it('✅ 变量引用 - 在属性值中使用', () => {
|
||||
const code = `GET "http://example.com" {
|
||||
timeout: {{timeout}}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 变量引用格式错误:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
console.log('\n完整语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
|
||||
// 验证是否有 VariableRef 节点
|
||||
let hasVariableRef = false;
|
||||
tree.iterate({
|
||||
enter: (node: any) => {
|
||||
if (node.name === 'VariableRef') hasVariableRef = true;
|
||||
}
|
||||
});
|
||||
|
||||
expect(hasVariableRef).toBe(true);
|
||||
});
|
||||
|
||||
it('✅ 变量引用 - 带默认值 {{variableName:default}}', () => {
|
||||
const code = `GET "http://example.com" {
|
||||
authorization: "Bearer {{token:default-token}}"
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 带默认值的变量引用格式错误:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
console.log('\n完整语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('✅ 完整示例 - 变量定义和使用(用户提供的示例)', () => {
|
||||
const code = `@var {
|
||||
baseUrl: "https://api.example.com",
|
||||
version: "v1",
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
GET "{{baseUrl}}/{{version}}/users" {
|
||||
timeout: {{timeout}},
|
||||
authorization: "Bearer {{token:default-token}}"
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 完整示例格式错误:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
console.log('\n完整语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('✅ 变量在 JSON 请求体中使用', () => {
|
||||
const code = `@var {
|
||||
userId: "12345",
|
||||
userName: "张三"
|
||||
}
|
||||
|
||||
POST "http://api.example.com/users" {
|
||||
@json {
|
||||
id: {{userId}},
|
||||
name: {{userName}},
|
||||
email: "{{userName}}@example.com"
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ JSON 中使用变量格式错误:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
console.log('\n完整语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('✅ 变量在多个请求中使用', () => {
|
||||
const code = `@var {
|
||||
baseUrl: "https://api.example.com",
|
||||
token: "Bearer abc123"
|
||||
}
|
||||
|
||||
GET "{{baseUrl}}/users" {
|
||||
authorization: {{token}}
|
||||
}
|
||||
|
||||
POST "{{baseUrl}}/users" {
|
||||
authorization: {{token}},
|
||||
|
||||
@json {
|
||||
name: "test"
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 多请求中使用变量格式错误:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
console.log('\n完整语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('✅ URL 中包含多个变量', () => {
|
||||
const code = `GET "{{protocol}}://{{host}}:{{port}}/{{path}}/{{resource}}" {
|
||||
host: "example.com"
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ URL 多变量格式错误:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
console.log('\n完整语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('✅ 变量名包含下划线和数字', () => {
|
||||
const code = `@var {
|
||||
api_base_url_v2: "https://api.example.com",
|
||||
timeout_30s: 30000,
|
||||
user_id_123: "123"
|
||||
}
|
||||
|
||||
GET "{{api_base_url_v2}}/users/{{user_id_123}}" {
|
||||
timeout: {{timeout_30s}}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 变量名包含下划线和数字格式错误:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
console.log('\n完整语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('✅ 变量默认值包含特殊字符', () => {
|
||||
const code = `GET "http://example.com" {
|
||||
authorization: "Bearer {{token:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0}}"
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 变量默认值特殊字符格式错误:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
console.log('\n完整语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('✅ 混合变量和普通值', () => {
|
||||
const code = `@var {
|
||||
baseUrl: "https://api.example.com",
|
||||
version: "v1"
|
||||
}
|
||||
|
||||
POST "{{baseUrl}}/{{version}}/users" {
|
||||
content-type: "application/json",
|
||||
authorization: "Bearer {{token:default}}",
|
||||
user-agent: "Mozilla/5.0",
|
||||
|
||||
@json {
|
||||
name: "张三",
|
||||
age: 25,
|
||||
apiUrl: {{baseUrl}},
|
||||
apiVersion: {{version}}
|
||||
}
|
||||
}`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('\n❌ 混合变量和普通值格式错误:');
|
||||
result.errors.forEach(err => {
|
||||
console.log(` - ${err.name} at ${err.from}-${err.to}: "${err.text}"`);
|
||||
});
|
||||
console.log('\n完整语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { styleTags, tags as t } from "@lezer/highlight"
|
||||
|
||||
/**
|
||||
* HTTP Client 语法高亮配置
|
||||
*/
|
||||
export const httpHighlighting = styleTags({
|
||||
// ========== HTTP 方法(使用不同的强调程度)==========
|
||||
// 查询方法 - 使用普通关键字
|
||||
"GET HEAD OPTIONS": t.keyword,
|
||||
|
||||
// 修改方法 - 使用控制关键字
|
||||
"POST PUT PATCH": t.controlKeyword,
|
||||
|
||||
// 删除方法 - 使用操作符
|
||||
"DELETE": t.operatorKeyword,
|
||||
|
||||
// 其他方法 - 使用修饰关键字
|
||||
"TRACE CONNECT": t.modifier,
|
||||
|
||||
// ========== @ 规则(请求体格式和变量声明)==========
|
||||
// @json, @formdata, @urlencoded - 使用类型名
|
||||
"JsonKeyword FormDataKeyword UrlEncodedKeyword": t.typeName,
|
||||
|
||||
// @text - 使用特殊类型
|
||||
"TextKeyword": t.special(t.typeName),
|
||||
|
||||
// @var - 变量声明关键字
|
||||
"VarKeyword": t.definitionKeyword,
|
||||
|
||||
// @response - 响应关键字
|
||||
"ResponseKeyword": t.keyword,
|
||||
|
||||
// @ 符号本身 - 使用元标记
|
||||
"AtKeyword": t.meta,
|
||||
|
||||
// ========== 变量引用 ==========
|
||||
// {{variableName}} - 使用特殊变量名
|
||||
"VariableRef": t.special(t.definitionKeyword),
|
||||
|
||||
// ========== URL(特殊处理)==========
|
||||
// URL 节点 - 使用链接颜色
|
||||
"Url": t.link,
|
||||
|
||||
// ========== 属性和值 ==========
|
||||
// 属性名 - 使用定义名称
|
||||
"PropertyName": t.definition(t.attributeName),
|
||||
|
||||
// 普通标识符值 - 使用常量名
|
||||
"identifier": t.constant(t.variableName),
|
||||
|
||||
// ========== 字面量 ==========
|
||||
// 数字 - 数字颜色
|
||||
"NumberLiteral": t.number,
|
||||
|
||||
// 字符串 - 字符串颜色
|
||||
"StringLiteral": t.string,
|
||||
|
||||
// 单位 - 单位颜色
|
||||
"Unit": t.unit,
|
||||
|
||||
// ========== 响应相关 ==========
|
||||
// 响应状态码 - 数字颜色
|
||||
"StatusCode": t.number,
|
||||
"ResponseStatus/StatusCode": t.number,
|
||||
|
||||
// 响应错误状态 - 关键字
|
||||
"ErrorStatus": t.operatorKeyword,
|
||||
|
||||
// 响应时间 - 数字颜色
|
||||
"TimeValue": t.number,
|
||||
"ResponseTime": t.number,
|
||||
|
||||
// 时间戳 - 字符串颜色
|
||||
"Timestamp": t.string,
|
||||
"ResponseTimestamp": t.string,
|
||||
|
||||
// ========== 注释 ==========
|
||||
// # 单行注释 - 行注释颜色
|
||||
"LineComment": t.lineComment,
|
||||
|
||||
// ========== JSON 语法(独立 JSON 块)==========
|
||||
// JSON 对象和数组
|
||||
"JsonObject": t.brace,
|
||||
"JsonArray": t.squareBracket,
|
||||
|
||||
// JSON 属性名 - 使用属性名颜色
|
||||
"JsonProperty/PropertyName": t.propertyName,
|
||||
"JsonProperty/StringLiteral": t.propertyName,
|
||||
|
||||
// JSON 成员(属性名)- 使用属性名颜色(适用于独立 JSON 对象)
|
||||
"JsonMember/StringLiteral": t.propertyName,
|
||||
"JsonMember/identifier": t.propertyName,
|
||||
|
||||
// JSON 字面量值
|
||||
"True False": t.bool,
|
||||
"Null": t.null,
|
||||
|
||||
// JSON 值(确保字符串和数字正确高亮)
|
||||
"JsonValue/StringLiteral": t.string,
|
||||
"JsonValue/NumberLiteral": t.number,
|
||||
|
||||
// ========== 标点符号 ==========
|
||||
// 冒号 - 分隔符
|
||||
":": t.separator,
|
||||
|
||||
// 逗号 - 分隔符
|
||||
",": t.separator,
|
||||
|
||||
// 花括号 - 大括号
|
||||
"{ }": t.brace,
|
||||
|
||||
// 方括号 - 方括号
|
||||
"[ ]": t.squareBracket,
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
LineComment = 1,
|
||||
Document = 2,
|
||||
VarDeclaration = 3,
|
||||
AtKeyword = 4,
|
||||
VarKeyword = 5,
|
||||
JsonBlock = 8,
|
||||
JsonProperty = 9,
|
||||
StringLiteral = 12,
|
||||
NumberLiteral = 13,
|
||||
Unit = 14,
|
||||
VariableRef = 15,
|
||||
JsonTrue = 16,
|
||||
True = 17,
|
||||
JsonFalse = 18,
|
||||
False = 19,
|
||||
JsonNull = 20,
|
||||
Null = 21,
|
||||
RequestStatement = 23,
|
||||
Method = 24,
|
||||
GET = 25,
|
||||
POST = 26,
|
||||
PUT = 27,
|
||||
DELETE = 28,
|
||||
PATCH = 29,
|
||||
HEAD = 30,
|
||||
OPTIONS = 31,
|
||||
CONNECT = 32,
|
||||
TRACE = 33,
|
||||
Url = 34,
|
||||
Block = 35,
|
||||
Property = 36,
|
||||
AtRule = 38,
|
||||
JsonRule = 39,
|
||||
JsonKeyword = 40,
|
||||
FormDataRule = 41,
|
||||
FormDataKeyword = 42,
|
||||
UrlEncodedRule = 43,
|
||||
UrlEncodedKeyword = 44,
|
||||
TextRule = 45,
|
||||
TextKeyword = 46,
|
||||
ResponseDeclaration = 47,
|
||||
ResponseKeyword = 48,
|
||||
ResponseStatus = 49,
|
||||
StatusCode = 50,
|
||||
ErrorStatus = 51,
|
||||
ResponseTime = 52,
|
||||
TimeValue = 53,
|
||||
ResponseTimestamp = 54,
|
||||
Timestamp = 55,
|
||||
ResponseBlock = 56,
|
||||
JsonObject = 57,
|
||||
JsonMember = 58,
|
||||
JsonValue = 59,
|
||||
JsonArray = 62
|
||||
@@ -0,0 +1,26 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {httpHighlighting} from "./http.highlight"
|
||||
const spec_AtKeyword = {__proto__:null,"@var":10, "@json":80, "@formdata":84, "@urlencoded":88, "@text":92, "@response":96}
|
||||
const spec_identifier = {__proto__:null,true:34, false:38, null:42, GET:50, POST:52, PUT:54, DELETE:56, PATCH:58, HEAD:60, OPTIONS:62, CONNECT:64, TRACE:66, error:102}
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "-bQYQPOOO!aQPO'#C_OOQO'#Ct'#CtO!aQPO'#DTO!aQPO'#DVO!aQPO'#DXO!aQPO'#DZO!fQPO'#DSO#yQPO'#CsO$jQPO'#DlO$qQPO'#DgO$|QPO'#D]OOQO'#Du'#DuOOQO'#Dm'#DmQYQPOOO%UQPO'#CdOOQO,58y,58yOOQO,59o,59oOOQO,59q,59qOOQO,59s,59sOOQO,59u,59uOOQO,59n,59nOOQO'#DO'#DOO%^QPO,59_O%cQPO'#CiOOQO'#Cl'#ClOOQO'#Cn'#CnOOQO'#Cp'#CpO&QQPO'#D}OOQO,5:W,5:WO&YQPO,5:WOOQO'#Di'#DiO&_QPO'#DhO&dQPO'#D|OOQO,5:R,5:RO&lQPO,5:ROOQO'#D_'#D_O&qQPO,59wOOQO-E7k-E7kOOQO'#Cf'#CfO&vQPO'#CeO&{QPO'#DvOOQO,59O,59OO'TQPO,59OO'kQPO'#DPOOQO1G.y1G.yOOQO,59T,59TO'rQPO,5:iO'yQPO,5:iOOQO1G/r1G/rO$OQPO,5:SO(RQPO,5:hO(^QPO,5:hOOQO1G/m1G/mOOQO'#Db'#DbO(fQPO1G/cO(kQPO,59PO)SQPO,5:bO)[QPO,5:bOOQO1G.j1G.jOOQO'#DR'#DRO)dQPO'#DQOOQO'#Do'#DoO)iQPO'#DzO)pQPO'#DzOOQO,59k,59kO)xQPO,59kOOQO,5:[,5:[O)}QPO1G0TOOQO-E7n-E7nOOQO1G/n1G/nOOQO,5:],5:]O*UQPO1G0SOOQO-E7o-E7oOOQO'#Dd'#DdO*aQPO7+$}OOQO'#Dx'#DxOOQO1G.k1G.kOOQO,5:Y,5:YO*iQPO1G/|OOQO-E7l-E7lO*qQPO,59lOOQO-E7m-E7mO+SQPO,5:fO+SQPO,5:fOOQO1G/V1G/VP$OQPO'#DpP$tQPO'#DqOOQO'#Df'#DfOOQO<<Hi<<HiP%XQPO'#DnOOQO'#D{'#D{O+[QPO1G/WO+sQPO1G0QOOQO7+$r7+$r",
|
||||
stateData: ",U~O!hOSPOS~OTPOVYOiQOjQOkQOlQOmQOnQOoQOpQOqQOxROzSO|TO!OUO!QZO!_XO~OV_O~OfeOTvXVvXivXjvXkvXlvXmvXnvXovXpvXqvXxvXzvX|vX!OvX!QvX!_vX!fvXUvX!kvX~O[fO~OVYO[oO_oOaiOcjOekO!_XO!mhO~O!^mO~P$OOUrO[pO!kpO~O!StO!TtO~OUzO!kwO~OV|O~O^!OOf]X!^]XU]Xx]Xz]X|]X!O]X!k]X~Of!PO!^!qX~O!^!RO~OZ!SO~Of!TOU!pX~OU!VO~O!V!WO~OZ!YO~Of!ZOU!jX~OU!]O~OxROzSO|TO!OUO!k!^O~OU!cO~P'YO!^!qa~P$OOf!fO!^!qa~O[pO!kpOU!pa~Of!jOU!pa~O!X!lO~OV_O[!nO_!nOaiOcjOekO!mhO~O!kwOU!ja~Of!qOU!ja~OZ!sO~OU!nX~P'YO!k!^OU!nX~OU!wO~O!^!qi~P$OO[pO!kpOU!pi~OVYO!_XO~O!kwOU!ji~OV|O[!}O_!}O!k!}O!mhO~O!k!^OU!na~Of#QOUtixtizti|ti!Oti!kti~O!k!^OU!ni~O!X!V!S!m_!k^!m~",
|
||||
goto: "'^!rPPP!sPPPP!w#Z#cPP#iPP#vP#vP#vPP!s$QPPPPPPPPP$U$X$_$g$o$yP$yP$yP$yP!sP%PPP%SP%VP%Y%]%k%sPP%]&O&U&[&j&pPPP&v&zP&}P'Q'T'W'ZT[O^Q`PQaRQbSQcTQdUR!n!YQy_V!p!Z!q!|Xx_!Z!q!|YoX!P!S!f!xQ!n!YR!}!sYoX!P!S!f!xR!n!YTWO^RgWQ}gR!}!s]!`|!a!b!u!v#P]!_|!a!b!u!v#PS[O^Q!b|R!u!aXVO^|!aRuZR!XuR!m!XR!{!mS[O^YoX!P!S!f!xR!z!mQqYV!i!T!j!yQlXU!e!P!f!xR!h!SQ^ORv^Q![yR!r![Q!a|U!t!a!v#PQ!v!bR#P!uQ!QlR!g!QQ!UqR!k!UT]O^R{_R!o!YR!d|R#O!sRsYRnX",
|
||||
nodeNames: "⚠ LineComment Document VarDeclaration AtKeyword VarKeyword } { JsonBlock JsonProperty PropertyName : StringLiteral NumberLiteral Unit VariableRef JsonTrue True JsonFalse False JsonNull Null , RequestStatement Method GET POST PUT DELETE PATCH HEAD OPTIONS CONNECT TRACE Url Block Property PropertyName AtRule JsonRule JsonKeyword FormDataRule FormDataKeyword UrlEncodedRule UrlEncodedKeyword TextRule TextKeyword ResponseDeclaration ResponseKeyword ResponseStatus StatusCode ErrorStatus ResponseTime TimeValue ResponseTimestamp Timestamp ResponseBlock JsonObject JsonMember JsonValue ] [ JsonArray",
|
||||
maxTerm: 79,
|
||||
nodeProps: [
|
||||
["openedBy", 6,"{",60,"["],
|
||||
["closedBy", 7,"}",61,"]"],
|
||||
["isolate", -3,12,15,55,""]
|
||||
],
|
||||
propSources: [httpHighlighting],
|
||||
skippedNodes: [0,1,4],
|
||||
repeatNodeCount: 5,
|
||||
tokenData: "3b~RlX^!ypq!yrs#nst%btu%ywx&b{|(P|})k}!O(P!O!P(Y!Q![)p![!]/Y!b!c/_!c!}0V!}#O0p#P#Q0u#R#S%y#T#o0V#o#p0z#q#r3]#y#z!y$f$g!y#BY#BZ!y$IS$I_!y$I|$JO!y$JT$JU!y$KV$KW!y&FU&FV!y~#OY!h~X^!ypq!y#y#z!y$f$g!y#BY#BZ!y$IS$I_!y$I|$JO!y$JT$JU!y$KV$KW!y&FU&FV!y~#qWOY#nZr#nrs$Zs#O#n#O#P$`#P;'S#n;'S;=`%[<%lO#n~$`O[~~$cRO;'S#n;'S;=`$l;=`O#n~$oXOY#nZr#nrs$Zs#O#n#O#P$`#P;'S#n;'S;=`%[;=`<%l#n<%lO#n~%_P;=`<%l#n~%gSP~OY%bZ;'S%b;'S;=`%s<%lO%b~%vP;=`<%l%b~&OU!k~tu%y}!O%y!Q![%y!c!}%y#R#S%y#T#o%y~&eWOY&bZw&bwx$Zx#O&b#O#P&}#P;'S&b;'S;=`'y<%lO&b~'QRO;'S&b;'S;=`'Z;=`O&b~'^XOY&bZw&bwx$Zx#O&b#O#P&}#P;'S&b;'S;=`'y;=`<%l&b<%lO&b~'|P;=`<%l&b~(SQ!O!P(Y!Q![)Y~(]P!Q![(`~(eR!m~!Q![(`!g!h(n#X#Y(n~(qR{|(z}!O(z!Q![)Q~(}P!Q![)Q~)VP!m~!Q![)Q~)_S!m~!O!P(`!Q![)Y!g!h(n#X#Y(n~)pOf~~)wU!S~!m~}!O*Z!O!P(`!Q![*r!g!h(n#X#Y(n#a#b.}~*^Q!c!}*d#T#o*d~*iR!S~}!O*d!c!}*d#T#o*d~*yU!S~!m~}!O*Z!O!P(`!Q![+]!g!h(n#X#Y(n#a#b.}~+dU!S~!m~}!O*Z!O!P(`!Q![+v!g!h(n#X#Y(n#a#b.}~+}U!S~!m~}!O,a!O!P(`!Q![.d!g!h(n#X#Y(n#a#b.}~,dR!Q![,m!c!}*d#T#o*d~,pP!Q![,s~,vP}!O,y~,|P!Q![-P~-SP!Q![-V~-YP!v!w-]~-`P!Q![-c~-fP!Q![-i~-lP![!]-o~-rP!Q![-u~-xP!Q![-{~.OP![!].R~.UP!Q![.X~.[P!Q![._~.dO!X~~.kU!S~!m~}!O*Z!O!P(`!Q![.d!g!h(n#X#Y(n#a#b.}~/QP#g#h/T~/YO!V~~/_OZ~~/bR}!O/k!c!}/t#T#o/t~/nQ!c!}/t#T#o/t~/ySS~}!O/t!Q![/t!c!}/t#T#o/t~0^U!k~^~tu%y}!O%y!Q![%y!c!}0V#R#S%y#T#o0V~0uO!_~~0zO!^~~1PPV~#o#p1S~1VStu1c!c!}1c#R#S1c#T#o1c~1fXtu1c}!O1c!O!P1c!Q![1c![!]2R!c!}1c#R#S1c#T#o1c#q#r3V~2UUOY2RZ#q2R#q#r2h#r;'S2R;'S;=`3P<%lO2R~2kTO#q2R#q#r2z#r;'S2R;'S;=`3P<%lO2R~3PO_~~3SP;=`<%l2R~3YP#q#r2z~3bOU~",
|
||||
tokenizers: [0],
|
||||
topRules: {"Document":[0,2]},
|
||||
specialized: [{term: 4, get: (value: keyof typeof spec_AtKeyword) => spec_AtKeyword[value] || -1},{term: 73, get: (value: keyof typeof spec_identifier) => spec_identifier[value] || -1}],
|
||||
tokenPrec: 503
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export { http, httpLanguage } from './http-language';
|
||||
export { parser } from './http.parser';
|
||||
export type { LRLanguage } from '@codemirror/language';
|
||||
@@ -0,0 +1,346 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { HttpRequestParser } from './request-parser';
|
||||
import { http } from '../language';
|
||||
|
||||
/**
|
||||
* 创建测试用的编辑器状态
|
||||
*/
|
||||
function createTestState(code: string): EditorState {
|
||||
return EditorState.create({
|
||||
doc: code,
|
||||
extensions: [http()],
|
||||
});
|
||||
}
|
||||
|
||||
describe('HttpRequestParser', () => {
|
||||
describe('基本请求解析', () => {
|
||||
it('应该解析简单的 GET 请求', () => {
|
||||
const code = `GET "http://api.example.com/users" {
|
||||
host: "api.example.com"
|
||||
content-type: "application/json"
|
||||
}`;
|
||||
|
||||
const state = createTestState(code);
|
||||
const parser = new HttpRequestParser(state);
|
||||
const request = parser.parseRequestAt(0);
|
||||
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.method).toBe('GET');
|
||||
expect(request?.url).toBe('http://api.example.com/users');
|
||||
expect(request?.headers).toEqual({
|
||||
host: 'api.example.com',
|
||||
'content-type': 'application/json',
|
||||
});
|
||||
expect(request?.bodyType).toBeUndefined();
|
||||
expect(request?.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该解析 POST 请求(带 JSON 请求体)', () => {
|
||||
const code = `POST "http://api.example.com/users" {
|
||||
content-type: "application/json"
|
||||
|
||||
@json {
|
||||
name: "张三",
|
||||
age: 25,
|
||||
email: "zhangsan@example.com"
|
||||
}
|
||||
}`;
|
||||
|
||||
const state = createTestState(code);
|
||||
const parser = new HttpRequestParser(state);
|
||||
const request = parser.parseRequestAt(0);
|
||||
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.method).toBe('POST');
|
||||
expect(request?.url).toBe('http://api.example.com/users');
|
||||
expect(request?.headers).toEqual({
|
||||
'content-type': 'application/json',
|
||||
});
|
||||
expect(request?.bodyType).toBe('json');
|
||||
expect(request?.body).toEqual({
|
||||
name: '张三',
|
||||
age: 25,
|
||||
email: 'zhangsan@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('应该解析 PUT 请求', () => {
|
||||
const code = `PUT "http://api.example.com/users/123" {
|
||||
authorization: "Bearer token123"
|
||||
|
||||
@json {
|
||||
name: "李四"
|
||||
}
|
||||
}`;
|
||||
|
||||
const state = createTestState(code);
|
||||
const parser = new HttpRequestParser(state);
|
||||
const request = parser.parseRequestAt(0);
|
||||
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.method).toBe('PUT');
|
||||
expect(request?.url).toBe('http://api.example.com/users/123');
|
||||
});
|
||||
|
||||
it('应该解析 DELETE 请求', () => {
|
||||
const code = `DELETE "http://api.example.com/users/123" {
|
||||
authorization: "Bearer token123"
|
||||
}`;
|
||||
|
||||
const state = createTestState(code);
|
||||
const parser = new HttpRequestParser(state);
|
||||
const request = parser.parseRequestAt(0);
|
||||
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.method).toBe('DELETE');
|
||||
expect(request?.url).toBe('http://api.example.com/users/123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('请求体类型解析', () => {
|
||||
it('应该解析 @json 请求体', () => {
|
||||
const code = `POST "http://api.example.com/test" {
|
||||
@json {
|
||||
username: "admin",
|
||||
password: "123456"
|
||||
}
|
||||
}`;
|
||||
|
||||
const state = createTestState(code);
|
||||
const parser = new HttpRequestParser(state);
|
||||
const request = parser.parseRequestAt(0);
|
||||
|
||||
expect(request?.bodyType).toBe('json');
|
||||
expect(request?.body).toEqual({
|
||||
username: 'admin',
|
||||
password: '123456',
|
||||
});
|
||||
});
|
||||
|
||||
it('应该解析 @formdata 请求体', () => {
|
||||
const code = `POST "http://api.example.com/upload" {
|
||||
@formdata {
|
||||
file: "avatar.png",
|
||||
description: "用户头像"
|
||||
}
|
||||
}`;
|
||||
|
||||
const state = createTestState(code);
|
||||
const parser = new HttpRequestParser(state);
|
||||
const request = parser.parseRequestAt(0);
|
||||
|
||||
expect(request?.bodyType).toBe('formdata');
|
||||
expect(request?.body).toEqual({
|
||||
file: 'avatar.png',
|
||||
description: '用户头像',
|
||||
});
|
||||
});
|
||||
|
||||
it('应该解析 @urlencoded 请求体', () => {
|
||||
const code = `POST "http://api.example.com/login" {
|
||||
@urlencoded {
|
||||
username: "admin",
|
||||
password: "123456"
|
||||
}
|
||||
}`;
|
||||
|
||||
const state = createTestState(code);
|
||||
const parser = new HttpRequestParser(state);
|
||||
const request = parser.parseRequestAt(0);
|
||||
|
||||
expect(request?.bodyType).toBe('urlencoded');
|
||||
expect(request?.body).toEqual({
|
||||
username: 'admin',
|
||||
password: '123456',
|
||||
});
|
||||
});
|
||||
|
||||
it('应该解析 @text 请求体', () => {
|
||||
const code = `POST "http://api.example.com/webhook" {
|
||||
@text {
|
||||
content: "纯文本内容"
|
||||
}
|
||||
}`;
|
||||
|
||||
const state = createTestState(code);
|
||||
const parser = new HttpRequestParser(state);
|
||||
const request = parser.parseRequestAt(0);
|
||||
|
||||
expect(request?.bodyType).toBe('text');
|
||||
expect(request?.body).toEqual({
|
||||
content: '纯文本内容',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('复杂数据类型', () => {
|
||||
it('应该解析嵌套对象', () => {
|
||||
const code = `POST "http://api.example.com/users" {
|
||||
@json {
|
||||
user: {
|
||||
name: "张三",
|
||||
age: 25
|
||||
},
|
||||
settings: {
|
||||
theme: "dark"
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const state = createTestState(code);
|
||||
const parser = new HttpRequestParser(state);
|
||||
const request = parser.parseRequestAt(0);
|
||||
|
||||
expect(request?.body).toEqual({
|
||||
user: {
|
||||
name: '张三',
|
||||
age: 25,
|
||||
},
|
||||
settings: {
|
||||
theme: 'dark',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('应该解析布尔值', () => {
|
||||
const code = `POST "http://api.example.com/test" {
|
||||
@json {
|
||||
enabled: true,
|
||||
disabled: false
|
||||
}
|
||||
}`;
|
||||
|
||||
const state = createTestState(code);
|
||||
const parser = new HttpRequestParser(state);
|
||||
const request = parser.parseRequestAt(0);
|
||||
|
||||
expect(request?.body).toEqual({
|
||||
enabled: true,
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('应该解析数字', () => {
|
||||
const code = `POST "http://api.example.com/test" {
|
||||
@json {
|
||||
count: 100,
|
||||
price: 19.99
|
||||
}
|
||||
}`;
|
||||
|
||||
const state = createTestState(code);
|
||||
const parser = new HttpRequestParser(state);
|
||||
const request = parser.parseRequestAt(0);
|
||||
|
||||
expect(request?.body).toEqual({
|
||||
count: 100,
|
||||
price: 19.99,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Headers 解析', () => {
|
||||
it('应该解析多个 headers', () => {
|
||||
const code = `GET "http://api.example.com/users" {
|
||||
host: "api.example.com"
|
||||
authorization: "Bearer token123"
|
||||
content-type: "application/json"
|
||||
user-agent: "Mozilla/5.0"
|
||||
accept: "application/json"
|
||||
}`;
|
||||
|
||||
const state = createTestState(code);
|
||||
const parser = new HttpRequestParser(state);
|
||||
const request = parser.parseRequestAt(0);
|
||||
|
||||
expect(request?.headers).toEqual({
|
||||
host: 'api.example.com',
|
||||
authorization: 'Bearer token123',
|
||||
'content-type': 'application/json',
|
||||
'user-agent': 'Mozilla/5.0',
|
||||
accept: 'application/json',
|
||||
});
|
||||
});
|
||||
|
||||
it('应该支持单引号字符串', () => {
|
||||
const code = `GET "http://api.example.com/users" {
|
||||
user-agent: 'Mozilla/5.0'
|
||||
}`;
|
||||
|
||||
const state = createTestState(code);
|
||||
const parser = new HttpRequestParser(state);
|
||||
const request = parser.parseRequestAt(0);
|
||||
|
||||
expect(request?.headers['user-agent']).toBe('Mozilla/5.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('位置信息', () => {
|
||||
it('应该记录请求的位置信息', () => {
|
||||
const code = `GET "http://api.example.com/users" {
|
||||
host: "api.example.com"
|
||||
}`;
|
||||
|
||||
const state = createTestState(code);
|
||||
const parser = new HttpRequestParser(state);
|
||||
const request = parser.parseRequestAt(0);
|
||||
|
||||
expect(request?.position).toBeDefined();
|
||||
expect(request?.position.line).toBe(1);
|
||||
expect(request?.position.from).toBe(0);
|
||||
expect(request?.position.to).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('错误处理', () => {
|
||||
it('解析不完整的请求应该返回 null', () => {
|
||||
const code = `GET {
|
||||
host: "test.com"
|
||||
}`;
|
||||
|
||||
const state = createTestState(code);
|
||||
const parser = new HttpRequestParser(state);
|
||||
const request = parser.parseRequestAt(0);
|
||||
|
||||
expect(request).toBeNull();
|
||||
});
|
||||
|
||||
it('解析无效位置应该返回 null', () => {
|
||||
const code = `GET "http://test.com" { }`;
|
||||
|
||||
const state = createTestState(code);
|
||||
const parser = new HttpRequestParser(state);
|
||||
const request = parser.parseRequestAt(1000); // 超出范围
|
||||
|
||||
expect(request).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('多个请求', () => {
|
||||
it('应该正确解析指定位置的请求', () => {
|
||||
const code = `GET "http://api.example.com/users" {
|
||||
host: "api.example.com"
|
||||
}
|
||||
|
||||
POST "http://api.example.com/users" {
|
||||
@json {
|
||||
name: "test"
|
||||
}
|
||||
}`;
|
||||
|
||||
const state = createTestState(code);
|
||||
const parser = new HttpRequestParser(state);
|
||||
|
||||
// 解析第一个请求
|
||||
const request1 = parser.parseRequestAt(0);
|
||||
expect(request1?.method).toBe('GET');
|
||||
|
||||
// 解析第二个请求(大概在 60+ 字符位置)
|
||||
const request2 = parser.parseRequestAt(70);
|
||||
expect(request2?.method).toBe('POST');
|
||||
expect(request2?.body).toEqual({ name: 'test' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import type { SyntaxNode } from '@lezer/common';
|
||||
import { VariableResolver } from './variable-resolver';
|
||||
|
||||
/**
|
||||
* HTTP 请求模型
|
||||
*/
|
||||
export interface HttpRequest {
|
||||
/** 请求方法 */
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
|
||||
|
||||
/** 请求 URL */
|
||||
url: string;
|
||||
|
||||
/** 请求头 */
|
||||
headers: Record<string, string>;
|
||||
|
||||
/** 请求体类型 */
|
||||
bodyType?: 'json' | 'formdata' | 'urlencoded' | 'text';
|
||||
|
||||
/** 请求体内容 */
|
||||
body?: any;
|
||||
|
||||
/** 原始文本位置信息(用于调试) */
|
||||
position: {
|
||||
from: number;
|
||||
to: number;
|
||||
line: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点类型常量
|
||||
*/
|
||||
const NODE_TYPES = {
|
||||
REQUEST_STATEMENT: 'RequestStatement',
|
||||
METHOD: 'Method',
|
||||
URL: 'Url',
|
||||
BLOCK: 'Block',
|
||||
PROPERTY: 'Property',
|
||||
PROPERTY_NAME: 'PropertyName',
|
||||
STRING_LITERAL: 'StringLiteral',
|
||||
NUMBER_LITERAL: 'NumberLiteral',
|
||||
IDENTIFIER: 'identifier',
|
||||
AT_RULE: 'AtRule',
|
||||
JSON_RULE: 'JsonRule',
|
||||
FORMDATA_RULE: 'FormDataRule',
|
||||
URLENCODED_RULE: 'UrlEncodedRule',
|
||||
TEXT_RULE: 'TextRule',
|
||||
JSON_KEYWORD: 'JsonKeyword',
|
||||
FORMDATA_KEYWORD: 'FormDataKeyword',
|
||||
URLENCODED_KEYWORD: 'UrlEncodedKeyword',
|
||||
TEXT_KEYWORD: 'TextKeyword',
|
||||
JSON_BLOCK: 'JsonBlock',
|
||||
JSON_PROPERTY: 'JsonProperty',
|
||||
VARIABLE_REF: 'VariableRef',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* HTTP 请求解析器
|
||||
*/
|
||||
export class HttpRequestParser {
|
||||
private variableResolver: VariableResolver | null = null;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param state EditorState
|
||||
* @param blockRange 块的范围(可选),如果提供则只解析该块内的变量
|
||||
*/
|
||||
constructor(
|
||||
private state: EditorState,
|
||||
private blockRange?: { from: number; to: number }
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建变量解析器(懒加载)
|
||||
*/
|
||||
private getVariableResolver(): VariableResolver {
|
||||
if (!this.variableResolver) {
|
||||
this.variableResolver = new VariableResolver(this.state, this.blockRange);
|
||||
}
|
||||
return this.variableResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析指定位置的 HTTP 请求
|
||||
* @param pos 光标位置或请求起始位置
|
||||
* @returns 解析后的 HTTP 请求对象,如果解析失败返回 null
|
||||
*/
|
||||
parseRequestAt(pos: number): HttpRequest | null {
|
||||
const tree = syntaxTree(this.state);
|
||||
|
||||
// 查找包含该位置的 RequestStatement 节点
|
||||
const requestNode = this.findRequestNode(tree, pos);
|
||||
if (!requestNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.parseRequest(requestNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找包含指定位置的 RequestStatement 节点
|
||||
*/
|
||||
private findRequestNode(tree: any, pos: number): SyntaxNode | null {
|
||||
let foundNode: SyntaxNode | null = null;
|
||||
|
||||
tree.iterate({
|
||||
enter: (node: any) => {
|
||||
if (node.name === NODE_TYPES.REQUEST_STATEMENT) {
|
||||
if (node.from <= pos && pos <= node.to) {
|
||||
foundNode = node.node;
|
||||
return false; // 停止迭代
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return foundNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 RequestStatement 节点
|
||||
*/
|
||||
private parseRequest(node: SyntaxNode): HttpRequest | null {
|
||||
// 使用 Lezer API 直接获取子节点
|
||||
const methodNode = node.getChild(NODE_TYPES.METHOD);
|
||||
const urlNode = node.getChild(NODE_TYPES.URL);
|
||||
const blockNode = node.getChild(NODE_TYPES.BLOCK);
|
||||
|
||||
// 验证必需节点
|
||||
if (!methodNode || !urlNode || !blockNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const method = this.getNodeText(methodNode).toUpperCase();
|
||||
const url = this.parseUrl(urlNode);
|
||||
|
||||
// 验证 URL 非空
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
let bodyType: HttpRequest['bodyType'] = undefined;
|
||||
let body: any = undefined;
|
||||
|
||||
// 解析 Block
|
||||
this.parseBlock(blockNode, headers, (type, content) => {
|
||||
bodyType = type;
|
||||
body = content;
|
||||
});
|
||||
|
||||
const line = this.state.doc.lineAt(node.from);
|
||||
|
||||
return {
|
||||
method: method as HttpRequest['method'],
|
||||
url,
|
||||
headers,
|
||||
bodyType,
|
||||
body,
|
||||
position: {
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
line: line.number,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 URL 节点
|
||||
*/
|
||||
private parseUrl(node: SyntaxNode): string {
|
||||
const urlText = this.getNodeText(node);
|
||||
// 移除引号
|
||||
const url = urlText.replace(/^["']|["']$/g, '');
|
||||
// 替换变量
|
||||
return this.getVariableResolver().replaceVariables(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Block 节点(包含 headers 和 body)
|
||||
*/
|
||||
private parseBlock(
|
||||
node: SyntaxNode,
|
||||
headers: Record<string, string>,
|
||||
onBody: (type: HttpRequest['bodyType'], content: any) => void
|
||||
): void {
|
||||
// 遍历 Block 的子节点
|
||||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||||
if (child.name === NODE_TYPES.PROPERTY) {
|
||||
// HTTP Header 属性
|
||||
const { name, value } = this.parseProperty(child);
|
||||
if (name && value !== null) {
|
||||
headers[name] = value;
|
||||
}
|
||||
} else if (child.name === NODE_TYPES.AT_RULE) {
|
||||
// AtRule 节点,直接获取第一个子节点(JsonRule, FormDataRule等)
|
||||
const ruleChild = child.firstChild;
|
||||
if (ruleChild) {
|
||||
const { type, content } = this.parseBodyRule(ruleChild);
|
||||
if (type) { // 只有有效的类型才处理
|
||||
onBody(type, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析请求体规则
|
||||
*/
|
||||
private parseBodyRule(node: SyntaxNode): { type: HttpRequest['bodyType']; content: any } {
|
||||
// 类型映射表
|
||||
const typeMap: Record<string, HttpRequest['bodyType']> = {
|
||||
[NODE_TYPES.JSON_RULE]: 'json',
|
||||
[NODE_TYPES.FORMDATA_RULE]: 'formdata',
|
||||
[NODE_TYPES.URLENCODED_RULE]: 'urlencoded',
|
||||
[NODE_TYPES.TEXT_RULE]: 'text',
|
||||
};
|
||||
|
||||
const type = typeMap[node.name];
|
||||
const blockNode = node.getChild(NODE_TYPES.JSON_BLOCK);
|
||||
|
||||
return {
|
||||
type,
|
||||
content: blockNode ? this.parseJsonBlock(blockNode) : null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JsonBlock(用于 @json, @form, @urlencoded)
|
||||
*/
|
||||
private parseJsonBlock(node: SyntaxNode): any {
|
||||
const result: any = {};
|
||||
|
||||
// 遍历 JsonProperty
|
||||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||||
if (child.name === NODE_TYPES.JSON_PROPERTY) {
|
||||
const { name, value } = this.parseJsonProperty(child);
|
||||
if (name && value !== null) {
|
||||
result[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JsonProperty
|
||||
*/
|
||||
private parseJsonProperty(node: SyntaxNode): { name: string | null; value: any } {
|
||||
// 使用 API 获取属性名
|
||||
const nameNode = node.getChild(NODE_TYPES.PROPERTY_NAME);
|
||||
if (!nameNode) {
|
||||
return { name: null, value: null };
|
||||
}
|
||||
|
||||
const name = this.getNodeText(nameNode);
|
||||
|
||||
// 尝试获取值节点(String, Number, JsonBlock, VariableRef)
|
||||
let value: any = null;
|
||||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||||
if (child.name === NODE_TYPES.STRING_LITERAL ||
|
||||
child.name === NODE_TYPES.NUMBER_LITERAL ||
|
||||
child.name === NODE_TYPES.JSON_BLOCK ||
|
||||
child.name === NODE_TYPES.VARIABLE_REF ||
|
||||
child.name === NODE_TYPES.IDENTIFIER) {
|
||||
value = this.parseValue(child);
|
||||
return { name, value };
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:从文本中提取值(用于 true/false 等标识符)
|
||||
const fullText = this.getNodeText(node);
|
||||
const colonIndex = fullText.indexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
const valueText = fullText.substring(colonIndex + 1).trim().replace(/,$/, '').trim();
|
||||
value = this.parseValueFromText(valueText);
|
||||
}
|
||||
|
||||
return { name, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文本解析值
|
||||
*/
|
||||
private parseValueFromText(text: string): any {
|
||||
// 布尔值
|
||||
if (text === 'true') return true;
|
||||
if (text === 'false') return false;
|
||||
if (text === 'null') return null;
|
||||
|
||||
// 数字
|
||||
if (/^-?\d+(\.\d+)?$/.test(text)) {
|
||||
return parseFloat(text);
|
||||
}
|
||||
|
||||
// 字符串(带引号)
|
||||
if ((text.startsWith('"') && text.endsWith('"')) ||
|
||||
(text.startsWith("'") && text.endsWith("'"))) {
|
||||
return text.slice(1, -1);
|
||||
}
|
||||
|
||||
// 其他标识符
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Property(HTTP Header)
|
||||
*/
|
||||
private parseProperty(node: SyntaxNode): { name: string | null; value: any } {
|
||||
const nameNode = node.getChild(NODE_TYPES.PROPERTY_NAME);
|
||||
if (!nameNode) {
|
||||
return { name: null, value: null };
|
||||
}
|
||||
|
||||
const name = this.getNodeText(nameNode);
|
||||
let value: any = null;
|
||||
|
||||
// 查找值节点(跳过冒号和逗号)
|
||||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||||
if (child.name !== NODE_TYPES.PROPERTY_NAME &&
|
||||
child.name !== ':' &&
|
||||
child.name !== ',') {
|
||||
value = this.parseValue(child);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP Header 的值必须转换为字符串
|
||||
if (value !== null && value !== undefined) {
|
||||
value = String(value);
|
||||
}
|
||||
|
||||
return { name, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析值节点(字符串、数字、标识符、嵌套块、变量引用)
|
||||
*/
|
||||
private parseValue(node: SyntaxNode): any {
|
||||
if (node.name === NODE_TYPES.STRING_LITERAL) {
|
||||
const text = this.getNodeText(node);
|
||||
// 移除引号
|
||||
const value = text.replace(/^["']|["']$/g, '');
|
||||
// 替换字符串中的变量
|
||||
return this.getVariableResolver().replaceVariables(value);
|
||||
} else if (node.name === NODE_TYPES.NUMBER_LITERAL) {
|
||||
const text = this.getNodeText(node);
|
||||
return parseFloat(text);
|
||||
} else if (node.name === NODE_TYPES.VARIABLE_REF) {
|
||||
// 处理变量引用节点
|
||||
const text = this.getNodeText(node);
|
||||
const resolver = this.getVariableResolver();
|
||||
const ref = resolver.parseVariableRef(text);
|
||||
if (ref) {
|
||||
return resolver.resolveVariable(ref.name, ref.defaultValue);
|
||||
}
|
||||
return text;
|
||||
} else if (node.name === NODE_TYPES.IDENTIFIER) {
|
||||
const text = this.getNodeText(node);
|
||||
// 处理布尔值
|
||||
if (text === 'true') return true;
|
||||
if (text === 'false') return false;
|
||||
// 处理 null
|
||||
if (text === 'null') return null;
|
||||
// 其他标识符作为字符串
|
||||
return text;
|
||||
} else if (node.name === NODE_TYPES.JSON_BLOCK) {
|
||||
// 嵌套对象
|
||||
return this.parseJsonBlock(node);
|
||||
} else {
|
||||
// 未知类型,返回原始文本
|
||||
return this.getNodeText(node);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点的文本内容
|
||||
*/
|
||||
private getNodeText(node: SyntaxNode): string {
|
||||
return this.state.doc.sliceString(node.from, node.to);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 便捷函数:解析指定位置的 HTTP 请求
|
||||
* @param state EditorState
|
||||
* @param pos 光标位置
|
||||
* @param blockRange 块的范围(可选),如果提供则只解析该块内的变量
|
||||
*/
|
||||
export function parseHttpRequest(
|
||||
state: EditorState,
|
||||
pos: number,
|
||||
blockRange?: { from: number; to: number }
|
||||
): HttpRequest | null {
|
||||
const parser = new HttpRequestParser(state, blockRange);
|
||||
return parser.parseRequestAt(pos);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { EditorState, ChangeSpec } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import type { SyntaxNode } from '@lezer/common';
|
||||
import { getNoteBlockFromPos } from '../../codeblock/state';
|
||||
|
||||
/**
|
||||
* 响应数据模型
|
||||
*/
|
||||
export interface HttpResponse {
|
||||
/** 状态码和状态文本,如"200 OK" */
|
||||
status: string;
|
||||
|
||||
/** 响应时间(毫秒) */
|
||||
time: number;
|
||||
|
||||
/** 请求大小 */
|
||||
requestSize?: string;
|
||||
|
||||
/** 响应体 */
|
||||
body: any;
|
||||
|
||||
/** 响应头 */
|
||||
headers?: { [_: string]: string[] };
|
||||
|
||||
/** 时间戳 */
|
||||
timestamp?: Date;
|
||||
|
||||
/** 错误信息 */
|
||||
error?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 响应插入器
|
||||
*/
|
||||
export class HttpResponseInserter {
|
||||
constructor(private view: EditorView) {}
|
||||
|
||||
/**
|
||||
* 插入HTTP响应
|
||||
* @param requestPos 请求的起始位置
|
||||
* @param response 响应数据
|
||||
*/
|
||||
insertResponse(requestPos: number, response: HttpResponse): void {
|
||||
const state = this.view.state;
|
||||
|
||||
// 获取当前代码块
|
||||
const blockInfo = getNoteBlockFromPos(state, requestPos);
|
||||
if (!blockInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blockFrom = blockInfo.range.from;
|
||||
const blockTo = blockInfo.range.to;
|
||||
|
||||
// 查找请求节点和旧响应
|
||||
const context = this.findRequestAndResponse(state, requestPos, blockFrom, blockTo);
|
||||
|
||||
if (!context.requestNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成新响应文本
|
||||
const responseText = this.formatResponse(response);
|
||||
|
||||
// 确定插入位置
|
||||
let insertFrom: number;
|
||||
let insertTo: number;
|
||||
|
||||
if (context.oldResponseNode) {
|
||||
// 替换旧响应
|
||||
insertFrom = context.oldResponseNode.from;
|
||||
insertTo = context.oldResponseNode.to;
|
||||
} else {
|
||||
// 在请求后插入新响应
|
||||
// 使用 requestNode.to - 1 定位到请求的最后一个字符所在行
|
||||
const lastCharPos = Math.max(context.requestNode.from, context.requestNode.to - 1);
|
||||
const requestEndLine = state.doc.lineAt(lastCharPos);
|
||||
insertFrom = requestEndLine.to;
|
||||
insertTo = insertFrom;
|
||||
}
|
||||
|
||||
const changes: ChangeSpec = {
|
||||
from: insertFrom,
|
||||
to: insertTo,
|
||||
insert: context.oldResponseNode ? responseText : `\n\n${responseText}`
|
||||
};
|
||||
|
||||
this.view.dispatch({
|
||||
changes,
|
||||
userEvent: 'http.response.insert',
|
||||
selection: { anchor: requestPos },
|
||||
scrollIntoView: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找请求节点和旧响应节点(使用 tree.iterate)
|
||||
*/
|
||||
private findRequestAndResponse(
|
||||
state: EditorState,
|
||||
requestPos: number,
|
||||
blockFrom: number,
|
||||
blockTo: number
|
||||
): {
|
||||
requestNode: SyntaxNode | null;
|
||||
oldResponseNode: { node: SyntaxNode; from: number; to: number } | null;
|
||||
} {
|
||||
const tree = syntaxTree(state);
|
||||
|
||||
let requestNode: SyntaxNode | null = null;
|
||||
let requestNodeTo = -1;
|
||||
let oldResponseNode: { node: SyntaxNode; from: number; to: number } | null = null;
|
||||
let nextRequestFrom = -1;
|
||||
|
||||
// 遍历查找:请求节点、旧响应、下一个请求
|
||||
tree.iterate({
|
||||
from: blockFrom,
|
||||
to: blockTo,
|
||||
enter: (node) => {
|
||||
// 1. 找到包含 requestPos 的 RequestStatement
|
||||
if (node.name === 'RequestStatement' &&
|
||||
node.from <= requestPos &&
|
||||
node.to >= requestPos) {
|
||||
requestNode = node.node;
|
||||
requestNodeTo = node.to;
|
||||
}
|
||||
|
||||
// 2. 找到请求后的第一个 ResponseDeclaration
|
||||
if (requestNode && !oldResponseNode &&
|
||||
node.name === 'ResponseDeclaration' &&
|
||||
node.from >= requestNodeTo) {
|
||||
oldResponseNode = {
|
||||
node: node.node,
|
||||
from: node.from,
|
||||
to: node.to
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 记录下一个请求的起始位置(用于确定响应范围)
|
||||
if (requestNode && nextRequestFrom === -1 &&
|
||||
node.name === 'RequestStatement' &&
|
||||
node.from > requestNodeTo) {
|
||||
nextRequestFrom = node.from;
|
||||
}
|
||||
|
||||
// 4. 早期退出优化:如果已找到请求节点,且满足以下任一条件,则停止遍历
|
||||
// - 找到了旧响应节点
|
||||
// - 找到了下一个请求(说明当前请求没有响应)
|
||||
if (requestNode !== null) {
|
||||
if (oldResponseNode !== null || nextRequestFrom !== -1) {
|
||||
return false; // 停止遍历
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 如果找到了下一个请求,且旧响应超出范围,则清除旧响应
|
||||
if (oldResponseNode && nextRequestFrom !== -1) {
|
||||
// TypeScript 类型收窄问题,使用非空断言
|
||||
if ((oldResponseNode as { from: number; to: number; node: SyntaxNode }).from >= nextRequestFrom) {
|
||||
oldResponseNode = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { requestNode, oldResponseNode };
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化响应数据(新格式:@response)
|
||||
* 格式:@response <status> <time>ms <timestamp> { <json> }
|
||||
* 状态格式:200 或 200-OK(支持完整状态文本)
|
||||
* 错误:@response error 0ms <timestamp> { "error": "..." }
|
||||
*/
|
||||
private formatResponse(response: HttpResponse): string {
|
||||
// 时间戳格式:ISO 8601(YYYY-MM-DDTHH:MM:SS)
|
||||
const timestamp = response.timestamp || new Date();
|
||||
const timestampStr = this.formatTimestampISO(timestamp);
|
||||
|
||||
// 格式化响应体为 JSON
|
||||
let bodyJson: string;
|
||||
if (response.error) {
|
||||
// 错误响应
|
||||
bodyJson = JSON.stringify({ error: String(response.error) }, null, 2);
|
||||
} else if (typeof response.body === 'string') {
|
||||
// 尝试解析 JSON 字符串
|
||||
try {
|
||||
const parsed = JSON.parse(response.body);
|
||||
bodyJson = JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
// 如果不是 JSON,包装为对象
|
||||
bodyJson = JSON.stringify({ data: response.body }, null, 2);
|
||||
}
|
||||
} else if (response.body === null || response.body === undefined) {
|
||||
// 空响应
|
||||
bodyJson = '{}';
|
||||
} else {
|
||||
// 对象或数组
|
||||
bodyJson = JSON.stringify(response.body, null, 2);
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
if (response.error) {
|
||||
// 错误格式:@response error 0ms <timestamp> { ... }
|
||||
return `@response error 0ms ${timestampStr} ${bodyJson}`;
|
||||
} else {
|
||||
// 成功格式:@response <status> <time>ms <timestamp> { ... }
|
||||
// 支持完整状态:200-OK 或 200
|
||||
const statusDisplay = this.formatStatus(response.status);
|
||||
return `@response ${statusDisplay} ${response.time}ms ${timestampStr} ${bodyJson}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化状态码显示
|
||||
* 输入:"200 OK" 或 "404 Not Found" 或 "200"
|
||||
* 输出:"200-OK" 或 "404-Not-Found" 或 "200"
|
||||
*/
|
||||
private formatStatus(status: string): string {
|
||||
// 提取状态码和状态文本
|
||||
const parts = status.trim().split(/\s+/);
|
||||
|
||||
if (parts.length === 1) {
|
||||
// 只有状态码:200
|
||||
return parts[0];
|
||||
} else {
|
||||
// 有状态码和文本:200 OK -> 200-OK
|
||||
const code = parts[0];
|
||||
const text = parts.slice(1).join('-');
|
||||
return `${code}-${text}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间戳为 ISO 8601 格式(YYYY-MM-DDTHH:MM:SS)
|
||||
*/
|
||||
private formatTimestampISO(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 便捷函数:插入响应数据
|
||||
*/
|
||||
export function insertHttpResponse(view: EditorView, requestPos: number, response: HttpResponse): void {
|
||||
const inserter = new HttpResponseInserter(view);
|
||||
inserter.insertResponse(requestPos, response);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { httpLanguage } from '../language/http-language';
|
||||
import { VariableResolver } from './variable-resolver';
|
||||
import { HttpRequestParser } from './request-parser';
|
||||
|
||||
/**
|
||||
* 创建测试用的 EditorState
|
||||
*/
|
||||
function createTestState(content: string): EditorState {
|
||||
return EditorState.create({
|
||||
doc: content,
|
||||
extensions: [httpLanguage],
|
||||
});
|
||||
}
|
||||
|
||||
describe('VariableResolver 测试', () => {
|
||||
|
||||
it('✅ 解析 @var 声明(无块范围)', () => {
|
||||
const content = `@var {
|
||||
baseUrl: "https://api.example.com",
|
||||
version: "v1",
|
||||
timeout: 30000
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
const resolver = new VariableResolver(state);
|
||||
const variables = resolver.getAllVariables();
|
||||
|
||||
expect(variables.get('baseUrl')).toBe('https://api.example.com');
|
||||
expect(variables.get('version')).toBe('v1');
|
||||
expect(variables.get('timeout')).toBe(30000);
|
||||
});
|
||||
|
||||
it('✅ 解析 @var 声明(指定块范围)', () => {
|
||||
const content = `@var {
|
||||
baseUrl: "https://api.example.com",
|
||||
version: "v1",
|
||||
timeout: 30000
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
// 指定块范围(整个内容)
|
||||
const blockRange = { from: 0, to: content.length };
|
||||
const resolver = new VariableResolver(state, blockRange);
|
||||
const variables = resolver.getAllVariables();
|
||||
|
||||
expect(variables.get('baseUrl')).toBe('https://api.example.com');
|
||||
expect(variables.get('version')).toBe('v1');
|
||||
expect(variables.get('timeout')).toBe(30000);
|
||||
});
|
||||
|
||||
it('✅ 解析变量引用', () => {
|
||||
const resolver = new VariableResolver(createTestState(''));
|
||||
|
||||
const ref1 = resolver.parseVariableRef('{{baseUrl}}');
|
||||
expect(ref1).toEqual({
|
||||
name: 'baseUrl',
|
||||
defaultValue: undefined,
|
||||
raw: '{{baseUrl}}',
|
||||
});
|
||||
|
||||
const ref2 = resolver.parseVariableRef('{{token:default-token}}');
|
||||
expect(ref2).toEqual({
|
||||
name: 'token',
|
||||
defaultValue: 'default-token',
|
||||
raw: '{{token:default-token}}',
|
||||
});
|
||||
});
|
||||
|
||||
it('✅ 替换字符串中的变量', () => {
|
||||
const content = `@var {
|
||||
baseUrl: "https://api.example.com",
|
||||
version: "v1"
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
const resolver = new VariableResolver(state);
|
||||
|
||||
const result = resolver.replaceVariables('{{baseUrl}}/{{version}}/users');
|
||||
expect(result).toBe('https://api.example.com/v1/users');
|
||||
});
|
||||
|
||||
it('✅ 使用默认值', () => {
|
||||
const content = `@var {
|
||||
baseUrl: "https://api.example.com"
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
const resolver = new VariableResolver(state);
|
||||
|
||||
const result = resolver.replaceVariables('{{baseUrl}}/{{version:v1}}/users');
|
||||
expect(result).toBe('https://api.example.com/v1/users');
|
||||
});
|
||||
|
||||
it('✅ 变量未定义且无默认值,保持原样', () => {
|
||||
const state = createTestState('');
|
||||
const resolver = new VariableResolver(state);
|
||||
|
||||
const result = resolver.replaceVariables('{{undefined}}/users');
|
||||
expect(result).toBe('{{undefined}}/users');
|
||||
});
|
||||
|
||||
it('✅ 嵌套对象变量 - 路径访问', () => {
|
||||
const content = `@var {
|
||||
config: {
|
||||
nested: {
|
||||
deep: {
|
||||
value: 123
|
||||
}
|
||||
}
|
||||
},
|
||||
simple: "test"
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
const resolver = new VariableResolver(state);
|
||||
|
||||
// 测试简单变量
|
||||
expect(resolver.resolveVariable('simple')).toBe('test');
|
||||
|
||||
// 测试嵌套路径访问
|
||||
expect(resolver.resolveVariable('config.nested.deep.value')).toBe(123);
|
||||
|
||||
// 测试部分路径访问
|
||||
const nestedObj = resolver.resolveVariable('config.nested');
|
||||
expect(nestedObj).toEqual({ deep: { value: 123 } });
|
||||
|
||||
// 测试不存在的路径
|
||||
expect(resolver.resolveVariable('config.notExist', 'default')).toBe('default');
|
||||
|
||||
// 测试字符串替换
|
||||
const result = resolver.replaceVariables('Value is {{config.nested.deep.value}}');
|
||||
expect(result).toBe('Value is 123');
|
||||
});
|
||||
|
||||
it('✅ 嵌套对象变量 - 整个对象引用', () => {
|
||||
const content = `@var {
|
||||
config: {
|
||||
host: "example.com",
|
||||
port: 8080
|
||||
}
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
const resolver = new VariableResolver(state);
|
||||
|
||||
// 引用整个对象
|
||||
const configObj = resolver.resolveVariable('config');
|
||||
expect(configObj).toEqual({
|
||||
host: 'example.com',
|
||||
port: 8080
|
||||
});
|
||||
|
||||
// 字符串中引用对象会转换为 JSON
|
||||
const result = resolver.replaceVariables('Config: {{config}}');
|
||||
expect(result).toBe('Config: {"host":"example.com","port":8080}');
|
||||
});
|
||||
|
||||
it('✅ 块范围限制 - 只解析块内的变量', () => {
|
||||
// 模拟多块文档
|
||||
const content = `@var {
|
||||
block1Var: "value1"
|
||||
}
|
||||
|
||||
GET "http://example.com" {}
|
||||
|
||||
--- 块分隔 ---
|
||||
|
||||
@var {
|
||||
block2Var: "value2"
|
||||
}
|
||||
|
||||
POST "http://example.com" {}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
|
||||
// 第一个块:只包含 block1Var
|
||||
const block1Range = { from: 0, to: 60 };
|
||||
const resolver1 = new VariableResolver(state, block1Range);
|
||||
expect(resolver1.getAllVariables().get('block1Var')).toBe('value1');
|
||||
expect(resolver1.getAllVariables().get('block2Var')).toBeUndefined();
|
||||
|
||||
// 第二个块:只包含 block2Var
|
||||
const block2Start = content.indexOf('@var {', 60);
|
||||
const block2Range = { from: block2Start, to: content.length };
|
||||
const resolver2 = new VariableResolver(state, block2Range);
|
||||
expect(resolver2.getAllVariables().get('block1Var')).toBeUndefined();
|
||||
expect(resolver2.getAllVariables().get('block2Var')).toBe('value2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HttpRequestParser 集成变量测试', () => {
|
||||
|
||||
it('✅ 解析带变量的 URL', () => {
|
||||
const content = `@var {
|
||||
baseUrl: "https://api.example.com",
|
||||
version: "v1"
|
||||
}
|
||||
|
||||
GET "{{baseUrl}}/{{version}}/users" {
|
||||
host: "example.com"
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
const parser = new HttpRequestParser(state);
|
||||
|
||||
// 查找 GET 请求的位置
|
||||
const getPos = content.indexOf('GET');
|
||||
const request = parser.parseRequestAt(getPos);
|
||||
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.url).toBe('https://api.example.com/v1/users');
|
||||
});
|
||||
|
||||
it('✅ 解析 HTTP 头中的变量', () => {
|
||||
const content = `@var {
|
||||
token: "Bearer abc123",
|
||||
timeout: 5000
|
||||
}
|
||||
|
||||
GET "http://example.com" {
|
||||
authorization: {{token}},
|
||||
timeout: {{timeout}}
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
const parser = new HttpRequestParser(state);
|
||||
|
||||
const getPos = content.indexOf('GET');
|
||||
const request = parser.parseRequestAt(getPos);
|
||||
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.headers.authorization).toBe('Bearer abc123');
|
||||
// HTTP Header 值会被转换为字符串
|
||||
expect(request?.headers.timeout).toBe('5000');
|
||||
});
|
||||
|
||||
it('✅ 解析 JSON 请求体中的变量', () => {
|
||||
const content = `@var {
|
||||
userId: "12345",
|
||||
userName: "张三"
|
||||
}
|
||||
|
||||
POST "http://api.example.com/users" {
|
||||
@json {
|
||||
id: {{userId}},
|
||||
name: {{userName}}
|
||||
}
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
const parser = new HttpRequestParser(state);
|
||||
|
||||
const postPos = content.indexOf('POST');
|
||||
const request = parser.parseRequestAt(postPos);
|
||||
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.body).toEqual({
|
||||
id: '12345',
|
||||
name: '张三',
|
||||
});
|
||||
});
|
||||
|
||||
it('✅ 字符串中嵌入变量', () => {
|
||||
const content = `@var {
|
||||
userName: "张三"
|
||||
}
|
||||
|
||||
POST "http://api.example.com/users" {
|
||||
@json {
|
||||
name: {{userName}},
|
||||
email: "{{userName}}@example.com"
|
||||
}
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
const parser = new HttpRequestParser(state);
|
||||
|
||||
const postPos = content.indexOf('POST');
|
||||
const request = parser.parseRequestAt(postPos);
|
||||
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.body).toEqual({
|
||||
name: '张三',
|
||||
email: '张三@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('✅ 使用变量默认值', () => {
|
||||
const content = `@var {
|
||||
baseUrl: "https://api.example.com"
|
||||
}
|
||||
|
||||
GET "{{baseUrl}}/users" {
|
||||
timeout: {{timeout:30000}},
|
||||
authorization: "Bearer {{token:default-token}}"
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
const parser = new HttpRequestParser(state);
|
||||
|
||||
const getPos = content.indexOf('GET');
|
||||
const request = parser.parseRequestAt(getPos);
|
||||
|
||||
expect(request).not.toBeNull();
|
||||
// HTTP Header 值会被转换为字符串
|
||||
expect(request?.headers.timeout).toBe('30000');
|
||||
expect(request?.headers.authorization).toBe('Bearer default-token');
|
||||
});
|
||||
|
||||
it('✅ 嵌套变量在 HTTP 请求中使用', () => {
|
||||
const content = `@var {
|
||||
api: {
|
||||
base: "https://api.example.com",
|
||||
version: "v1",
|
||||
endpoints: {
|
||||
users: "/users",
|
||||
posts: "/posts"
|
||||
}
|
||||
},
|
||||
config: {
|
||||
timeout: 5000
|
||||
}
|
||||
}
|
||||
|
||||
GET "{{api.base}}/{{api.version}}{{api.endpoints.users}}" {
|
||||
timeout: {{config.timeout}}
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
const parser = new HttpRequestParser(state);
|
||||
|
||||
const getPos = content.indexOf('GET');
|
||||
const request = parser.parseRequestAt(getPos);
|
||||
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.url).toBe('https://api.example.com/v1/users');
|
||||
expect(request?.headers.timeout).toBe('5000');
|
||||
});
|
||||
|
||||
it('✅ 完整示例 - 用户提供的场景', () => {
|
||||
const content = `@var {
|
||||
baseUrl: "https://api.example.com",
|
||||
version: "v1",
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
GET "{{baseUrl}}/{{version}}/users" {
|
||||
timeout: {{timeout}},
|
||||
authorization: "Bearer {{token:default-token}}"
|
||||
}`;
|
||||
|
||||
const state = createTestState(content);
|
||||
const parser = new HttpRequestParser(state);
|
||||
|
||||
const getPos = content.indexOf('GET');
|
||||
const request = parser.parseRequestAt(getPos);
|
||||
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.url).toBe('https://api.example.com/v1/users');
|
||||
// HTTP Header 值会被转换为字符串(Go 后端要求)
|
||||
expect(request?.headers.timeout).toBe('30000');
|
||||
expect(request?.headers.authorization).toBe('Bearer default-token');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import type { SyntaxNode } from '@lezer/common';
|
||||
|
||||
/**
|
||||
* 变量引用模型
|
||||
*/
|
||||
export interface VariableReference {
|
||||
/** 变量名 */
|
||||
name: string;
|
||||
/** 默认值(可选) */
|
||||
defaultValue?: string;
|
||||
/** 原始文本 */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点类型常量
|
||||
*/
|
||||
const NODE_TYPES = {
|
||||
VAR_DECLARATION: 'VarDeclaration',
|
||||
VAR_KEYWORD: 'VarKeyword',
|
||||
JSON_BLOCK: 'JsonBlock',
|
||||
JSON_PROPERTY: 'JsonProperty',
|
||||
PROPERTY_NAME: 'PropertyName',
|
||||
STRING_LITERAL: 'StringLiteral',
|
||||
NUMBER_LITERAL: 'NumberLiteral',
|
||||
IDENTIFIER: 'identifier',
|
||||
VARIABLE_REF: 'VariableRef',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 变量解析器
|
||||
* 负责解析 @var 块和变量引用(块级作用域)
|
||||
*/
|
||||
export class VariableResolver {
|
||||
/** 变量存储 */
|
||||
private variables: Map<string, any> = new Map();
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param state EditorState
|
||||
* @param blockRange 块的范围(可选),如果提供则只解析该范围内的变量
|
||||
*/
|
||||
constructor(
|
||||
private state: EditorState,
|
||||
private blockRange?: { from: number; to: number }
|
||||
) {
|
||||
// 初始化时解析变量定义
|
||||
this.parseBlockVariables();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析块范围内的 @var 声明
|
||||
* 如果提供了 blockRange,只解析该范围内的变量
|
||||
* 否则解析整个文档(向后兼容)
|
||||
*/
|
||||
private parseBlockVariables(): void {
|
||||
const tree = syntaxTree(this.state);
|
||||
|
||||
tree.iterate({
|
||||
enter: (node: any) => {
|
||||
// 如果指定了块范围,检查节点是否在范围内
|
||||
if (this.blockRange) {
|
||||
// 节点完全在块范围外,跳过
|
||||
if (node.to < this.blockRange.from || node.from > this.blockRange.to) {
|
||||
return false; // 停止遍历此分支
|
||||
}
|
||||
}
|
||||
|
||||
if (node.name === NODE_TYPES.VAR_DECLARATION) {
|
||||
this.parseVarDeclaration(node.node);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析单个 @var 声明
|
||||
*/
|
||||
private parseVarDeclaration(node: SyntaxNode): void {
|
||||
// 获取 JsonBlock 节点
|
||||
const jsonBlockNode = node.getChild(NODE_TYPES.JSON_BLOCK);
|
||||
if (!jsonBlockNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析 JsonBlock 中的所有属性
|
||||
const variables = this.parseJsonBlock(jsonBlockNode);
|
||||
|
||||
// 存储变量
|
||||
for (const [name, value] of Object.entries(variables)) {
|
||||
this.variables.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JsonBlock
|
||||
*/
|
||||
private parseJsonBlock(node: SyntaxNode): Record<string, any> {
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
// 遍历所有 JsonProperty 子节点
|
||||
let child = node.firstChild;
|
||||
while (child) {
|
||||
if (child.name === NODE_TYPES.JSON_PROPERTY) {
|
||||
const { name, value } = this.parseJsonProperty(child);
|
||||
if (name) {
|
||||
result[name] = value;
|
||||
}
|
||||
}
|
||||
child = child.nextSibling;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JsonProperty
|
||||
*/
|
||||
private parseJsonProperty(node: SyntaxNode): { name: string | null; value: any } {
|
||||
// 获取属性名节点
|
||||
const nameNode = node.getChild(NODE_TYPES.PROPERTY_NAME);
|
||||
if (!nameNode) {
|
||||
return { name: null, value: null };
|
||||
}
|
||||
|
||||
const name = this.getNodeText(nameNode);
|
||||
|
||||
// 获取值节点
|
||||
let valueNode = nameNode.nextSibling;
|
||||
|
||||
// 跳过冒号
|
||||
while (valueNode && valueNode.name === ':') {
|
||||
valueNode = valueNode.nextSibling;
|
||||
}
|
||||
|
||||
if (!valueNode) {
|
||||
return { name, value: null };
|
||||
}
|
||||
|
||||
const value = this.parseValue(valueNode);
|
||||
return { name, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析值节点
|
||||
*/
|
||||
private parseValue(node: SyntaxNode): any {
|
||||
switch (node.name) {
|
||||
case NODE_TYPES.STRING_LITERAL: {
|
||||
const text = this.getNodeText(node);
|
||||
// 移除引号
|
||||
return text.replace(/^["']|["']$/g, '');
|
||||
}
|
||||
|
||||
case NODE_TYPES.NUMBER_LITERAL: {
|
||||
const text = this.getNodeText(node);
|
||||
return parseFloat(text);
|
||||
}
|
||||
|
||||
case NODE_TYPES.IDENTIFIER: {
|
||||
const text = this.getNodeText(node);
|
||||
// 处理布尔值和 null
|
||||
if (text === 'true') return true;
|
||||
if (text === 'false') return false;
|
||||
if (text === 'null') return null;
|
||||
return text;
|
||||
}
|
||||
|
||||
case NODE_TYPES.JSON_BLOCK: {
|
||||
// 嵌套对象
|
||||
return this.parseJsonBlock(node);
|
||||
}
|
||||
|
||||
default:
|
||||
// 其他类型返回原始文本
|
||||
return this.getNodeText(node);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析变量引用
|
||||
* 从 {{variableName}} 或 {{variableName:default}} 或 {{obj.nested.path}} 中提取信息
|
||||
*/
|
||||
public parseVariableRef(text: string): VariableReference | null {
|
||||
// 正则匹配:
|
||||
// - {{variableName}}
|
||||
// - {{variableName:default}}
|
||||
// - {{obj.nested.path}}
|
||||
// - {{obj.nested.path:default}}
|
||||
const match = text.match(/^\{\{([a-zA-Z_$][a-zA-Z0-9_$.-]*)(:(.*))?\}\}$/);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: match[1],
|
||||
defaultValue: match[3],
|
||||
raw: text,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析字符串中的所有变量引用
|
||||
* 例如: "{{baseUrl}}/{{version}}/users" 或 "{{config.nested.value}}"
|
||||
*/
|
||||
public parseVariableRefsInString(text: string): VariableReference[] {
|
||||
const refs: VariableReference[] = [];
|
||||
// 支持路径访问:允许变量名中包含 "."
|
||||
const regex = /\{\{([a-zA-Z_$][a-zA-Z0-9_$.-]*)(:[^}]+)?\}\}/g;
|
||||
|
||||
let match;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
refs.push({
|
||||
name: match[1],
|
||||
defaultValue: match[2] ? match[2].substring(1) : undefined, // 去掉冒号
|
||||
raw: match[0],
|
||||
});
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析变量值(支持路径访问)
|
||||
* @param name 变量名,支持路径访问如 "config.nested.deep.value"
|
||||
* @param defaultValue 默认值
|
||||
* @returns 解析后的值
|
||||
*/
|
||||
public resolveVariable(name: string, defaultValue?: string): any {
|
||||
// 检查是否包含路径访问符 "."
|
||||
if (name.includes('.')) {
|
||||
return this.resolveNestedVariable(name, defaultValue);
|
||||
}
|
||||
|
||||
// 简单变量名直接查找
|
||||
if (this.variables.has(name)) {
|
||||
return this.variables.get(name);
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析嵌套变量(路径访问)
|
||||
* @param path 变量路径,如 "config.nested.deep.value"
|
||||
* @param defaultValue 默认值
|
||||
* @returns 解析后的值
|
||||
*/
|
||||
private resolveNestedVariable(path: string, defaultValue?: string): any {
|
||||
const parts = path.split('.');
|
||||
const rootName = parts[0];
|
||||
|
||||
// 获取根变量
|
||||
if (!this.variables.has(rootName)) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
let current: any = this.variables.get(rootName);
|
||||
|
||||
// 遍历路径访问嵌套属性
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const key = parts[i];
|
||||
|
||||
// 检查当前值是否是对象
|
||||
if (current === null || current === undefined || typeof current !== 'object') {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// 访问嵌套属性
|
||||
if (!(key in current)) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换字符串中的所有变量引用
|
||||
* 支持:
|
||||
* - 简单变量: "{{baseUrl}}/{{version}}/users"
|
||||
* - 路径访问: "{{config.nested.deep.value}}"
|
||||
* - 默认值: "{{timeout:30000}}"
|
||||
*/
|
||||
public replaceVariables(text: string): string {
|
||||
return text.replace(
|
||||
/\{\{([a-zA-Z_$][a-zA-Z0-9_$.-]*)(:[^}]+)?\}\}/g,
|
||||
(match, name, defaultPart) => {
|
||||
const defaultValue = defaultPart ? defaultPart.substring(1) : undefined;
|
||||
const value = this.resolveVariable(name, defaultValue);
|
||||
|
||||
// 如果值是对象或数组,转换为 JSON 字符串
|
||||
if (value !== undefined && value !== null) {
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
// 如果没有找到变量,保持原样
|
||||
return match;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已定义的变量
|
||||
*/
|
||||
public getAllVariables(): Map<string, any> {
|
||||
return new Map(this.variables);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点的文本内容
|
||||
*/
|
||||
private getNodeText(node: SyntaxNode): string {
|
||||
return this.state.doc.sliceString(node.from, node.to);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { EditorView, GutterMarker, gutter } from '@codemirror/view';
|
||||
import { StateField } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { blockState } from '../../codeblock/state';
|
||||
import { parseHttpRequest, type HttpRequest } from '../parser/request-parser';
|
||||
import { insertHttpResponse, type HttpResponse } from '../parser/response-inserter';
|
||||
import { createDebounce } from '@/common/utils/debounce';
|
||||
import { ExecuteRequest } from '@/../bindings/voidraft/internal/services/httpclientservice';
|
||||
|
||||
/**
|
||||
* 语法树节点类型常量
|
||||
*/
|
||||
const NODE_TYPES = {
|
||||
REQUEST_STATEMENT: 'RequestStatement',
|
||||
METHOD: 'Method',
|
||||
URL: 'Url',
|
||||
BLOCK: 'Block',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* HTTP 请求缓存信息
|
||||
*/
|
||||
interface CachedHttpRequest {
|
||||
lineNumber: number; // 行号(用于快速查找)
|
||||
position: number; // 字符位置(用于解析)
|
||||
request: HttpRequest; // 完整的解析结果
|
||||
}
|
||||
|
||||
/**
|
||||
* 预解析所有 HTTP 块中的请求
|
||||
* 只在文档改变时调用,结果缓存在 StateField 中
|
||||
*
|
||||
* 优化:一次遍历完成验证和解析,避免重复工作
|
||||
*/
|
||||
function parseHttpRequests(state: any): Map<number, CachedHttpRequest> {
|
||||
const requestsMap = new Map<number, CachedHttpRequest>();
|
||||
const blocks = state.field(blockState, false);
|
||||
|
||||
if (!blocks) return requestsMap;
|
||||
|
||||
const tree = syntaxTree(state);
|
||||
|
||||
// 只遍历 HTTP 块
|
||||
for (const block of blocks) {
|
||||
if (block.language.name !== 'http') continue;
|
||||
|
||||
// 在块范围内查找所有 RequestStatement
|
||||
tree.iterate({
|
||||
from: block.content.from,
|
||||
to: block.content.to,
|
||||
enter: (node) => {
|
||||
if (node.name === NODE_TYPES.REQUEST_STATEMENT) {
|
||||
// 检查是否包含错误节点
|
||||
let hasError = false;
|
||||
node.node.cursor().iterate((nodeRef) => {
|
||||
if (nodeRef.name === '⚠') {
|
||||
hasError = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasError) return;
|
||||
|
||||
// 直接解析请求
|
||||
const request = parseHttpRequest(state, node.from,{from: block.content.from, to: block.content.to});
|
||||
|
||||
if (request) {
|
||||
const line = state.doc.lineAt(request.position.from);
|
||||
requestsMap.set(line.number, {
|
||||
lineNumber: line.number,
|
||||
position: request.position.from,
|
||||
request: request,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return requestsMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* StateField:缓存所有 HTTP 请求的完整解析结果
|
||||
* 只在文档改变时重新解析
|
||||
*/
|
||||
const httpRequestsField = StateField.define<Map<number, CachedHttpRequest>>({
|
||||
create(state) {
|
||||
return parseHttpRequests(state);
|
||||
},
|
||||
|
||||
update(requests, transaction) {
|
||||
// 只有文档改变或缓存为空时才重新解析
|
||||
if (transaction.docChanged || requests.size === 0) {
|
||||
return parseHttpRequests(transaction.state);
|
||||
}
|
||||
return requests;
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 运行按钮 Marker ====================
|
||||
|
||||
/**
|
||||
* 运行按钮 Gutter Marker
|
||||
*/
|
||||
class RunButtonMarker extends GutterMarker {
|
||||
private isLoading = false;
|
||||
private buttonElement: HTMLButtonElement | null = null;
|
||||
private readonly debouncedExecute: ((view: EditorView) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly cachedRequest: HttpRequest
|
||||
) {
|
||||
super();
|
||||
|
||||
// 创建防抖执行函数
|
||||
const { debouncedFn } = createDebounce(
|
||||
(view: EditorView) => this.executeRequestInternal(view),
|
||||
{ delay: 500 }
|
||||
);
|
||||
this.debouncedExecute = debouncedFn;
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'cm-http-run-button';
|
||||
button.innerHTML = '▶';
|
||||
button.title = 'Run HTTP Request';
|
||||
button.setAttribute('aria-label', 'Run HTTP Request');
|
||||
|
||||
this.buttonElement = button;
|
||||
|
||||
button.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (this.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.executeRequest(view);
|
||||
};
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
private executeRequest(view: EditorView) {
|
||||
if (this.debouncedExecute) {
|
||||
this.debouncedExecute(view);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeRequestInternal(view: EditorView) {
|
||||
if (this.isLoading) return;
|
||||
|
||||
this.setLoadingState(true);
|
||||
try {
|
||||
const response = await ExecuteRequest(this.cachedRequest);
|
||||
if (!response) {
|
||||
throw new Error('No response');
|
||||
}
|
||||
|
||||
// 转换后端响应为前端格式
|
||||
const httpResponse: HttpResponse = {
|
||||
status: response.status, // 后端已返回完整状态如"200 OK"
|
||||
time: response.time,
|
||||
requestSize: response.requestSize,
|
||||
body: response.body,
|
||||
headers: response.headers,
|
||||
timestamp: response.timestamp ? new Date(response.timestamp) : new Date(),
|
||||
error: response.error
|
||||
};
|
||||
|
||||
// 插入响应数据
|
||||
insertHttpResponse(view, this.cachedRequest.position.from, httpResponse);
|
||||
} catch (error) {
|
||||
// 创建错误响应
|
||||
const errorResponse: HttpResponse = {
|
||||
status: 'Request Failed',
|
||||
time: 0,
|
||||
body: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
timestamp: new Date(),
|
||||
error: error
|
||||
};
|
||||
|
||||
// 插入错误响应
|
||||
insertHttpResponse(view, this.cachedRequest.position.from, errorResponse);
|
||||
} finally {
|
||||
this.setLoadingState(false);
|
||||
}
|
||||
}
|
||||
|
||||
private setLoadingState(loading: boolean) {
|
||||
this.isLoading = loading;
|
||||
|
||||
if (this.buttonElement) {
|
||||
if (loading) {
|
||||
this.buttonElement.className = 'cm-http-run-button cm-http-run-button-loading';
|
||||
this.buttonElement.title = 'Request in progress...';
|
||||
this.buttonElement.disabled = true;
|
||||
} else {
|
||||
this.buttonElement.className = 'cm-http-run-button';
|
||||
this.buttonElement.title = 'Run HTTP Request';
|
||||
this.buttonElement.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 创建运行按钮 Gutter
|
||||
*/
|
||||
export const httpRunButtonGutter = gutter({
|
||||
class: 'cm-http-gutter',
|
||||
|
||||
lineMarker(view, line) {
|
||||
// O(1) 查找:从缓存中获取请求
|
||||
const requestsMap = view.state.field(httpRequestsField, false);
|
||||
if (!requestsMap) return null;
|
||||
|
||||
const lineNumber = view.state.doc.lineAt(line.from).number;
|
||||
const cached = requestsMap.get(lineNumber);
|
||||
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 创建并返回运行按钮,传递缓存的解析结果
|
||||
return new RunButtonMarker(cached.request);
|
||||
},
|
||||
});
|
||||
|
||||
export const httpRunButtonTheme = EditorView.baseTheme({
|
||||
// 运行按钮样式
|
||||
'.cm-http-run-button': {
|
||||
// width: '18px',
|
||||
// height: '18px',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#4CAF50', // 绿色三角
|
||||
// fontSize: '13px',
|
||||
// lineHeight: '16px',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0',
|
||||
transition: 'color 0.15s ease, opacity 0.15s ease',
|
||||
},
|
||||
|
||||
// 悬停效果
|
||||
'.cm-http-run-button:hover': {
|
||||
color: '#45a049', // 深绿色
|
||||
// backgroundColor: 'rgba(76, 175, 80, 0.1)', // 淡绿色背景
|
||||
},
|
||||
|
||||
// 激活效果
|
||||
'.cm-http-run-button:active': {
|
||||
color: '#3d8b40',
|
||||
// backgroundColor: 'rgba(76, 175, 80, 0.2)',
|
||||
},
|
||||
|
||||
// 加载状态样式
|
||||
'.cm-http-run-button-loading': {
|
||||
color: '#999999 !important', // 灰色
|
||||
cursor: 'not-allowed !important',
|
||||
opacity: '0.6',
|
||||
},
|
||||
|
||||
// 禁用悬停效果当加载时
|
||||
'.cm-http-run-button-loading:hover': {
|
||||
color: '#999999 !important',
|
||||
},
|
||||
});
|
||||
|
||||
// 导出 StateField 供扩展系统使用
|
||||
export { httpRequestsField };
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@codemirror/view';
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import * as runtime from "@wailsio/runtime";
|
||||
import { getNoteBlockFromPos } from '../codeblock/state';
|
||||
const pathStr = `<svg viewBox="0 0 1024 1024" width="16" height="16" fill="currentColor"><path d="M607.934444 417.856853c-6.179746-6.1777-12.766768-11.746532-19.554358-16.910135l-0.01228 0.011256c-6.986111-6.719028-16.47216-10.857279-26.930349-10.857279-21.464871 0-38.864146 17.400299-38.864146 38.864146 0 9.497305 3.411703 18.196431 9.071609 24.947182l-0.001023 0c0.001023 0.001023 0.00307 0.00307 0.005117 0.004093 2.718925 3.242857 5.953595 6.03853 9.585309 8.251941 3.664459 3.021823 7.261381 5.997598 10.624988 9.361205l3.203972 3.204995c40.279379 40.229237 28.254507 109.539812-12.024871 149.820214L371.157763 796.383956c-40.278355 40.229237-105.761766 40.229237-146.042167 0l-3.229554-3.231601c-40.281425-40.278355-40.281425-105.809861 0-145.991002l75.93546-75.909877c9.742898-7.733125 15.997346-19.668968 15.997346-33.072233 0-23.312962-18.898419-42.211381-42.211381-42.211381-8.797363 0-16.963347 2.693342-23.725354 7.297197-0.021489-0.045025-0.044002-0.088004-0.066515-0.134053l-0.809435 0.757247c-2.989077 2.148943-5.691629 4.669346-8.025791 7.510044l-78.913281 73.841775c-74.178443 74.229608-74.178443 195.632609 0 269.758863l3.203972 3.202948c74.178443 74.127278 195.529255 74.127278 269.707698 0l171.829484-171.880649c74.076112-74.17435 80.357166-191.184297 6.282077-265.311575L607.934444 417.856853z"></path><path d="M855.61957 165.804257l-3.203972-3.203972c-74.17742-74.178443-195.528232-74.178443-269.706675 0L410.87944 334.479911c-74.178443 74.178443-78.263481 181.296089-4.085038 255.522628l3.152806 3.104711c3.368724 3.367701 6.865361 6.54302 10.434653 9.588379 2.583848 2.885723 5.618974 5.355985 8.992815 7.309476 0.025583 0.020466 0.052189 0.041956 0.077771 0.062422l0.011256-0.010233c5.377474 3.092431 11.608386 4.870938 18.257829 4.870938 20.263509 0 36.68962-16.428158 36.68962-36.68962 0-5.719258-1.309832-11.132548-3.645017-15.95846l0 0c-4.850471-10.891048-13.930267-17.521049-20.210297-23.802102l-3.15383-3.102664c-40.278355-40.278355-24.982998-98.79612 15.295358-139.074476l171.930791-171.830507c40.179095-40.280402 105.685018-40.280402 145.965419 0l3.206018 3.152806c40.279379 40.281425 40.279379 105.838513 0 146.06775l-75.686796 75.737962c-10.296507 7.628748-16.97358 19.865443-16.97358 33.662681 0 23.12365 18.745946 41.87062 41.87062 41.87062 8.048303 0 15.563464-2.275833 21.944801-6.211469 0.048095 0.081864 0.093121 0.157589 0.141216 0.240477l1.173732-1.083681c3.616364-2.421142 6.828522-5.393847 9.529027-8.792247l79.766718-73.603345C929.798013 361.334535 929.798013 239.981676 855.61957 165.804257z"></path></svg>`;
|
||||
const defaultRegexp = /\b((?:https?|ftp):\/\/[^\s/$.?#].[^\s]*)\b/gi;
|
||||
|
||||
@@ -53,6 +54,13 @@ function hyperLinkDecorations(view: EditorView, anchor?: HyperLinkExtensionOptio
|
||||
const from = match.index;
|
||||
const to = from + match[0].length;
|
||||
|
||||
// 检查当前位置是否在 HTTP 代码块中
|
||||
const block = getNoteBlockFromPos(view.state, from);
|
||||
if (block && block.language.name === 'http') {
|
||||
// 如果在 HTTP 代码块中,跳过超链接装饰
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkMark = Decoration.mark({
|
||||
class: 'cm-hyper-link-text',
|
||||
attributes: {
|
||||
@@ -84,6 +92,13 @@ const linkDecorator = (
|
||||
new MatchDecorator({
|
||||
regexp: regexp || defaultRegexp,
|
||||
decorate: (add, from, to, match, view) => {
|
||||
// 检查当前位置是否在 HTTP 代码块中
|
||||
const block = getNoteBlockFromPos(view.state, from);
|
||||
if (block && block.language.name === 'http') {
|
||||
// 如果在 HTTP 代码块中,跳过超链接装饰
|
||||
return;
|
||||
}
|
||||
|
||||
const url = match[0];
|
||||
let urlStr = matchFn && typeof matchFn === 'function' ? matchFn(url, match.input, from, to) : url;
|
||||
if (matchData && matchData[url]) {
|
||||
|
||||
@@ -1,364 +1,355 @@
|
||||
import { Extension, StateField, StateEffect } from '@codemirror/state';
|
||||
import { Extension, StateField, StateEffect, StateEffectType } from '@codemirror/state';
|
||||
import { EditorView, showTooltip, Tooltip } from '@codemirror/view';
|
||||
import { createTranslationTooltip } from './tooltip';
|
||||
|
||||
/**
|
||||
* 翻译器扩展配置
|
||||
*/
|
||||
export interface TranslatorConfig {
|
||||
/** 默认翻译服务提供商 */
|
||||
defaultTranslator: string;
|
||||
/** 最小选择字符数才显示翻译按钮 */
|
||||
minSelectionLength: number;
|
||||
/** 最大翻译字符数 */
|
||||
maxTranslationLength: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认翻译器配置
|
||||
*/
|
||||
export const defaultConfig: TranslatorConfig = {
|
||||
defaultTranslator: 'bing',
|
||||
minSelectionLength: 2,
|
||||
maxTranslationLength: 5000,
|
||||
};
|
||||
|
||||
// 全局配置存储
|
||||
let currentConfig: TranslatorConfig = {...defaultConfig};
|
||||
// 存储选择的文本用于翻译
|
||||
let selectedTextForTranslation = "";
|
||||
import {
|
||||
TranslatorConfig,
|
||||
DEFAULT_TRANSLATION_CONFIG,
|
||||
TRANSLATION_ICON_SVG
|
||||
} from '@/common/constant/translation';
|
||||
|
||||
|
||||
/**
|
||||
* 翻译图标SVG
|
||||
*/
|
||||
const translationIconSvg = `
|
||||
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
|
||||
<path d="M599.68 485.056h-8l30.592 164.672c20.352-7.04 38.72-17.344 54.912-31.104a271.36 271.36 0 0 1-40.704-64.64l32.256-4.032c8.896 17.664 19.072 33.28 30.592 46.72 23.872-27.968 42.24-65.152 55.04-111.744l-154.688 0.128z m121.92 133.76c18.368 15.36 39.36 26.56 62.848 33.472l14.784 4.416-8.64 30.336-14.72-4.352a205.696 205.696 0 0 1-76.48-41.728c-20.672 17.92-44.928 31.552-71.232 40.064l20.736 110.912H519.424l-9.984 72.512h385.152c18.112 0 32.704-14.144 32.704-31.616V295.424a32.128 32.128 0 0 0-32.704-31.552H550.528l35.2 189.696h79.424v-31.552h61.44v31.552h102.4v31.616h-42.688c-14.272 55.488-35.712 100.096-64.64 133.568zM479.36 791.68H193.472c-36.224 0-65.472-28.288-65.472-63.168V191.168C128 156.16 157.312 128 193.472 128h327.68l20.544 104.32h352.832c36.224 0 65.472 28.224 65.472 63.104v537.408c0 34.944-29.312 63.168-65.472 63.168H468.608l10.688-104.32zM337.472 548.352v-33.28H272.768v-48.896h60.16V433.28h-60.16v-41.728h64.704v-32.896h-102.4v189.632h102.4z m158.272 0V453.76c0-17.216-4.032-30.272-12.16-39.488-8.192-9.152-20.288-13.696-36.032-13.696a55.04 55.04 0 0 0-24.768 5.376 39.04 39.04 0 0 0-17.088 15.936h-1.984l-5.056-18.56h-28.352V548.48h37.12V480c0-17.088 2.304-29.376 6.912-36.736 4.608-7.424 12.16-11.072 22.528-11.072 7.616 0 13.248 2.56 16.64 7.872 3.52 5.248 5.312 13.056 5.312 23.488v84.736h36.928z" fill="currentColor"></path>
|
||||
</svg>`;
|
||||
class TranslatorExtension {
|
||||
private config: TranslatorConfig;
|
||||
private setTranslationTooltip: StateEffectType<Tooltip | null>;
|
||||
private translationTooltipField: StateField<readonly Tooltip[]>;
|
||||
private translationButtonField: StateField<readonly Tooltip[]>;
|
||||
|
||||
// 用于设置翻译气泡的状态效果
|
||||
const setTranslationTooltip = StateEffect.define<Tooltip | null>();
|
||||
constructor(config?: Partial<TranslatorConfig>) {
|
||||
// 初始化配置
|
||||
this.config = {
|
||||
minSelectionLength: DEFAULT_TRANSLATION_CONFIG.minSelectionLength,
|
||||
maxTranslationLength: DEFAULT_TRANSLATION_CONFIG.maxTranslationLength,
|
||||
...config
|
||||
};
|
||||
|
||||
/**
|
||||
* 翻译气泡的状态字段
|
||||
*/
|
||||
const translationTooltipField = StateField.define<readonly Tooltip[]>({
|
||||
create() {
|
||||
return [];
|
||||
},
|
||||
update(tooltips, tr) {
|
||||
// 如果文档或选择变化,隐藏气泡
|
||||
if (tr.docChanged || tr.selection) {
|
||||
// 初始化状态效果
|
||||
this.setTranslationTooltip = StateEffect.define<Tooltip | null>();
|
||||
|
||||
// 初始化翻译气泡状态字段
|
||||
this.translationTooltipField = StateField.define<readonly Tooltip[]>({
|
||||
create: () => [],
|
||||
update: (tooltips, tr) => {
|
||||
// 检查是否有特定的状态效果来更新tooltips
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(this.setTranslationTooltip)) {
|
||||
return effect.value ? [effect.value] : [];
|
||||
}
|
||||
}
|
||||
|
||||
// 如果文档或选择变化,隐藏气泡
|
||||
if (tr.docChanged || tr.selection) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return tooltips;
|
||||
},
|
||||
provide: field => showTooltip.computeN([field], state => state.field(field))
|
||||
});
|
||||
|
||||
// 初始化翻译按钮状态字段
|
||||
this.translationButtonField = StateField.define<readonly Tooltip[]>({
|
||||
create: (state) => this.getTranslationButtonTooltips(state),
|
||||
update: (tooltips, tr) => {
|
||||
// 如果文档或选择变化,重新计算tooltip
|
||||
if (tr.docChanged || tr.selection) {
|
||||
return this.getTranslationButtonTooltips(tr.state);
|
||||
}
|
||||
|
||||
// 检查是否有翻译气泡显示,如果有则不显示按钮
|
||||
if (tr.state.field(this.translationTooltipField).length > 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return tooltips;
|
||||
},
|
||||
provide: field => showTooltip.computeN([field], state => state.field(field))
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前选择获取翻译按钮tooltip
|
||||
*/
|
||||
private getTranslationButtonTooltips(state: any): readonly Tooltip[] {
|
||||
// 如果气泡已显示,则不显示按钮
|
||||
if (state.field(this.translationTooltipField).length > 0) return [];
|
||||
|
||||
const selection = state.selection.main;
|
||||
|
||||
// 如果没有选中文本,不显示按钮
|
||||
if (selection.empty) return [];
|
||||
|
||||
// 获取选中的文本
|
||||
const selectedText = state.sliceDoc(selection.from, selection.to);
|
||||
|
||||
// 检查文本是否只包含空格
|
||||
if (!selectedText.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 检查是否有特定的状态效果来更新tooltips
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(setTranslationTooltip)) {
|
||||
return effect.value ? [effect.value] : [];
|
||||
// 检查文本长度条件
|
||||
if (selectedText.length < this.config.minSelectionLength ||
|
||||
selectedText.length > this.config.maxTranslationLength) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 返回翻译按钮tooltip配置
|
||||
return [{
|
||||
pos: selection.to,
|
||||
above: false,
|
||||
strictSide: true,
|
||||
arrow: false,
|
||||
create: (view) => {
|
||||
// 创建按钮DOM
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-translator-button';
|
||||
dom.innerHTML = TRANSLATION_ICON_SVG;
|
||||
|
||||
// 点击事件
|
||||
dom.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 显示翻译气泡
|
||||
this.showTranslationTooltip(view);
|
||||
});
|
||||
|
||||
return { dom };
|
||||
}
|
||||
}
|
||||
|
||||
return tooltips;
|
||||
},
|
||||
provide: field => showTooltip.computeN([field], state => state.field(field))
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* 根据当前选择获取翻译按钮tooltip
|
||||
*/
|
||||
function getTranslationButtonTooltips(state: any): readonly Tooltip[] {
|
||||
|
||||
// 如果气泡已显示,则不显示按钮
|
||||
if (state.field(translationTooltipField).length > 0) return [];
|
||||
|
||||
const selection = state.selection.main;
|
||||
|
||||
// 如果没有选中文本,不显示按钮
|
||||
if (selection.empty) return [];
|
||||
|
||||
// 获取选中的文本
|
||||
const selectedText = state.sliceDoc(selection.from, selection.to);
|
||||
|
||||
// 检查文本是否只包含空格
|
||||
if (!selectedText.trim()) {
|
||||
return [];
|
||||
}];
|
||||
}
|
||||
|
||||
// 检查文本长度条件
|
||||
if (selectedText.length < currentConfig.minSelectionLength ||
|
||||
selectedText.length > currentConfig.maxTranslationLength) {
|
||||
return [];
|
||||
/**
|
||||
* 显示翻译气泡
|
||||
*/
|
||||
private showTranslationTooltip(view: EditorView) {
|
||||
// 直接从当前选择获取文本
|
||||
const selection = view.state.selection.main;
|
||||
if (selection.empty) return;
|
||||
|
||||
const selectedText = view.state.sliceDoc(selection.from, selection.to);
|
||||
if (!selectedText.trim()) return;
|
||||
|
||||
// 创建翻译气泡
|
||||
const tooltip = createTranslationTooltip(view, selectedText);
|
||||
|
||||
// 更新状态以显示气泡
|
||||
view.dispatch({
|
||||
effects: this.setTranslationTooltip.of(tooltip)
|
||||
});
|
||||
}
|
||||
|
||||
// 保存选中的文本用于翻译
|
||||
selectedTextForTranslation = selectedText;
|
||||
/**
|
||||
* 创建扩展
|
||||
*/
|
||||
createExtension(): Extension {
|
||||
return [
|
||||
// 翻译按钮tooltip
|
||||
this.translationButtonField,
|
||||
// 翻译气泡tooltip
|
||||
this.translationTooltipField,
|
||||
|
||||
// 返回翻译按钮tooltip配置
|
||||
return [{
|
||||
pos: selection.to,
|
||||
above: false,
|
||||
strictSide: true,
|
||||
arrow: false,
|
||||
create: (view) => {
|
||||
// 创建按钮DOM
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-translator-button';
|
||||
dom.innerHTML = translationIconSvg;
|
||||
// 添加基础样式
|
||||
EditorView.baseTheme({
|
||||
".cm-translator-button": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
background: "var(--bg-secondary, transparent)",
|
||||
color: "var(--text-muted, #4285f4)",
|
||||
border: "1px solid var(--border-color, #dadce0)",
|
||||
borderRadius: "3px",
|
||||
padding: "2px",
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.08)",
|
||||
userSelect: "none",
|
||||
"&:hover": {
|
||||
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))"
|
||||
}
|
||||
},
|
||||
|
||||
// 点击事件
|
||||
dom.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// 翻译气泡样式
|
||||
".cm-translation-tooltip": {
|
||||
background: "var(--bg-secondary, #fff)",
|
||||
color: "var(--text-primary, #333)",
|
||||
border: "1px solid var(--border-color, #dadce0)",
|
||||
borderRadius: "3px",
|
||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
|
||||
padding: "8px",
|
||||
maxWidth: "300px",
|
||||
maxHeight: "200px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
fontFamily: "var(--font-family, system-ui, -apple-system, sans-serif)",
|
||||
fontSize: "11px",
|
||||
userSelect: "none",
|
||||
cursor: "grab"
|
||||
},
|
||||
|
||||
// 显示翻译气泡
|
||||
showTranslationTooltip(view);
|
||||
});
|
||||
// 拖拽状态样式
|
||||
".cm-translation-dragging": {
|
||||
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.2)",
|
||||
zIndex: "1000",
|
||||
cursor: "grabbing !important"
|
||||
},
|
||||
|
||||
return { dom };
|
||||
}
|
||||
}];
|
||||
".cm-translation-header": {
|
||||
marginBottom: "8px",
|
||||
flexShrink: "0"
|
||||
},
|
||||
|
||||
".cm-translation-controls": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
flexWrap: "nowrap"
|
||||
},
|
||||
|
||||
".cm-translation-select": {
|
||||
padding: "2px 4px",
|
||||
borderRadius: "3px",
|
||||
border: "1px solid var(--border-color, #dadce0)",
|
||||
background: "var(--bg-primary, #f5f5f5)",
|
||||
fontSize: "11px",
|
||||
color: "var(--text-primary, #333)",
|
||||
flex: "1",
|
||||
minWidth: "0",
|
||||
maxWidth: "80px"
|
||||
},
|
||||
|
||||
".cm-translation-swap": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
borderRadius: "3px",
|
||||
border: "1px solid var(--border-color, #dadce0)",
|
||||
background: "var(--bg-primary, transparent)",
|
||||
color: "var(--text-muted, #666)",
|
||||
cursor: "pointer",
|
||||
padding: "0",
|
||||
flexShrink: "0",
|
||||
"&:hover": {
|
||||
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))"
|
||||
}
|
||||
},
|
||||
|
||||
// 滚动容器
|
||||
".cm-translation-scroll-container": {
|
||||
overflowY: "auto",
|
||||
flex: "1",
|
||||
minHeight: "0"
|
||||
},
|
||||
|
||||
".cm-translation-result": {
|
||||
display: "flex",
|
||||
flexDirection: "column"
|
||||
},
|
||||
|
||||
".cm-translation-result-header": {
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginBottom: "4px"
|
||||
},
|
||||
|
||||
".cm-translation-result-wrapper": {
|
||||
position: "relative",
|
||||
width: "100%"
|
||||
},
|
||||
|
||||
".cm-translation-copy-btn": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
borderRadius: "3px",
|
||||
border: "1px solid var(--border-color, #dadce0)",
|
||||
background: "var(--bg-primary, transparent)",
|
||||
color: "var(--text-muted, #666)",
|
||||
cursor: "pointer",
|
||||
padding: "0",
|
||||
position: "absolute",
|
||||
top: "4px",
|
||||
right: "4px",
|
||||
zIndex: "2",
|
||||
opacity: "0.7",
|
||||
"&:hover": {
|
||||
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))",
|
||||
opacity: "1"
|
||||
},
|
||||
"&.copied": {
|
||||
background: "var(--bg-success, #4caf50)",
|
||||
color: "white",
|
||||
border: "1px solid var(--bg-success, #4caf50)",
|
||||
opacity: "1"
|
||||
}
|
||||
},
|
||||
|
||||
".cm-translation-target": {
|
||||
padding: "6px",
|
||||
paddingRight: "28px", // 为复制按钮留出空间
|
||||
background: "var(--bg-primary, rgba(66, 133, 244, 0.05))",
|
||||
color: "var(--text-primary, #333)",
|
||||
borderRadius: "3px",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word"
|
||||
},
|
||||
|
||||
".cm-translation-notice": {
|
||||
fontSize: "10px",
|
||||
color: "var(--text-muted, #888)",
|
||||
padding: "2px 0",
|
||||
fontStyle: "italic",
|
||||
textAlign: "center",
|
||||
marginBottom: "2px"
|
||||
},
|
||||
|
||||
".cm-translation-error": {
|
||||
color: "var(--text-danger, #d32f2f)",
|
||||
fontStyle: "italic"
|
||||
},
|
||||
|
||||
".cm-translation-loading": {
|
||||
padding: "8px",
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted, #666)",
|
||||
fontSize: "11px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "6px"
|
||||
},
|
||||
|
||||
".cm-translation-loading::before": {
|
||||
content: "''",
|
||||
display: "inline-block",
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "50%",
|
||||
border: "2px solid var(--text-muted, #666)",
|
||||
borderTopColor: "transparent",
|
||||
animation: "cm-translation-spin 1s linear infinite"
|
||||
},
|
||||
|
||||
"@keyframes cm-translation-spin": {
|
||||
"0%": { transform: "rotate(0deg)" },
|
||||
"100%": { transform: "rotate(360deg)" }
|
||||
}
|
||||
})
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示翻译气泡
|
||||
*/
|
||||
function showTranslationTooltip(view: EditorView) {
|
||||
if (!selectedTextForTranslation) return;
|
||||
|
||||
// 创建翻译气泡
|
||||
const tooltip = createTranslationTooltip(view, selectedTextForTranslation);
|
||||
|
||||
// 更新状态以显示气泡
|
||||
view.dispatch({
|
||||
effects: setTranslationTooltip.of(tooltip)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译按钮的状态字段
|
||||
*/
|
||||
const translationButtonField = StateField.define<readonly Tooltip[]>({
|
||||
create(state) {
|
||||
return getTranslationButtonTooltips(state);
|
||||
},
|
||||
|
||||
update(tooltips, tr) {
|
||||
// 如果文档或选择变化,重新计算tooltip
|
||||
if (tr.docChanged || tr.selection) {
|
||||
return getTranslationButtonTooltips(tr.state);
|
||||
}
|
||||
|
||||
// 检查是否有翻译气泡显示,如果有则不显示按钮
|
||||
if (tr.state.field(translationTooltipField).length > 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return tooltips;
|
||||
},
|
||||
|
||||
provide: field => showTooltip.computeN([field], state => state.field(field))
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建翻译扩展
|
||||
*/
|
||||
export function createTranslatorExtension(config?: Partial<TranslatorConfig>): Extension {
|
||||
// 更新配置
|
||||
currentConfig = { ...defaultConfig, ...config };
|
||||
|
||||
return [
|
||||
// 翻译按钮tooltip
|
||||
translationButtonField,
|
||||
// 翻译气泡tooltip
|
||||
translationTooltipField,
|
||||
|
||||
// 添加基础样式
|
||||
EditorView.baseTheme({
|
||||
".cm-translator-button": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
background: "var(--bg-secondary, transparent)",
|
||||
color: "var(--text-muted, #4285f4)",
|
||||
border: "1px solid var(--border-color, #dadce0)",
|
||||
borderRadius: "3px",
|
||||
padding: "2px",
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.08)",
|
||||
userSelect: "none",
|
||||
"&:hover": {
|
||||
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))"
|
||||
}
|
||||
},
|
||||
|
||||
// 翻译气泡样式
|
||||
".cm-translation-tooltip": {
|
||||
background: "var(--bg-secondary, #fff)",
|
||||
color: "var(--text-primary, #333)",
|
||||
border: "1px solid var(--border-color, #dadce0)",
|
||||
borderRadius: "3px",
|
||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
|
||||
padding: "8px",
|
||||
maxWidth: "300px",
|
||||
maxHeight: "200px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
fontFamily: "var(--font-family, system-ui, -apple-system, sans-serif)",
|
||||
fontSize: "11px"
|
||||
},
|
||||
|
||||
".cm-translation-header": {
|
||||
marginBottom: "8px",
|
||||
flexShrink: "0"
|
||||
},
|
||||
|
||||
".cm-translation-controls": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
flexWrap: "nowrap"
|
||||
},
|
||||
|
||||
".cm-translation-select": {
|
||||
padding: "2px 4px",
|
||||
borderRadius: "3px",
|
||||
border: "1px solid var(--border-color, #dadce0)",
|
||||
background: "var(--bg-primary, #f5f5f5)",
|
||||
fontSize: "11px",
|
||||
color: "var(--text-primary, #333)",
|
||||
flex: "1",
|
||||
minWidth: "0",
|
||||
maxWidth: "80px"
|
||||
},
|
||||
|
||||
".cm-translation-swap": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
borderRadius: "3px",
|
||||
border: "1px solid var(--border-color, #dadce0)",
|
||||
background: "var(--bg-primary, transparent)",
|
||||
color: "var(--text-muted, #666)",
|
||||
cursor: "pointer",
|
||||
padding: "0",
|
||||
flexShrink: "0",
|
||||
"&:hover": {
|
||||
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))"
|
||||
}
|
||||
},
|
||||
|
||||
// 滚动容器
|
||||
".cm-translation-scroll-container": {
|
||||
overflowY: "auto",
|
||||
flex: "1",
|
||||
minHeight: "0"
|
||||
},
|
||||
|
||||
".cm-translation-result": {
|
||||
display: "flex",
|
||||
flexDirection: "column"
|
||||
},
|
||||
|
||||
".cm-translation-result-header": {
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginBottom: "4px"
|
||||
},
|
||||
|
||||
".cm-translation-result-wrapper": {
|
||||
position: "relative",
|
||||
width: "100%"
|
||||
},
|
||||
|
||||
".cm-translation-copy-btn": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
borderRadius: "3px",
|
||||
border: "1px solid var(--border-color, #dadce0)",
|
||||
background: "var(--bg-primary, transparent)",
|
||||
color: "var(--text-muted, #666)",
|
||||
cursor: "pointer",
|
||||
padding: "0",
|
||||
position: "absolute",
|
||||
top: "4px",
|
||||
right: "4px",
|
||||
zIndex: "2",
|
||||
opacity: "0.7",
|
||||
"&:hover": {
|
||||
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))",
|
||||
opacity: "1"
|
||||
},
|
||||
"&.copied": {
|
||||
background: "var(--bg-success, #4caf50)",
|
||||
color: "white",
|
||||
border: "1px solid var(--bg-success, #4caf50)",
|
||||
opacity: "1"
|
||||
}
|
||||
},
|
||||
|
||||
".cm-translation-target": {
|
||||
padding: "6px",
|
||||
paddingRight: "28px", // 为复制按钮留出空间
|
||||
background: "var(--bg-primary, rgba(66, 133, 244, 0.05))",
|
||||
color: "var(--text-primary, #333)",
|
||||
borderRadius: "3px",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word"
|
||||
},
|
||||
|
||||
".cm-translation-notice": {
|
||||
fontSize: "10px",
|
||||
color: "var(--text-muted, #888)",
|
||||
padding: "2px 0",
|
||||
fontStyle: "italic",
|
||||
textAlign: "center",
|
||||
marginBottom: "2px"
|
||||
},
|
||||
|
||||
".cm-translation-error": {
|
||||
color: "var(--text-danger, #d32f2f)",
|
||||
fontStyle: "italic"
|
||||
},
|
||||
|
||||
".cm-translation-loading": {
|
||||
padding: "8px",
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted, #666)",
|
||||
fontSize: "11px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "6px"
|
||||
},
|
||||
|
||||
".cm-translation-loading::before": {
|
||||
content: "''",
|
||||
display: "inline-block",
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "50%",
|
||||
border: "2px solid var(--text-muted, #666)",
|
||||
borderTopColor: "transparent",
|
||||
animation: "cm-translation-spin 1s linear infinite"
|
||||
},
|
||||
|
||||
"@keyframes cm-translation-spin": {
|
||||
"0%": { transform: "rotate(0deg)" },
|
||||
"100%": { transform: "rotate(360deg)" }
|
||||
}
|
||||
})
|
||||
];
|
||||
const translatorExtension = new TranslatorExtension(config);
|
||||
return translatorExtension.createExtension();
|
||||
}
|
||||
|
||||
export default createTranslatorExtension;
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user