Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 286b0159d7 | |||
| cc98e556c6 | |||
| 5902f482d9 | |||
| 551e7e2cfd | |||
| e0179b5838 | |||
| df79267e16 | |||
| 1f0254822f | |||
| e9b6fef3cd |
Binary file not shown.
@@ -39,7 +39,7 @@ tasks:
|
||||
summary: Generates Windows `.syso` file
|
||||
dir: build
|
||||
cmds:
|
||||
- wails3 generate syso -arch {{.ARCH}} -icon windows/favicon_256x256.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
|
||||
- wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
|
||||
vars:
|
||||
ARCH: '{{.ARCH | default ARCH}}'
|
||||
|
||||
|
||||
@@ -62,19 +62,4 @@ export class ServiceOptions {
|
||||
}
|
||||
}
|
||||
|
||||
export class WebviewWindow {
|
||||
|
||||
/** Creates a new WebviewWindow instance. */
|
||||
constructor($$source: Partial<WebviewWindow> = {}) {
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new WebviewWindow instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): WebviewWindow {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new WebviewWindow($$parsedSource as Partial<WebviewWindow>);
|
||||
}
|
||||
}
|
||||
export type Window = any;
|
||||
|
||||
@@ -10,10 +10,17 @@
|
||||
// @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";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
/**
|
||||
* Get 获取配置项
|
||||
*/
|
||||
@@ -34,22 +41,6 @@ export function GetConfig(): Promise<models$0.AppConfig | null> & { cancel(): vo
|
||||
return $typingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* GetConfigDir 获取配置目录
|
||||
*/
|
||||
export function GetConfigDir(): Promise<string> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2275626561) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* GetSettingsPath 获取设置文件路径
|
||||
*/
|
||||
export function GetSettingsPath(): Promise<string> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2175583370) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* MigrateConfig 执行配置迁移
|
||||
*/
|
||||
@@ -74,6 +65,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(3311949428, options) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set 设置配置项
|
||||
*/
|
||||
@@ -83,34 +82,18 @@ export function Set(key: string, value: any): Promise<void> & { cancel(): void }
|
||||
}
|
||||
|
||||
/**
|
||||
* SetBackupConfigChangeCallback 设置备份配置变更回调
|
||||
* Watch 注册配置变更监听器
|
||||
*/
|
||||
export function SetBackupConfigChangeCallback(callback: any): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3264871659, callback) as any;
|
||||
export function Watch(path: string, callback: $models.ObserverCallback): Promise<$models.CancelFunc> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1143583035, path, callback) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* SetDataPathChangeCallback 设置数据路径配置变更回调
|
||||
* WatchWithContext 使用 Context 注册监听器
|
||||
*/
|
||||
export function SetDataPathChangeCallback(callback: any): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(393017412, callback) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* SetHotkeyChangeCallback 设置热键配置变更回调
|
||||
*/
|
||||
export function SetHotkeyChangeCallback(callback: any): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(283872321, callback) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* SetWindowSnapConfigChangeCallback 设置窗口吸附配置变更回调
|
||||
*/
|
||||
export function SetWindowSnapConfigChangeCallback(callback: any): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2324961653, callback) as any;
|
||||
export function WatchWithContext(path: string, callback: $models.ObserverCallback): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1454973098, path, callback) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* HotkeyService Windows全局热键服务
|
||||
* HotkeyService 全局热键服务
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -48,8 +48,8 @@ export function IsRegistered(): Promise<boolean> & { cancel(): void } {
|
||||
/**
|
||||
* RegisterHotkey 注册全局热键
|
||||
*/
|
||||
export function RegisterHotkey(hotkey: models$0.HotkeyCombo | null): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1103945691, hotkey) as any;
|
||||
export function RegisterHotkey(combo: models$0.HotkeyCombo | null): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1103945691, combo) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceStartup initializes the service when the application starts
|
||||
* ServiceStartup 服务启动时初始化
|
||||
*/
|
||||
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3079990808, options) as any;
|
||||
@@ -80,8 +80,8 @@ export function UnregisterHotkey(): Promise<void> & { cancel(): void } {
|
||||
/**
|
||||
* UpdateHotkey 更新热键配置
|
||||
*/
|
||||
export function UpdateHotkey(enable: boolean, hotkey: models$0.HotkeyCombo | null): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(823285555, enable, hotkey) as any;
|
||||
export function UpdateHotkey(enable: boolean, combo: models$0.HotkeyCombo | null): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(823285555, enable, combo) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +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 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";
|
||||
@@ -15,6 +12,12 @@ import * as http$0 from "../../../net/http/models.js";
|
||||
// @ts-ignore: Unused imports
|
||||
import * as time$0 from "../../../time/models.js";
|
||||
|
||||
/**
|
||||
* CancelFunc 取消订阅函数
|
||||
* 调用此函数可以取消对配置的监听
|
||||
*/
|
||||
export type CancelFunc = any;
|
||||
|
||||
/**
|
||||
* HttpRequest HTTP请求结构
|
||||
*/
|
||||
@@ -263,6 +266,11 @@ export class OSInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ObserverCallback 观察者回调函数
|
||||
*/
|
||||
export type ObserverCallback = any;
|
||||
|
||||
/**
|
||||
* SelfUpdateResult 自我更新结果
|
||||
*/
|
||||
@@ -394,62 +402,6 @@ export class SystemInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WindowInfo 窗口信息
|
||||
*/
|
||||
export class WindowInfo {
|
||||
"Window": application$0.WebviewWindow | null;
|
||||
"DocumentID": number;
|
||||
"Title": string;
|
||||
|
||||
/** Creates a new WindowInfo instance. */
|
||||
constructor($$source: Partial<WindowInfo> = {}) {
|
||||
if (!("Window" in $$source)) {
|
||||
this["Window"] = null;
|
||||
}
|
||||
if (!("DocumentID" in $$source)) {
|
||||
this["DocumentID"] = 0;
|
||||
}
|
||||
if (!("Title" in $$source)) {
|
||||
this["Title"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new WindowInfo instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): WindowInfo {
|
||||
const $$createField0_0 = $$createType8;
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
if ("Window" in $$parsedSource) {
|
||||
$$parsedSource["Window"] = $$createField0_0($$parsedSource["Window"]);
|
||||
}
|
||||
return new WindowInfo($$parsedSource as Partial<WindowInfo>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WindowSnapService 窗口吸附服务
|
||||
*/
|
||||
export class WindowSnapService {
|
||||
|
||||
/** Creates a new WindowSnapService instance. */
|
||||
constructor($$source: Partial<WindowSnapService> = {}) {
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new WindowSnapService instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): WindowSnapService {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new WindowSnapService($$parsedSource as Partial<WindowSnapService>);
|
||||
}
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = $Create.Map($Create.Any, $Create.Any);
|
||||
var $$createType1 = (function $$initCreateType1(...args): any {
|
||||
@@ -463,5 +415,3 @@ 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);
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
// @ts-ignore: Unused imports
|
||||
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
|
||||
|
||||
/**
|
||||
* AutoShowHide 自动显示/隐藏主窗口
|
||||
*/
|
||||
export function AutoShowHide(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(4044219428) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* HandleWindowClose 处理窗口关闭事件
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* WindowService 窗口管理服务(专注于窗口生命周期管理)
|
||||
* WindowService 窗口管理服务
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -12,15 +12,15 @@ 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";
|
||||
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
|
||||
|
||||
/**
|
||||
* GetOpenWindows 获取所有打开的窗口信息
|
||||
* GetOpenWindows 获取所有打开的文档窗口
|
||||
*/
|
||||
export function GetOpenWindows(): Promise<$models.WindowInfo[]> & { cancel(): void } {
|
||||
export function GetOpenWindows(): Promise<application$0.Window[]> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1464997251) as any;
|
||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||
return $$createType1($result);
|
||||
return $$createType0($result);
|
||||
}) as any;
|
||||
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
|
||||
return $typingPromise;
|
||||
@@ -51,13 +51,12 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
|
||||
}
|
||||
|
||||
/**
|
||||
* SetWindowSnapService 设置窗口吸附服务引用
|
||||
* ServiceStartup 服务启动时初始化
|
||||
*/
|
||||
export function SetWindowSnapService(snapService: $models.WindowSnapService | null): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1105193745, snapService) as any;
|
||||
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2432987694, options) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = $models.WindowInfo.createFrom;
|
||||
const $$createType1 = $Create.Array($$createType0);
|
||||
const $$createType0 = $Create.Array($Create.Any);
|
||||
|
||||
@@ -11,10 +11,15 @@
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix",
|
||||
"build:lang-parser": "node src/views/editor/extensions/codeblock/lang-parser/build-parser.js",
|
||||
"build:mermaid-parser": "node src/views/editor/language/mermaid/build-parsers.js",
|
||||
"test": "vitest",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs"
|
||||
"docs:preview": "vitepress preview docs",
|
||||
"app:dev": "cd .. &&wails3 dev",
|
||||
"app:build": "cd .. && wails3 task build",
|
||||
"app:package": "cd .. && wails3 package",
|
||||
"app:generate": "cd .. && wails3 generate bindings -ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.19.1",
|
||||
|
||||
@@ -80,7 +80,7 @@ function animationLoop() {
|
||||
// 等待一段时间后重置动画
|
||||
resetTimeoutId = window.setTimeout(() => {
|
||||
reset();
|
||||
}, 750);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,8 @@ onBeforeUnmount(() => {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--voidraft-bg-gradient, radial-gradient(#222922, #000500));
|
||||
//background: var(--voidraft-bg-gradient, rgba(0, 5, 0, 0.15));
|
||||
//backdrop-filter: blur(2px);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,175 +1,49 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, readonly, ref, shallowRef, watchEffect, onScopeDispose } from 'vue';
|
||||
import type { GitBackupConfig } from '@/../bindings/voidraft/internal/models';
|
||||
import { ref, onScopeDispose } from 'vue';
|
||||
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;
|
||||
|
||||
|
||||
export const useBackupStore = defineStore('backup', () => {
|
||||
// === 核心状态 ===
|
||||
const config = shallowRef<GitBackupConfig | null>(null);
|
||||
|
||||
// 统一的备份结果状态
|
||||
const backupResult = ref<BackupResult>({
|
||||
status: BackupStatus.IDLE
|
||||
});
|
||||
|
||||
// === 定时器管理 ===
|
||||
const statusTimer = createTimerManager();
|
||||
const isPushing = ref(false);
|
||||
const message = ref<string | null>(null);
|
||||
const isError = ref(false);
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onScopeDispose(() => {
|
||||
statusTimer.clear();
|
||||
});
|
||||
|
||||
// === 外部依赖 ===
|
||||
const timer = createTimerManager();
|
||||
const configStore = useConfigStore();
|
||||
|
||||
// === 计算属性 ===
|
||||
const isEnabled = computed(() => configStore.config.backup.enabled);
|
||||
const isConfigured = computed(() => Boolean(configStore.config.backup.repo_url?.trim()));
|
||||
|
||||
// 派生状态计算属性
|
||||
const isPushing = computed(() => backupResult.value.status === BackupStatus.PUSHING);
|
||||
const isSuccess = computed(() => backupResult.value.status === BackupStatus.SUCCESS);
|
||||
const isError = computed(() => backupResult.value.status === BackupStatus.ERROR);
|
||||
const errorMessage = computed(() =>
|
||||
backupResult.value.status === BackupStatus.ERROR ? backupResult.value.message : null
|
||||
);
|
||||
onScopeDispose(() => timer.clear());
|
||||
|
||||
// === 状态管理方法 ===
|
||||
|
||||
/**
|
||||
* 设置备份状态
|
||||
* @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();
|
||||
const pushToRemote = async () => {
|
||||
const isConfigured = Boolean(configStore.config.backup.repo_url?.trim());
|
||||
|
||||
backupResult.value = {
|
||||
status,
|
||||
message,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// 自动隐藏逻辑
|
||||
if (autoHide && (status === BackupStatus.SUCCESS || status === BackupStatus.ERROR)) {
|
||||
statusTimer.set(() => {
|
||||
if (backupResult.value.status === status) {
|
||||
backupResult.value = { status: BackupStatus.IDLE };
|
||||
}
|
||||
}, autoHide);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除当前状态
|
||||
*/
|
||||
const clearStatus = (): void => {
|
||||
statusTimer.clear();
|
||||
backupResult.value = { status: BackupStatus.IDLE };
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理错误的通用方法
|
||||
*/
|
||||
const handleError = (error: unknown): void => {
|
||||
const message: ErrorMessage<typeof error> = isBackupError(error)
|
||||
? error.message
|
||||
: 'Backup operation failed';
|
||||
|
||||
setBackupStatus(BackupStatus.ERROR, message, 5000);
|
||||
};
|
||||
|
||||
// === 业务逻辑方法 ===
|
||||
|
||||
/**
|
||||
* 推送到远程仓库
|
||||
* 使用现代 async/await 和错误处理
|
||||
*/
|
||||
const pushToRemote = async (): Promise<void> => {
|
||||
// 前置条件检查
|
||||
if (isPushing.value || !isConfigured.value) {
|
||||
if (isPushing.value || !isConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setBackupStatus(BackupStatus.PUSHING);
|
||||
isPushing.value = true;
|
||||
message.value = null;
|
||||
timer.clear();
|
||||
|
||||
await BackupService.PushToRemote();
|
||||
|
||||
setBackupStatus(BackupStatus.SUCCESS, 'Backup completed successfully', 3000);
|
||||
isError.value = false;
|
||||
message.value = 'push successful';
|
||||
timer.set(() => { message.value = null; }, 3000);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
isError.value = true;
|
||||
message.value = error instanceof Error ? error.message : 'backup operation failed';
|
||||
timer.set(() => { message.value = null; }, 5000);
|
||||
} finally {
|
||||
isPushing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 重试备份操作
|
||||
*/
|
||||
const retryBackup = async (): Promise<void> => {
|
||||
if (isError.value) {
|
||||
await pushToRemote();
|
||||
}
|
||||
};
|
||||
|
||||
// === 响应式副作用 ===
|
||||
|
||||
// 监听配置变化,自动清除错误状态
|
||||
watchEffect(() => {
|
||||
if (isEnabled.value && isConfigured.value && isError.value) {
|
||||
// 配置修复后清除错误状态
|
||||
clearStatus();
|
||||
}
|
||||
});
|
||||
|
||||
// === 返回的 API ===
|
||||
return {
|
||||
// 只读状态
|
||||
config: readonly(config),
|
||||
backupResult: readonly(backupResult),
|
||||
|
||||
// 计算属性
|
||||
isEnabled,
|
||||
isConfigured,
|
||||
isPushing,
|
||||
isSuccess,
|
||||
message,
|
||||
isError,
|
||||
errorMessage,
|
||||
|
||||
// 方法
|
||||
pushToRemote,
|
||||
retryBackup,
|
||||
clearStatus
|
||||
} as const;
|
||||
pushToRemote
|
||||
};
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, readonly, ref, shallowRef, onScopeDispose } from 'vue';
|
||||
import { computed, readonly, ref, 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';
|
||||
|
||||
@@ -55,9 +55,11 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div class="editor-container">
|
||||
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
|
||||
<div ref="editorElement" class="editor"></div>
|
||||
<Toolbar/>
|
||||
<transition name="loading-fade">
|
||||
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -85,4 +87,15 @@ onBeforeUnmount(() => {
|
||||
:deep(.cm-scroller) {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
// 加载动画过渡效果
|
||||
.loading-fade-enter-active,
|
||||
.loading-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.loading-fade-enter-from,
|
||||
.loading-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -18,7 +18,7 @@ BlockLanguage {
|
||||
"go" | "clj" | "ex" | "erl" | "js" | "ts" | "swift" | "kt" | "groovy" |
|
||||
"ps1" | "dart" | "scala" | "math" | "dockerfile" | "lua" | "vue" | "lezer" |
|
||||
"liquid" | "wast" | "sass" | "less" | "angular" | "svelte" |
|
||||
"http"
|
||||
"http" | "mermaid"
|
||||
}
|
||||
|
||||
@tokens {
|
||||
|
||||
@@ -24,7 +24,7 @@ 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 { mermaidLanguage } from '@/views/editor/language/mermaid';
|
||||
import {StreamLanguage} from "@codemirror/language";
|
||||
import {ruby} from "@codemirror/legacy-modes/mode/ruby";
|
||||
import {shell} from "@codemirror/legacy-modes/mode/shell";
|
||||
@@ -226,6 +226,7 @@ export const LANGUAGES: LanguageInfo[] = [
|
||||
}
|
||||
}),
|
||||
new LanguageInfo("http", "Http", httpLanguage.parser, ["http"]),
|
||||
new LanguageInfo("mermaid", "Mermaid", mermaidLanguage.parser, ["mermaid"]),
|
||||
|
||||
];
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@ import {LRParser} from "@lezer/lr"
|
||||
import {blockContent} from "./external-tokens.js"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "!jQQOQOOOVOQO'#C`O#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",
|
||||
states: "!jQQOQOOOVOQO'#C`O#{OPO'#C_OOOO'#Cc'#CcQQOQOOOOOO'#Ca'#CaO$QOSO,58zOOOO,58y,58yOOOO-E6a-E6aOOOP1G.f1G.fO$YOSO1G.fOOOP7+$Q7+$Q",
|
||||
stateData: "$_~OXPO~OYTOZTO[TO]TO^TO_TO`TOaTObTOcTOdTOeTOfTOgTOhTOiTOjTOkTOlTOmTOnTOoTOpTOqTOrTOsTOtTOuTOvTOwTOxTOyTOzTO{TO|TO}TO!OTO!PTO!QTO!RTO!STO!TTO~OPVO~OUYO!UXO~O!UZO~O",
|
||||
goto: "jWPPPX]aPdTROSTQOSRUPQSORWS",
|
||||
nodeNames: "⚠ BlockContent Document Block BlockDelimiter BlockLanguage Auto",
|
||||
maxTerm: 51,
|
||||
maxTerm: 52,
|
||||
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~",
|
||||
tokenData: "4m~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-q#f#g.j#g#h.}#h#i1t#j#k2y#k#l3[#l#m3s#m#n4UR!fP!UQ%&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~~,]R#T#U,f#W#X,w#X#Y,|~,iP#h#i,l~,oP#[#],r~,wOw~~,|O_~~-PP#f#g-S~-VP#a#b-Y~-]P#T#U-`~-cP#]#^-f~-iP#W#X-l~-qO!T~~-tR#[#]-}#g#h.Y#m#n.e~.QP#d#e.T~.YOa~~.]P!R!S.`~.eOt~~.jO[~~.mQ#U#V.s#g#h.x~.xOg~~.}Oe~~/QU#T#U/d#V#W/u#[#]0^#e#f0c#j#k0n#k#l1]~/gP#g#h/j~/mP#g#h/p~/uO!O~~/xP#T#U/{~0OP#`#a0R~0UP#T#U0X~0^Ov~~0cOh~~0fP#`#a0i~0nO^~~0qP#X#Y0t~0wP#`#a0z~0}P#h#i1Q~1TP#X#Y1W~1]O!R~~1`P#]#^1c~1fP#Y#Z1i~1lP#h#i1o~1tOq~~1wR#X#Y2Q#c#d2c#g#h2t~2TP#l#m2W~2ZP#h#i2^~2cOY~~2fP#a#b2i~2lP#`#a2o~2tOj~~2yOp~~2|P#i#j3P~3SP#X#Y3V~3[Oz~~3_P#T#U3b~3eP#g#h3h~3kP#h#i3n~3sO}~~3vP#a#b3y~3|P#`#a4P~4UOc~~4XP#T#U4[~4_P#a#b4b~4eP#`#a4h~4mOi~",
|
||||
tokenizers: [blockContent, 0, 1],
|
||||
topRules: {"Document":[0,2]},
|
||||
tokenPrec: 0
|
||||
|
||||
@@ -66,6 +66,7 @@ export type SupportedLanguage =
|
||||
| 'angular'
|
||||
| 'svelte'
|
||||
| 'http' // HTTP Client
|
||||
| 'mermaid'
|
||||
|
||||
/**
|
||||
* 创建块的选项
|
||||
@@ -85,7 +86,6 @@ export interface EditorOptions {
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 分隔符格式常量
|
||||
export const DELIMITER_REGEX = /^\n∞∞∞([a-zA-Z0-9_-]+)(-a)?\n/gm;
|
||||
export const DELIMITER_PREFIX = '\n∞∞∞';
|
||||
|
||||
57
frontend/src/views/editor/language/mermaid/build-parsers.js
Normal file
57
frontend/src/views/editor/language/mermaid/build-parsers.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { buildParserFile } from '@lezer/generator';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const parsersDir = join(__dirname, 'parsers');
|
||||
|
||||
const parserTypes = [
|
||||
'mermaid',
|
||||
'mindmap',
|
||||
'pie',
|
||||
'flowchart',
|
||||
'sequence',
|
||||
'journey',
|
||||
'requirement',
|
||||
'gantt'
|
||||
];
|
||||
|
||||
console.log('开始构建 Mermaid 语法解析器...\n');
|
||||
|
||||
for (const type of parserTypes) {
|
||||
try {
|
||||
const grammarPath = join(parsersDir, type, `${type}.grammar`);
|
||||
const outputPath = join(parsersDir, type, `${type}.parser.grammar.ts`);
|
||||
|
||||
console.log(`正在处理: ${type}`);
|
||||
console.log(` 读取: ${grammarPath}`);
|
||||
|
||||
const grammar = readFileSync(grammarPath, 'utf-8');
|
||||
const result = buildParserFile(grammar, {
|
||||
fileName: `${type}.grammar`,
|
||||
typeScript: true,
|
||||
warn: (message) => console.warn(` 警告: ${message}`)
|
||||
});
|
||||
|
||||
writeFileSync(outputPath, result.parser);
|
||||
console.log(` ✓ 生成: ${outputPath}`);
|
||||
|
||||
// 生成 terms 文件
|
||||
if (result.terms) {
|
||||
const termsPath = join(parsersDir, type, `${type}.grammar.terms.ts`);
|
||||
writeFileSync(termsPath, result.terms);
|
||||
console.log(` ✓ 生成: ${termsPath}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
console.error(` ✗ 错误: ${type} - ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✓ 所有解析器构建完成!');
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { foldService } from '@codemirror/language';
|
||||
|
||||
const countLeadingSpaces = (str: string) => {
|
||||
let count = 0;
|
||||
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
if (str[i] === ' ') {
|
||||
count++;
|
||||
} else if (str[i] === '\t') {
|
||||
count += 4;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
const isEmptyLine = (text: string) => {
|
||||
return /^[ \t]*$/.test(text);
|
||||
};
|
||||
|
||||
export const foldByIndent = () => {
|
||||
return foldService.of((state, lineStart, lineEnd) => {
|
||||
const line = state.doc.lineAt(lineStart);
|
||||
const lineCount = state.doc.lines;
|
||||
|
||||
let indents = countLeadingSpaces(line.text);
|
||||
let foldStart = lineStart;
|
||||
let foldEnd = lineEnd;
|
||||
let nextLine = line;
|
||||
|
||||
while (nextLine.number < lineCount) {
|
||||
nextLine = state.doc.line(nextLine.number + 1);
|
||||
|
||||
if (nextLine.text === '' || isEmptyLine(nextLine.text)) continue;
|
||||
|
||||
let nextIndents = countLeadingSpaces(nextLine.text);
|
||||
|
||||
if (nextIndents > indents && !isEmptyLine(nextLine.text)) {
|
||||
foldEnd = nextLine.to;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
state.doc.lineAt(foldStart).number === state.doc.lineAt(foldEnd).number
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foldStart = line.to;
|
||||
const lineAtFoldStart = state.doc.lineAt(foldStart);
|
||||
|
||||
if (lineAtFoldStart.text === '' || isEmptyLine(lineAtFoldStart.text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { from: foldStart, to: foldEnd };
|
||||
});
|
||||
};
|
||||
45
frontend/src/views/editor/language/mermaid/index.ts
Normal file
45
frontend/src/views/editor/language/mermaid/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export {
|
||||
mermaidLanguage,
|
||||
mindmapLanguage,
|
||||
pieLanguage,
|
||||
flowchartLanguage,
|
||||
sequenceLanguage,
|
||||
journeyLanguage,
|
||||
requirementLanguage,
|
||||
ganttLanguage,
|
||||
} from './language-definitions';
|
||||
|
||||
export {
|
||||
mermaidLanguageDescription,
|
||||
mindmapLanguageDescription,
|
||||
pieLanguageDescription,
|
||||
flowchartLanguageDescription,
|
||||
sequenceLanguageDescription,
|
||||
journeyLanguageDescription,
|
||||
requirementLanguageDescription,
|
||||
ganttLanguageDescription,
|
||||
} from './language-descriptions';
|
||||
|
||||
export {
|
||||
mermaid,
|
||||
mindmap,
|
||||
pie,
|
||||
flowchart,
|
||||
sequence,
|
||||
journey,
|
||||
requirement,
|
||||
gantt,
|
||||
} from './language-support';
|
||||
|
||||
export {
|
||||
mermaidTags,
|
||||
mindmapTags,
|
||||
pieTags,
|
||||
flowchartTags,
|
||||
sequenceTags,
|
||||
journeyTags,
|
||||
requirementTags,
|
||||
ganttTags,
|
||||
} from './tags';
|
||||
|
||||
export { foldByIndent } from './extensions';
|
||||
@@ -0,0 +1,74 @@
|
||||
import { LRLanguage } from '@codemirror/language';
|
||||
import { parseMixed } from '@lezer/common';
|
||||
import {
|
||||
mermaidParser,
|
||||
mindmapParser,
|
||||
pieParser,
|
||||
flowchartParser,
|
||||
sequenceParser,
|
||||
journeyParser,
|
||||
requirementParser,
|
||||
ganttParser,
|
||||
} from '../parsers';
|
||||
import { DiagramType, MermaidLanguageType } from '../types';
|
||||
|
||||
export const mermaidLanguage = LRLanguage.define({
|
||||
name: MermaidLanguageType.Mermaid,
|
||||
parser: mermaidParser.configure({
|
||||
wrap: parseMixed((node) => {
|
||||
switch (node.name) {
|
||||
case DiagramType.Mindmap:
|
||||
return { parser: mindmapParser };
|
||||
case DiagramType.Pie:
|
||||
return { parser: pieParser };
|
||||
case DiagramType.Flowchart:
|
||||
return { parser: flowchartParser };
|
||||
case DiagramType.Sequence:
|
||||
return { parser: sequenceParser };
|
||||
case DiagramType.Journey:
|
||||
return { parser: journeyParser };
|
||||
case DiagramType.Requirement:
|
||||
return { parser: requirementParser };
|
||||
case DiagramType.Gantt:
|
||||
return { parser: ganttParser };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const mindmapLanguage = LRLanguage.define({
|
||||
name: MermaidLanguageType.Mindmap,
|
||||
parser: mindmapParser,
|
||||
});
|
||||
|
||||
export const pieLanguage = LRLanguage.define({
|
||||
name: MermaidLanguageType.Pie,
|
||||
parser: pieParser,
|
||||
});
|
||||
|
||||
export const flowchartLanguage = LRLanguage.define({
|
||||
name: MermaidLanguageType.Flowchart,
|
||||
parser: flowchartParser,
|
||||
});
|
||||
|
||||
export const sequenceLanguage = LRLanguage.define({
|
||||
name: MermaidLanguageType.Sequence,
|
||||
parser: sequenceParser,
|
||||
});
|
||||
|
||||
export const journeyLanguage = LRLanguage.define({
|
||||
name: MermaidLanguageType.Journey,
|
||||
parser: journeyParser,
|
||||
});
|
||||
|
||||
export const requirementLanguage = LRLanguage.define({
|
||||
name: MermaidLanguageType.Requirement,
|
||||
parser: requirementParser,
|
||||
});
|
||||
|
||||
export const ganttLanguage = LRLanguage.define({
|
||||
name: MermaidLanguageType.Gantt,
|
||||
parser: ganttParser,
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { LanguageDescription } from '@codemirror/language';
|
||||
import {
|
||||
mermaid,
|
||||
mindmap,
|
||||
pie,
|
||||
flowchart,
|
||||
sequence,
|
||||
journey,
|
||||
requirement,
|
||||
gantt,
|
||||
} from '../language-support';
|
||||
import { MermaidDescriptionName, MermaidAlias } from '../types';
|
||||
|
||||
export const mermaidLanguageDescription = LanguageDescription.of({
|
||||
name: MermaidDescriptionName.Mermaid,
|
||||
load: async () => {
|
||||
return mermaid();
|
||||
},
|
||||
});
|
||||
|
||||
export const mindmapLanguageDescription = LanguageDescription.of({
|
||||
name: MermaidDescriptionName.Mindmap,
|
||||
load: async () => {
|
||||
return mindmap();
|
||||
},
|
||||
});
|
||||
|
||||
export const pieLanguageDescription = LanguageDescription.of({
|
||||
name: MermaidDescriptionName.Pie,
|
||||
load: async () => {
|
||||
return pie();
|
||||
},
|
||||
});
|
||||
|
||||
export const flowchartLanguageDescription = LanguageDescription.of({
|
||||
name: MermaidDescriptionName.Flowchart,
|
||||
alias: [MermaidAlias.Graph],
|
||||
load: async () => {
|
||||
return flowchart();
|
||||
},
|
||||
});
|
||||
|
||||
export const sequenceLanguageDescription = LanguageDescription.of({
|
||||
name: MermaidDescriptionName.Sequence,
|
||||
alias: [MermaidAlias.Sequence],
|
||||
load: async () => {
|
||||
return sequence();
|
||||
},
|
||||
});
|
||||
|
||||
export const journeyLanguageDescription = LanguageDescription.of({
|
||||
name: MermaidDescriptionName.Journey,
|
||||
load: async () => {
|
||||
return journey();
|
||||
},
|
||||
});
|
||||
|
||||
export const requirementLanguageDescription = LanguageDescription.of({
|
||||
name: MermaidDescriptionName.Requirement,
|
||||
alias: [MermaidAlias.Requirement],
|
||||
load: async () => {
|
||||
return requirement();
|
||||
},
|
||||
});
|
||||
|
||||
export const ganttLanguageDescription = LanguageDescription.of({
|
||||
name: MermaidDescriptionName.Gantt,
|
||||
load: async () => {
|
||||
return gantt();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { LanguageSupport } from '@codemirror/language';
|
||||
import {
|
||||
mermaidLanguage,
|
||||
mindmapLanguage,
|
||||
pieLanguage,
|
||||
flowchartLanguage,
|
||||
sequenceLanguage,
|
||||
journeyLanguage,
|
||||
requirementLanguage,
|
||||
ganttLanguage,
|
||||
} from '../language-definitions';
|
||||
|
||||
export function mermaid() {
|
||||
return new LanguageSupport(mermaidLanguage);
|
||||
}
|
||||
|
||||
export function mindmap() {
|
||||
return new LanguageSupport(mindmapLanguage);
|
||||
}
|
||||
|
||||
export function pie() {
|
||||
return new LanguageSupport(pieLanguage);
|
||||
}
|
||||
|
||||
export function flowchart() {
|
||||
return new LanguageSupport(flowchartLanguage);
|
||||
}
|
||||
|
||||
export function sequence() {
|
||||
return new LanguageSupport(sequenceLanguage);
|
||||
}
|
||||
|
||||
export function journey() {
|
||||
return new LanguageSupport(journeyLanguage);
|
||||
}
|
||||
|
||||
export function requirement() {
|
||||
return new LanguageSupport(requirementLanguage);
|
||||
}
|
||||
|
||||
export function gantt() {
|
||||
return new LanguageSupport(ganttLanguage);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
@top FlowchartDiagram { document+ }
|
||||
|
||||
@skip { spaces | LineComment }
|
||||
|
||||
@skip {} {
|
||||
String {
|
||||
singleQuote (stringContentSingle)* (singleQuote) |
|
||||
doubleQuote (stringContentDouble)* (doubleQuote) |
|
||||
backTick (stringContentBackTick)* (backTick)
|
||||
}
|
||||
}
|
||||
|
||||
document {
|
||||
(
|
||||
DiagramName |
|
||||
DiagramName Orientation |
|
||||
DiagramName Orientation newlines* subDocument (newlines* subDocument semicolon?)*
|
||||
) newlines*
|
||||
}
|
||||
|
||||
|
||||
subDocument {
|
||||
NodeId |
|
||||
Node |
|
||||
Link |
|
||||
NodeEdge |
|
||||
ampersand |
|
||||
Keyword |
|
||||
emptyParentheses |
|
||||
colon |
|
||||
tripleColon |
|
||||
String |
|
||||
StyleKeyword NodeId StyleText
|
||||
}
|
||||
|
||||
NodeId {
|
||||
identifier | Orientation
|
||||
}
|
||||
|
||||
text {
|
||||
NodeText | String
|
||||
}
|
||||
|
||||
edgeText {
|
||||
NodeEdgeText | String
|
||||
}
|
||||
|
||||
emptyParentheses { "()" }
|
||||
|
||||
Node {
|
||||
"(" text ")" |
|
||||
"[" text "]" |
|
||||
"|" text "|" |
|
||||
"([" text "])" |
|
||||
"[(" text "])" |
|
||||
"[[" text "]]" |
|
||||
"[(" text ")]" |
|
||||
"((" text "))" |
|
||||
">" text "]" |
|
||||
"{" text "}" |
|
||||
"{{" text "}}" |
|
||||
"(((" text ")))"
|
||||
}
|
||||
|
||||
NodeEdge {
|
||||
(DoubleHyphen | DoubleEqual) edgeText Link
|
||||
}
|
||||
|
||||
Link {
|
||||
linkType1 |
|
||||
linkType2 |
|
||||
linkType3 |
|
||||
linkType4 |
|
||||
linkType5 |
|
||||
linkType6
|
||||
}
|
||||
|
||||
DiagramName {
|
||||
kw<"flowchart"> |
|
||||
kw<"graph">
|
||||
}
|
||||
|
||||
Orientation {
|
||||
kw<"TB"> |
|
||||
kw<"TD"> |
|
||||
kw<"BT"> |
|
||||
kw<"RL"> |
|
||||
kw<"LR">
|
||||
}
|
||||
|
||||
StyleKeyword {
|
||||
kw<"style"> |
|
||||
kw<"linkStyle"> |
|
||||
kw<"class"> |
|
||||
kw<"classDef">
|
||||
}
|
||||
|
||||
Keyword {
|
||||
kw<"subgraph"> |
|
||||
kw<"end"> |
|
||||
kw<"direction"> |
|
||||
kw<"click"> |
|
||||
kw<"call"> |
|
||||
kw<"href"> |
|
||||
kw<"_self"> |
|
||||
kw<"_blank"> |
|
||||
kw<"_parent"> |
|
||||
kw<"_to">
|
||||
}
|
||||
|
||||
kw<term> { @specialize<identifier, term> }
|
||||
|
||||
@external tokens nodeEdgeText from "./tokens" { NodeEdgeText }
|
||||
@external tokens nodeText from "./tokens" { NodeText }
|
||||
@external tokens styleText from "./tokens" { StyleText }
|
||||
|
||||
/*
|
||||
Single character tokens will need to go inside the @tokens rule to specify precedence over "char".
|
||||
Longer tokens always beat shorter tokens, which is why "flowchart" takes priority over multiple "char" tokens.
|
||||
The @specialize rule also helps makes "DiagramName" have higher priority for "flowchart" even though it overlaps with "char" and "identifier" tokens
|
||||
*/
|
||||
@tokens {
|
||||
char { @asciiLetter | @digit | $[!"\#$%&'*+\.`?\\_\/\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC] }
|
||||
identifier { char+ }
|
||||
newlines { $[\n]+ }
|
||||
spaces { @whitespace+ }
|
||||
stringContentSingle { ![']+ }
|
||||
stringContentDouble { !["]+ }
|
||||
stringContentBackTick { ![`]+ }
|
||||
LineComment { "%%" ![\n]* }
|
||||
DoubleHyphen { "--" }
|
||||
DoubleEqual { "==" }
|
||||
linkType1 { ("<-" | "x-" | "o-") ("->" | "-x" | "-o")}
|
||||
linkType2 { ("<=" | "x=" | "o=") ("=>" | "=x" | "=o")}
|
||||
linkType3 { ("<-" | "x-" | "o-")? "-"+ ("->" | "-x" | "-o")?}
|
||||
linkType4 { ("<=" | "x=" | "o=")? "="+ ("=>" | "=x" | "=o")?}
|
||||
linkType5 { ("<-" | "x-" | "o-" | "-")? "."+ ("->" | "-x" | "-o" | "-")?}
|
||||
linkType6 { "~~~" | "---" | "===" }
|
||||
ampersand { "&" }
|
||||
semicolon { ";" }
|
||||
singleQuote { "'" }
|
||||
doubleQuote { '"' }
|
||||
backTick { "`" }
|
||||
tripleColon[@name=":::"] { ":::" }
|
||||
colon[@name=":"] { ":" }
|
||||
|
||||
@precedence {
|
||||
newlines,
|
||||
spaces,
|
||||
LineComment,
|
||||
linkType1,
|
||||
linkType2,
|
||||
linkType3,
|
||||
linkType4,
|
||||
linkType5,
|
||||
linkType6,
|
||||
DoubleHyphen,
|
||||
DoubleEqual,
|
||||
ampersand,
|
||||
semicolon,
|
||||
singleQuote,
|
||||
doubleQuote,
|
||||
backTick,
|
||||
tripleColon,
|
||||
colon,
|
||||
identifier
|
||||
}
|
||||
}
|
||||
|
||||
@external propSource flowchartHighlighting from "./highlight"
|
||||
3
frontend/src/views/editor/language/mermaid/parsers/flowchart/flowchart.grammar.terms.d.ts
vendored
Normal file
3
frontend/src/views/editor/language/mermaid/parsers/flowchart/flowchart.grammar.terms.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
export declare const NodeText: number;
|
||||
export declare const NodeEdgeText: number;
|
||||
export declare const StyleText: number;
|
||||
@@ -0,0 +1,20 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
NodeEdgeText = 1,
|
||||
NodeText = 2,
|
||||
StyleText = 3,
|
||||
LineComment = 4,
|
||||
FlowchartDiagram = 5,
|
||||
DiagramName = 6,
|
||||
Orientation = 7,
|
||||
NodeId = 8,
|
||||
Node = 9,
|
||||
String = 10,
|
||||
Link = 11,
|
||||
NodeEdge = 12,
|
||||
DoubleHyphen = 13,
|
||||
DoubleEqual = 14,
|
||||
Keyword = 15,
|
||||
colon = 16,
|
||||
tripleColon = 17,
|
||||
StyleKeyword = 18
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parser } from './flowchart.parser.grammar';
|
||||
|
||||
/**
|
||||
* Flowchart Grammar 测试
|
||||
*
|
||||
* 测试目标:验证标准的 Mermaid Flowchart 语法是否能正确解析,不应该出现错误节点(⚠)
|
||||
*/
|
||||
describe('Flowchart 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 current = targetNode;
|
||||
while (current.parent) {
|
||||
depth++;
|
||||
current = current.parent;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
it('应该正确解析基础的 flowchart 声明(TB 方向)', () => {
|
||||
const code = `flowchart TB
|
||||
A
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析基础的 graph 声明(LR 方向)', () => {
|
||||
const code = `graph LR
|
||||
A
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带方框节点的流程图', () => {
|
||||
const code = `flowchart TD
|
||||
A[Christmas]
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带箭头连接的节点', () => {
|
||||
const code = `flowchart TD
|
||||
A --> B
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带标签的连接线', () => {
|
||||
const code = `flowchart TD
|
||||
A -- text --> B
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析不同形状的节点(圆形)', () => {
|
||||
const code = `flowchart TD
|
||||
A((Circle))
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析不同形状的节点(菱形)', () => {
|
||||
const code = `flowchart TD
|
||||
A{Diamond}
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析完整的流程图示例', () => {
|
||||
const code = `flowchart TD
|
||||
A[Start] --> B{Is it?}
|
||||
B -->|Yes| C[OK]
|
||||
B -->|No| D[End]
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
3
frontend/src/views/editor/language/mermaid/parsers/flowchart/flowchart.parser.grammar.d.ts
vendored
Normal file
3
frontend/src/views/editor/language/mermaid/parsers/flowchart/flowchart.parser.grammar.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { LRParser } from '@lezer/lr';
|
||||
|
||||
export declare const parser: LRParser;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
|
||||
import { styleTags, tags as t } from '@lezer/highlight';
|
||||
import { flowchartTags } from '../../tags';
|
||||
|
||||
export const flowchartHighlighting = styleTags({
|
||||
'( )': t.paren,
|
||||
'[ ]': t.squareBracket,
|
||||
'{ }': t.brace,
|
||||
'<': t.angleBracket,
|
||||
DiagramName: flowchartTags.diagramName,
|
||||
DoubleEqual: flowchartTags.link,
|
||||
DoubleHyphen: flowchartTags.link,
|
||||
Keyword: flowchartTags.keyword,
|
||||
LineComment: flowchartTags.lineComment,
|
||||
Link: flowchartTags.link,
|
||||
NodeEdge: flowchartTags.nodeEdge,
|
||||
NodeEdgeText: flowchartTags.nodeEdgeText,
|
||||
NodeId: flowchartTags.nodeId,
|
||||
NodeText: flowchartTags.nodeText,
|
||||
Number: flowchartTags.number,
|
||||
Orientation: flowchartTags.orientation,
|
||||
String: flowchartTags.string,
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ExternalTokenizer } from '@lezer/lr';
|
||||
import { NodeText, NodeEdgeText, StyleText } from './flowchart.grammar.terms';
|
||||
|
||||
const skipCodePoints = [-1, 9, 13, 32, 34, 39, 96];
|
||||
const startBracketCodePoints = [40, 62, 91, 123, 124];
|
||||
const endBracketCodePoints = [41, 93, 124, 125];
|
||||
const hyphen = 45;
|
||||
const equal = 61;
|
||||
const dot = 46;
|
||||
|
||||
export const nodeText = new ExternalTokenizer((input) => {
|
||||
if (
|
||||
skipCodePoints.includes(input.next) ||
|
||||
startBracketCodePoints.includes(input.next)
|
||||
)
|
||||
return;
|
||||
|
||||
while (!endBracketCodePoints.includes(input.next) && input.next !== -1) {
|
||||
input.advance();
|
||||
}
|
||||
|
||||
input.acceptToken(NodeText);
|
||||
});
|
||||
|
||||
export const nodeEdgeText = new ExternalTokenizer((input) => {
|
||||
if (
|
||||
skipCodePoints.includes(input.next) ||
|
||||
startBracketCodePoints.includes(input.next) ||
|
||||
input.next === hyphen ||
|
||||
input.next === equal ||
|
||||
input.next === dot
|
||||
)
|
||||
return;
|
||||
|
||||
while (
|
||||
input.next !== hyphen &&
|
||||
input.next !== equal &&
|
||||
input.next !== dot &&
|
||||
input.next !== -1
|
||||
) {
|
||||
input.advance();
|
||||
}
|
||||
|
||||
input.acceptToken(NodeEdgeText);
|
||||
});
|
||||
|
||||
export const styleText = new ExternalTokenizer((input) => {
|
||||
if (input.next === 10 || input.next === -1) return;
|
||||
|
||||
while (input.next !== 10 && input.next !== -1) {
|
||||
input.advance();
|
||||
}
|
||||
|
||||
input.acceptToken(StyleText);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
@top GanttDiagram {
|
||||
document
|
||||
}
|
||||
|
||||
@skip { spaces }
|
||||
|
||||
document {
|
||||
DiagramName newlines? |
|
||||
DiagramName newlines (subDocument newlines?)+
|
||||
}
|
||||
|
||||
subDocument {
|
||||
Title ImportantText |
|
||||
Section ImportantText |
|
||||
DateFormat Text |
|
||||
AxisFormat Text |
|
||||
Excludes Text |
|
||||
TickInterval Text |
|
||||
TodayMarker Text |
|
||||
Weekday Text |
|
||||
Text |
|
||||
InclusiveEndDates |
|
||||
LineComment
|
||||
}
|
||||
|
||||
ImportantText {
|
||||
text
|
||||
}
|
||||
|
||||
Text {
|
||||
text
|
||||
}
|
||||
|
||||
DiagramName { kw<"gantt"> }
|
||||
|
||||
kw<term> { @specialize<identifier, term> }
|
||||
|
||||
@external tokens textToken from "./tokens" {
|
||||
AxisFormat[group=Keyword],
|
||||
DateFormat[group=Keyword],
|
||||
Excludes[group=Keyword],
|
||||
InclusiveEndDates[group=Keyword],
|
||||
TickInterval[group=Keyword],
|
||||
Title[group=Keyword],
|
||||
TodayMarker[group=Keyword],
|
||||
Weekday[group=Keyword],
|
||||
Section,
|
||||
text
|
||||
}
|
||||
|
||||
@tokens {
|
||||
identifier { @asciiLetter+ }
|
||||
spaces { @whitespace+ }
|
||||
newlines { $[\n]+ }
|
||||
LineComment { "%%" ![\n]* }
|
||||
|
||||
@precedence { newlines, spaces }
|
||||
}
|
||||
|
||||
@external propSource ganttHighlighting from "./highlight"
|
||||
10
frontend/src/views/editor/language/mermaid/parsers/gantt/gantt.grammar.terms.d.ts
vendored
Normal file
10
frontend/src/views/editor/language/mermaid/parsers/gantt/gantt.grammar.terms.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
export declare const AxisFormat: number;
|
||||
export declare const DateFormat: number;
|
||||
export declare const Excludes: number;
|
||||
export declare const InclusiveEndDates: number;
|
||||
export declare const Section: number;
|
||||
export declare const TickInterval: number;
|
||||
export declare const Title: number;
|
||||
export declare const TodayMarker: number;
|
||||
export declare const Weekday: number;
|
||||
export declare const text: number;
|
||||
@@ -0,0 +1,17 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
AxisFormat = 1,
|
||||
DateFormat = 2,
|
||||
Excludes = 3,
|
||||
InclusiveEndDates = 4,
|
||||
TickInterval = 5,
|
||||
Title = 6,
|
||||
TodayMarker = 7,
|
||||
Weekday = 8,
|
||||
Section = 9,
|
||||
text = 17,
|
||||
GanttDiagram = 10,
|
||||
DiagramName = 11,
|
||||
ImportantText = 12,
|
||||
Text = 13,
|
||||
LineComment = 14
|
||||
@@ -0,0 +1,273 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parser } from './gantt.parser.grammar';
|
||||
|
||||
/**
|
||||
* Gantt Diagram Grammar 测试
|
||||
*
|
||||
* 测试目标:验证标准的 Mermaid Gantt Diagram 语法是否能正确解析,不应该出现错误节点(⚠)
|
||||
*/
|
||||
describe('Gantt Diagram 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 current = targetNode;
|
||||
while (current.parent) {
|
||||
depth++;
|
||||
current = current.parent;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
it('应该正确解析基础的 gantt 声明', () => {
|
||||
const code = `gantt
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带标题的 gantt 图', () => {
|
||||
const code = `gantt
|
||||
title A Gantt Diagram
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带日期格式的 gantt 图', () => {
|
||||
const code = `gantt
|
||||
dateFormat YYYY-MM-DD
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带章节的 gantt 图', () => {
|
||||
const code = `gantt
|
||||
dateFormat YYYY-MM-DD
|
||||
section Section
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带任务的 gantt 图', () => {
|
||||
const code = `gantt
|
||||
dateFormat YYYY-MM-DD
|
||||
title Adding GANTT diagram functionality to mermaid
|
||||
section A section
|
||||
Completed task :done, des1, 2014-01-06,2014-01-08
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带活动任务的 gantt 图', () => {
|
||||
const code = `gantt
|
||||
dateFormat YYYY-MM-DD
|
||||
section A section
|
||||
Active task :active, des2, 2014-01-09, 3d
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带 axisFormat 的 gantt 图', () => {
|
||||
const code = `gantt
|
||||
dateFormat YYYY-MM-DD
|
||||
axisFormat %m-%d
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带 excludes 的 gantt 图', () => {
|
||||
const code = `gantt
|
||||
dateFormat YYYY-MM-DD
|
||||
excludes weekends
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带 todayMarker 的 gantt 图', () => {
|
||||
const code = `gantt
|
||||
dateFormat YYYY-MM-DD
|
||||
todayMarker off
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析完整的 gantt 图示例', () => {
|
||||
const code = `gantt
|
||||
dateFormat YYYY-MM-DD
|
||||
title Adding GANTT diagram functionality to mermaid
|
||||
excludes weekends
|
||||
|
||||
section A section
|
||||
Completed task :done, des1, 2014-01-06,2014-01-08
|
||||
Active task :active, des2, 2014-01-09, 3d
|
||||
Future task : des3, after des2, 5d
|
||||
Future task2 : des4, after des3, 5d
|
||||
|
||||
section Critical tasks
|
||||
Completed task in the critical line :crit, done, 2014-01-06,24h
|
||||
Implement parser and jison :crit, done, after des1, 2d
|
||||
Create tests for parser :crit, active, 3d
|
||||
Future task in critical line :crit, 5d
|
||||
Create tests for renderer :2d
|
||||
Add to mermaid :1d
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
3
frontend/src/views/editor/language/mermaid/parsers/gantt/gantt.parser.grammar.d.ts
vendored
Normal file
3
frontend/src/views/editor/language/mermaid/parsers/gantt/gantt.parser.grammar.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { LRParser } from '@lezer/lr';
|
||||
|
||||
export declare const parser: LRParser;
|
||||
@@ -0,0 +1,24 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {textToken} from "./tokens"
|
||||
import {ganttHighlighting} from "./highlight"
|
||||
const spec_identifier = {__proto__:null,gantt:44}
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "!|OVQQOOO[QQO'#CpQOQQOOOOQO'#Cg'#CgO!XQRO,59[OOQP'#Ci'#CiO!`QRO'#CtO!SQRO'#CtOOQP'#Ct'#CtO!eQRO'#CkO#`QRO1G.vOOQP'#Ch'#ChOOQP,59`,59`OOQP,59V,59VOOQP-E6i-E6i",
|
||||
stateData: "#j~OcOS~OfRO~OgSO`dX~OPVOQVORVOSWOTVOUUOVVOWVOXUO^WOaTO~O`da~PdOaZO~Og]OP_XQ_XR_XS_XT_XU_XV_XW_XX_X^_X`_Xa_X~O`di~PdOgc~",
|
||||
goto: "!UiPPPPPPPPPPPjmpPwPPPP}PPP!QRPOR[USWSYR[VQYSR^YRQOTXSY",
|
||||
nodeNames: "⚠ AxisFormat DateFormat Excludes InclusiveEndDates TickInterval Title TodayMarker Weekday Section GanttDiagram DiagramName ImportantText Text LineComment",
|
||||
maxTerm: 24,
|
||||
nodeProps: [
|
||||
["group", -8,1,2,3,4,5,6,7,8,"Keyword"]
|
||||
],
|
||||
propSources: [ganttHighlighting],
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 1,
|
||||
tokenData: "$l~R_XY!QYZ!uZ^!Qpq!Quv#r!c!}$a#T#o$a#y#z!Q$f$g!Q#BY#BZ!Q$IS$I_!Q$I|$JO!Q$JT$JU!Q$KV$KW!Q&FU&FV!Q~!VYc~X^!Qpq!Q#y#z!Q$f$g!Q#BY#BZ!Q$IS$I_!Q$I|$JO!Q$JT$JU!Q$KV$KW!Q&FU&FV!Q~!|[g~c~XY!QYZ!uZ^!Qpq!Q#y#z!Q$f$g!Q#BY#BZ!Q$IS$I_!Q$I|$JO!Q$JT$JU!Q$KV$KW!Q&FU&FV!Q~#uPuv#x~#}S^~OY#xZ;'S#x;'S;=`$Z<%lO#x~$^P;=`<%l#x~$fQe~!c!}$a#T#o$a",
|
||||
tokenizers: [textToken, 0],
|
||||
topRules: {"GanttDiagram":[0,10]},
|
||||
specialized: [{term: 21, get: (value: keyof typeof spec_identifier) => spec_identifier[value] || -1}],
|
||||
tokenPrec: 115
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { styleTags } from '@lezer/highlight';
|
||||
import { ganttTags } from '../../tags';
|
||||
|
||||
export const ganttHighlighting = styleTags({
|
||||
'DiagramName Section': ganttTags.diagramName,
|
||||
Keyword: ganttTags.keyword,
|
||||
ImportantText: ganttTags.string,
|
||||
LineComment: ganttTags.lineComment,
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ExternalTokenizer } from '@lezer/lr';
|
||||
import {
|
||||
AxisFormat,
|
||||
DateFormat,
|
||||
Excludes,
|
||||
InclusiveEndDates,
|
||||
Section,
|
||||
TickInterval,
|
||||
Title,
|
||||
TodayMarker,
|
||||
Weekday,
|
||||
text,
|
||||
} from './gantt.grammar.terms';
|
||||
|
||||
const keywordMap: { [key: string]: number } = {
|
||||
axisFormat: AxisFormat,
|
||||
dateFormat: DateFormat,
|
||||
excludes: Excludes,
|
||||
inclusiveEndDates: InclusiveEndDates,
|
||||
section: Section,
|
||||
tickInterval: TickInterval,
|
||||
title: Title,
|
||||
todayMarker: TodayMarker,
|
||||
weekday: Weekday,
|
||||
};
|
||||
|
||||
const keywords = Object.keys(keywordMap);
|
||||
|
||||
export const textToken = new ExternalTokenizer((input) => {
|
||||
if (input.next === 32 || input.next === 10 || input.next === -1) return;
|
||||
|
||||
if (input.next === 37 && input.peek(1) === 37) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tokens = '';
|
||||
|
||||
while (input.next !== 10 && input.next !== -1) {
|
||||
tokens += String.fromCodePoint(input.next);
|
||||
input.advance();
|
||||
}
|
||||
|
||||
const activeKeyword = keywords.filter((keyword) => {
|
||||
if (keyword === tokens) {
|
||||
return tokens.startsWith(keyword);
|
||||
}
|
||||
return tokens.startsWith(keyword + ' ');
|
||||
});
|
||||
|
||||
if (activeKeyword.length > 0) {
|
||||
input.acceptToken(
|
||||
keywordMap[activeKeyword[0]],
|
||||
activeKeyword[0].length - tokens.length
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
input.acceptToken(text);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export { parser as mermaidParser } from './mermaid/mermaid.parser.grammar';
|
||||
export { parser as mindmapParser } from './mindmap/mindmap.parser.grammar';
|
||||
export { parser as pieParser } from './pie/pie.parser.grammar';
|
||||
export { parser as flowchartParser } from './flowchart/flowchart.parser.grammar';
|
||||
export { parser as sequenceParser } from './sequence/sequence.parser.grammar';
|
||||
export { parser as journeyParser } from './journey/journey.parser.grammar';
|
||||
export { parser as requirementParser } from './requirement/requirement.parser.grammar';
|
||||
export { parser as ganttParser } from './gantt/gantt.parser.grammar';
|
||||
@@ -0,0 +1,11 @@
|
||||
import { styleTags } from '@lezer/highlight';
|
||||
import { journeyTags } from '../../tags';
|
||||
|
||||
export const journeyHighlighting = styleTags({
|
||||
DiagramName: journeyTags.diagramName,
|
||||
'Text TaskName': journeyTags.text,
|
||||
Actor: journeyTags.actor,
|
||||
Keyword: journeyTags.keyword,
|
||||
LineComment: journeyTags.lineComment,
|
||||
Score: journeyTags.score,
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
@top JourneyDiagram {
|
||||
document
|
||||
}
|
||||
|
||||
@skip { spaces }
|
||||
|
||||
document {
|
||||
DiagramName newlines* (
|
||||
() |
|
||||
subDocument newlines* |
|
||||
subDocument (newlines+ subDocument)+ newlines*
|
||||
)
|
||||
}
|
||||
|
||||
subDocument {
|
||||
LineComment |
|
||||
Keyword Text |
|
||||
Task
|
||||
}
|
||||
|
||||
Task {
|
||||
TaskName ":" Score (":" Actor ("," Actor)*)?
|
||||
}
|
||||
|
||||
Text {
|
||||
text1
|
||||
}
|
||||
|
||||
TaskName {
|
||||
text2
|
||||
}
|
||||
|
||||
Score {
|
||||
text2
|
||||
}
|
||||
|
||||
Actor {
|
||||
text3
|
||||
}
|
||||
|
||||
DiagramName { kw<"journey"> }
|
||||
|
||||
kw<term> { @specialize<identifier, term> }
|
||||
|
||||
@external tokens keywordTokens from "./tokens" { Keyword }
|
||||
@external tokens textTokens1 from "./tokens" { text1 }
|
||||
@external tokens textTokens2 from "./tokens" { text2 }
|
||||
@external tokens textTokens3 from "./tokens" { text3 }
|
||||
|
||||
@tokens {
|
||||
spaces { @whitespace+ }
|
||||
newlines { $[\n]+ }
|
||||
LineComment { "%%" ![\n]* }
|
||||
identifier { @asciiLetter+ }
|
||||
|
||||
@precedence { newlines, spaces }
|
||||
}
|
||||
|
||||
@external propSource journeyHighlighting from "./highlight"
|
||||
5
frontend/src/views/editor/language/mermaid/parsers/journey/journey.grammar.terms.d.ts
vendored
Normal file
5
frontend/src/views/editor/language/mermaid/parsers/journey/journey.grammar.terms.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
export declare const Keyword: number;
|
||||
export declare const text1: number;
|
||||
export declare const text2: number;
|
||||
export declare const text3: number;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
Keyword = 1,
|
||||
text1 = 14,
|
||||
text2 = 15,
|
||||
text3 = 16,
|
||||
JourneyDiagram = 2,
|
||||
DiagramName = 3,
|
||||
LineComment = 4,
|
||||
Text = 5,
|
||||
Task = 6,
|
||||
TaskName = 7,
|
||||
Score = 8,
|
||||
Actor = 9
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parser } from './journey.parser.grammar';
|
||||
|
||||
/**
|
||||
* Journey Diagram Grammar 测试
|
||||
*
|
||||
* 测试目标:验证标准的 Mermaid Journey Diagram 语法是否能正确解析,不应该出现错误节点(⚠)
|
||||
*/
|
||||
describe('Journey Diagram 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 current = targetNode;
|
||||
while (current.parent) {
|
||||
depth++;
|
||||
current = current.parent;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
it('应该正确解析基础的 journey 声明', () => {
|
||||
const code = `journey
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带标题的 journey 图', () => {
|
||||
const code = `journey
|
||||
title My working day
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带章节的 journey 图', () => {
|
||||
const code = `journey
|
||||
title My working day
|
||||
section Go to work
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带任务和分数的 journey 图', () => {
|
||||
const code = `journey
|
||||
title My working day
|
||||
section Go to work
|
||||
Make tea: 5
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带任务、分数和参与者的 journey 图', () => {
|
||||
const code = `journey
|
||||
title My working day
|
||||
section Go to work
|
||||
Make tea: 5: Me
|
||||
Go upstairs: 3: Me
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带多个参与者的任务', () => {
|
||||
const code = `journey
|
||||
title My working day
|
||||
section Go to work
|
||||
Make tea: 5: Me, Cat
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析多个章节的 journey 图', () => {
|
||||
const code = `journey
|
||||
title My working day
|
||||
section Go to work
|
||||
Make tea: 5: Me
|
||||
Go upstairs: 3: Me
|
||||
section Work
|
||||
Do work: 1: Me, Cat
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析完整的 journey 图示例', () => {
|
||||
const code = `journey
|
||||
title My working day
|
||||
section Go to work
|
||||
Make tea: 5: Me
|
||||
Go upstairs: 3: Me
|
||||
Do work: 1: Me, Cat
|
||||
section Go home
|
||||
Go downstairs: 5: Me
|
||||
Sit down: 5: Me
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
3
frontend/src/views/editor/language/mermaid/parsers/journey/journey.parser.grammar.d.ts
vendored
Normal file
3
frontend/src/views/editor/language/mermaid/parsers/journey/journey.parser.grammar.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { LRParser } from '@lezer/lr';
|
||||
|
||||
export declare const parser: LRParser;
|
||||
@@ -0,0 +1,21 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {keywordTokens, textTokens1, textTokens2, textTokens3} from "./tokens"
|
||||
import {journeyHighlighting} from "./highlight"
|
||||
const spec_identifier = {__proto__:null,journey:42}
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "%^OVQ`OOO[QeO'#CoQOQ`OOOOQT'#C_'#C_OOQT'#Cf'#CfOmQeO,59ZOOQO'#Cc'#CcO!OQ`O'#CbOOQO'#Cs'#CsO!TQbO'#CsOvQ`O,59ZOOQT-E6d-E6dO!YQ`O1G.uO!bQdO,58|OOQO'#Ca'#CaOOQO,59_,59_O!gQeO1G.uO!YQ`O1G.uO!xQeO7+$aO#RQ`O7+$aOOQO'#Cd'#CdO#ZQ`O1G.hOOQO,59S,59SOOQO-E6f-E6fO#fQeO<<G{O#wQhO7+$SP#|QeO'#CfOOQO'#Ce'#CeO$[Q`O<<GnO#wQhO'#CgO$gQ`OAN=YOOQO,59R,59ROOQO-E6e-E6e",
|
||||
stateData: "$u~ObOS~OeRO~OPXOSWO_UOfSO]cX~OPXOSWO_UOfSO]ca~Oh]O~O^^O~OfSO]ci~O_dO~OPXOSWO_UOfSO]ci~OPXOSWO_UOfSO]cq~OhiO]UifUi~OPXOSWO_UOfSO]cy~O`kO~OPXOSWO_UOfSO~OimO]UyfUy~OimO]U!RfU!R~Ofb~",
|
||||
goto: "#_hPPPiPlow!P!S!Y!n!tPPPPPP#OPPP#RRPOR_X]WPT`bhj]VPT`bhjRe]QliRomQTPYZT`bhjQ`YSb[aRhcQnlRpnQaYQc[TgacRQOQYPQ[TXf`bhj",
|
||||
nodeNames: "⚠ Keyword JourneyDiagram DiagramName LineComment Text Task TaskName Score Actor",
|
||||
maxTerm: 25,
|
||||
propSources: [journeyHighlighting],
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 3,
|
||||
tokenData: "$|~RaXY!WYZ!{Z^!Wpq!Wuv#x|}$g![!]$l!c!}$q#T#o$q#y#z!W$f$g!W#BY#BZ!W$IS$I_!W$I|$JO!W$JT$JU!W$KV$KW!W&FU&FV!W~!]Yb~X^!Wpq!W#y#z!W$f$g!W#BY#BZ!W$IS$I_!W$I|$JO!W$JT$JU!W$KV$KW!W&FU&FV!W~#S[f~b~XY!WYZ!{Z^!Wpq!W#y#z!W$f$g!W#BY#BZ!W$IS$I_!W$I|$JO!W$JT$JU!W$KV$KW!W&FU&FV!W~#{Puv$O~$TSS~OY$OZ;'S$O;'S;=`$a<%lO$O~$dP;=`<%l$O~$lOi~~$qOh~~$vQd~!c!}$q#T#o$q",
|
||||
tokenizers: [keywordTokens, textTokens1, textTokens2, textTokens3, 0],
|
||||
topRules: {"JourneyDiagram":[0,2]},
|
||||
specialized: [{term: 20, get: (value: keyof typeof spec_identifier) => spec_identifier[value] || -1}],
|
||||
tokenPrec: 172
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ExternalTokenizer } from '@lezer/lr';
|
||||
import { Keyword, text1, text2, text3 } from './journey.grammar.terms';
|
||||
|
||||
import type { InputStream } from '@lezer/lr';
|
||||
|
||||
const skipCodePoints = [-1, 9, 10, 13, 32];
|
||||
|
||||
const keywords = ['title', 'section'];
|
||||
|
||||
const isComment = (input: InputStream) => {
|
||||
return input.peek(0) === 37 && input.peek(1) === 37;
|
||||
};
|
||||
|
||||
const shouldSkip = (input: InputStream) => {
|
||||
return skipCodePoints.includes(input.next) || isComment(input);
|
||||
};
|
||||
|
||||
export const keywordTokens = new ExternalTokenizer((input) => {
|
||||
if (shouldSkip(input)) return;
|
||||
|
||||
let tokens = '';
|
||||
|
||||
while (!skipCodePoints.includes(input.next)) {
|
||||
tokens += String.fromCodePoint(input.next);
|
||||
input.advance();
|
||||
}
|
||||
|
||||
const activeKeyword = keywords.filter((keyword) => {
|
||||
if (keyword === tokens) {
|
||||
return tokens.toLowerCase().startsWith(keyword);
|
||||
}
|
||||
return tokens.toLowerCase().startsWith(keyword + ' '); // ensure the keyword isn't used as a token unless there's a space at the end e.g. titleStuff
|
||||
});
|
||||
|
||||
if (activeKeyword.length > 0) {
|
||||
input.acceptToken(Keyword, activeKeyword[0].length - tokens.length);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
export const textTokens1 = new ExternalTokenizer((input) => {
|
||||
if (shouldSkip(input)) return;
|
||||
|
||||
while (input.next !== 10 && input.next !== -1) {
|
||||
input.advance();
|
||||
}
|
||||
|
||||
input.acceptToken(text1);
|
||||
});
|
||||
|
||||
export const textTokens2 = new ExternalTokenizer((input) => {
|
||||
if (shouldSkip(input)) return;
|
||||
|
||||
while (input.next !== 58 && input.next !== 10 && input.next !== -1) {
|
||||
input.advance();
|
||||
}
|
||||
|
||||
input.acceptToken(text2);
|
||||
});
|
||||
|
||||
export const textTokens3 = new ExternalTokenizer((input) => {
|
||||
if (shouldSkip(input)) return;
|
||||
|
||||
while (input.next !== 44 && input.next !== 10 && input.next !== -1) {
|
||||
input.advance();
|
||||
}
|
||||
|
||||
input.acceptToken(text3);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// See this link for entire Pie chart syntax in Mermaid: https://mermaid.js.org/syntax/pie.html#syntax
|
||||
@top MermaidDiagram {
|
||||
preDiagramLine* (
|
||||
PieDiagram |
|
||||
MindmapDiagram |
|
||||
FlowchartDiagram |
|
||||
SequenceDiagram |
|
||||
JourneyDiagram |
|
||||
RequirementDiagram |
|
||||
GanttDiagram
|
||||
)
|
||||
}
|
||||
|
||||
@skip { space }
|
||||
|
||||
@tokens {
|
||||
space { $[ \t\r]+ }
|
||||
}
|
||||
|
||||
@external tokens diagramText from "./tokens" {
|
||||
preDiagramLine,
|
||||
PieDiagram,
|
||||
MindmapDiagram,
|
||||
FlowchartDiagram,
|
||||
SequenceDiagram,
|
||||
JourneyDiagram,
|
||||
RequirementDiagram,
|
||||
GanttDiagram
|
||||
}
|
||||
8
frontend/src/views/editor/language/mermaid/parsers/mermaid/mermaid.grammar.terms.d.ts
vendored
Normal file
8
frontend/src/views/editor/language/mermaid/parsers/mermaid/mermaid.grammar.terms.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export declare const preDiagramLine: number;
|
||||
export declare const MindmapDiagram: number;
|
||||
export declare const PieDiagram: number;
|
||||
export declare const FlowchartDiagram: number;
|
||||
export declare const SequenceDiagram: number;
|
||||
export declare const JourneyDiagram: number;
|
||||
export declare const RequirementDiagram: number;
|
||||
export declare const GanttDiagram: number;
|
||||
@@ -0,0 +1,11 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
preDiagramLine = 11,
|
||||
PieDiagram = 1,
|
||||
MindmapDiagram = 2,
|
||||
FlowchartDiagram = 3,
|
||||
SequenceDiagram = 4,
|
||||
JourneyDiagram = 5,
|
||||
RequirementDiagram = 6,
|
||||
GanttDiagram = 7,
|
||||
MermaidDiagram = 8
|
||||
@@ -0,0 +1,283 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parser } from './mermaid.parser.grammar';
|
||||
|
||||
/**
|
||||
* Mermaid Grammar 测试
|
||||
*
|
||||
* 测试目标:验证标准的 Mermaid 综合语法是否能正确解析,不应该出现错误节点(⚠)
|
||||
* 这个测试涵盖所有类型的 Mermaid 图表
|
||||
*/
|
||||
describe('Mermaid 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 current = targetNode;
|
||||
while (current.parent) {
|
||||
depth++;
|
||||
current = current.parent;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
it('应该正确解析 Pie 图', () => {
|
||||
const code = `pie title Pets
|
||||
"Dogs" : 386
|
||||
"Cats" : 85
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析 Mindmap 图', () => {
|
||||
const code = `mindmap
|
||||
root((mindmap))
|
||||
Origins
|
||||
Long history
|
||||
Research
|
||||
On effectiveness
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析 Flowchart 图', () => {
|
||||
const code = `flowchart TD
|
||||
A[Start] --> B{Is it?}
|
||||
B -->|Yes| C[OK]
|
||||
B -->|No| D[End]
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析 Sequence 图', () => {
|
||||
const code = `sequenceDiagram
|
||||
Alice->>John: Hello John
|
||||
John-->>Alice: Great!
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析 Journey 图', () => {
|
||||
const code = `journey
|
||||
title My working day
|
||||
section Go to work
|
||||
Make tea: 5: Me
|
||||
Go upstairs: 3: Me
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析 Requirement 图', () => {
|
||||
const code = `requirementDiagram
|
||||
|
||||
requirement test_req {
|
||||
id: 1
|
||||
text: the test text
|
||||
risk: high
|
||||
}
|
||||
|
||||
element test_entity {
|
||||
type: simulation
|
||||
}
|
||||
|
||||
test_entity - satisfies -> test_req
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析 Gantt 图', () => {
|
||||
const code = `gantt
|
||||
dateFormat YYYY-MM-DD
|
||||
title Adding GANTT diagram
|
||||
section A section
|
||||
Completed task :done, des1, 2014-01-06,2014-01-08
|
||||
Active task :active, des2, 2014-01-09, 3d
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带注释的 flowchart 图', () => {
|
||||
const code = `%% This is a comment
|
||||
flowchart TD
|
||||
A[Start] --> B[End]
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带空行的 sequence 图', () => {
|
||||
const code = `
|
||||
sequenceDiagram
|
||||
participant Alice
|
||||
|
||||
Alice->>John: Hello
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析 graph 类型的流程图', () => {
|
||||
const code = `graph LR
|
||||
A[Square Rect] -- Link text --> B((Circle))
|
||||
A --> C(Round Rect)
|
||||
B --> D{Rhombus}
|
||||
C --> D
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
3
frontend/src/views/editor/language/mermaid/parsers/mermaid/mermaid.parser.grammar.d.ts
vendored
Normal file
3
frontend/src/views/editor/language/mermaid/parsers/mermaid/mermaid.parser.grammar.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { LRParser } from '@lezer/lr';
|
||||
|
||||
export declare const parser: LRParser;
|
||||
@@ -0,0 +1,17 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {diagramText} from "./tokens"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "nOVQROOOOQQ'#Ce'#CeOVQROOQOQPOOOOQQ-E6c-E6c",
|
||||
stateData: "q~O]OS~OPROQRORROSROTROUROVROZPO~O",
|
||||
goto: "aYPPPPPPPPPZQQORSQ",
|
||||
nodeNames: "⚠ PieDiagram MindmapDiagram FlowchartDiagram SequenceDiagram JourneyDiagram RequirementDiagram GanttDiagram MermaidDiagram",
|
||||
maxTerm: 13,
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 1,
|
||||
tokenData: "j~RRXY[]^[pq[~aR]~XY[]^[pq[",
|
||||
tokenizers: [0, diagramText],
|
||||
topRules: {"MermaidDiagram":[0,8]},
|
||||
tokenPrec: 0
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ExternalTokenizer } from '@lezer/lr';
|
||||
import {
|
||||
preDiagramLine,
|
||||
MindmapDiagram,
|
||||
PieDiagram,
|
||||
FlowchartDiagram,
|
||||
SequenceDiagram,
|
||||
JourneyDiagram,
|
||||
RequirementDiagram,
|
||||
GanttDiagram,
|
||||
} from './mermaid.grammar.terms';
|
||||
|
||||
const skipCodePoints = [-1, 9, 13, 32];
|
||||
|
||||
const diagramMap: Record<string, number> = {
|
||||
mindmap: MindmapDiagram,
|
||||
pie: PieDiagram,
|
||||
flowchart: FlowchartDiagram,
|
||||
graph: FlowchartDiagram,
|
||||
sequenceDiagram: SequenceDiagram,
|
||||
journey: JourneyDiagram,
|
||||
requirementDiagram: RequirementDiagram,
|
||||
gantt: GanttDiagram,
|
||||
};
|
||||
|
||||
const diagrams = Object.keys(diagramMap);
|
||||
|
||||
export const diagramText = new ExternalTokenizer((input) => {
|
||||
if (skipCodePoints.includes(input.next)) return;
|
||||
|
||||
let tokens = '';
|
||||
|
||||
while (input.next != 10 && input.next !== -1) {
|
||||
tokens += String.fromCodePoint(input.next);
|
||||
input.advance();
|
||||
}
|
||||
|
||||
input.advance();
|
||||
|
||||
const activeDiagram = diagrams.filter((diagram) => {
|
||||
return tokens.startsWith(diagram);
|
||||
});
|
||||
|
||||
if (activeDiagram.length > 0) {
|
||||
while (input.next !== -1) {
|
||||
input.advance();
|
||||
}
|
||||
input.acceptToken(diagramMap[activeDiagram[0]]);
|
||||
} else {
|
||||
input.acceptToken(preDiagramLine);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { styleTags } from '@lezer/highlight';
|
||||
import { mindmapTags } from '../../tags';
|
||||
|
||||
export const mindmapHighlighting = styleTags({
|
||||
DiagramName: mindmapTags.diagramName,
|
||||
LineText1: mindmapTags.lineText1,
|
||||
LineText2: mindmapTags.lineText2,
|
||||
LineText3: mindmapTags.lineText3,
|
||||
LineText4: mindmapTags.lineText4,
|
||||
LineText5: mindmapTags.lineText5,
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
@top MindmapDiagram {
|
||||
newline+ |
|
||||
DiagramName Line*
|
||||
}
|
||||
|
||||
@skip { spaces | newlineEmpty }
|
||||
|
||||
lineText {
|
||||
LineText1 |
|
||||
LineText2 |
|
||||
LineText3 |
|
||||
LineText4 |
|
||||
LineText5
|
||||
}
|
||||
|
||||
ShapedText {
|
||||
square |
|
||||
roundedSquare |
|
||||
circle |
|
||||
bang |
|
||||
cloud |
|
||||
hexagon
|
||||
}
|
||||
|
||||
square {
|
||||
"[" lineText "]"
|
||||
}
|
||||
|
||||
roundedSquare {
|
||||
"(" lineText ")"
|
||||
}
|
||||
|
||||
circle {
|
||||
"((" lineText "))"
|
||||
}
|
||||
|
||||
bang {
|
||||
"))" lineText "(("
|
||||
}
|
||||
|
||||
cloud {
|
||||
")" lineText "("
|
||||
}
|
||||
|
||||
hexagon {
|
||||
"{{" lineText "}}"
|
||||
}
|
||||
|
||||
IconLine {
|
||||
"::" Icon "(" lineText ")"
|
||||
}
|
||||
|
||||
ClassLine {
|
||||
":::" lineText
|
||||
}
|
||||
|
||||
Line {
|
||||
newline |
|
||||
newline indent (
|
||||
lineText |
|
||||
IconLine |
|
||||
ClassLine |
|
||||
ShapedText |
|
||||
lineText ShapedText
|
||||
)
|
||||
}
|
||||
|
||||
DiagramName { kw<"mindmap"> }
|
||||
|
||||
Icon { kw<"icon"> }
|
||||
|
||||
kw<term> { @specialize<word, term> }
|
||||
|
||||
@context trackIndent from "./tokens.js"
|
||||
|
||||
@external tokens indentation from "./tokens" { indent }
|
||||
@external tokens lineTextType from "./tokens" {
|
||||
LineText1,
|
||||
LineText2,
|
||||
LineText3,
|
||||
LineText4,
|
||||
LineText5
|
||||
}
|
||||
|
||||
@tokens {
|
||||
spaces { ($[ \t\f] | "\\" $[\n\r])+ }
|
||||
word { @asciiLetter+ }
|
||||
}
|
||||
|
||||
@external tokens newlines from "./tokens" { newline, newlineEmpty }
|
||||
|
||||
@external propSource mindmapHighlighting from "./highlight"
|
||||
8
frontend/src/views/editor/language/mermaid/parsers/mindmap/mindmap.grammar.terms.d.ts
vendored
Normal file
8
frontend/src/views/editor/language/mermaid/parsers/mindmap/mindmap.grammar.terms.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export declare const newline: number;
|
||||
export declare const newlineEmpty: number;
|
||||
export declare const indent: number;
|
||||
export declare const LineText1: number;
|
||||
export declare const LineText2: number;
|
||||
export declare const LineText3: number;
|
||||
export declare const LineText4: number;
|
||||
export declare const LineText5: number;
|
||||
@@ -0,0 +1,17 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
indent = 16,
|
||||
LineText1 = 1,
|
||||
LineText2 = 2,
|
||||
LineText3 = 3,
|
||||
LineText4 = 4,
|
||||
LineText5 = 5,
|
||||
newline = 17,
|
||||
newlineEmpty = 18,
|
||||
MindmapDiagram = 6,
|
||||
DiagramName = 7,
|
||||
Line = 8,
|
||||
IconLine = 9,
|
||||
Icon = 10,
|
||||
ClassLine = 11,
|
||||
ShapedText = 12
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parser } from './mindmap.parser.grammar';
|
||||
|
||||
/**
|
||||
* Mindmap Grammar 测试
|
||||
*
|
||||
* 测试目标:验证标准的 Mermaid Mindmap 语法是否能正确解析,不应该出现错误节点(⚠)
|
||||
*/
|
||||
describe('Mindmap 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 current = targetNode;
|
||||
while (current.parent) {
|
||||
depth++;
|
||||
current = current.parent;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
it('应该正确解析基础的 mindmap 声明', () => {
|
||||
const code = `mindmap
|
||||
Root
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带子节点的 mindmap', () => {
|
||||
const code = `mindmap
|
||||
Root
|
||||
A
|
||||
B
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析多层级的 mindmap', () => {
|
||||
const code = `mindmap
|
||||
Root
|
||||
A
|
||||
A1
|
||||
A2
|
||||
B
|
||||
B1
|
||||
B2
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带方括号形状的节点', () => {
|
||||
const code = `mindmap
|
||||
Root
|
||||
[Square node]
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带圆括号形状的节点', () => {
|
||||
const code = `mindmap
|
||||
Root
|
||||
(Rounded node)
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带双圆括号形状的节点(圆形)', () => {
|
||||
const code = `mindmap
|
||||
Root
|
||||
((Circle node))
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带花括号形状的节点(六边形)', () => {
|
||||
const code = `mindmap
|
||||
Root
|
||||
{{Hexagon node}}
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析完整的 mindmap 示例', () => {
|
||||
const code = `mindmap
|
||||
root((mindmap))
|
||||
Origins
|
||||
Long history
|
||||
::icon(fa fa-book)
|
||||
Popularisation
|
||||
British popular psychology author Tony Buzan
|
||||
Research
|
||||
On effectiveness<br/>and features
|
||||
On Automatic creation
|
||||
Uses
|
||||
Creative techniques
|
||||
Strategic planning
|
||||
Argument mapping
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
3
frontend/src/views/editor/language/mermaid/parsers/mindmap/mindmap.parser.grammar.d.ts
vendored
Normal file
3
frontend/src/views/editor/language/mermaid/parsers/mindmap/mindmap.parser.grammar.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { LRParser } from '@lezer/lr';
|
||||
|
||||
export declare const parser: LRParser;
|
||||
@@ -0,0 +1,23 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {indentation, lineTextType, newlines} from "./tokens"
|
||||
import {trackIndent} from "./tokens.js"
|
||||
import {mindmapHighlighting} from "./highlight"
|
||||
const spec_word = {__proto__:null,mindmap:44, icon:50}
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "&fOYQ[OOOOQW'#Ci'#CiQbQ[OOQgQ[OOOOQW'#Cc'#CcOOQW-E6g-E6gOlQ]O'#CdOOQW'#Cj'#CjQgQ[OOO!]Q^O,59OOOQW-E6h-E6hOOQW'#Cs'#CsO!vQ[O'#CeO!{Q^O'#CgO!{Q^O'#CyO!{Q^O'#C|O!{Q^O'#C}O!{Q^O'#DQO!{Q^O'#DRO!{Q^O'#DSOOQW'#Ch'#ChO#^Q[O1G.jOOQW1G.j1G.jO#hQ[O,59POOQW'#Cf'#CfOOQW,59R,59RO#mQ[O,59eO#rQ[O,59hO#wQ[O,59iO#|Q[O,59lO$RQ[O,59mO$WQ[O,59nOOQW7+$U7+$UO!{Q^O1G.kOOQW1G/P1G/POOQW1G/S1G/SOOQW1G/T1G/TOOQW1G/W1G/WOOQW1G/X1G/XOOQW1G/Y1G/YO$]Q[O7+$VOOQW<<Gq<<Gq",
|
||||
stateData: "$b~OdOSbOS~OaPOfSO~OaPO~OaUO~O`XO_WXaWX~Oj_OkbOn^Or`OsaOwcO~OPZOQZORZOSZOTZOh[Ol]O~PwOihO~OPZOQZORZOSZOTZO~O_WiaWi~PwOjqO~OorO~OksO~OstO~OruO~OjvO~OxwO~OkyO~O",
|
||||
goto: "#YwPPPPPPPx{!P!S!P!V!]!cPPPPPPPP!iPPPPP#UPP#U#UPP#U#U#URROTVRWRfXRg[QfXRpeQQORTQQWRRYWQeXQi]Qj^Qk_Ql`QmaQnbQocRxqTdXe",
|
||||
nodeNames: "⚠ LineText1 LineText2 LineText3 LineText4 LineText5 MindmapDiagram DiagramName Line IconLine Icon ClassLine ShapedText",
|
||||
maxTerm: 40,
|
||||
context: trackIndent,
|
||||
propSources: [mindmapHighlighting],
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 2,
|
||||
tokenData: "$b~R]XYz[]zpqzxy!fyz!s![!]#Q!c!}#e!}#O#p#O#P!]#P#Q#u#T#o#e#o#p#z#q#r$V~!PSd~XYz[]zpqz#O#P!]~!`QYZz]^z~!kPj~xy!n~!sOr~~!xPk~yz!{~#QOs~~#TP![!]#W~#]Ph~![!]#`~#eOl~~#jQe~!c!}#e#T#o#e~#uOn~~#zOo~~#}P#o#p$Q~$VOw~~$YP#q#r$]~$bOx~",
|
||||
tokenizers: [indentation, lineTextType, 0, newlines],
|
||||
topRules: {"MindmapDiagram":[0,6]},
|
||||
specialized: [{term: 21, get: (value: keyof typeof spec_word) => spec_word[value] || -1}],
|
||||
tokenPrec: 0
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ExternalTokenizer, ContextTracker } from '@lezer/lr';
|
||||
import {
|
||||
newline as newlineToken,
|
||||
newlineEmpty,
|
||||
indent,
|
||||
LineText1,
|
||||
LineText2,
|
||||
LineText3,
|
||||
LineText4,
|
||||
LineText5,
|
||||
} from './mindmap.grammar.terms';
|
||||
|
||||
import type { InputStream } from '@lezer/lr';
|
||||
|
||||
type InputStreamWithRead = InputStream & {
|
||||
read: (inputPosition: number, stackPosition: number) => string;
|
||||
};
|
||||
|
||||
const LineTextTokens = [LineText1, LineText2, LineText3, LineText4, LineText5];
|
||||
|
||||
const newline = 10,
|
||||
carriageReturn = 13,
|
||||
space = 32,
|
||||
tab = 9,
|
||||
hash = 35,
|
||||
colon = 58,
|
||||
parenL = 40,
|
||||
parenR = 41,
|
||||
bracketL = 91,
|
||||
bracketR = 93,
|
||||
braceL = 123,
|
||||
braceR = 125;
|
||||
|
||||
export const newlines = new ExternalTokenizer(
|
||||
(input, _stack) => {
|
||||
if (input.next < 0) return;
|
||||
else {
|
||||
input.advance();
|
||||
let spaces = 0;
|
||||
while ((input.next as number) == space || (input.next as number) == tab) {
|
||||
input.advance();
|
||||
spaces++;
|
||||
}
|
||||
let empty =
|
||||
input.next == newline ||
|
||||
input.next == carriageReturn ||
|
||||
input.next == hash;
|
||||
input.acceptToken(empty ? newlineEmpty : newlineToken, -spaces);
|
||||
}
|
||||
},
|
||||
{ contextual: true, fallback: true }
|
||||
);
|
||||
|
||||
export const lineTextType = new ExternalTokenizer((input, stack) => {
|
||||
let chars = 0;
|
||||
|
||||
while (input.next > -1 && input.next !== newline) {
|
||||
if (input.next === colon) return;
|
||||
|
||||
if (
|
||||
input.next === parenL ||
|
||||
input.next === bracketL ||
|
||||
input.next === braceL
|
||||
) {
|
||||
if (chars > 0) {
|
||||
input.acceptToken(stack.context.lineType);
|
||||
return;
|
||||
} else return;
|
||||
}
|
||||
|
||||
if (
|
||||
(input.next === parenR ||
|
||||
input.next === bracketR ||
|
||||
input.next === braceR) &&
|
||||
chars > 0
|
||||
) {
|
||||
input.acceptToken(stack.context.lineType);
|
||||
return;
|
||||
}
|
||||
|
||||
input.advance();
|
||||
chars++;
|
||||
}
|
||||
|
||||
input.acceptToken(stack.context.lineType);
|
||||
});
|
||||
|
||||
const tabDepth = (depth: number) => {
|
||||
return 4 - (depth % 4);
|
||||
};
|
||||
|
||||
export const indentation = new ExternalTokenizer((input, _stack) => {
|
||||
let prev = input.peek(-1);
|
||||
if (prev == newline || prev == carriageReturn) {
|
||||
let depth = 0;
|
||||
let chars = 0;
|
||||
|
||||
while (true) {
|
||||
if (input.next == space) depth++;
|
||||
else if (input.next == tab) depth += tabDepth(depth);
|
||||
else break;
|
||||
input.advance();
|
||||
chars++;
|
||||
}
|
||||
|
||||
if (
|
||||
input.next != newline &&
|
||||
input.next != carriageReturn &&
|
||||
input.next != hash
|
||||
) {
|
||||
input.acceptToken(indent);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const indentTracker = {
|
||||
lineType: LineText1,
|
||||
};
|
||||
|
||||
const countIndent = (space: string) => {
|
||||
let depth = 0;
|
||||
for (let i = 0; i < space.length; i++)
|
||||
depth += space.charCodeAt(i) == tab ? tabDepth(depth) : 1;
|
||||
return depth;
|
||||
};
|
||||
|
||||
const getLineType = (depth: number) => {
|
||||
return LineTextTokens[depth % 5];
|
||||
};
|
||||
|
||||
export const trackIndent = new ContextTracker({
|
||||
start: indentTracker,
|
||||
shift(context, term, stack, input: InputStreamWithRead) {
|
||||
if (term === indent) {
|
||||
const depth = countIndent(input.read(input.pos, stack.pos));
|
||||
context.lineType = getLineType(depth);
|
||||
}
|
||||
return context;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { styleTags } from '@lezer/highlight';
|
||||
import { pieTags } from '../../tags';
|
||||
|
||||
export const pieHighlighting = styleTags({
|
||||
DiagramName: pieTags.diagramName,
|
||||
LineComment: pieTags.lineComment,
|
||||
Number: pieTags.number,
|
||||
ShowData: pieTags.showData,
|
||||
String: pieTags.string,
|
||||
Title: pieTags.title,
|
||||
TitleText: pieTags.titleText,
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
@top PieDiagram { document+ }
|
||||
|
||||
@skip { spaces | LineComment }
|
||||
|
||||
document {
|
||||
DiagramName ShowData? (
|
||||
() |
|
||||
Title |
|
||||
Title TitleText |
|
||||
Title TitleText kvPair+ |
|
||||
Title kvPair+ |
|
||||
kvPair+
|
||||
)
|
||||
}
|
||||
|
||||
@skip {} {
|
||||
String {
|
||||
'"' (stringContentDouble)* '"'
|
||||
}
|
||||
}
|
||||
|
||||
kvPair {
|
||||
String ":" Number
|
||||
}
|
||||
|
||||
DiagramName { kw<"pie"> }
|
||||
|
||||
ShowData { kw<"showData"> }
|
||||
|
||||
Title { kw<"title"> }
|
||||
|
||||
kw<term> { @specialize<identifier, term> }
|
||||
|
||||
@external tokens titleText from "./tokens" { TitleText }
|
||||
|
||||
@tokens {
|
||||
identifier { @asciiLetter+ }
|
||||
stringContentDouble { !["]+ }
|
||||
spaces { @whitespace+ }
|
||||
Number { @digit+ ("." @digit+)? }
|
||||
LineComment { "%%" ![\n]* }
|
||||
}
|
||||
|
||||
@external propSource pieHighlighting from "./highlight"
|
||||
2
frontend/src/views/editor/language/mermaid/parsers/pie/pie.grammar.terms.d.ts
vendored
Normal file
2
frontend/src/views/editor/language/mermaid/parsers/pie/pie.grammar.terms.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export declare const LineComment: number;
|
||||
export declare const TitleText: number;
|
||||
@@ -0,0 +1,10 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
TitleText = 1,
|
||||
LineComment = 2,
|
||||
PieDiagram = 3,
|
||||
DiagramName = 4,
|
||||
ShowData = 5,
|
||||
Title = 6,
|
||||
String = 7,
|
||||
Number = 8
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parser } from './pie.parser.grammar';
|
||||
|
||||
/**
|
||||
* Pie Chart Grammar 测试
|
||||
*
|
||||
* 测试目标:验证标准的 Mermaid Pie Chart 语法是否能正确解析,不应该出现错误节点(⚠)
|
||||
*/
|
||||
describe('Pie Chart 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 current = targetNode;
|
||||
while (current.parent) {
|
||||
depth++;
|
||||
current = current.parent;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
it('应该正确解析基础的 pie 声明', () => {
|
||||
const code = `pie
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带 showData 的 pie 图', () => {
|
||||
const code = `pie showData
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带标题的 pie 图', () => {
|
||||
const code = `pie title My Pie Chart
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带数据的 pie 图', () => {
|
||||
const code = `pie
|
||||
"Dogs" : 386
|
||||
"Cats" : 85
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带标题和数据的完整 pie 图', () => {
|
||||
const code = `pie title Pets adopted by volunteers
|
||||
"Dogs" : 386
|
||||
"Cats" : 85
|
||||
"Rats" : 15
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带小数值的数据', () => {
|
||||
const code = `pie
|
||||
"A" : 10.5
|
||||
"B" : 20.75
|
||||
"C" : 30.25
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带 showData 和完整数据的 pie 图', () => {
|
||||
const code = `pie showData
|
||||
title Key elements in Product X
|
||||
"Calcium" : 42.96
|
||||
"Potassium" : 50.05
|
||||
"Magnesium" : 10.01
|
||||
"Iron" : 5
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
3
frontend/src/views/editor/language/mermaid/parsers/pie/pie.parser.grammar.d.ts
vendored
Normal file
3
frontend/src/views/editor/language/mermaid/parsers/pie/pie.parser.grammar.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { LRParser } from '@lezer/lr';
|
||||
|
||||
export declare const parser: LRParser;
|
||||
@@ -0,0 +1,21 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {titleText} from "./tokens"
|
||||
import {pieHighlighting} from "./highlight"
|
||||
const spec_identifier = {__proto__:null,pie:34, showData:36, title:38}
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "$nOYQQOOO_QQO'#CkOOQO'#Ce'#CeQYQQOOOOQO'#C`'#C`OpOSO'#CcOxQQO'#CpOOQO'#Cf'#CfO}QQO,59VO!YQRO,59VO!hQQO,59VOOQO'#Ca'#CaOOQP'#Cb'#CbOOQO-E6c-E6cOOOO'#Cg'#CgO!vOSO,58}OOQO,58},58}O#OQQO,59[OOQO-E6d-E6dO#TQQO1G.qO#TQQO1G.qO#`QRO1G.qOOOO-E6e-E6eOOQO1G.i1G.iOOQO1G.v1G.vO#nQQO7+$]O#nQQO7+$]O#yQQO<<Gw",
|
||||
stateData: "$U~O^OSQOS~OaSO~ObZOc[OeTO[_Xa_X~Oe`Of^O~OgaO~OeTO[_aa_a~OPdOeTO[_aa_a~Oc[OeTO[_aa_a~OegOf^O~OWhO~OeTO[_ia_i~OPjOeTO[_ia_i~OeTO[_qa_q~OeTO[_ya_y~O",
|
||||
goto: "#RePPPPfjmsP!P!V!kPPP!qPPPP!uTPORRYPQXPReYeUPWXYcdeijkQROR]RQWPWbWcikScXYSideRkjQ_TRf_TQOReVPWXYcdeijk",
|
||||
nodeNames: "⚠ TitleText LineComment PieDiagram DiagramName ShowData Title String Number",
|
||||
maxTerm: 23,
|
||||
propSources: [pieHighlighting],
|
||||
skippedNodes: [0,2],
|
||||
repeatNodeCount: 3,
|
||||
tokenData: "*V~RrOX#]X^#t^p#]pq#tqr#]rs%gsu#]uv%lv!Q#]!Q!['`![!])R!]!c#]!c!})f!}#T#]#T#o)f#o#y#]#y#z#t#z$f#]$f$g#t$g#BY#]#BY#BZ#t#BZ$IS#]$IS$I_#t$I_$I|#]$I|$JO#t$JO$JT#]$JT$JU#t$JU$KV#]$KV$KW#t$KW&FU#]&FU&FV#t&FV;'S#];'S;=`#n<%lO#]Q#bSfQOr#]s;'S#];'S;=`#n<%lO#]Q#qP;=`<%l#]R#{h^PfQOX#]X^#t^p#]pq#tqr#]s#y#]#y#z#t#z$f#]$f$g#t$g#BY#]#BY#BZ#t#BZ$IS#]$IS$I_#t$I_$I|#]$I|$JO#t$JO$JT#]$JT$JU#t$JU$KV#]$KV$KW#t$KW&FU#]&FU&FV#t&FV;'S#];'S;=`#n<%lO#]~%lOe~R%qUfQOr#]su#]uv&Tv;'S#];'S;=`#n<%lO#]R&[VQPfQOY&TYZ#]Zr&Trs&qs;'S&T;'S;=`'Y<%lO&TP&vSQPOY&qZ;'S&q;'S;=`'S<%lO&qP'VP;=`<%l&qR']P;=`<%l&TR'gWWPfQOr#]s!O#]!O!P(P!P!Q#]!Q!['`![;'S#];'S;=`#n<%lO#]R(UUfQOr#]s!Q#]!Q![(h![;'S#];'S;=`#n<%lO#]R(oUWPfQOr#]s!Q#]!Q![(h![;'S#];'S;=`#n<%lO#]R)YSgPfQOr#]s;'S#];'S;=`#n<%lO#]R)mW`PfQOr#]s!c#]!c!})f!}#T#]#T#o)f#o;'S#];'S;=`#n<%lO#]",
|
||||
tokenizers: [titleText, 0, 1],
|
||||
topRules: {"PieDiagram":[0,3]},
|
||||
specialized: [{term: 16, get: (value: keyof typeof spec_identifier) => spec_identifier[value] || -1}],
|
||||
tokenPrec: 0
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ExternalTokenizer } from '@lezer/lr';
|
||||
import { TitleText } from './pie.grammar.terms';
|
||||
|
||||
export const titleText = new ExternalTokenizer((input) => {
|
||||
if (input.next === 10) {
|
||||
input.acceptToken(TitleText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.next === -1) return;
|
||||
|
||||
while (input.next !== 10 && input.next !== -1) {
|
||||
input.advance();
|
||||
}
|
||||
|
||||
input.acceptToken(TitleText);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { styleTags } from '@lezer/highlight';
|
||||
import { requirementTags } from '../../tags';
|
||||
|
||||
export const requirementHighlighting = styleTags({
|
||||
'DiagramName SubDiagramType': requirementTags.diagramName,
|
||||
LineComment: requirementTags.lineComment,
|
||||
IDNumber: requirementTags.number,
|
||||
'UnquotedString RelationshipStart': requirementTags.unquotedString,
|
||||
QuotedString: requirementTags.quotedString,
|
||||
PropKeyword: requirementTags.unquotedString,
|
||||
Keyword: requirementTags.keyword,
|
||||
'ForwardArrow BackArrow Hyphen': requirementTags.arrow,
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
@top RequirementDiagram {
|
||||
document
|
||||
}
|
||||
|
||||
@skip { spaces | LineComment }
|
||||
|
||||
@skip {} {
|
||||
UnquotedString { unquotedString }
|
||||
|
||||
QuotedString {
|
||||
'"' (stringContent)* '"'
|
||||
}
|
||||
}
|
||||
|
||||
document {
|
||||
DiagramName newlines? |
|
||||
DiagramName newlines ((subDiagram | RelationshipLine ) newlines?)+
|
||||
}
|
||||
|
||||
subDiagram {
|
||||
SubDiagramType subDiagramName "{" newlines "}" |
|
||||
SubDiagramType subDiagramName "{" newlines subDiagramLine+ "}"
|
||||
}
|
||||
|
||||
subDiagramName {
|
||||
UnquotedString | QuotedString
|
||||
}
|
||||
|
||||
subDiagramLine {
|
||||
(
|
||||
idLine |
|
||||
textLine |
|
||||
riskLine |
|
||||
verifyMethodLine |
|
||||
typeLine |
|
||||
docRefLine
|
||||
) newlines?
|
||||
}
|
||||
|
||||
idLine {
|
||||
ID ":" IDNumber
|
||||
}
|
||||
|
||||
textLine {
|
||||
Text ":" textContent
|
||||
}
|
||||
|
||||
riskLine {
|
||||
Risk ":" RiskType
|
||||
}
|
||||
|
||||
verifyMethodLine {
|
||||
VerifyMethod ":" VerifyMethodType
|
||||
}
|
||||
|
||||
typeLine {
|
||||
Type ":" textContent
|
||||
}
|
||||
|
||||
docRefLine {
|
||||
DocRef ":" textContent
|
||||
}
|
||||
|
||||
textContent {
|
||||
UnquotedString | QuotedString
|
||||
}
|
||||
|
||||
RelationshipLine {
|
||||
relationshipStart Hyphen RelationshipType ForwardArrow relationshipEnd |
|
||||
relationshipStart BackArrow RelationshipType Hyphen relationshipEnd
|
||||
}
|
||||
|
||||
relationshipStart {
|
||||
RelationshipStart | QuotedString
|
||||
}
|
||||
|
||||
relationshipEnd {
|
||||
UnquotedString | QuotedString
|
||||
}
|
||||
|
||||
DiagramName { diagramKw<"requirementDiagram"> }
|
||||
|
||||
SubDiagramType {
|
||||
diagramKw<"requirement"> | diagramKw<"Requirement"> |
|
||||
diagramKw<"functionalRequirement"> | diagramKw<"FunctionalRequirement"> |
|
||||
diagramKw<"performanceRequirement"> | diagramKw<"PerformanceRequirement"> |
|
||||
diagramKw<"interfaceRequirement"> | diagramKw<"InterfaceRequirement"> |
|
||||
diagramKw<"physicalRequirement"> | diagramKw<"PhysicalRequirement"> |
|
||||
diagramKw<"designConstraint"> | diagramKw<"DesignConstraint"> |
|
||||
diagramKw<"element"> | diagramKw<"Element">
|
||||
}
|
||||
|
||||
ID { propKw<"id"> | propKw<"Id"> | propKw<"ID"> }
|
||||
|
||||
Text { propKw<"text"> | propKw<"Text"> }
|
||||
|
||||
Risk { propKw<"risk"> | propKw<"Risk"> }
|
||||
|
||||
VerifyMethod { propKw<"verifymethod"> | propKw<"verifyMethod"> | propKw<"VerifyMethod"> }
|
||||
|
||||
Type { propKw<"type"> | propKw<"Type"> }
|
||||
|
||||
DocRef { propKw<"docRef"> | propKw<"DocRef"> }
|
||||
|
||||
RiskType {
|
||||
kw<"low"> | kw<"Low"> |
|
||||
kw<"medium"> | kw<"Medium"> |
|
||||
kw<"high"> | kw<"High">
|
||||
}
|
||||
|
||||
VerifyMethodType {
|
||||
kw<"analysis"> | kw<"Analysis"> |
|
||||
kw<"demonstration"> | kw<"Demonstration"> |
|
||||
kw<"inspection"> | kw<"Inspection"> |
|
||||
kw<"test"> | kw<"Test">
|
||||
}
|
||||
|
||||
RelationshipType {
|
||||
kw<"contains"> | kw<"Contains"> |
|
||||
kw<"copies"> | kw<"Copies"> |
|
||||
kw<"derives"> | kw<"Derives"> |
|
||||
kw<"satisfies"> | kw<"Satisfies"> |
|
||||
kw<"verifies"> | kw<"Verifies"> |
|
||||
kw<"refines"> | kw<"Refines"> |
|
||||
kw<"traces"> | kw<"Traces">
|
||||
}
|
||||
|
||||
diagramKw<term> { @specialize<word, term> }
|
||||
propKw<term> { @specialize[@name=PropKeyword]<word, term> }
|
||||
kw<term> { @specialize[@name=Keyword]<word, term> }
|
||||
|
||||
@external tokens relationshipStart from "./tokens" { RelationshipStart }
|
||||
|
||||
@tokens {
|
||||
word { @asciiLetter+ }
|
||||
spaces { @whitespace+ }
|
||||
newlines { $[\n]+ }
|
||||
LineComment { "%%" ![\n]* }
|
||||
unquotedString { word ![\r\n\{\<\>\-\=]* }
|
||||
IDNumber { @digit+ ("." @digit+)* }
|
||||
stringContent { !["]+ }
|
||||
ForwardArrow { "->"}
|
||||
BackArrow { "<-"}
|
||||
Hyphen { "-" }
|
||||
|
||||
@precedence { newlines, spaces }
|
||||
@precedence { ForwardArrow, Hyphen }
|
||||
@precedence { BackArrow, Hyphen }
|
||||
}
|
||||
|
||||
@external propSource requirementHighlighting from "./highlight"
|
||||
@@ -0,0 +1 @@
|
||||
export declare const RelationshipStart: number;
|
||||
@@ -0,0 +1,25 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
RelationshipStart = 1,
|
||||
LineComment = 2,
|
||||
RequirementDiagram = 3,
|
||||
DiagramName = 4,
|
||||
SubDiagramType = 5,
|
||||
UnquotedString = 6,
|
||||
QuotedString = 7,
|
||||
ID = 8,
|
||||
PropKeyword = 44,
|
||||
IDNumber = 12,
|
||||
Text = 13,
|
||||
Risk = 16,
|
||||
RiskType = 19,
|
||||
Keyword = 61,
|
||||
VerifyMethod = 26,
|
||||
VerifyMethodType = 30,
|
||||
Type = 39,
|
||||
DocRef = 42,
|
||||
RelationshipLine = 45,
|
||||
Hyphen = 46,
|
||||
RelationshipType = 47,
|
||||
ForwardArrow = 62,
|
||||
BackArrow = 63
|
||||
@@ -0,0 +1,330 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parser } from './requirement.parser.grammar';
|
||||
|
||||
/**
|
||||
* Requirement Diagram Grammar 测试
|
||||
*
|
||||
* 测试目标:验证标准的 Mermaid Requirement Diagram 语法是否能正确解析,不应该出现错误节点(⚠)
|
||||
*/
|
||||
describe('Requirement Diagram 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 current = targetNode;
|
||||
while (current.parent) {
|
||||
depth++;
|
||||
current = current.parent;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
it('应该正确解析基础的 requirementDiagram 声明', () => {
|
||||
const code = `requirementDiagram
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析空的需求定义', () => {
|
||||
const code = `requirementDiagram
|
||||
|
||||
requirement test_req {
|
||||
}
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带 ID 的需求', () => {
|
||||
const code = `requirementDiagram
|
||||
|
||||
requirement test_req {
|
||||
id: 1
|
||||
}
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带文本的需求', () => {
|
||||
const code = `requirementDiagram
|
||||
|
||||
requirement test_req {
|
||||
id: 1
|
||||
text: the test text
|
||||
}
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带风险级别的需求', () => {
|
||||
const code = `requirementDiagram
|
||||
|
||||
requirement test_req {
|
||||
id: 1
|
||||
text: the test text
|
||||
risk: high
|
||||
}
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带验证方法的需求', () => {
|
||||
const code = `requirementDiagram
|
||||
|
||||
requirement test_req {
|
||||
id: 1
|
||||
text: the test text
|
||||
risk: high
|
||||
verifymethod: test
|
||||
}
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析不同类型的需求(functionalRequirement)', () => {
|
||||
const code = `requirementDiagram
|
||||
|
||||
functionalRequirement test_req {
|
||||
id: 1.1
|
||||
text: the test text
|
||||
}
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析元素定义', () => {
|
||||
const code = `requirementDiagram
|
||||
|
||||
element test_entity {
|
||||
type: simulation
|
||||
}
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析关系(contains)', () => {
|
||||
const code = `requirementDiagram
|
||||
|
||||
requirement test_req {
|
||||
id: 1
|
||||
}
|
||||
|
||||
element test_entity {
|
||||
type: simulation
|
||||
}
|
||||
|
||||
test_entity - contains -> test_req
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析反向关系', () => {
|
||||
const code = `requirementDiagram
|
||||
|
||||
requirement test_req {
|
||||
id: 1
|
||||
}
|
||||
|
||||
element test_entity {
|
||||
type: simulation
|
||||
}
|
||||
|
||||
test_req <- satisfies - test_entity
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析完整的需求图示例', () => {
|
||||
const code = `requirementDiagram
|
||||
|
||||
requirement test_req {
|
||||
id: 1
|
||||
text: the test text.
|
||||
risk: high
|
||||
verifymethod: test
|
||||
}
|
||||
|
||||
functionalRequirement test_req2 {
|
||||
id: 1.1
|
||||
text: the second test text.
|
||||
risk: low
|
||||
verifymethod: inspection
|
||||
}
|
||||
|
||||
element test_entity {
|
||||
type: simulation
|
||||
}
|
||||
|
||||
test_entity - satisfies -> test_req2
|
||||
test_req - traces -> test_req2
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { LRParser } from '@lezer/lr';
|
||||
|
||||
export declare const parser: LRParser;
|
||||
@@ -0,0 +1,21 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {relationshipStart} from "./tokens"
|
||||
import {requirementHighlighting} from "./highlight"
|
||||
const spec_word = {__proto__:null,requirementDiagram:144, requirement:150, Requirement:152, functionalRequirement:154, FunctionalRequirement:156, performanceRequirement:158, PerformanceRequirement:160, interfaceRequirement:162, InterfaceRequirement:164, physicalRequirement:166, PhysicalRequirement:168, designConstraint:170, DesignConstraint:172, element:174, Element:176, id:18, Id:20, ID:22, text:28, Text:30, risk:34, Risk:36, low:40, Low:42, medium:44, Medium:46, high:48, High:50, verifymethod:54, verifyMethod:56, VerifyMethod:58, analysis:62, Analysis:64, demonstration:66, Demonstration:68, inspection:70, Inspection:72, test:74, Test:76, type:80, Type:82, docRef:86, DocRef:88, contains:96, Contains:98, copies:100, Copies:102, derives:104, Derives:106, satisfies:108, Satisfies:110, verifies:112, Verifies:114, refines:116, Refines:118, traces:120, Traces:122}
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: ")`OYQQOOO_QQO'#DtQOQQOOOOQO'#C`'#C`O!kQRO,5:`O!rOSO'#CcOOQO'#Ef'#EfO!zQQO'#DZO#SQRO'#DnO$^QRO1G/zOOQO'#Ca'#CaO$eQWO'#DxOOOO'#Do'#DoO$mOSO,58}OOQP,58},58}O$uQQO,59uO$uQQO,59uOOQP,5:Y,5:YOOQP-E7l-E7lOOQP'#Cb'#CbOOQP'#Eg'#EgO%sQQO,5:dOOOO-E7m-E7mOOQP1G.i1G.iO%xQQO1G/aOOQO'#D]'#D]O%}QQO1G/aO&SQQO1G0OO$eQWO7+${O'VQQO7+%jOOQP<<Hg<<HgO'^QQO'#E_O'cQQO'#EbO'hQQO'#EcO'mQQO'#E^OOQO'#Dp'#DpO(qQQO<<IUOOQO'#Cd'#CdOOQO'#Ci'#CiOOQO'#Cl'#ClOOQO'#Cv'#CvOOQO'#DT'#DTOOQO'#DW'#DWO(xQQO'#EaO(}QQO'#EdO)SQQO'#EeOOQP<<IU<<IUO)XQQO,5:yO)^QQO,5:|O)rQQO,5:}OOQO,5:x,5:xOOQO-E7n-E7nOOQPAN>pAN>pO$eQWO,5:{O$eQWO,5;OO$eQWO,5;POOQO1G0e1G0eOOQO1G0h1G0hOOQO'#Co'#CoOOQO1G0i1G0iOOQO'#Cz'#CzOOQO1G0g1G0gOOQO1G0j1G0jOOQO1G0k1G0k",
|
||||
stateData: "*e~O!gOSQOS~O!jRO~O!kSO!e!hX~OPUO!mYO!nYO!oYO!pYO!qYO!rYO!sYO!tYO!uYO!vYO!wYO!xYO!yYO!zYO!|TO~O!e!ha~PgO!|^O!}[O~O!O_O!a`O~O!kaOP!bX!e!bX!m!bX!n!bX!o!bX!p!bX!q!bX!r!bX!s!bX!t!bX!u!bX!v!bX!w!bX!x!bX!y!bX!z!bX!|!bX~O!e!hi~PgO!{cO!|TO~O!|gO!}[O~O!QiO!RiO!SiO!TiO!UiO!ViO!WiO!XiO!YiO!ZiO![iO!]iO!^iO!_iO~O#OkO~O!`lO~O!OlO~O!kmO~OXuOYuOZuO^vO_vOawObwOkxOlxOmxOxyOyyO{zO|zO~O#P!OO~P&XO#S!PO~O#S!QO~O#S!RO~O!k!SOX#QXY#QXZ#QX^#QX_#QXa#QXb#QXk#QXl#QXm#QXx#QXy#QX{#QX|#QX#P#QX~O#P!UO~P&XO#S!VO~O#S!WO~O#S!XO~O[!YO~Od![Oe![Of![Og![Oh![Oi![O~Oo!^Op!^Oq!^Or!^Os!^Ot!^Ou!^Ov!^O~O!k!a!g!`!O!`~",
|
||||
goto: "%r#[PPPP#]#`#d#k#vPPPP#zPP$OPP$SPPPPPP$VPPP$ZPPPPPPPP$^PP$bPP$fP$jPPPPPPPPPPPPPPPP$p$v$|PPP%SPPP$fPPPPPPPPPPPPPPPPPPP%V%ZP%Z%Z%Z%Z%Z%_%cRPOTZSXZdZl!V!W!XSUSXZdZl!V!W!XTomtT{mtTpmtR!Z!QTqmtR!]!RT|mtT}mtTWSXQh_Rj`QXSRbXQ]TRf]QtmR!TtRQOTsmtTrmtTVSXQeZQnlQ!_!VQ!`!WR!a!X",
|
||||
nodeNames: "⚠ RelationshipStart LineComment RequirementDiagram DiagramName SubDiagramType UnquotedString QuotedString ID PropKeyword PropKeyword PropKeyword IDNumber Text PropKeyword PropKeyword Risk PropKeyword PropKeyword RiskType Keyword Keyword Keyword Keyword Keyword Keyword VerifyMethod PropKeyword PropKeyword PropKeyword VerifyMethodType Keyword Keyword Keyword Keyword Keyword Keyword Keyword Keyword Type PropKeyword PropKeyword DocRef PropKeyword PropKeyword RelationshipLine Hyphen RelationshipType Keyword Keyword Keyword Keyword Keyword Keyword Keyword Keyword Keyword Keyword Keyword Keyword Keyword Keyword ForwardArrow BackArrow",
|
||||
maxTerm: 103,
|
||||
propSources: [requirementHighlighting],
|
||||
skippedNodes: [0,2],
|
||||
repeatNodeCount: 3,
|
||||
tokenData: "1g~R{OX#xXY$aYZ&SZ^$a^p#xpq$aqr#xrs'}su#xuv(Sv}#x}!O)v!O!Q#x!Q![*t![!]+|!]!^#x!^!_,a!_!c#x!c!}-]!}#T#x#T#o-]#o#p0o#p#q#x#q#r1S#r#y#x#y#z$a#z$f#x$f$g$a$g#BY#x#BY#BZ$a#BZ$IS#x$IS$I_$a$I_$I|#x$I|$JO$a$JO$JT#x$JT$JU$a$JU$KV#x$KV$KW$a$KW&FU#x&FU&FV$a&FV;'S#x;'S;=`$Z<%lO#xQ#}S!}QOr#xs;'S#x;'S;=`$Z<%lO#xQ$^P;=`<%l#xV$hh!}Q!gTOX#xX^$a^p#xpq$aqr#xs#y#x#y#z$a#z$f#x$f$g$a$g#BY#x#BY#BZ$a#BZ$IS#x$IS$I_$a$I_$I|#x$I|$JO$a$JO$JT#x$JT$JU$a$JU$KV#x$KV$KW$a$KW&FU#x&FU&FV$a&FV;'S#x;'S;=`$Z<%lO#xV&]j!}Q!kP!gTOX#xXY$aYZ&SZ^$a^p#xpq$aqr#xs#y#x#y#z$a#z$f#x$f$g$a$g#BY#x#BY#BZ$a#BZ$IS#x$IS$I_$a$I_$I|#x$I|$JO$a$JO$JT#x$JT$JU$a$JU$KV#x$KV$KW$a$KW&FU#x&FU&FV$a&FV;'S#x;'S;=`$Z<%lO#x~(SO!|~V(XU!}QOr#xsu#xuv(kv;'S#x;'S;=`$Z<%lO#xV(rVQT!}QOY(kYZ#xZr(krs)Xs;'S(k;'S;=`)p<%lO(kT)^SQTOY)XZ;'S)X;'S;=`)j<%lO)XT)mP;=`<%l)XV)sP;=`<%l(kR)}U!}Q!OPOr#xs!`#x!`!a*a!a;'S#x;'S;=`$Z<%lO#xR*hS!}Q!`POr#xs;'S#x;'S;=`$Z<%lO#xR*{W[P!}QOr#xs!O#x!O!P+e!P!Q#x!Q![*t![;'S#x;'S;=`$Z<%lO#xR+jU!}QOr#xs!Q#x!Q![*t![;'S#x;'S;=`$Z<%lO#xR,TS#SP!}QOr#xs;'S#x;'S;=`$Z<%lO#xR,fU!}QOr#xs}#x}!O,x!O;'S#x;'S;=`$Z<%lO#xR-PS!}Q!aPOr#xs;'S#x;'S;=`$Z<%lO#xV-fb!}Q!{S!iPOY.nYZ#xZ].n]^#x^r.nrs/ts}.n}!O#x!O!^.n!^!a#x!a!c.n!c!}-]!}#T.n#T#o-]#o#p#x#p;'S.n;'S;=`0i<%lO.nU.u_!}Q!{SOY.nYZ#xZ].n]^#x^r.nrs/ts}.n}!O#x!O!^.n!^!a#x!a#o.n#o#p#x#p;'S.n;'S;=`0i<%lO.nS/yW!{SOY/tZ]/t^}/t!O!^/t!a#o/t#p;'S/t;'S;=`0c<%lO/tS0fP;=`<%l/tU0lP;=`<%l.nR0vS#OP!}QOr#xs;'S#x;'S;=`$Z<%lO#xR1ZS#PP!}QOr#xs;'S#x;'S;=`$Z<%lO#x",
|
||||
tokenizers: [relationshipStart, 0, 1, 2],
|
||||
topRules: {"RequirementDiagram":[0,3]},
|
||||
specialized: [{term: 71, get: (value: keyof typeof spec_word) => spec_word[value] || -1}],
|
||||
tokenPrec: 428
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ExternalTokenizer } from '@lezer/lr';
|
||||
import { RelationshipStart } from './requirement.grammar.terms';
|
||||
|
||||
const notAllowedCodePoints = [-1, 45, 60, 62, 10, 13, 123, 61];
|
||||
|
||||
export const relationshipStart = new ExternalTokenizer((input) => {
|
||||
if (notAllowedCodePoints.includes(input.next) || input.next === 32) return;
|
||||
|
||||
let peek;
|
||||
let tokens = '';
|
||||
let count = 0;
|
||||
|
||||
do {
|
||||
peek = input.peek(count);
|
||||
if (peek === -1) return;
|
||||
tokens += String.fromCodePoint(peek);
|
||||
count++;
|
||||
} while (!notAllowedCodePoints.includes(peek));
|
||||
|
||||
if (peek === 45 || peek === 60) {
|
||||
tokens = tokens.slice(0, -1).trim();
|
||||
input.acceptToken(RelationshipStart, tokens.length);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { styleTags } from '@lezer/highlight';
|
||||
import { sequenceTags } from '../../tags';
|
||||
|
||||
export const sequenceHighlighting = styleTags({
|
||||
DiagramName: sequenceTags.diagramName,
|
||||
NodeText: sequenceTags.nodeText,
|
||||
Keyword1: sequenceTags.keyword1,
|
||||
Keyword2: sequenceTags.keyword2,
|
||||
LineComment: sequenceTags.lineComment,
|
||||
'Arrow ArrowSuffix': sequenceTags.arrow,
|
||||
Position: sequenceTags.position,
|
||||
MessageText1: sequenceTags.messageText1,
|
||||
MessageText2: sequenceTags.messageText2,
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
@top SequenceDiagram {
|
||||
document
|
||||
}
|
||||
|
||||
@skip { spaces }
|
||||
|
||||
document {
|
||||
DiagramName newlines? |
|
||||
DiagramName newlines subDocument newlines? |
|
||||
DiagramName newlines subDocument (newlines subDocument)+ newlines?
|
||||
}
|
||||
|
||||
subDocument {
|
||||
LineComment |
|
||||
NodeText Arrow ArrowSuffix? newlines? NodeText ":" MessageText1 |
|
||||
(Create | Destroy)? (Participant | Actor)? NodeText (As NodeText)? |
|
||||
(Activate | Deactivate) NodeText |
|
||||
Note Position NodeText ("," NodeText)? ":" MessageText1 |
|
||||
Keyword MessageText2 |
|
||||
Autonumber |
|
||||
End |
|
||||
Link NodeText ":" MessageText1
|
||||
}
|
||||
|
||||
MessageText1 {
|
||||
messageText
|
||||
}
|
||||
|
||||
MessageText2 {
|
||||
messageText
|
||||
}
|
||||
|
||||
ArrowSuffix {
|
||||
"+" | "-"
|
||||
}
|
||||
|
||||
Link[group=Keyword1] {
|
||||
link | links
|
||||
}
|
||||
|
||||
Keyword[group=Keyword1] {
|
||||
alt |
|
||||
and |
|
||||
box |
|
||||
break |
|
||||
critical |
|
||||
else |
|
||||
loop |
|
||||
opt |
|
||||
option |
|
||||
par |
|
||||
rect
|
||||
}
|
||||
|
||||
DiagramName { kw<"sequenceDiagram"> }
|
||||
|
||||
kw<term> { @specialize<identifier, term> }
|
||||
|
||||
@external tokens messageTextToken from "./tokens" { messageText }
|
||||
@external tokens textTokens from "./tokens" {
|
||||
Activate[group=Keyword1],
|
||||
Autonumber[group=Keyword1],
|
||||
Create[group=Keyword1],
|
||||
Deactivate[group=Keyword1],
|
||||
Destroy[group=Keyword1],
|
||||
End[group=Keyword1],
|
||||
Note[group=Keyword1],
|
||||
Actor[group=Keyword2],
|
||||
As[group=Keyword2],
|
||||
Participant[group=Keyword2],
|
||||
NodeText,
|
||||
Position,
|
||||
alt,
|
||||
and,
|
||||
box,
|
||||
break,
|
||||
critical,
|
||||
else,
|
||||
link,
|
||||
links
|
||||
loop,
|
||||
opt,
|
||||
option,
|
||||
par,
|
||||
rect
|
||||
}
|
||||
|
||||
@tokens {
|
||||
spaces { @whitespace+ }
|
||||
newlines { $[\n]+ }
|
||||
LineComment { "%%" ![\n]* }
|
||||
identifierChar { @asciiLetter | $[$\u{a1}-\u{10ffff}] }
|
||||
word { identifierChar (identifierChar | @digit)* }
|
||||
identifier { word }
|
||||
Arrow {
|
||||
"->" |
|
||||
"-->" |
|
||||
"->>" |
|
||||
"-->>" |
|
||||
"-x" |
|
||||
"--x" |
|
||||
"-)" |
|
||||
"--)"
|
||||
}
|
||||
|
||||
@precedence {
|
||||
newlines,
|
||||
spaces,
|
||||
Arrow,
|
||||
identifier
|
||||
}
|
||||
}
|
||||
|
||||
@external propSource sequenceHighlighting from "./highlight"
|
||||
26
frontend/src/views/editor/language/mermaid/parsers/sequence/sequence.grammar.terms.d.ts
vendored
Normal file
26
frontend/src/views/editor/language/mermaid/parsers/sequence/sequence.grammar.terms.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
export declare const _break: number;
|
||||
export declare const _else: number;
|
||||
export declare const Activate: number;
|
||||
export declare const Actor: number;
|
||||
export declare const alt: number;
|
||||
export declare const and: number;
|
||||
export declare const As: number;
|
||||
export declare const Autonumber: number;
|
||||
export declare const box: number;
|
||||
export declare const Create: number;
|
||||
export declare const critical: number;
|
||||
export declare const Deactivate: number;
|
||||
export declare const Destroy: number;
|
||||
export declare const End: number;
|
||||
export declare const link: number;
|
||||
export declare const links: number;
|
||||
export declare const loop: number;
|
||||
export declare const messageText: number;
|
||||
export declare const NodeText: number;
|
||||
export declare const Note: number;
|
||||
export declare const opt: number;
|
||||
export declare const option: number;
|
||||
export declare const par: number;
|
||||
export declare const Participant: number;
|
||||
export declare const Position: number;
|
||||
export declare const rect: number;
|
||||
@@ -0,0 +1,37 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
messageText = 24,
|
||||
Activate = 1,
|
||||
Autonumber = 2,
|
||||
Create = 3,
|
||||
Deactivate = 4,
|
||||
Destroy = 5,
|
||||
End = 6,
|
||||
Note = 7,
|
||||
Actor = 8,
|
||||
As = 9,
|
||||
Participant = 10,
|
||||
NodeText = 11,
|
||||
Position = 12,
|
||||
alt = 25,
|
||||
and = 26,
|
||||
box = 27,
|
||||
_break = 28,
|
||||
critical = 29,
|
||||
_else = 30,
|
||||
link = 31,
|
||||
links = 32,
|
||||
loop = 33,
|
||||
opt = 34,
|
||||
option = 35,
|
||||
par = 36,
|
||||
rect = 37,
|
||||
SequenceDiagram = 13,
|
||||
DiagramName = 14,
|
||||
LineComment = 15,
|
||||
Arrow = 16,
|
||||
ArrowSuffix = 17,
|
||||
MessageText1 = 18,
|
||||
Keyword = 19,
|
||||
MessageText2 = 20,
|
||||
Link = 21
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parser } from './sequence.parser.grammar';
|
||||
|
||||
/**
|
||||
* Sequence Diagram Grammar 测试
|
||||
*
|
||||
* 测试目标:验证标准的 Mermaid Sequence Diagram 语法是否能正确解析,不应该出现错误节点(⚠)
|
||||
*/
|
||||
describe('Sequence Diagram 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 current = targetNode;
|
||||
while (current.parent) {
|
||||
depth++;
|
||||
current = current.parent;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
it('应该正确解析基础的 sequenceDiagram 声明', () => {
|
||||
const code = `sequenceDiagram
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带 participant 的序列图', () => {
|
||||
const code = `sequenceDiagram
|
||||
participant Alice
|
||||
participant Bob
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析简单的消息传递', () => {
|
||||
const code = `sequenceDiagram
|
||||
Alice->John: Hello John, how are you?
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带虚线箭头的消息', () => {
|
||||
const code = `sequenceDiagram
|
||||
Alice-->John: Hello
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带异步箭头的消息', () => {
|
||||
const code = `sequenceDiagram
|
||||
Alice->>John: Hello
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带激活/停用的序列图', () => {
|
||||
const code = `sequenceDiagram
|
||||
Alice->>John: Hello
|
||||
activate John
|
||||
John-->>Alice: Hi there!
|
||||
deactivate John
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带 note 的序列图', () => {
|
||||
const code = `sequenceDiagram
|
||||
Alice->John: Hello John
|
||||
Note over Alice,John: A typical interaction
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带 alt/else 的序列图', () => {
|
||||
const code = `sequenceDiagram
|
||||
Alice->John: Hello John
|
||||
alt is sick
|
||||
John-->Alice: Not so good
|
||||
else is well
|
||||
John-->Alice: Feeling fresh
|
||||
end
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析带 loop 的序列图', () => {
|
||||
const code = `sequenceDiagram
|
||||
Alice->John: Hello John
|
||||
loop Every minute
|
||||
John-->Alice: Great!
|
||||
end
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确解析完整的序列图示例', () => {
|
||||
const code = `sequenceDiagram
|
||||
participant Alice
|
||||
participant Bob
|
||||
Alice->>John: Hello John, how are you?
|
||||
loop HealthCheck
|
||||
John->>John: Fight against hypochondria
|
||||
end
|
||||
Note right of John: Rational thoughts!
|
||||
John-->>Alice: Great!
|
||||
John->>Bob: How about you?
|
||||
Bob-->>John: Jolly good!
|
||||
`;
|
||||
|
||||
const tree = parseCode(code);
|
||||
const result = hasErrorNodes(tree);
|
||||
|
||||
if (result.hasError) {
|
||||
console.log('语法树:');
|
||||
console.log(printTree(tree, code));
|
||||
console.log('错误节点:', result.errors);
|
||||
}
|
||||
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
3
frontend/src/views/editor/language/mermaid/parsers/sequence/sequence.parser.grammar.d.ts
vendored
Normal file
3
frontend/src/views/editor/language/mermaid/parsers/sequence/sequence.parser.grammar.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { LRParser } from '@lezer/lr';
|
||||
|
||||
export declare const parser: LRParser;
|
||||
@@ -0,0 +1,24 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {messageTextToken, textTokens} from "./tokens"
|
||||
import {sequenceHighlighting} from "./highlight"
|
||||
const spec_identifier = {__proto__:null,sequenceDiagram:84}
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "'nOVQSOOO[QSO'#DUQOQSOOOOQO'#Cj'#CjO#QQUO,59pOOQP'#Co'#CoOOQQ'#Cq'#CqOOQO'#DY'#DYO#XQUO'#DYO#gQUO'#DYO#lQUO'#DYO#wQUO'#DYO#|QUO'#DYO$RQTO'#DYO$WQUO'#DYO$]QSO1G/[O$eQYO,59tO$sQUO,59tO$xQUO,59tO%TQUO,59tOOQO,59t,59tO%YQUO,59tOOQO'#Cp'#CpO%_QSO,59tO%dQUO7+$vO%kQSO7+$vOOQQ'#Cm'#CmO%sQSO1G/`O%xQUO1G/`O%}QUO1G/`OOQO1G/`1G/`O&VQUO1G/`O&[QUO1G/`O&gQSO1G/`O&oQTO1G/`OOQO,59^,59^O&tQUO<<HbOOQO-E6p-E6pO&oQTO7+$zO&{QSO7+$zO'QQUO7+$zOOQO7+$z7+$zO'VQUO7+$zOOQO'#Cn'#CnPdQUO'#CrOOQO<<Hf<<HfO&oQTO<<HfO'[QSO<<HfOOQOAN>QAN>QO&oQTOAN>QOOQOG23lG23l",
|
||||
stateData: "'g~OwOS~OzRO~O{SOgxX~OPZOQVORYOSZOTYOUVOV[OWXOYXOZWO_VOiTOjTOkTOlTOmTOnTOoUOpUOqTOrTOsTOtTOuTO~Ogxa~PdOXaO``Og|X{|X~OZbO~OWcOYcOZbO~OZdO~O[eO~OhfO~OZgO~O{hOgxi~OZkO{lO}jO!OjO~OZnO~OXoOg|a{|a~OZpO~OZqO~O!PrO~Ogxq~PdO{tOgxq~O!PvO~OZwO~OZwO{xO~OZyO~OXzOg|i{|i~O!PvO!QxO~Oh{O~Ogxy~PdO!P!OO~OZ!PO~OZ}O~O!P!RO~O{w`y`~",
|
||||
goto: "#S}PPPPPPPPPPPPPP!OPP!R!U!b!h!k!qPPPPPPPPPPPPPPPPP!wPPP!zRPORm`QyrQ}vQ!Q!OR!S!RX]Sht|Rd]X^Sht|Qi_RuiRQOQ_SVsht|",
|
||||
nodeNames: "⚠ Activate Autonumber Create Deactivate Destroy End Note Actor As Participant NodeText Position SequenceDiagram DiagramName LineComment Arrow ArrowSuffix MessageText1 Keyword MessageText2 Link",
|
||||
maxTerm: 48,
|
||||
nodeProps: [
|
||||
["group", -9,1,2,3,4,5,6,7,19,21,"Keyword1",-3,8,9,10,"Keyword2"]
|
||||
],
|
||||
propSources: [sequenceHighlighting],
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 1,
|
||||
tokenData: "(x~RmXY!|YZ#qZ^!|pq!|tu$nuv%`{|%}|}&S}!O&X![!]'T!c!}$n#T#o$n#y#z!|$f$g!|$g#BY$n#BY#BZ'Y#BZ$IS$n$IS$I_'Y$I_$I|$n$I|$JO'Y$JO$JT$n$JT$JU'Y$JU$KV$n$KV$KW'Y$KW&FU$n&FU&FV'Y&FV;'S$n;'S;=`%Y<%lO$n~#RYw~X^!|pq!|#y#z!|$f$g!|#BY#BZ!|$IS$I_!|$I|$JO!|$JT$JU!|$KV$KW!|&FU&FV!|~#x[{~w~XY!|YZ#qZ^!|pq!|#y#z!|$f$g!|#BY#BZ!|$IS$I_!|$I|$JO!|$JT$JU!|$KV$KW!|&FU&FV!|~$sVy~tu$n!Q![$n!c!}$n#T#o$n$g;'S$n;'S;=`%Y<%lO$n~%]P;=`<%l$n~%cPuv%f~%kS_~OY%fZ;'S%f;'S;=`%w<%lO%f~%zP;=`<%l%f~&SO}~~&XO!Q~R&^S!OQyz&j}!O&o!`!a&{#l#m&jP&oO`PP&rRyz&j!`!a&{#l#m&jP'QP`P!`!a&j~'YO!P~~'agw~y~X^!|pq!|tu$n!Q![$n!c!}$n#T#o$n#y#z!|$f$g!|$g#BY$n#BY#BZ'Y#BZ$IS$n$IS$I_'Y$I_$I|$n$I|$JO'Y$JO$JT$n$JT$JU'Y$JU$KV$n$KV$KW'Y$KW&FU$n&FU&FV'Y&FV;'S$n;'S;=`%Y<%lO$n",
|
||||
tokenizers: [messageTextToken, textTokens, 0, 1],
|
||||
topRules: {"SequenceDiagram":[0,13]},
|
||||
specialized: [{term: 41, get: (value: keyof typeof spec_identifier) => spec_identifier[value] || -1}],
|
||||
tokenPrec: 293
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ExternalTokenizer } from '@lezer/lr';
|
||||
import {
|
||||
_break,
|
||||
_else,
|
||||
Activate,
|
||||
Actor,
|
||||
alt,
|
||||
and,
|
||||
As,
|
||||
Autonumber,
|
||||
box,
|
||||
Create,
|
||||
critical,
|
||||
Deactivate,
|
||||
Destroy,
|
||||
End,
|
||||
link,
|
||||
links,
|
||||
loop,
|
||||
messageText,
|
||||
NodeText,
|
||||
Note,
|
||||
opt,
|
||||
option,
|
||||
par,
|
||||
Participant,
|
||||
Position,
|
||||
rect,
|
||||
} from './sequence.grammar.terms';
|
||||
|
||||
const skipCodePoints = [-1, 9, 10, 13, 32, 37];
|
||||
const arrowSuffixCodePoints = [43, 45];
|
||||
const notAllowedCodePoints = [44, 58, 62];
|
||||
const notAllowed2Chars = ['->', '-x', '-)', ' -', ' '];
|
||||
const notAllowed3Chars = ['-->', '->>', '--x', '--)', ' as'];
|
||||
|
||||
const keywordMap: { [key: string]: number } = {
|
||||
'left of': Position,
|
||||
'right of': Position,
|
||||
activate: Activate,
|
||||
actor: Actor,
|
||||
alt: alt,
|
||||
and: and,
|
||||
as: As,
|
||||
autonumber: Autonumber,
|
||||
box: box,
|
||||
break: _break,
|
||||
create: Create,
|
||||
critical: critical,
|
||||
deactivate: Deactivate,
|
||||
destroy: Destroy,
|
||||
else: _else,
|
||||
end: End,
|
||||
link: link,
|
||||
links: links,
|
||||
loop: loop,
|
||||
note: Note,
|
||||
opt: opt,
|
||||
option: option,
|
||||
over: Position,
|
||||
par: par,
|
||||
participant: Participant,
|
||||
rect: rect,
|
||||
};
|
||||
|
||||
const keywords = Object.keys(keywordMap);
|
||||
|
||||
export const messageTextToken = new ExternalTokenizer((input) => {
|
||||
if (skipCodePoints.includes(input.next)) return;
|
||||
|
||||
while (input.next !== 10 && input.next !== -1) {
|
||||
input.advance();
|
||||
}
|
||||
|
||||
input.acceptToken(messageText);
|
||||
});
|
||||
|
||||
export const textTokens = new ExternalTokenizer((input) => {
|
||||
if (
|
||||
skipCodePoints.includes(input.next) ||
|
||||
arrowSuffixCodePoints.includes(input.next)
|
||||
)
|
||||
return;
|
||||
|
||||
const isArrowNext = () => {
|
||||
if (input.peek(0) === -1 || input.peek(1) === -1 || input.peek(2) === -1)
|
||||
return false;
|
||||
|
||||
let result =
|
||||
String.fromCodePoint(input.peek(0)) + String.fromCodePoint(input.peek(1));
|
||||
|
||||
if (notAllowed2Chars.includes(result)) return true;
|
||||
|
||||
result += String.fromCodePoint(input.peek(2));
|
||||
|
||||
if (notAllowed3Chars.includes(result)) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
let tokens = '';
|
||||
|
||||
while (
|
||||
!notAllowedCodePoints.includes(input.next) &&
|
||||
!isArrowNext() &&
|
||||
input.next !== 10 &&
|
||||
input.next !== -1
|
||||
) {
|
||||
tokens += String.fromCodePoint(input.next);
|
||||
input.advance();
|
||||
}
|
||||
|
||||
const activeKeyword = keywords.filter((keyword) => {
|
||||
if (keyword === tokens) {
|
||||
return tokens.toLowerCase().startsWith(keyword);
|
||||
}
|
||||
return tokens.toLowerCase().startsWith(keyword + ' ');
|
||||
});
|
||||
|
||||
if (activeKeyword.length > 0) {
|
||||
input.acceptToken(
|
||||
keywordMap[activeKeyword[0]],
|
||||
activeKeyword[0].length - tokens.length
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
input.acceptToken(NodeText);
|
||||
});
|
||||
76
frontend/src/views/editor/language/mermaid/tags/index.ts
Normal file
76
frontend/src/views/editor/language/mermaid/tags/index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Tag, tags as t } from '@lezer/highlight';
|
||||
|
||||
export const mermaidTags = {
|
||||
diagramName: Tag.define(t.typeName),
|
||||
};
|
||||
|
||||
export const mindmapTags = {
|
||||
diagramName: Tag.define(mermaidTags.diagramName),
|
||||
lineText1: Tag.define(),
|
||||
lineText2: Tag.define(),
|
||||
lineText3: Tag.define(),
|
||||
lineText4: Tag.define(),
|
||||
lineText5: Tag.define(),
|
||||
};
|
||||
|
||||
export const pieTags = {
|
||||
diagramName: Tag.define(mermaidTags.diagramName),
|
||||
lineComment: Tag.define(t.lineComment),
|
||||
number: Tag.define(t.number),
|
||||
showData: Tag.define(t.keyword),
|
||||
string: Tag.define(t.string),
|
||||
title: Tag.define(t.keyword),
|
||||
titleText: Tag.define(t.string),
|
||||
};
|
||||
|
||||
export const flowchartTags = {
|
||||
diagramName: Tag.define(mermaidTags.diagramName),
|
||||
keyword: Tag.define(t.keyword),
|
||||
lineComment: Tag.define(t.lineComment),
|
||||
link: Tag.define(t.contentSeparator),
|
||||
nodeEdge: Tag.define(t.contentSeparator),
|
||||
nodeEdgeText: Tag.define(t.string),
|
||||
nodeId: Tag.define(t.variableName),
|
||||
nodeText: Tag.define(t.string),
|
||||
number: Tag.define(t.number),
|
||||
orientation: Tag.define(t.modifier),
|
||||
string: Tag.define(t.string),
|
||||
};
|
||||
|
||||
export const sequenceTags = {
|
||||
diagramName: Tag.define(mermaidTags.diagramName),
|
||||
arrow: Tag.define(t.contentSeparator),
|
||||
keyword1: Tag.define(t.keyword),
|
||||
keyword2: Tag.define(t.controlKeyword),
|
||||
lineComment: Tag.define(t.lineComment),
|
||||
messageText1: Tag.define(t.string),
|
||||
messageText2: Tag.define(t.content),
|
||||
nodeText: Tag.define(t.variableName),
|
||||
position: Tag.define(t.modifier),
|
||||
};
|
||||
|
||||
export const journeyTags = {
|
||||
diagramName: Tag.define(mermaidTags.diagramName),
|
||||
actor: Tag.define(t.variableName),
|
||||
keyword: Tag.define(t.keyword),
|
||||
lineComment: Tag.define(t.lineComment),
|
||||
score: Tag.define(t.number),
|
||||
text: Tag.define(t.string),
|
||||
};
|
||||
|
||||
export const requirementTags = {
|
||||
diagramName: Tag.define(mermaidTags.diagramName),
|
||||
arrow: Tag.define(t.contentSeparator),
|
||||
keyword: Tag.define(t.keyword),
|
||||
lineComment: Tag.define(t.lineComment),
|
||||
number: Tag.define(t.number),
|
||||
quotedString: Tag.define(t.string),
|
||||
unquotedString: Tag.define(t.content),
|
||||
};
|
||||
|
||||
export const ganttTags = {
|
||||
diagramName: Tag.define(mermaidTags.diagramName),
|
||||
keyword: Tag.define(t.keyword),
|
||||
lineComment: Tag.define(t.lineComment),
|
||||
string: Tag.define(t.string),
|
||||
};
|
||||
38
frontend/src/views/editor/language/mermaid/types/index.ts
Normal file
38
frontend/src/views/editor/language/mermaid/types/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export enum DiagramType {
|
||||
Mermaid = 'MermaidDiagram',
|
||||
Mindmap = 'MindmapDiagram',
|
||||
Pie = 'PieDiagram',
|
||||
Flowchart = 'FlowchartDiagram',
|
||||
Sequence = 'SequenceDiagram',
|
||||
Journey = 'JourneyDiagram',
|
||||
Requirement = 'RequirementDiagram',
|
||||
Gantt = 'GanttDiagram',
|
||||
}
|
||||
|
||||
export enum MermaidDescriptionName {
|
||||
Mermaid = 'mermaid',
|
||||
Mindmap = 'mindmap',
|
||||
Pie = 'pie',
|
||||
Flowchart = 'flowchart',
|
||||
Sequence = 'sequenceDiagram',
|
||||
Journey = 'journey',
|
||||
Requirement = 'requirementDiagram',
|
||||
Gantt = 'gantt',
|
||||
}
|
||||
|
||||
export enum MermaidLanguageType {
|
||||
Mermaid = 'mermaid',
|
||||
Mindmap = 'mindmap',
|
||||
Pie = 'pie',
|
||||
Flowchart = 'flowchart',
|
||||
Sequence = 'sequence',
|
||||
Journey = 'journey',
|
||||
Requirement = 'requirement',
|
||||
Gantt = 'gantt',
|
||||
}
|
||||
|
||||
export enum MermaidAlias {
|
||||
Graph = 'graph',
|
||||
Sequence = 'sequence',
|
||||
Requirement = 'requirement',
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
defineProps<{
|
||||
title: string;
|
||||
description?: string;
|
||||
descriptionType?: 'default' | 'success' | 'error';
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -9,7 +10,16 @@ defineProps<{
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-title">{{ title }}</div>
|
||||
<div v-if="description" class="setting-description">{{ description }}</div>
|
||||
<div
|
||||
v-if="description"
|
||||
class="setting-description"
|
||||
:class="{
|
||||
'description-success': descriptionType === 'success',
|
||||
'description-error': descriptionType === 'error'
|
||||
}"
|
||||
>
|
||||
{{ description }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<slot></slot>
|
||||
@@ -48,6 +58,14 @@ defineProps<{
|
||||
font-size: 11px;
|
||||
color: var(--settings-text-secondary);
|
||||
line-height: 1.4;
|
||||
|
||||
&.description-success {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
&.description-error {
|
||||
color: #f44336;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import {useConfigStore} from '@/stores/configStore';
|
||||
import {useBackupStore} from '@/stores/backupStore';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {computed, onUnmounted} from 'vue';
|
||||
import {computed} from 'vue';
|
||||
import SettingSection from '../components/SettingSection.vue';
|
||||
import SettingItem from '../components/SettingItem.vue';
|
||||
import ToggleSwitch from '../components/ToggleSwitch.vue';
|
||||
@@ -13,18 +13,12 @@ const {t} = useI18n();
|
||||
const configStore = useConfigStore();
|
||||
const backupStore = useBackupStore();
|
||||
|
||||
onUnmounted(() => {
|
||||
backupStore.clearStatus();
|
||||
});
|
||||
|
||||
// 认证方式选项
|
||||
const authMethodOptions = computed(() => [
|
||||
{value: AuthMethod.Token, label: t('settings.backup.authMethods.token')},
|
||||
{value: AuthMethod.SSHKey, label: t('settings.backup.authMethods.sshKey')},
|
||||
{value: AuthMethod.UserPass, label: t('settings.backup.authMethods.userPass')}
|
||||
]);
|
||||
|
||||
// 备份间隔选项(分钟)
|
||||
const backupIntervalOptions = computed(() => [
|
||||
{value: 5, label: t('settings.backup.intervals.5min')},
|
||||
{value: 10, label: t('settings.backup.intervals.10min')},
|
||||
@@ -33,124 +27,11 @@ const backupIntervalOptions = computed(() => [
|
||||
{value: 60, label: t('settings.backup.intervals.1hour')}
|
||||
]);
|
||||
|
||||
// 计算属性 - 启用备份
|
||||
const enableBackup = computed({
|
||||
get: () => configStore.config.backup.enabled,
|
||||
set: (value: boolean) => configStore.setEnableBackup(value)
|
||||
});
|
||||
|
||||
// 计算属性 - 自动备份
|
||||
const autoBackup = computed({
|
||||
get: () => configStore.config.backup.auto_backup,
|
||||
set: (value: boolean) => configStore.setAutoBackup(value)
|
||||
});
|
||||
|
||||
// 仓库URL
|
||||
const repoUrl = computed({
|
||||
get: () => configStore.config.backup.repo_url,
|
||||
set: (value: string) => configStore.setRepoUrl(value)
|
||||
});
|
||||
|
||||
|
||||
// 认证方式
|
||||
const authMethod = computed({
|
||||
get: () => configStore.config.backup.auth_method,
|
||||
set: (value: AuthMethod) => configStore.setAuthMethod(value)
|
||||
});
|
||||
|
||||
// 备份间隔
|
||||
const backupInterval = computed({
|
||||
get: () => configStore.config.backup.backup_interval,
|
||||
set: (value: number) => configStore.setBackupInterval(value)
|
||||
});
|
||||
|
||||
// 用户名
|
||||
const username = computed({
|
||||
get: () => configStore.config.backup.username,
|
||||
set: (value: string) => configStore.setUsername(value)
|
||||
});
|
||||
|
||||
// 密码
|
||||
const password = computed({
|
||||
get: () => configStore.config.backup.password,
|
||||
set: (value: string) => configStore.setPassword(value)
|
||||
});
|
||||
|
||||
// 访问令牌
|
||||
const token = computed({
|
||||
get: () => configStore.config.backup.token,
|
||||
set: (value: string) => configStore.setToken(value)
|
||||
});
|
||||
|
||||
// SSH密钥路径
|
||||
const sshKeyPath = computed({
|
||||
get: () => configStore.config.backup.ssh_key_path,
|
||||
set: (value: string) => configStore.setSshKeyPath(value)
|
||||
});
|
||||
|
||||
// SSH密钥密码
|
||||
const sshKeyPassphrase = computed({
|
||||
get: () => configStore.config.backup.ssh_key_passphrase,
|
||||
set: (value: string) => configStore.setSshKeyPassphrase(value)
|
||||
});
|
||||
|
||||
// 处理输入变化
|
||||
const handleRepoUrlChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
repoUrl.value = target.value;
|
||||
};
|
||||
|
||||
|
||||
const handleUsernameChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
username.value = target.value;
|
||||
};
|
||||
|
||||
const handlePasswordChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
password.value = target.value;
|
||||
};
|
||||
|
||||
const handleTokenChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
token.value = target.value;
|
||||
};
|
||||
|
||||
const handleSshKeyPassphraseChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
sshKeyPassphrase.value = target.value;
|
||||
};
|
||||
|
||||
const handleAuthMethodChange = (event: Event) => {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
authMethod.value = target.value as AuthMethod;
|
||||
};
|
||||
|
||||
const handleBackupIntervalChange = (event: Event) => {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
backupInterval.value = parseInt(target.value);
|
||||
};
|
||||
|
||||
// 推送到远程
|
||||
const pushToRemote = async () => {
|
||||
await backupStore.pushToRemote();
|
||||
};
|
||||
|
||||
// 重试备份
|
||||
const retryBackup = async () => {
|
||||
await backupStore.retryBackup();
|
||||
};
|
||||
|
||||
// 选择SSH密钥文件
|
||||
const selectSshKeyFile = async () => {
|
||||
// 使用DialogService选择文件
|
||||
const selectedPath = await DialogService.SelectFile();
|
||||
// 检查用户是否取消了选择或路径为空
|
||||
if (!selectedPath.trim()) {
|
||||
return;
|
||||
if (selectedPath.trim()) {
|
||||
configStore.setSshKeyPath(selectedPath.trim());
|
||||
}
|
||||
// 更新SSH密钥路径
|
||||
sshKeyPath.value = selectedPath.trim();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -158,34 +39,35 @@ const selectSshKeyFile = async () => {
|
||||
<div class="settings-page">
|
||||
<!-- 基本设置 -->
|
||||
<SettingSection :title="t('settings.backup.basicSettings')">
|
||||
<SettingItem
|
||||
:title="t('settings.backup.enableBackup')"
|
||||
>
|
||||
<ToggleSwitch v-model="enableBackup"/>
|
||||
<SettingItem :title="t('settings.backup.enableBackup')">
|
||||
<ToggleSwitch
|
||||
:modelValue="configStore.config.backup.enabled"
|
||||
@update:modelValue="configStore.setEnableBackup"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
:title="t('settings.backup.autoBackup')"
|
||||
:class="{ 'disabled-setting': !enableBackup }"
|
||||
:title="t('settings.backup.autoBackup')"
|
||||
:class="{ 'disabled-setting': !configStore.config.backup.enabled }"
|
||||
>
|
||||
<ToggleSwitch v-model="autoBackup" :disabled="!enableBackup"/>
|
||||
<ToggleSwitch
|
||||
:modelValue="configStore.config.backup.auto_backup"
|
||||
@update:modelValue="configStore.setAutoBackup"
|
||||
:disabled="!configStore.config.backup.enabled"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
:title="t('settings.backup.backupInterval')"
|
||||
:class="{ 'disabled-setting': !enableBackup || !autoBackup }"
|
||||
:title="t('settings.backup.backupInterval')"
|
||||
:class="{ 'disabled-setting': !configStore.config.backup.enabled || !configStore.config.backup.auto_backup }"
|
||||
>
|
||||
<select
|
||||
class="backup-interval-select"
|
||||
:value="backupInterval"
|
||||
@change="handleBackupIntervalChange"
|
||||
:disabled="!enableBackup || !autoBackup"
|
||||
class="backup-interval-select"
|
||||
:value="configStore.config.backup.backup_interval"
|
||||
@change="(e) => configStore.setBackupInterval(Number((e.target as HTMLSelectElement).value))"
|
||||
:disabled="!configStore.config.backup.enabled || !configStore.config.backup.auto_backup"
|
||||
>
|
||||
<option
|
||||
v-for="option in backupIntervalOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
<option v-for="option in backupIntervalOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -194,110 +76,94 @@ const selectSshKeyFile = async () => {
|
||||
|
||||
<!-- 仓库配置 -->
|
||||
<SettingSection :title="t('settings.backup.repositoryConfig')">
|
||||
<SettingItem
|
||||
:title="t('settings.backup.repoUrl')"
|
||||
>
|
||||
<SettingItem :title="t('settings.backup.repoUrl')">
|
||||
<input
|
||||
type="text"
|
||||
class="repo-url-input"
|
||||
:value="repoUrl"
|
||||
@input="handleRepoUrlChange"
|
||||
:placeholder="t('settings.backup.repoUrlPlaceholder')"
|
||||
:disabled="!enableBackup"
|
||||
type="text"
|
||||
class="repo-url-input"
|
||||
:value="configStore.config.backup.repo_url"
|
||||
@input="(e) => configStore.setRepoUrl((e.target as HTMLInputElement).value)"
|
||||
:placeholder="t('settings.backup.repoUrlPlaceholder')"
|
||||
:disabled="!configStore.config.backup.enabled"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
|
||||
</SettingSection>
|
||||
|
||||
<!-- 认证配置 -->
|
||||
<SettingSection :title="t('settings.backup.authConfig')">
|
||||
<SettingItem
|
||||
:title="t('settings.backup.authMethod')"
|
||||
>
|
||||
<SettingItem :title="t('settings.backup.authMethod')">
|
||||
<select
|
||||
class="auth-method-select"
|
||||
:value="authMethod"
|
||||
@change="handleAuthMethodChange"
|
||||
:disabled="!enableBackup"
|
||||
class="auth-method-select"
|
||||
:value="configStore.config.backup.auth_method"
|
||||
@change="(e) => configStore.setAuthMethod((e.target as HTMLSelectElement).value as AuthMethod)"
|
||||
:disabled="!configStore.config.backup.enabled"
|
||||
>
|
||||
<option
|
||||
v-for="option in authMethodOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
<option v-for="option in authMethodOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</SettingItem>
|
||||
|
||||
<!-- 用户名密码认证 -->
|
||||
<template v-if="authMethod === AuthMethod.UserPass">
|
||||
<template v-if="configStore.config.backup.auth_method === AuthMethod.UserPass">
|
||||
<SettingItem :title="t('settings.backup.username')">
|
||||
<input
|
||||
type="text"
|
||||
class="username-input"
|
||||
:value="username"
|
||||
@input="handleUsernameChange"
|
||||
:placeholder="t('settings.backup.usernamePlaceholder')"
|
||||
:disabled="!enableBackup"
|
||||
type="text"
|
||||
class="username-input"
|
||||
:value="configStore.config.backup.username"
|
||||
@input="(e) => configStore.setUsername((e.target as HTMLInputElement).value)"
|
||||
:placeholder="t('settings.backup.usernamePlaceholder')"
|
||||
:disabled="!configStore.config.backup.enabled"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem :title="t('settings.backup.password')">
|
||||
<input
|
||||
type="password"
|
||||
class="password-input"
|
||||
:value="password"
|
||||
@input="handlePasswordChange"
|
||||
:placeholder="t('settings.backup.passwordPlaceholder')"
|
||||
:disabled="!enableBackup"
|
||||
type="password"
|
||||
class="password-input"
|
||||
:value="configStore.config.backup.password"
|
||||
@input="(e) => configStore.setPassword((e.target as HTMLInputElement).value)"
|
||||
:placeholder="t('settings.backup.passwordPlaceholder')"
|
||||
:disabled="!configStore.config.backup.enabled"
|
||||
/>
|
||||
</SettingItem>
|
||||
</template>
|
||||
|
||||
<!-- 访问令牌认证 -->
|
||||
<template v-if="authMethod === AuthMethod.Token">
|
||||
<SettingItem
|
||||
:title="t('settings.backup.token')"
|
||||
>
|
||||
<template v-if="configStore.config.backup.auth_method === AuthMethod.Token">
|
||||
<SettingItem :title="t('settings.backup.token')">
|
||||
<input
|
||||
type="password"
|
||||
class="token-input"
|
||||
:value="token"
|
||||
@input="handleTokenChange"
|
||||
:placeholder="t('settings.backup.tokenPlaceholder')"
|
||||
:disabled="!enableBackup"
|
||||
type="password"
|
||||
class="token-input"
|
||||
:value="configStore.config.backup.token"
|
||||
@input="(e) => configStore.setToken((e.target as HTMLInputElement).value)"
|
||||
:placeholder="t('settings.backup.tokenPlaceholder')"
|
||||
:disabled="!configStore.config.backup.enabled"
|
||||
/>
|
||||
</SettingItem>
|
||||
</template>
|
||||
|
||||
<!-- SSH密钥认证 -->
|
||||
<template v-if="authMethod === AuthMethod.SSHKey">
|
||||
<SettingItem
|
||||
:title="t('settings.backup.sshKeyPath')"
|
||||
>
|
||||
<template v-if="configStore.config.backup.auth_method === AuthMethod.SSHKey">
|
||||
<SettingItem :title="t('settings.backup.sshKeyPath')">
|
||||
<input
|
||||
type="text"
|
||||
class="ssh-key-path-input"
|
||||
:value="sshKeyPath"
|
||||
:placeholder="t('settings.backup.sshKeyPathPlaceholder')"
|
||||
:disabled="!enableBackup"
|
||||
readonly
|
||||
@click="enableBackup && selectSshKeyFile()"
|
||||
type="text"
|
||||
class="ssh-key-path-input"
|
||||
:value="configStore.config.backup.ssh_key_path"
|
||||
:placeholder="t('settings.backup.sshKeyPathPlaceholder')"
|
||||
:disabled="!configStore.config.backup.enabled"
|
||||
readonly
|
||||
@click="configStore.config.backup.enabled && selectSshKeyFile()"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
:title="t('settings.backup.sshKeyPassphrase')"
|
||||
>
|
||||
<SettingItem :title="t('settings.backup.sshKeyPassphrase')">
|
||||
<input
|
||||
type="password"
|
||||
class="ssh-passphrase-input"
|
||||
:value="sshKeyPassphrase"
|
||||
@input="handleSshKeyPassphraseChange"
|
||||
:placeholder="t('settings.backup.sshKeyPassphrasePlaceholder')"
|
||||
:disabled="!enableBackup"
|
||||
type="password"
|
||||
class="ssh-passphrase-input"
|
||||
:value="configStore.config.backup.ssh_key_passphrase"
|
||||
@input="(e) => configStore.setSshKeyPassphrase((e.target as HTMLInputElement).value)"
|
||||
:placeholder="t('settings.backup.sshKeyPassphrasePlaceholder')"
|
||||
:disabled="!configStore.config.backup.enabled"
|
||||
/>
|
||||
</SettingItem>
|
||||
</template>
|
||||
@@ -305,36 +171,21 @@ const selectSshKeyFile = async () => {
|
||||
|
||||
<!-- 备份操作 -->
|
||||
<SettingSection :title="t('settings.backup.backupOperations')">
|
||||
<SettingItem
|
||||
:title="t('settings.backup.pushToRemote')"
|
||||
<SettingItem
|
||||
:title="t('settings.backup.pushToRemote')"
|
||||
:description="backupStore.message || undefined"
|
||||
:descriptionType="backupStore.message ? (backupStore.isError ? 'error' : 'success') : 'default'"
|
||||
>
|
||||
<div class="backup-operation-container">
|
||||
<div class="backup-status-icons">
|
||||
<span v-if="backupStore.isSuccess" class="success-icon">✓</span>
|
||||
<span v-if="backupStore.isError" class="error-icon">✗</span>
|
||||
</div>
|
||||
<button
|
||||
class="push-button"
|
||||
@click="() => pushToRemote()"
|
||||
:disabled="!enableBackup || !repoUrl || backupStore.isPushing"
|
||||
:class="{ 'backing-up': backupStore.isPushing }"
|
||||
>
|
||||
<span v-if="backupStore.isPushing" class="loading-spinner"></span>
|
||||
{{ backupStore.isPushing ? t('settings.backup.pushing') : t('settings.backup.actions.push') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="backupStore.isError"
|
||||
class="retry-button"
|
||||
@click="() => retryBackup()"
|
||||
:disabled="backupStore.isPushing"
|
||||
>
|
||||
{{ t('settings.backup.actions.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="push-button"
|
||||
@click="backupStore.pushToRemote"
|
||||
:disabled="!configStore.config.backup.enabled || !configStore.config.backup.repo_url || backupStore.isPushing"
|
||||
:class="{ 'backing-up': backupStore.isPushing }"
|
||||
>
|
||||
<span v-if="backupStore.isPushing" class="loading-spinner"></span>
|
||||
{{ backupStore.isPushing ? t('settings.backup.pushing') : t('settings.backup.actions.push') }}
|
||||
</button>
|
||||
</SettingItem>
|
||||
<div v-if="backupStore.errorMessage" class="error-message-row">
|
||||
{{ backupStore.errorMessage }}
|
||||
</div>
|
||||
</SettingSection>
|
||||
</div>
|
||||
</template>
|
||||
@@ -405,38 +256,8 @@ const selectSshKeyFile = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 备份操作容器
|
||||
.backup-operation-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// 备份状态图标
|
||||
.backup-status-icons {
|
||||
width: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
// 成功和错误图标
|
||||
.success-icon {
|
||||
color: #4caf50;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: #f44336;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
// 按钮样式
|
||||
.push-button,
|
||||
.retry-button {
|
||||
.push-button {
|
||||
padding: 8px 16px;
|
||||
background-color: var(--settings-input-bg);
|
||||
border: 1px solid var(--settings-input-border);
|
||||
@@ -480,30 +301,6 @@ const selectSshKeyFile = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
background-color: #ff9800;
|
||||
border-color: #ff9800;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: #f57c00;
|
||||
border-color: #f57c00;
|
||||
}
|
||||
}
|
||||
|
||||
// 错误信息行样式
|
||||
.error-message-row {
|
||||
color: #f44336;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
margin-top: 8px;
|
||||
padding: 8px 16px;
|
||||
background-color: rgba(244, 67, 54, 0.1);
|
||||
border-left: 3px solid #f44336;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
.disabled-setting {
|
||||
opacity: 0.5;
|
||||
|
||||
8
go.mod
8
go.mod
@@ -10,11 +10,11 @@ require (
|
||||
github.com/knadh/koanf/providers/structs v1.0.0
|
||||
github.com/knadh/koanf/v2 v2.3.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.36
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.38
|
||||
golang.org/x/net v0.46.0
|
||||
golang.org/x/sys v0.37.0
|
||||
golang.org/x/text v0.30.0
|
||||
modernc.org/sqlite v1.39.1
|
||||
modernc.org/sqlite v1.40.0
|
||||
resty.dev/v3 v3.0.0-beta.3
|
||||
)
|
||||
|
||||
@@ -29,11 +29,11 @@ require (
|
||||
github.com/adrg/xdg v0.5.3 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.5.1 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.9.0 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/fatih/structs v1.1.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
|
||||
16
go.sum
16
go.sum
@@ -25,8 +25,8 @@ github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/creativeprojects/go-selfupdate v1.5.1 h1:fuyEGFFfqcC8SxDGolcEPYPLXGQ9Mcrc5uRyRG2Mqnk=
|
||||
github.com/creativeprojects/go-selfupdate v1.5.1/go.mod h1:2uY75rP8z/D/PBuDn6mlBnzu+ysEmwOJfcgF8np0JIM=
|
||||
github.com/cyphar/filepath-securejoin v0.5.1 h1:eYgfMq5yryL4fbWfkLpFFy2ukSELzaJOTaUTuh+oF48=
|
||||
github.com/cyphar/filepath-securejoin v0.5.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
||||
github.com/cyphar/filepath-securejoin v0.6.0 h1:BtGB77njd6SVO6VztOHfPxKitJvd/VPT+OFBFMOi1Is=
|
||||
github.com/cyphar/filepath-securejoin v0.6.0/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -34,8 +34,8 @@ github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454Wv
|
||||
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
|
||||
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
@@ -166,8 +166,8 @@ github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6N
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.36 h1:GQ8vSrFgafITwMd/p4k+WBjG9K/anma9Pk2eJ/5CLsI=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.36/go.mod h1:7i8tSuA74q97zZ5qEJlcVZdnO+IR7LT2KU8UpzYMPsw=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.38 h1:pknuf+fecyZtP7hLCWTILttj6xB/VXRiXoy4T/7iorQ=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.38/go.mod h1:7i8tSuA74q97zZ5qEJlcVZdnO+IR7LT2KU8UpzYMPsw=
|
||||
github.com/xanzy/go-gitlab v0.115.0 h1:6DmtItNcVe+At/liXSgfE/DZNZrGfalQmBRmOcJjOn8=
|
||||
github.com/xanzy/go-gitlab v0.115.0/go.mod h1:5XCDtM7AM6WMKmfDdOiEpyRWUqui2iS9ILfvCZ2gJ5M=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
@@ -256,8 +256,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
|
||||
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/sqlite v1.40.0 h1:bNWEDlYhNPAUdUdBzjAvn8icAs/2gaKlj4vM+tQ6KdQ=
|
||||
modernc.org/sqlite v1.40.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
817
internal/common/hotkey/README.md
Normal file
817
internal/common/hotkey/README.md
Normal file
@@ -0,0 +1,817 @@
|
||||
# Hotkey - 跨平台全局热键库
|
||||
|
||||
跨平台 Go 语言全局热键库,支持 Windows、Linux (X11) 和 macOS 操作系统。
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- **跨平台支持**:Windows、Linux (X11)、macOS 统一 API
|
||||
- **线程安全**:所有公共 API 使用互斥锁保护
|
||||
- **标准化错误**:提供统一的错误类型,便于错误处理
|
||||
- **资源管理**:支持手动和自动(finalizer)资源清理
|
||||
- **独立实现**:除系统库外无第三方 Go 依赖
|
||||
- **状态查询**:提供 `IsRegistered()` 和 `IsClosed()` 方法
|
||||
|
||||
## 📦 安装
|
||||
|
||||
```bash
|
||||
go get -u voidraft/internal/common/hotkey
|
||||
```
|
||||
|
||||
### 平台特定依赖
|
||||
|
||||
#### Linux
|
||||
|
||||
需要安装 X11 开发库:
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
sudo apt install -y libx11-dev
|
||||
|
||||
# CentOS/RHEL
|
||||
sudo yum install -y libX11-devel
|
||||
|
||||
# Arch Linux
|
||||
sudo pacman -S libx11
|
||||
```
|
||||
|
||||
**无界面环境(云服务器等)**:
|
||||
|
||||
```bash
|
||||
# 安装虚拟显示服务器
|
||||
sudo apt install -y xvfb
|
||||
|
||||
# 启动虚拟显示
|
||||
Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
|
||||
export DISPLAY=:99.0
|
||||
```
|
||||
|
||||
#### macOS
|
||||
|
||||
**GUI 应用**(Wails、Fyne、Cocoa 等):框架管理主事件循环,直接使用即可。
|
||||
|
||||
**CLI 应用**:需要使用 `darwin.Init()` 启动 NSApplication 主事件循环,参见下文"macOS CLI 应用示例"。
|
||||
|
||||
#### Windows
|
||||
|
||||
无额外依赖,开箱即用。
|
||||
|
||||
## 📖 使用指南
|
||||
|
||||
### 基本用法
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"voidraft/internal/common/hotkey"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 创建热键:Ctrl+Shift+S
|
||||
hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl, hotkey.ModShift}, hotkey.KeyS)
|
||||
|
||||
// 注册热键
|
||||
if err := hk.Register(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer hk.Close() // 确保资源清理
|
||||
|
||||
fmt.Println("热键已注册,按 Ctrl+Shift+S 触发...")
|
||||
|
||||
// 监听热键事件
|
||||
for {
|
||||
select {
|
||||
case <-hk.Keydown():
|
||||
fmt.Println("热键按下!")
|
||||
case <-hk.Keyup():
|
||||
fmt.Println("热键释放!")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 完整示例:带错误处理
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"voidraft/internal/common/hotkey"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 创建热键
|
||||
hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl, hotkey.ModAlt}, hotkey.KeyQ)
|
||||
|
||||
// 注册热键,处理可能的错误
|
||||
if err := hk.Register(); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, hotkey.ErrHotkeyConflict):
|
||||
log.Fatal("热键冲突:该组合键已被其他程序占用")
|
||||
case errors.Is(err, hotkey.ErrPlatformUnavailable):
|
||||
log.Fatal("平台不支持:", err)
|
||||
case errors.Is(err, hotkey.ErrAlreadyRegistered):
|
||||
log.Fatal("热键已经注册")
|
||||
default:
|
||||
log.Fatal("注册热键失败:", err)
|
||||
}
|
||||
}
|
||||
defer hk.Close()
|
||||
|
||||
fmt.Println("热键 Ctrl+Alt+Q 已注册")
|
||||
fmt.Println("按下热键触发,或按 Ctrl+C 退出...")
|
||||
|
||||
// 优雅退出
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
// 事件循环
|
||||
for {
|
||||
select {
|
||||
case <-hk.Keydown():
|
||||
fmt.Println("[事件] 热键按下")
|
||||
// 执行你的业务逻辑
|
||||
|
||||
case <-hk.Keyup():
|
||||
fmt.Println("[事件] 热键释放")
|
||||
|
||||
case <-sigChan:
|
||||
fmt.Println("\n正在退出...")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 防抖处理(应用层)
|
||||
|
||||
如果热键按住会持续触发,建议在应用层添加防抖:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
"voidraft/internal/common/hotkey"
|
||||
)
|
||||
|
||||
func main() {
|
||||
hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl}, hotkey.KeyD)
|
||||
if err := hk.Register(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer hk.Close()
|
||||
|
||||
// 防抖参数
|
||||
var lastTrigger time.Time
|
||||
debounceInterval := 800 * time.Millisecond // 推荐 800ms
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-hk.Keydown():
|
||||
now := time.Now()
|
||||
// 检查距离上次触发的时间
|
||||
if !lastTrigger.IsZero() && now.Sub(lastTrigger) < debounceInterval {
|
||||
fmt.Println("触发被忽略(防抖)")
|
||||
continue
|
||||
}
|
||||
lastTrigger = now
|
||||
|
||||
fmt.Println("热键触发!")
|
||||
// 执行你的业务逻辑
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 动态修改热键
|
||||
|
||||
```go
|
||||
// 注销旧热键
|
||||
if err := hk.Unregister(); err != nil {
|
||||
log.Printf("注销失败:%v", err)
|
||||
}
|
||||
|
||||
// 修改热键组合
|
||||
hk = hotkey.New([]hotkey.Modifier{hotkey.ModCtrl}, hotkey.KeyF1)
|
||||
|
||||
// 重新注册
|
||||
if err := hk.Register(); err != nil {
|
||||
log.Printf("注册失败:%v", err)
|
||||
}
|
||||
|
||||
// 重要:重新获取通道引用!
|
||||
keydownChan := hk.Keydown()
|
||||
keyupChan := hk.Keyup()
|
||||
```
|
||||
|
||||
### 状态检查
|
||||
|
||||
```go
|
||||
// 检查热键是否已注册
|
||||
if hk.IsRegistered() {
|
||||
fmt.Println("热键已注册")
|
||||
}
|
||||
|
||||
// 检查热键是否已关闭
|
||||
if hk.IsClosed() {
|
||||
fmt.Println("热键已关闭,无法再使用")
|
||||
}
|
||||
```
|
||||
|
||||
### 资源管理最佳实践
|
||||
|
||||
```go
|
||||
func registerHotkey() error {
|
||||
hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl}, hotkey.KeyH)
|
||||
|
||||
// 使用 defer 确保资源释放
|
||||
defer hk.Close()
|
||||
|
||||
if err := hk.Register(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ... 使用热键 ...
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close() 是幂等的,多次调用是安全的
|
||||
hk.Close()
|
||||
hk.Close() // 不会 panic
|
||||
```
|
||||
|
||||
## 🔑 支持的修饰键
|
||||
|
||||
### 所有平台通用
|
||||
|
||||
```go
|
||||
hotkey.ModCtrl // Ctrl 键
|
||||
hotkey.ModShift // Shift 键
|
||||
hotkey.ModAlt // Alt 键(Linux: 通常映射到 Mod1)
|
||||
```
|
||||
|
||||
### Linux 额外支持
|
||||
|
||||
```go
|
||||
hotkey.Mod1 // 通常是 Alt
|
||||
hotkey.Mod2 // 通常是 Num Lock
|
||||
hotkey.Mod3 // (较少使用)
|
||||
hotkey.Mod4 // 通常是 Super/Windows 键
|
||||
hotkey.Mod5 // (较少使用)
|
||||
```
|
||||
|
||||
### 组合使用
|
||||
|
||||
```go
|
||||
// Ctrl+Shift+Alt+S
|
||||
hk := hotkey.New(
|
||||
[]hotkey.Modifier{hotkey.ModCtrl, hotkey.ModShift, hotkey.ModAlt},
|
||||
hotkey.KeyS,
|
||||
)
|
||||
```
|
||||
|
||||
## ⌨️ 支持的按键
|
||||
|
||||
### 字母键
|
||||
|
||||
```go
|
||||
hotkey.KeyA - hotkey.KeyZ // A-Z
|
||||
```
|
||||
|
||||
### 数字键
|
||||
|
||||
```go
|
||||
hotkey.Key0 - hotkey.Key9 // 0-9
|
||||
```
|
||||
|
||||
### 功能键
|
||||
|
||||
```go
|
||||
hotkey.KeyF1 - hotkey.KeyF20 // F1-F20
|
||||
```
|
||||
|
||||
### 特殊键
|
||||
|
||||
```go
|
||||
hotkey.KeySpace // 空格
|
||||
hotkey.KeyReturn // 回车
|
||||
hotkey.KeyEscape // ESC
|
||||
hotkey.KeyDelete // Delete
|
||||
hotkey.KeyTab // Tab
|
||||
hotkey.KeyLeft // 左箭头
|
||||
hotkey.KeyRight // 右箭头
|
||||
hotkey.KeyUp // 上箭头
|
||||
hotkey.KeyDown // 下箭头
|
||||
```
|
||||
|
||||
### 自定义键码
|
||||
|
||||
如果需要的键未预定义,可以直接使用键码:
|
||||
|
||||
```go
|
||||
// 使用自定义键码 0x15
|
||||
hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl}, hotkey.Key(0x15))
|
||||
```
|
||||
|
||||
## ⚠️ 错误类型
|
||||
|
||||
```go
|
||||
// 检查特定错误类型
|
||||
if errors.Is(err, hotkey.ErrAlreadyRegistered) {
|
||||
// 热键已经注册
|
||||
}
|
||||
|
||||
if errors.Is(err, hotkey.ErrNotRegistered) {
|
||||
// 热键未注册
|
||||
}
|
||||
|
||||
if errors.Is(err, hotkey.ErrClosed) {
|
||||
// 热键已关闭,无法再使用
|
||||
}
|
||||
|
||||
if errors.Is(err, hotkey.ErrHotkeyConflict) {
|
||||
// 热键冲突,已被其他程序占用
|
||||
}
|
||||
|
||||
if errors.Is(err, hotkey.ErrPlatformUnavailable) {
|
||||
// 平台不可用(如 Linux 无 X11)
|
||||
}
|
||||
|
||||
if errors.Is(err, hotkey.ErrFailedToRegister) {
|
||||
// 注册失败(其他原因)
|
||||
}
|
||||
|
||||
if errors.Is(err, hotkey.ErrFailedToUnregister) {
|
||||
// 注销失败
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 平台特定注意事项
|
||||
|
||||
### Linux
|
||||
|
||||
#### 1. AutoRepeat 行为
|
||||
|
||||
Linux 的 X11 会在按键持续按下时重复发送 `KeyPress` 事件。如果你的应用对此敏感,需要做防抖处理:
|
||||
|
||||
```go
|
||||
var lastTrigger time.Time
|
||||
debounceInterval := 500 * time.Millisecond
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-hk.Keydown():
|
||||
now := time.Now()
|
||||
if now.Sub(lastTrigger) < debounceInterval {
|
||||
continue // 忽略重复触发
|
||||
}
|
||||
lastTrigger = now
|
||||
|
||||
// 处理热键事件
|
||||
fmt.Println("热键触发!")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 键位映射差异
|
||||
|
||||
不同的 Linux 发行版和桌面环境可能有不同的键位映射。建议:
|
||||
|
||||
- 使用标准的 `ModCtrl`、`ModShift`、`ModAlt`
|
||||
- 避免依赖 `Mod2`、`Mod3`、`Mod5`(映射不一致)
|
||||
- `Mod4` 通常是 Super/Windows 键,但也可能不同
|
||||
|
||||
#### 3. Wayland 支持
|
||||
|
||||
当前版本仅支持 X11。在 Wayland 环境下:
|
||||
|
||||
- 需要运行在 XWayland 兼容层
|
||||
- 或设置 `GDK_BACKEND=x11` 环境变量
|
||||
- 原生 Wayland 支持正在开发中
|
||||
|
||||
#### 4. Display 连接复用
|
||||
|
||||
本库已优化 Linux 实现,在热键注册期间保持 X11 Display 连接:
|
||||
|
||||
```
|
||||
注册时: XOpenDisplay → XGrabKey → 保持连接
|
||||
事件循环: 使用相同连接 → XNextEvent
|
||||
注销时: XUngrabKey → XCloseDisplay
|
||||
```
|
||||
|
||||
这大幅降低了资源开销和延迟。
|
||||
|
||||
### Windows
|
||||
|
||||
#### 1. 热键按下事件
|
||||
|
||||
Windows 使用 `RegisterHotKey` API 注册系统级热键,通过 `WM_HOTKEY` 消息接收按键事件。
|
||||
|
||||
**实现细节**:
|
||||
- 使用 `PeekMessageW` (Unicode) 轮询消息队列
|
||||
- 10ms 轮询间隔,CPU 占用约 0.3-0.5%
|
||||
- `isKeyDown` 状态标志防止重复触发
|
||||
- 按下事件延迟通常 < 10ms
|
||||
|
||||
#### 2. 热键释放事件
|
||||
|
||||
Windows `RegisterHotKey` API 不提供键释放通知,本库使用 `GetAsyncKeyState` 轮询检测:
|
||||
|
||||
```go
|
||||
// 检测键释放(每 10ms 检查一次)
|
||||
if isKeyDown && GetAsyncKeyState(key) == 0 {
|
||||
keyupIn <- struct{}{}
|
||||
isKeyDown = false
|
||||
}
|
||||
```
|
||||
|
||||
**特性**:
|
||||
- 释放检测延迟:通常 10-20ms
|
||||
- 仅在按键按下后激活检测
|
||||
- 依赖于 `GetAsyncKeyState` 的精度
|
||||
|
||||
#### 3. 持续按住行为
|
||||
|
||||
Windows 在持续按住热键时会重复发送 `WM_HOTKEY` 消息。本库通过 `isKeyDown` 标志防止同一次按住重复触发 Keydown 事件。
|
||||
|
||||
如果需要防止快速连续按键,建议在应用层添加防抖:
|
||||
|
||||
```go
|
||||
var lastTrigger time.Time
|
||||
debounceInterval := 100 * time.Millisecond
|
||||
|
||||
for {
|
||||
<-hk.Keydown()
|
||||
now := time.Now()
|
||||
if now.Sub(lastTrigger) < debounceInterval {
|
||||
continue // 忽略
|
||||
}
|
||||
lastTrigger = now
|
||||
// 处理事件...
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 系统保留热键
|
||||
|
||||
某些热键被 Windows 系统保留,无法注册:
|
||||
|
||||
- `Win+L`:锁定屏幕
|
||||
- `Ctrl+Alt+Del`:安全选项
|
||||
- `Alt+Tab`:切换窗口
|
||||
- `Alt+F4`:关闭窗口
|
||||
- `Win+D`:显示桌面
|
||||
|
||||
尝试注册这些热键会返回 `ErrFailedToRegister`。
|
||||
|
||||
#### 5. 热键冲突
|
||||
|
||||
如果热键已被其他应用注册,`RegisterHotKey` 会失败。常见冲突来源:
|
||||
|
||||
- 游戏快捷键
|
||||
- 输入法快捷键
|
||||
- 快捷键管理工具(AutoHotkey 等)
|
||||
- 其他全局热键应用
|
||||
|
||||
返回错误为 `ErrFailedToRegister`(Windows 不区分冲突和其他失败)。
|
||||
|
||||
#### 6. 线程模型
|
||||
|
||||
- 热键注册和消息循环运行在同一个 OS 线程上
|
||||
- 使用 `runtime.LockOSThread()` 确保线程亲和性
|
||||
- 不要求是主线程(与 macOS 不同)
|
||||
- `ph.funcs` channel 用于在事件循环线程中执行注册/注销操作
|
||||
|
||||
---
|
||||
|
||||
### macOS
|
||||
|
||||
#### 1. 主事件循环要求
|
||||
|
||||
macOS 的 Carbon 事件 API 通过 GCD(Grand Central Dispatch)调度到主队列执行。
|
||||
|
||||
**GUI 应用(Wails、Cocoa 等)**:
|
||||
- ✅ 框架已自动管理主事件循环
|
||||
- ✅ 热键功能开箱即用,无需额外配置
|
||||
|
||||
**纯 CLI 应用**:
|
||||
- ⚠️ 需要手动启动 macOS 运行循环
|
||||
- 参见下文"macOS 纯 CLI 应用示例"
|
||||
|
||||
#### 2. 权限问题
|
||||
|
||||
macOS 可能需要辅助功能权限。如果热键无法注册,请检查:
|
||||
|
||||
```
|
||||
系统偏好设置 → 安全性与隐私 → 隐私 → 辅助功能
|
||||
```
|
||||
|
||||
将你的应用添加到允许列表。
|
||||
|
||||
#### 3. Carbon vs Cocoa
|
||||
|
||||
当前实现使用 Carbon API(稳定且兼容性好)。未来版本可能会迁移到更现代的 Cocoa API。
|
||||
|
||||
#### 4. macOS 纯 CLI 应用示例
|
||||
|
||||
如果你的应用**不是 GUI 应用**,需要启动主事件循环。
|
||||
|
||||
**最简单的方法:使用 darwin.Init()(推荐)**
|
||||
|
||||
```go
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"voidraft/internal/common/hotkey"
|
||||
"voidraft/internal/common/hotkey/darwin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 使用 darwin.Init 启动主事件循环
|
||||
darwin.Init(run)
|
||||
}
|
||||
|
||||
func run() {
|
||||
hk := hotkey.New([]hotkey.Modifier{hotkey.ModCmd, hotkey.ModShift}, hotkey.KeyA)
|
||||
if err := hk.Register(); err != nil {
|
||||
fmt.Printf("注册失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer hk.Close()
|
||||
|
||||
fmt.Println("热键已注册: Cmd+Shift+A")
|
||||
|
||||
for {
|
||||
<-hk.Keydown()
|
||||
fmt.Println("热键触发!")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**高级用法:使用 darwin.Call() 在主线程执行操作**
|
||||
|
||||
```go
|
||||
darwin.Init(func() {
|
||||
hk := hotkey.New(...)
|
||||
hk.Register()
|
||||
|
||||
for {
|
||||
<-hk.Keydown()
|
||||
// 在主线程执行 macOS API 调用
|
||||
darwin.Call(func() {
|
||||
// 例如调用 Cocoa/AppKit API
|
||||
fmt.Println("这段代码在主线程执行")
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- GUI 应用无需调用 `darwin.Init()`,框架已处理
|
||||
- `darwin.Call()` 用于需要主线程的特定 macOS API
|
||||
- 热键注册本身已通过 `dispatch_get_main_queue()` 自动调度到主线程
|
||||
|
||||
## 🔬 架构设计
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
internal/common/hotkey/
|
||||
├── hotkey.go # 统一的公共 API
|
||||
├── hotkey_windows.go # Windows 平台适配器
|
||||
├── hotkey_darwin.go # macOS 平台适配器
|
||||
├── hotkey_linux.go # Linux 平台适配器
|
||||
├── hotkey_nocgo.go # 非 CGO 平台占位符
|
||||
├── windows/
|
||||
│ ├── hotkey.go # Windows 核心实现
|
||||
│ └── mainthread.go # Windows 线程管理
|
||||
├── darwin/
|
||||
│ ├── hotkey.go # macOS 核心实现
|
||||
│ ├── hotkey.m # Objective-C/Carbon 代码
|
||||
│ └── mainthread.go # macOS 线程管理
|
||||
└── linux/
|
||||
├── hotkey.go # Linux 核心实现
|
||||
├── hotkey.c # X11 C 代码
|
||||
└── mainthread.go # Linux 线程管理
|
||||
```
|
||||
|
||||
### 设计原则
|
||||
|
||||
1. **平台隔离**:每个平台的实现完全独立,通过构建标签分离
|
||||
2. **统一接口**:所有平台提供相同的 Go API
|
||||
3. **资源安全**:自动资源管理,防止泄漏
|
||||
4. **并发安全**:所有公共方法都是线程安全的
|
||||
5. **错误透明**:标准化错误类型,便于处理
|
||||
|
||||
### 事件流程
|
||||
|
||||
```
|
||||
用户按键
|
||||
↓
|
||||
操作系统捕获
|
||||
↓
|
||||
平台特定 API(Win32/X11/Carbon)
|
||||
↓
|
||||
C/Objective-C 回调
|
||||
↓
|
||||
Go channel(类型转换)
|
||||
↓
|
||||
用户应用代码
|
||||
```
|
||||
|
||||
### 并发模型
|
||||
|
||||
```
|
||||
主 Goroutine 事件 Goroutine 转换 Goroutine
|
||||
│ │ │
|
||||
Register() ────启动──────→ eventLoop() │
|
||||
│ │ │
|
||||
│ 等待 OS 事件 │
|
||||
│ │ │
|
||||
│ ├────发送────→ 类型转换 │
|
||||
│ │ │ │
|
||||
│ │ └─→ Keydown()/Keyup()
|
||||
│ │ │
|
||||
Unregister() ──停止信号──→ 退出循环 │
|
||||
│ │ │
|
||||
└──────等待清理─────────────┴──────────────────────────┘
|
||||
```
|
||||
|
||||
## 📊 性能特性
|
||||
|
||||
### 资源占用
|
||||
|
||||
- **内存**:每个热键约 1-2 KB(包括 goroutines、channels、CGO handles)
|
||||
- **Goroutines**:每个热键 3 个
|
||||
- 1 个事件循环 goroutine
|
||||
- 2 个通道转换 goroutine (interface{} → Event)
|
||||
- **CPU**:
|
||||
- **Windows**:10ms 轮询,约 0.3-0.5% CPU(单核,空闲时)
|
||||
- **Linux**:事件驱动 (`XNextEvent` 阻塞),几乎无 CPU 占用
|
||||
- **macOS**:事件驱动 (GCD 调度),几乎无 CPU 占用
|
||||
- **线程**:
|
||||
- 每个热键 1 个 OS 线程(通过 `runtime.LockOSThread()` 锁定)
|
||||
|
||||
### 延迟
|
||||
|
||||
**按下事件 (Keydown)**:
|
||||
- Windows: < 10ms(取决于轮询间隔)
|
||||
- Linux: < 10ms(X11 事件延迟)
|
||||
- macOS: < 5ms(Carbon 事件延迟)
|
||||
|
||||
**释放事件 (Keyup)**:
|
||||
- Windows: 10-20ms(`GetAsyncKeyState` 轮询检测)
|
||||
- Linux: < 15ms(X11 KeyRelease 事件)
|
||||
- macOS: < 10ms(Carbon 事件延迟)
|
||||
|
||||
### 使用建议
|
||||
|
||||
1. **资源管理**:
|
||||
- 使用 `defer hk.Close()` 确保资源释放
|
||||
- 不需要时及时调用 `Unregister()` 或 `Close()`
|
||||
- 避免频繁创建/销毁热键对象
|
||||
|
||||
2. **事件处理**:
|
||||
- 热键事件处理应快速返回,避免阻塞 channel
|
||||
- 复杂逻辑应在新 goroutine 中处理
|
||||
- 考虑应用层防抖(特别是 Linux AutoRepeat)
|
||||
|
||||
3. **错误处理**:
|
||||
- 始终检查 `Register()` 返回的错误
|
||||
- 使用 `errors.Is()` 判断错误类型
|
||||
- 处理热键冲突场景(提供备用方案或用户提示)
|
||||
|
||||
4. **平台差异**:
|
||||
- Windows Keyup 事件有轻微延迟(正常现象)
|
||||
- Linux 可能需要防抖处理 AutoRepeat
|
||||
- macOS CLI 应用需要启动主事件循环
|
||||
|
||||
## 🐛 故障排查
|
||||
|
||||
### Linux: "Failed to initialize the X11 display"
|
||||
|
||||
**问题**:无法连接到 X11 显示服务器
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 检查 DISPLAY 环境变量
|
||||
echo $DISPLAY
|
||||
|
||||
# 如果为空,设置它
|
||||
export DISPLAY=:0
|
||||
|
||||
# 或使用虚拟显示
|
||||
Xvfb :99 -screen 0 1024x768x24 &
|
||||
export DISPLAY=:99
|
||||
```
|
||||
|
||||
### macOS: 热键不触发
|
||||
|
||||
**问题**:注册成功但热键无响应
|
||||
|
||||
**解决方案**:
|
||||
1. 检查辅助功能权限(见上文)
|
||||
2. 如果是纯 CLI 应用,确保启动了主运行循环(见上文示例)
|
||||
3. 检查其他应用是否占用了该热键
|
||||
|
||||
### Windows: ErrFailedToRegister
|
||||
|
||||
**问题**:热键注册失败
|
||||
|
||||
**可能原因**:
|
||||
1. 热键已被其他应用占用(AutoHotkey、游戏、输入法等)
|
||||
2. 尝试注册系统保留热键(Win+L、Ctrl+Alt+Del 等)
|
||||
3. 热键 ID 冲突(极少见)
|
||||
|
||||
**解决方案**:
|
||||
1. 检查任务管理器,关闭可能冲突的应用
|
||||
2. 使用不同的热键组合
|
||||
3. 在应用中提供热键自定义功能,让用户选择可用组合
|
||||
4. 提供友好的错误提示,说明热键被占用
|
||||
|
||||
**调试方法**:
|
||||
```go
|
||||
if err := hk.Register(); err != nil {
|
||||
if errors.Is(err, hotkey.ErrFailedToRegister) {
|
||||
log.Printf("热键注册失败: %v", err)
|
||||
log.Printf("提示:该热键可能已被其他应用使用")
|
||||
// 尝试备用热键...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Windows: Keyup 事件延迟
|
||||
|
||||
**问题**:释放事件比预期晚 10-20ms
|
||||
|
||||
**原因**:Windows API 限制,`RegisterHotKey` 不提供释放通知,需要轮询检测。
|
||||
|
||||
**这是正常行为**:
|
||||
- 10ms 轮询间隔导致的固有延迟
|
||||
- 对大多数应用场景影响很小
|
||||
- 如果需要精确的释放时机,考虑使用底层键盘钩子(复杂度更高)
|
||||
|
||||
### 所有平台: Keyup 事件丢失
|
||||
|
||||
**问题**:只收到 Keydown,没有 Keyup
|
||||
|
||||
**可能原因**:
|
||||
1. 在接收 Keyup 前调用了 `Unregister()`
|
||||
2. 通道缓冲区满(不太可能,使用了无缓冲通道)
|
||||
3. Windows 上正常(有轻微延迟)
|
||||
|
||||
**解决方案**:
|
||||
```go
|
||||
// 确保在处理完事件后再注销
|
||||
for {
|
||||
select {
|
||||
case <-hk.Keydown():
|
||||
// 处理...
|
||||
case <-hk.Keyup():
|
||||
// 处理...
|
||||
// 现在可以安全注销
|
||||
hk.Unregister()
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎贡献!请遵循以下原则:
|
||||
|
||||
1. **保持简洁**:不要过度优化
|
||||
2. **平台一致**:确保 API 在所有平台表现一致
|
||||
3. **测试充分**:在所有支持的平台测试
|
||||
4. **文档完善**:更新相关文档
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目是 Voidraft 的一部分,遵循项目主许可证。
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
本库的设计和实现参考了多个开源项目的经验:
|
||||
|
||||
- `golang.design/x/hotkey` - 基础架构设计
|
||||
- `github.com/robotn/gohook` - 跨平台事件处理思路
|
||||
|
||||
特别感谢所有为跨平台 Go 开发做出贡献的开发者们!
|
||||
|
||||
---
|
||||
|
||||
**如有问题或建议,欢迎提交 Issue!** 🚀
|
||||
|
||||
191
internal/common/hotkey/darwin/hotkey.go
Normal file
191
internal/common/hotkey/darwin/hotkey.go
Normal file
@@ -0,0 +1,191 @@
|
||||
//go:build darwin
|
||||
|
||||
package darwin
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c
|
||||
#cgo LDFLAGS: -framework Cocoa -framework Carbon
|
||||
#include <stdint.h>
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <Carbon/Carbon.h>
|
||||
|
||||
extern void keydownCallback(uintptr_t handle);
|
||||
extern void keyupCallback(uintptr_t handle);
|
||||
int registerHotKey(int mod, int key, uintptr_t handle, EventHotKeyRef* ref);
|
||||
int unregisterHotKey(EventHotKeyRef ref);
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime/cgo"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Standard errors
|
||||
var (
|
||||
ErrAlreadyRegistered = errors.New("hotkey: already registered")
|
||||
ErrNotRegistered = errors.New("hotkey: not registered")
|
||||
ErrFailedToRegister = errors.New("hotkey: failed to register")
|
||||
ErrFailedToUnregister = errors.New("hotkey: failed to unregister")
|
||||
)
|
||||
|
||||
// PlatformHotkey is a combination of modifiers and key to trigger an event
|
||||
type PlatformHotkey struct {
|
||||
mu sync.Mutex
|
||||
registered bool
|
||||
hkref C.EventHotKeyRef
|
||||
handle cgo.Handle
|
||||
}
|
||||
|
||||
func (ph *PlatformHotkey) Register(mods []Modifier, key Key, keydownIn, keyupIn chan<- interface{}) error {
|
||||
ph.mu.Lock()
|
||||
defer ph.mu.Unlock()
|
||||
if ph.registered {
|
||||
return ErrAlreadyRegistered
|
||||
}
|
||||
|
||||
// Store callbacks info for C side
|
||||
callbacks := &callbackData{
|
||||
keydownIn: keydownIn,
|
||||
keyupIn: keyupIn,
|
||||
}
|
||||
|
||||
ph.handle = cgo.NewHandle(callbacks)
|
||||
var mod Modifier
|
||||
for _, m := range mods {
|
||||
mod += m
|
||||
}
|
||||
|
||||
ret := C.registerHotKey(C.int(mod), C.int(key), C.uintptr_t(ph.handle), &ph.hkref)
|
||||
if ret == C.int(-1) {
|
||||
ph.handle.Delete()
|
||||
return fmt.Errorf("%w: Carbon API returned error", ErrFailedToRegister)
|
||||
}
|
||||
|
||||
ph.registered = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ph *PlatformHotkey) Unregister() error {
|
||||
ph.mu.Lock()
|
||||
defer ph.mu.Unlock()
|
||||
if !ph.registered {
|
||||
return ErrNotRegistered
|
||||
}
|
||||
|
||||
ret := C.unregisterHotKey(ph.hkref)
|
||||
if ret == C.int(-1) {
|
||||
return fmt.Errorf("%w: Carbon API returned error", ErrFailedToUnregister)
|
||||
}
|
||||
|
||||
// Clean up CGO handle
|
||||
ph.handle.Delete()
|
||||
ph.registered = false
|
||||
return nil
|
||||
}
|
||||
|
||||
type callbackData struct {
|
||||
keydownIn chan<- interface{}
|
||||
keyupIn chan<- interface{}
|
||||
}
|
||||
|
||||
//export keydownCallback
|
||||
func keydownCallback(h uintptr) {
|
||||
cb := cgo.Handle(h).Value().(*callbackData)
|
||||
cb.keydownIn <- struct{}{}
|
||||
}
|
||||
|
||||
//export keyupCallback
|
||||
func keyupCallback(h uintptr) {
|
||||
cb := cgo.Handle(h).Value().(*callbackData)
|
||||
cb.keyupIn <- struct{}{}
|
||||
}
|
||||
|
||||
// Modifier represents a modifier.
|
||||
// See: /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/Headers/Events.h
|
||||
type Modifier uint32
|
||||
|
||||
// All kinds of Modifiers
|
||||
const (
|
||||
ModCtrl Modifier = 0x1000
|
||||
ModShift Modifier = 0x200
|
||||
ModOption Modifier = 0x800
|
||||
ModCmd Modifier = 0x100
|
||||
)
|
||||
|
||||
// Key represents a key.
|
||||
// See: /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/Headers/Events.h
|
||||
type Key uint8
|
||||
|
||||
// All kinds of keys
|
||||
const (
|
||||
KeySpace Key = 49
|
||||
Key1 Key = 18
|
||||
Key2 Key = 19
|
||||
Key3 Key = 20
|
||||
Key4 Key = 21
|
||||
Key5 Key = 23
|
||||
Key6 Key = 22
|
||||
Key7 Key = 26
|
||||
Key8 Key = 28
|
||||
Key9 Key = 25
|
||||
Key0 Key = 29
|
||||
KeyA Key = 0
|
||||
KeyB Key = 11
|
||||
KeyC Key = 8
|
||||
KeyD Key = 2
|
||||
KeyE Key = 14
|
||||
KeyF Key = 3
|
||||
KeyG Key = 5
|
||||
KeyH Key = 4
|
||||
KeyI Key = 34
|
||||
KeyJ Key = 38
|
||||
KeyK Key = 40
|
||||
KeyL Key = 37
|
||||
KeyM Key = 46
|
||||
KeyN Key = 45
|
||||
KeyO Key = 31
|
||||
KeyP Key = 35
|
||||
KeyQ Key = 12
|
||||
KeyR Key = 15
|
||||
KeyS Key = 1
|
||||
KeyT Key = 17
|
||||
KeyU Key = 32
|
||||
KeyV Key = 9
|
||||
KeyW Key = 13
|
||||
KeyX Key = 7
|
||||
KeyY Key = 16
|
||||
KeyZ Key = 6
|
||||
|
||||
KeyReturn Key = 0x24
|
||||
KeyEscape Key = 0x35
|
||||
KeyDelete Key = 0x33
|
||||
KeyTab Key = 0x30
|
||||
|
||||
KeyLeft Key = 0x7B
|
||||
KeyRight Key = 0x7C
|
||||
KeyUp Key = 0x7E
|
||||
KeyDown Key = 0x7D
|
||||
|
||||
KeyF1 Key = 0x7A
|
||||
KeyF2 Key = 0x78
|
||||
KeyF3 Key = 0x63
|
||||
KeyF4 Key = 0x76
|
||||
KeyF5 Key = 0x60
|
||||
KeyF6 Key = 0x61
|
||||
KeyF7 Key = 0x62
|
||||
KeyF8 Key = 0x64
|
||||
KeyF9 Key = 0x65
|
||||
KeyF10 Key = 0x6D
|
||||
KeyF11 Key = 0x67
|
||||
KeyF12 Key = 0x6F
|
||||
KeyF13 Key = 0x69
|
||||
KeyF14 Key = 0x6B
|
||||
KeyF15 Key = 0x71
|
||||
KeyF16 Key = 0x6A
|
||||
KeyF17 Key = 0x40
|
||||
KeyF18 Key = 0x4F
|
||||
KeyF19 Key = 0x50
|
||||
KeyF20 Key = 0x5A
|
||||
)
|
||||
59
internal/common/hotkey/darwin/hotkey.m
Normal file
59
internal/common/hotkey/darwin/hotkey.m
Normal file
@@ -0,0 +1,59 @@
|
||||
//go:build darwin
|
||||
|
||||
#include <stdint.h>
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <Carbon/Carbon.h>
|
||||
extern void keydownCallback(uintptr_t handle);
|
||||
extern void keyupCallback(uintptr_t handle);
|
||||
|
||||
static OSStatus
|
||||
keydownHandler(EventHandlerCallRef nextHandler, EventRef theEvent, void *userData) {
|
||||
EventHotKeyID k;
|
||||
GetEventParameter(theEvent, kEventParamDirectObject, typeEventHotKeyID, NULL, sizeof(k), NULL, &k);
|
||||
keydownCallback((uintptr_t)k.id); // use id as handle
|
||||
return noErr;
|
||||
}
|
||||
|
||||
static OSStatus
|
||||
keyupHandler(EventHandlerCallRef nextHandler, EventRef theEvent, void *userData) {
|
||||
EventHotKeyID k;
|
||||
GetEventParameter(theEvent, kEventParamDirectObject, typeEventHotKeyID, NULL, sizeof(k), NULL, &k);
|
||||
keyupCallback((uintptr_t)k.id); // use id as handle
|
||||
return noErr;
|
||||
}
|
||||
|
||||
// registerHotkeyWithCallback registers a global system hotkey for callbacks.
|
||||
int registerHotKey(int mod, int key, uintptr_t handle, EventHotKeyRef* ref) {
|
||||
__block OSStatus s;
|
||||
dispatch_sync(dispatch_get_main_queue(), ^{
|
||||
EventTypeSpec keydownEvent;
|
||||
keydownEvent.eventClass = kEventClassKeyboard;
|
||||
keydownEvent.eventKind = kEventHotKeyPressed;
|
||||
EventTypeSpec keyupEvent;
|
||||
keyupEvent.eventClass = kEventClassKeyboard;
|
||||
keyupEvent.eventKind = kEventHotKeyReleased;
|
||||
InstallApplicationEventHandler(
|
||||
&keydownHandler, 1, &keydownEvent, NULL, NULL
|
||||
);
|
||||
InstallApplicationEventHandler(
|
||||
&keyupHandler, 1, &keyupEvent, NULL, NULL
|
||||
);
|
||||
|
||||
EventHotKeyID hkid = {.id = handle};
|
||||
s = RegisterEventHotKey(
|
||||
key, mod, hkid, GetApplicationEventTarget(), 0, ref
|
||||
);
|
||||
});
|
||||
if (s != noErr) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int unregisterHotKey(EventHotKeyRef ref) {
|
||||
OSStatus s = UnregisterEventHotKey(ref);
|
||||
if (s != noErr) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
68
internal/common/hotkey/darwin/mainthread.go
Normal file
68
internal/common/hotkey/darwin/mainthread.go
Normal file
@@ -0,0 +1,68 @@
|
||||
//go:build darwin
|
||||
|
||||
package darwin
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c
|
||||
#cgo LDFLAGS: -framework Cocoa
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <Dispatch/Dispatch.h>
|
||||
|
||||
extern void os_main(void);
|
||||
extern void wakeupMainThread(void);
|
||||
static bool isMainThread() {
|
||||
return [NSThread isMainThread];
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func init() {
|
||||
runtime.LockOSThread()
|
||||
}
|
||||
|
||||
// Call calls f on the main thread and blocks until f finishes.
|
||||
func Call(f func()) {
|
||||
if C.isMainThread() {
|
||||
f()
|
||||
return
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
mainFuncs <- func() {
|
||||
f()
|
||||
close(done)
|
||||
}
|
||||
C.wakeupMainThread()
|
||||
}()
|
||||
<-done
|
||||
}
|
||||
|
||||
// Init initializes the functionality of running arbitrary subsequent functions be called on the main system thread.
|
||||
//
|
||||
// Init must be called in the main.main function.
|
||||
func Init(f func()) {
|
||||
go func() {
|
||||
f()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
C.os_main()
|
||||
}
|
||||
|
||||
var mainFuncs = make(chan func(), 1)
|
||||
|
||||
//export dispatchMainFuncs
|
||||
func dispatchMainFuncs() {
|
||||
for {
|
||||
select {
|
||||
case f := <-mainFuncs:
|
||||
f()
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
22
internal/common/hotkey/darwin/mainthread.m
Normal file
22
internal/common/hotkey/darwin/mainthread.m
Normal file
@@ -0,0 +1,22 @@
|
||||
//go:build darwin
|
||||
|
||||
#include <stdint.h>
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
extern void dispatchMainFuncs();
|
||||
|
||||
void wakeupMainThread(void) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
dispatchMainFuncs();
|
||||
});
|
||||
}
|
||||
|
||||
// The following three lines of code must run on the main thread.
|
||||
// It must handle it using golang.design/x/mainthread.
|
||||
//
|
||||
// inspired from here: https://github.com/cehoffman/dotfiles/blob/4be8e893517e970d40746a9bdc67fe5832dd1c33/os/mac/iTerm2HotKey.m
|
||||
void os_main(void) {
|
||||
[NSApplication sharedApplication];
|
||||
[NSApp disableRelaunchOnLogin];
|
||||
[NSApp run];
|
||||
}
|
||||
334
internal/common/hotkey/hotkey.go
Normal file
334
internal/common/hotkey/hotkey.go
Normal file
@@ -0,0 +1,334 @@
|
||||
// Package hotkey provides a high-performance, thread-safe facility to register
|
||||
// system-level global hotkey shortcuts. Applications can be notified when users
|
||||
// trigger hotkeys. A hotkey consists of a combination of modifier keys (Ctrl, Alt,
|
||||
// Shift, etc.) and a single key.
|
||||
//
|
||||
// # Basic Usage
|
||||
//
|
||||
// hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl, hotkey.ModShift}, hotkey.KeyS)
|
||||
// if err := hk.Register(); err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// defer hk.Close()
|
||||
//
|
||||
// for {
|
||||
// select {
|
||||
// case <-hk.Keydown():
|
||||
// fmt.Println("Hotkey pressed!")
|
||||
// case <-hk.Keyup():
|
||||
// fmt.Println("Hotkey released!")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// # Error Handling
|
||||
//
|
||||
// The package provides standardized error types for robust error handling:
|
||||
//
|
||||
// if err := hk.Register(); err != nil {
|
||||
// switch {
|
||||
// case errors.Is(err, hotkey.ErrHotkeyConflict):
|
||||
// // Key combination already grabbed by another application
|
||||
// case errors.Is(err, hotkey.ErrPlatformUnavailable):
|
||||
// // Platform support unavailable (e.g., Linux without X11)
|
||||
// case errors.Is(err, hotkey.ErrAlreadyRegistered):
|
||||
// // Hotkey already registered
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// # Platform-Specific Notes
|
||||
//
|
||||
// Linux (X11):
|
||||
// - Requires libx11-dev: `sudo apt install -y libx11-dev`
|
||||
// - For headless environments, use Xvfb virtual display
|
||||
// - AutoRepeat may cause repeated Keydown events - implement debouncing if needed
|
||||
// - Display connection is kept open during registration for optimal performance
|
||||
// - Conflict detection: XSetErrorHandler catches BadAccess and returns ErrHotkeyConflict
|
||||
//
|
||||
// macOS:
|
||||
// - For GUI applications (like Wails): works out of the box
|
||||
// - For pure CLI applications: use darwin.Init(yourMainFunc) to start event loop
|
||||
// - Advanced: use darwin.Call(func) to execute code on main thread
|
||||
// - May require Accessibility permissions in System Preferences
|
||||
// - Uses Carbon API with GCD (dispatch_get_main_queue)
|
||||
//
|
||||
// Windows:
|
||||
// - No additional dependencies required
|
||||
// - Keyup events simulated via GetAsyncKeyState polling (10-30ms delay)
|
||||
// - Some system hotkeys (Win+L, Ctrl+Alt+Del) are reserved
|
||||
//
|
||||
// # Resource Management
|
||||
//
|
||||
// Always use Close() to release resources:
|
||||
//
|
||||
// hk := hotkey.New(mods, key)
|
||||
// defer hk.Close() // Safe to call multiple times
|
||||
//
|
||||
// if err := hk.Register(); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// // ... use hotkey ...
|
||||
//
|
||||
// After Unregister() or Close(), you must re-obtain channel references:
|
||||
//
|
||||
// hk.Unregister()
|
||||
// // ... modify hotkey ...
|
||||
// hk.Register()
|
||||
// keydownChan := hk.Keydown() // Get new channel reference
|
||||
//
|
||||
// # Performance
|
||||
//
|
||||
// - Memory: ~1KB per hotkey
|
||||
// - Goroutines: 3 per hotkey (event loop + 2 channel converters)
|
||||
// - Latency: Keydown < 10ms, Keyup < 30ms (Windows polling overhead)
|
||||
// - Thread-safe: All public APIs use mutex protection
|
||||
//
|
||||
// For complete documentation and examples, see README.md in this package.
|
||||
package hotkey
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Standard errors
|
||||
var (
|
||||
ErrAlreadyRegistered = errors.New("hotkey: already registered")
|
||||
ErrNotRegistered = errors.New("hotkey: not registered")
|
||||
ErrClosed = errors.New("hotkey: hotkey has been closed")
|
||||
ErrFailedToRegister = errors.New("hotkey: failed to register")
|
||||
ErrFailedToUnregister = errors.New("hotkey: failed to unregister")
|
||||
ErrHotkeyConflict = errors.New("hotkey: hotkey conflict with other applications")
|
||||
ErrPlatformUnavailable = errors.New("hotkey: platform support unavailable")
|
||||
)
|
||||
|
||||
// Event represents a hotkey event
|
||||
type Event struct{}
|
||||
|
||||
// Hotkey is a combination of modifiers and key to trigger an event
|
||||
type Hotkey struct {
|
||||
platformHotkey
|
||||
|
||||
mods []Modifier
|
||||
key Key
|
||||
|
||||
keydownIn chan<- Event
|
||||
keydownOut <-chan Event
|
||||
keyupIn chan<- Event
|
||||
keyupOut <-chan Event
|
||||
|
||||
// 用于停止 newEventChan goroutines
|
||||
eventChansWg sync.WaitGroup
|
||||
|
||||
// 状态管理
|
||||
mu sync.RWMutex
|
||||
registered bool
|
||||
closed bool
|
||||
|
||||
// 用于防止 Finalizer 和 Unregister 并发
|
||||
finalizerMu sync.Mutex
|
||||
finalized bool
|
||||
}
|
||||
|
||||
// New creates a new hotkey for the given modifiers and keycode.
|
||||
func New(mods []Modifier, key Key) *Hotkey {
|
||||
hk := &Hotkey{
|
||||
mods: mods,
|
||||
key: key,
|
||||
}
|
||||
|
||||
hk.eventChansWg.Add(2)
|
||||
keydownIn, keydownOut := newEventChan(&hk.eventChansWg)
|
||||
keyupIn, keyupOut := newEventChan(&hk.eventChansWg)
|
||||
|
||||
hk.keydownIn = keydownIn
|
||||
hk.keydownOut = keydownOut
|
||||
hk.keyupIn = keyupIn
|
||||
hk.keyupOut = keyupOut
|
||||
|
||||
// Make sure the hotkey is unregistered when the created
|
||||
// hotkey is garbage collected.
|
||||
// Note: This is a safety net only. Users should explicitly call Unregister().
|
||||
runtime.SetFinalizer(hk, func(x interface{}) {
|
||||
hk := x.(*Hotkey)
|
||||
hk.finalizerMu.Lock()
|
||||
defer hk.finalizerMu.Unlock()
|
||||
|
||||
if hk.finalized {
|
||||
return
|
||||
}
|
||||
hk.finalized = true
|
||||
|
||||
// Best effort cleanup - ignore errors
|
||||
_ = hk.unregister()
|
||||
})
|
||||
return hk
|
||||
}
|
||||
|
||||
// Register registers a combination of hotkeys. If the hotkey has
|
||||
// already been registered, this function will return an error.
|
||||
// Use Unregister first if you want to re-register.
|
||||
func (hk *Hotkey) Register() error {
|
||||
hk.mu.Lock()
|
||||
if hk.closed {
|
||||
hk.mu.Unlock()
|
||||
return ErrClosed
|
||||
}
|
||||
if hk.registered {
|
||||
hk.mu.Unlock()
|
||||
return ErrAlreadyRegistered
|
||||
}
|
||||
hk.mu.Unlock()
|
||||
|
||||
err := hk.register()
|
||||
if err == nil {
|
||||
hk.mu.Lock()
|
||||
hk.registered = true
|
||||
hk.mu.Unlock()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Keydown returns a channel that receives a signal when the hotkey is triggered.
|
||||
func (hk *Hotkey) Keydown() <-chan Event { return hk.keydownOut }
|
||||
|
||||
// Keyup returns a channel that receives a signal when the hotkey is released.
|
||||
func (hk *Hotkey) Keyup() <-chan Event { return hk.keyupOut }
|
||||
|
||||
// Unregister unregisters the hotkey. After unregister, the hotkey can be
|
||||
// registered again with Register(). If you don't plan to reuse the hotkey,
|
||||
// use Close() instead for proper cleanup.
|
||||
func (hk *Hotkey) Unregister() error {
|
||||
hk.mu.Lock()
|
||||
if hk.closed {
|
||||
hk.mu.Unlock()
|
||||
return ErrClosed
|
||||
}
|
||||
if !hk.registered {
|
||||
hk.mu.Unlock()
|
||||
return ErrNotRegistered
|
||||
}
|
||||
hk.mu.Unlock()
|
||||
|
||||
err := hk.unregister()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hk.mu.Lock()
|
||||
hk.registered = false
|
||||
hk.mu.Unlock()
|
||||
|
||||
// Close old event channels and wait for goroutines to exit
|
||||
close(hk.keydownIn)
|
||||
close(hk.keyupIn)
|
||||
hk.eventChansWg.Wait()
|
||||
|
||||
// Reset new event channels for potential re-registration
|
||||
hk.eventChansWg.Add(2)
|
||||
hk.keydownIn, hk.keydownOut = newEventChan(&hk.eventChansWg)
|
||||
hk.keyupIn, hk.keyupOut = newEventChan(&hk.eventChansWg)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close unregisters the hotkey and releases all resources.
|
||||
// After Close(), the hotkey cannot be used again. This is the recommended
|
||||
// way to cleanup resources when you're done with the hotkey.
|
||||
// Close is safe to call multiple times.
|
||||
func (hk *Hotkey) Close() error {
|
||||
hk.finalizerMu.Lock()
|
||||
if hk.finalized {
|
||||
hk.finalizerMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
hk.finalized = true
|
||||
hk.finalizerMu.Unlock()
|
||||
|
||||
hk.mu.Lock()
|
||||
if hk.closed {
|
||||
hk.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
hk.closed = true
|
||||
wasRegistered := hk.registered
|
||||
hk.registered = false
|
||||
hk.mu.Unlock()
|
||||
|
||||
var err error
|
||||
if wasRegistered {
|
||||
err = hk.unregister()
|
||||
}
|
||||
|
||||
// Close event channels and wait for goroutines
|
||||
close(hk.keydownIn)
|
||||
close(hk.keyupIn)
|
||||
hk.eventChansWg.Wait()
|
||||
|
||||
// Remove finalizer since we're cleaning up properly
|
||||
runtime.SetFinalizer(hk, nil)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// IsRegistered returns true if the hotkey is currently registered.
|
||||
func (hk *Hotkey) IsRegistered() bool {
|
||||
hk.mu.RLock()
|
||||
defer hk.mu.RUnlock()
|
||||
return hk.registered && !hk.closed
|
||||
}
|
||||
|
||||
// IsClosed returns true if the hotkey has been closed.
|
||||
func (hk *Hotkey) IsClosed() bool {
|
||||
hk.mu.RLock()
|
||||
defer hk.mu.RUnlock()
|
||||
return hk.closed
|
||||
}
|
||||
|
||||
// String returns a string representation of the hotkey.
|
||||
func (hk *Hotkey) String() string {
|
||||
s := fmt.Sprintf("%v", hk.key)
|
||||
for _, mod := range hk.mods {
|
||||
s += fmt.Sprintf("+%v", mod)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// newEventChan returns a sender and a receiver of a buffered channel
|
||||
// with infinite capacity.
|
||||
func newEventChan(wg *sync.WaitGroup) (chan<- Event, <-chan Event) {
|
||||
in, out := make(chan Event), make(chan Event)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var q []Event
|
||||
|
||||
for {
|
||||
e, ok := <-in
|
||||
if !ok {
|
||||
close(out)
|
||||
return
|
||||
}
|
||||
q = append(q, e)
|
||||
for len(q) > 0 {
|
||||
select {
|
||||
case out <- q[0]:
|
||||
q[0] = Event{}
|
||||
q = q[1:]
|
||||
case e, ok := <-in:
|
||||
if ok {
|
||||
q = append(q, e)
|
||||
break
|
||||
}
|
||||
for _, e := range q {
|
||||
out <- e
|
||||
}
|
||||
close(out)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return in, out
|
||||
}
|
||||
152
internal/common/hotkey/hotkey_darwin.go
Normal file
152
internal/common/hotkey/hotkey_darwin.go
Normal file
@@ -0,0 +1,152 @@
|
||||
//go:build darwin
|
||||
|
||||
package hotkey
|
||||
|
||||
import "voidraft/internal/common/hotkey/darwin"
|
||||
|
||||
type platformHotkey struct {
|
||||
ph darwin.PlatformHotkey
|
||||
keydownIn chan interface{}
|
||||
keyupIn chan interface{}
|
||||
stopChans chan struct{} // 用于停止通道转换 goroutines
|
||||
}
|
||||
|
||||
// Modifier represents a modifier.
|
||||
type Modifier = darwin.Modifier
|
||||
|
||||
// All kinds of Modifiers
|
||||
const (
|
||||
ModCtrl = darwin.ModCtrl
|
||||
ModShift = darwin.ModShift
|
||||
ModOption = darwin.ModOption
|
||||
ModCmd = darwin.ModCmd
|
||||
)
|
||||
|
||||
// Key represents a key.
|
||||
type Key = darwin.Key
|
||||
|
||||
// All kinds of keys
|
||||
const (
|
||||
KeySpace = darwin.KeySpace
|
||||
Key1 = darwin.Key1
|
||||
Key2 = darwin.Key2
|
||||
Key3 = darwin.Key3
|
||||
Key4 = darwin.Key4
|
||||
Key5 = darwin.Key5
|
||||
Key6 = darwin.Key6
|
||||
Key7 = darwin.Key7
|
||||
Key8 = darwin.Key8
|
||||
Key9 = darwin.Key9
|
||||
Key0 = darwin.Key0
|
||||
KeyA = darwin.KeyA
|
||||
KeyB = darwin.KeyB
|
||||
KeyC = darwin.KeyC
|
||||
KeyD = darwin.KeyD
|
||||
KeyE = darwin.KeyE
|
||||
KeyF = darwin.KeyF
|
||||
KeyG = darwin.KeyG
|
||||
KeyH = darwin.KeyH
|
||||
KeyI = darwin.KeyI
|
||||
KeyJ = darwin.KeyJ
|
||||
KeyK = darwin.KeyK
|
||||
KeyL = darwin.KeyL
|
||||
KeyM = darwin.KeyM
|
||||
KeyN = darwin.KeyN
|
||||
KeyO = darwin.KeyO
|
||||
KeyP = darwin.KeyP
|
||||
KeyQ = darwin.KeyQ
|
||||
KeyR = darwin.KeyR
|
||||
KeyS = darwin.KeyS
|
||||
KeyT = darwin.KeyT
|
||||
KeyU = darwin.KeyU
|
||||
KeyV = darwin.KeyV
|
||||
KeyW = darwin.KeyW
|
||||
KeyX = darwin.KeyX
|
||||
KeyY = darwin.KeyY
|
||||
KeyZ = darwin.KeyZ
|
||||
|
||||
KeyReturn = darwin.KeyReturn
|
||||
KeyEscape = darwin.KeyEscape
|
||||
KeyDelete = darwin.KeyDelete
|
||||
KeyTab = darwin.KeyTab
|
||||
|
||||
KeyLeft = darwin.KeyLeft
|
||||
KeyRight = darwin.KeyRight
|
||||
KeyUp = darwin.KeyUp
|
||||
KeyDown = darwin.KeyDown
|
||||
|
||||
KeyF1 = darwin.KeyF1
|
||||
KeyF2 = darwin.KeyF2
|
||||
KeyF3 = darwin.KeyF3
|
||||
KeyF4 = darwin.KeyF4
|
||||
KeyF5 = darwin.KeyF5
|
||||
KeyF6 = darwin.KeyF6
|
||||
KeyF7 = darwin.KeyF7
|
||||
KeyF8 = darwin.KeyF8
|
||||
KeyF9 = darwin.KeyF9
|
||||
KeyF10 = darwin.KeyF10
|
||||
KeyF11 = darwin.KeyF11
|
||||
KeyF12 = darwin.KeyF12
|
||||
KeyF13 = darwin.KeyF13
|
||||
KeyF14 = darwin.KeyF14
|
||||
KeyF15 = darwin.KeyF15
|
||||
KeyF16 = darwin.KeyF16
|
||||
KeyF17 = darwin.KeyF17
|
||||
KeyF18 = darwin.KeyF18
|
||||
KeyF19 = darwin.KeyF19
|
||||
KeyF20 = darwin.KeyF20
|
||||
)
|
||||
|
||||
func (hk *Hotkey) register() error {
|
||||
// Convert channels
|
||||
hk.platformHotkey.keydownIn = make(chan interface{}, 1)
|
||||
hk.platformHotkey.keyupIn = make(chan interface{}, 1)
|
||||
hk.platformHotkey.stopChans = make(chan struct{})
|
||||
|
||||
// Start goroutines to convert interface{} events to Event{}
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-hk.platformHotkey.stopChans:
|
||||
return
|
||||
case <-hk.platformHotkey.keydownIn:
|
||||
hk.keydownIn <- Event{}
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-hk.platformHotkey.stopChans:
|
||||
return
|
||||
case <-hk.platformHotkey.keyupIn:
|
||||
hk.keyupIn <- Event{}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return hk.platformHotkey.ph.Register(hk.mods, hk.key, hk.platformHotkey.keydownIn, hk.platformHotkey.keyupIn)
|
||||
}
|
||||
|
||||
func (hk *Hotkey) unregister() error {
|
||||
// Stop channel conversion goroutines first
|
||||
if hk.platformHotkey.stopChans != nil {
|
||||
select {
|
||||
case <-hk.platformHotkey.stopChans:
|
||||
// Already closed, do nothing
|
||||
default:
|
||||
close(hk.platformHotkey.stopChans)
|
||||
}
|
||||
hk.platformHotkey.stopChans = nil
|
||||
}
|
||||
|
||||
// Then unregister the hotkey
|
||||
err := hk.platformHotkey.ph.Unregister()
|
||||
|
||||
// Close conversion channels (don't close, just set to nil)
|
||||
// The goroutines will drain them when stopChans is closed
|
||||
hk.platformHotkey.keydownIn = nil
|
||||
hk.platformHotkey.keyupIn = nil
|
||||
|
||||
return err
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user