Compare commits
3 Commits
4b0f39d747
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| ac086db1ed | |||
| 6dff0181d2 | |||
| ad24d3a140 |
852
frontend/package-lock.json
generated
852
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@
|
||||
"app:generate": "cd .. && wails3 generate bindings -ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.19.1",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/commands": "^6.10.0",
|
||||
"@codemirror/lang-angular": "^0.1.4",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
@@ -50,7 +50,7 @@
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.38.6",
|
||||
"@codemirror/view": "^6.38.8",
|
||||
"@cospaia/prettier-plugin-clojure": "^0.0.2",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/lr": "^1.4.3",
|
||||
@@ -72,37 +72,37 @@
|
||||
"linguist-languages": "^9.1.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"mermaid": "^11.12.1",
|
||||
"npm": "^11.6.2",
|
||||
"npm": "^11.6.3",
|
||||
"php-parser": "^3.2.5",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"prettier": "^3.6.2",
|
||||
"sass": "^1.94.0",
|
||||
"sass": "^1.94.2",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-i18n": "^11.2.1",
|
||||
"vue-pick-colors": "^1.8.0",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@lezer/generator": "^1.8.0",
|
||||
"@types/node": "^24.9.2",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@wailsio/runtime": "latest",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^10.5.1",
|
||||
"eslint-plugin-vue": "^10.6.0",
|
||||
"globals": "^16.5.0",
|
||||
"happy-dom": "^20.0.10",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"typescript-eslint": "^8.47.0",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"vite-plugin-node-polyfills": "^0.24.0",
|
||||
"vitepress": "^2.0.0-alpha.12",
|
||||
"vitest": "^4.0.8",
|
||||
"vitest": "^4.0.13",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^3.1.3"
|
||||
"vue-tsc": "^3.1.4"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@latest"
|
||||
|
||||
@@ -1,45 +1,3 @@
|
||||
/**
|
||||
* 默认翻译配置
|
||||
*/
|
||||
export const DEFAULT_TRANSLATION_CONFIG = {
|
||||
minSelectionLength: 2,
|
||||
maxTranslationLength: 5000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 翻译相关的错误消息
|
||||
*/
|
||||
export const TRANSLATION_ERRORS = {
|
||||
NO_TEXT: 'no text to translate',
|
||||
TRANSLATION_FAILED: 'translation failed',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 翻译结果接口
|
||||
*/
|
||||
export interface TranslationResult {
|
||||
translatedText: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 语言信息接口
|
||||
*/
|
||||
export interface LanguageInfo {
|
||||
Code: string; // 语言代码
|
||||
Name: string; // 语言名称
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译器扩展配置
|
||||
*/
|
||||
export interface TranslatorConfig {
|
||||
/** 最小选择字符数才显示翻译按钮 */
|
||||
minSelectionLength: number;
|
||||
/** 最大翻译字符数 */
|
||||
maxTranslationLength: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译图标SVG
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getActiveNoteBlock } from '@/views/editor/extensions/codeblock/state';
|
||||
import { changeCurrentBlockLanguage } from '@/views/editor/extensions/codeblock/commands';
|
||||
|
||||
const { t } = useI18n();
|
||||
const editorStore = readonly(useEditorStore());
|
||||
const editorStore = useEditorStore();
|
||||
|
||||
// 组件状态
|
||||
const showLanguageMenu = shallowRef(false);
|
||||
|
||||
@@ -14,14 +14,13 @@ import {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/langu
|
||||
import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode';
|
||||
import {createDebounce} from '@/common/utils/debounce';
|
||||
import {toggleMarkdownPreview} from '@/views/editor/extensions/markdownPreview';
|
||||
import {usePanelStore} from '@/stores/panelStore';
|
||||
import {markdownPreviewManager} from "@/views/editor/extensions/markdownPreview/manager";
|
||||
|
||||
const editorStore = readonly(useEditorStore());
|
||||
const configStore = readonly(useConfigStore());
|
||||
const updateStore = readonly(useUpdateStore());
|
||||
const windowStore = readonly(useWindowStore());
|
||||
const systemStore = readonly(useSystemStore());
|
||||
const panelStore = readonly(usePanelStore());
|
||||
const editorStore = useEditorStore();
|
||||
const configStore = useConfigStore();
|
||||
const updateStore = useUpdateStore();
|
||||
const windowStore = useWindowStore();
|
||||
const systemStore = useSystemStore();
|
||||
const {t} = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -39,7 +38,7 @@ const isCurrentWindowOnTop = computed(() => {
|
||||
|
||||
// 当前文档的预览是否打开
|
||||
const isCurrentBlockPreviewing = computed(() => {
|
||||
return panelStore.markdownPreview.isOpen && !panelStore.markdownPreview.isClosing;
|
||||
return markdownPreviewManager.isVisible();
|
||||
});
|
||||
|
||||
// 切换窗口置顶状态
|
||||
|
||||
@@ -4,7 +4,6 @@ import {EditorView} from '@codemirror/view';
|
||||
import {EditorState, Extension} from '@codemirror/state';
|
||||
import {useConfigStore} from './configStore';
|
||||
import {useDocumentStore} from './documentStore';
|
||||
import {usePanelStore} from './panelStore';
|
||||
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {DocumentService, ExtensionService} from '@/../bindings/voidraft/internal/services';
|
||||
import {ensureSyntaxTree} from "@codemirror/language";
|
||||
@@ -30,7 +29,7 @@ import {generateContentHash} from "@/common/utils/hashUtils";
|
||||
import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
|
||||
import {EDITOR_CONFIG} from '@/common/constant/editor';
|
||||
import {createHttpClientExtension} from "@/views/editor/extensions/httpclient";
|
||||
import {markdownPreviewExtension, updateMarkdownPreviewTheme} from "@/views/editor/extensions/markdownPreview";
|
||||
import {markdownPreviewExtension} from "@/views/editor/extensions/markdownPreview";
|
||||
import {createDebounce} from '@/common/utils/debounce';
|
||||
|
||||
export interface DocumentStats {
|
||||
@@ -642,12 +641,6 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
});
|
||||
};
|
||||
|
||||
// 应用 Markdown 预览主题
|
||||
const applyPreviewThemeSettings = () => {
|
||||
editorCache.values().forEach(instance => {
|
||||
updateMarkdownPreviewTheme(instance.view);
|
||||
});
|
||||
};
|
||||
|
||||
// 应用Tab设置
|
||||
const applyTabSettings = () => {
|
||||
@@ -701,10 +694,6 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
instance.view.destroy();
|
||||
});
|
||||
|
||||
// 清理 panelStore 状态(导航离开编辑器页面时)
|
||||
const panelStore = usePanelStore();
|
||||
panelStore.reset();
|
||||
|
||||
currentEditor.value = null;
|
||||
};
|
||||
|
||||
@@ -790,7 +779,6 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
// 配置更新方法
|
||||
applyFontSettings,
|
||||
applyThemeSettings,
|
||||
applyPreviewThemeSettings,
|
||||
applyTabSettings,
|
||||
applyKeymapSettings,
|
||||
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { useDocumentStore } from './documentStore';
|
||||
|
||||
/**
|
||||
* 单个文档的预览状态
|
||||
*/
|
||||
interface DocumentPreviewState {
|
||||
isOpen: boolean;
|
||||
isClosing: boolean;
|
||||
blockFrom: number;
|
||||
blockTo: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 面板状态管理 Store
|
||||
* 管理编辑器中各种面板的显示状态(按文档ID区分)
|
||||
*/
|
||||
export const usePanelStore = defineStore('panel', () => {
|
||||
// 当前编辑器视图引用
|
||||
const editorView = ref<EditorView | null>(null);
|
||||
|
||||
// 每个文档的预览状态 Map<documentId, PreviewState>
|
||||
const documentPreviews = ref<Map<number, DocumentPreviewState>>(new Map());
|
||||
|
||||
/**
|
||||
* 获取当前文档的预览状态
|
||||
*/
|
||||
const markdownPreview = computed(() => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) {
|
||||
return {
|
||||
isOpen: false,
|
||||
isClosing: false,
|
||||
blockFrom: 0,
|
||||
blockTo: 0
|
||||
};
|
||||
}
|
||||
|
||||
return documentPreviews.value.get(currentDocId) || {
|
||||
isOpen: false,
|
||||
isClosing: false,
|
||||
blockFrom: 0,
|
||||
blockTo: 0
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 设置编辑器视图
|
||||
*/
|
||||
const setEditorView = (view: EditorView | null) => {
|
||||
editorView.value = view;
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开 Markdown 预览面板
|
||||
*/
|
||||
const openMarkdownPreview = (from: number, to: number) => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) return;
|
||||
|
||||
documentPreviews.value.set(currentDocId, {
|
||||
isOpen: true,
|
||||
isClosing: false,
|
||||
blockFrom: from,
|
||||
blockTo: to
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 开始关闭 Markdown 预览面板
|
||||
*/
|
||||
const startClosingMarkdownPreview = () => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) return;
|
||||
|
||||
const state = documentPreviews.value.get(currentDocId);
|
||||
if (state?.isOpen) {
|
||||
documentPreviews.value.set(currentDocId, {
|
||||
...state,
|
||||
isClosing: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭 Markdown 预览面板
|
||||
*/
|
||||
const closeMarkdownPreview = () => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) return;
|
||||
|
||||
documentPreviews.value.set(currentDocId, {
|
||||
isOpen: false,
|
||||
isClosing: false,
|
||||
blockFrom: 0,
|
||||
blockTo: 0
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新预览块的范围(用于实时预览)
|
||||
*/
|
||||
const updatePreviewRange = (from: number, to: number) => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) return;
|
||||
|
||||
const state = documentPreviews.value.get(currentDocId);
|
||||
if (state?.isOpen) {
|
||||
documentPreviews.value.set(currentDocId, {
|
||||
...state,
|
||||
blockFrom: from,
|
||||
blockTo: to
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查指定块是否正在预览
|
||||
*/
|
||||
const isBlockPreviewing = (from: number, to: number): boolean => {
|
||||
const preview = markdownPreview.value;
|
||||
return preview.isOpen &&
|
||||
preview.blockFrom === from &&
|
||||
preview.blockTo === to;
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置所有面板状态
|
||||
*/
|
||||
const reset = () => {
|
||||
documentPreviews.value.clear();
|
||||
editorView.value = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理指定文档的预览状态(文档关闭时调用)
|
||||
*/
|
||||
const clearDocumentPreview = (documentId: number) => {
|
||||
documentPreviews.value.delete(documentId);
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
editorView,
|
||||
markdownPreview,
|
||||
|
||||
// 方法
|
||||
setEditorView,
|
||||
openMarkdownPreview,
|
||||
startClosingMarkdownPreview,
|
||||
closeMarkdownPreview,
|
||||
updatePreviewRange,
|
||||
isBlockPreviewing,
|
||||
reset,
|
||||
clearDocumentPreview
|
||||
};
|
||||
});
|
||||
|
||||
@@ -141,7 +141,6 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
|
||||
const editorStore = useEditorStore();
|
||||
editorStore?.applyThemeSettings();
|
||||
editorStore?.applyPreviewThemeSettings();
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
import {defineStore} from 'pinia';
|
||||
import {ref} from 'vue';
|
||||
import {TranslationService} from '@/../bindings/voidraft/internal/services';
|
||||
import {LanguageInfo, TRANSLATION_ERRORS, TranslationResult} from '@/common/constant/translation';
|
||||
/**
|
||||
* 翻译结果接口
|
||||
*/
|
||||
export interface TranslationResult {
|
||||
translatedText: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 语言信息接口
|
||||
*/
|
||||
export interface LanguageInfo {
|
||||
Code: string; // 语言代码
|
||||
Name: string; // 语言名称
|
||||
}
|
||||
/**
|
||||
* 翻译相关的错误消息
|
||||
*/
|
||||
export const TRANSLATION_ERRORS = {
|
||||
NO_TEXT: 'no text to translate',
|
||||
TRANSLATION_FAILED: 'translation failed',
|
||||
} as const;
|
||||
|
||||
export const useTranslationStore = defineStore('translation', () => {
|
||||
// 基础状态
|
||||
|
||||
@@ -9,6 +9,10 @@ import LoadingScreen from '@/components/loading/LoadingScreen.vue';
|
||||
import { useTabStore } from '@/stores/tabStore';
|
||||
import ContextMenu from './contextMenu/ContextMenu.vue';
|
||||
import { contextMenuManager } from './contextMenu/manager';
|
||||
import TranslatorDialog from './extensions/translator/TranslatorDialog.vue';
|
||||
import { translatorManager } from './extensions/translator/manager';
|
||||
import {markdownPreviewManager} from "@/views/editor/extensions/markdownPreview/manager";
|
||||
import PreviewPanel from "@/views/editor/extensions/markdownPreview/PreviewPanel.vue";
|
||||
|
||||
const editorStore = useEditorStore();
|
||||
const documentStore = useDocumentStore();
|
||||
@@ -34,17 +38,30 @@ onMounted(async () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
contextMenuManager.destroy();
|
||||
translatorManager.destroy();
|
||||
markdownPreviewManager.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-container">
|
||||
<!-- 加载动画 -->
|
||||
<transition name="loading-fade">
|
||||
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT" />
|
||||
</transition>
|
||||
<!-- 编辑器和预览面板的容器 -->
|
||||
<div class="editor-wrapper">
|
||||
<!-- 编辑器区域 -->
|
||||
<div ref="editorElement" class="editor"></div>
|
||||
<!-- Markdown 预览面板 -->
|
||||
<PreviewPanel />
|
||||
</div>
|
||||
<!-- 工具栏 -->
|
||||
<Toolbar />
|
||||
<!-- 右键菜单 -->
|
||||
<ContextMenu :portal-target="editorElement" />
|
||||
<!-- 翻译器弹窗 -->
|
||||
<TranslatorDialog :portal-target="editorElement" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -57,9 +74,18 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
.editor {
|
||||
.editor-wrapper {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
|
||||
import { contextMenuManager } from './manager';
|
||||
import type { RenderMenuItem } from './menuSchema';
|
||||
|
||||
@@ -30,9 +30,19 @@ watch(
|
||||
watch(isVisible, (visible) => {
|
||||
if (visible) {
|
||||
nextTick(adjustMenuWithinViewport);
|
||||
// 显示时添加 outside 点击监听
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
} else {
|
||||
// 隐藏时移除监听
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
});
|
||||
|
||||
// 清理
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
});
|
||||
|
||||
const menuStyle = computed(() => ({
|
||||
left: `${adjustedPosition.value.x}px`,
|
||||
top: `${adjustedPosition.value.y}px`
|
||||
@@ -65,26 +75,24 @@ function handleItemClick(item: RenderMenuItem) {
|
||||
contextMenuManager.runCommand(item);
|
||||
}
|
||||
|
||||
function handleOverlayMouseDown() {
|
||||
contextMenuManager.hide();
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
// 如果点击在菜单内部,不关闭
|
||||
if (menuRef.value?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
function stopPropagation(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
contextMenuManager.hide();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="teleportTarget">
|
||||
<template v-if="isVisible">
|
||||
<div class="cm-context-overlay" @mousedown="handleOverlayMouseDown" />
|
||||
<div
|
||||
ref="menuRef"
|
||||
class="cm-context-menu show"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
@contextmenu.prevent
|
||||
@mousedown="stopPropagation"
|
||||
>
|
||||
<template v-for="item in items" :key="item.id">
|
||||
<div v-if="item.type === 'separator'" class="cm-context-menu-divider" />
|
||||
@@ -110,13 +118,6 @@ function stopPropagation(event: MouseEvent) {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cm-context-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 9000;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cm-context-menu {
|
||||
position: fixed;
|
||||
min-width: 180px;
|
||||
|
||||
@@ -27,6 +27,26 @@ class ContextMenuManager {
|
||||
}
|
||||
|
||||
show(view: EditorView, clientX: number, clientY: number, items: RenderMenuItem[]): void {
|
||||
const currentState = this.state.value;
|
||||
|
||||
// 如果菜单已经显示,且位置很接近(20px范围内),则只更新内容,避免闪烁
|
||||
if (currentState.visible) {
|
||||
const dx = Math.abs(currentState.position.x - clientX);
|
||||
const dy = Math.abs(currentState.position.y - clientY);
|
||||
const isSamePosition = dx < 20 && dy < 20;
|
||||
|
||||
if (isSamePosition) {
|
||||
// 只更新items和view,保持visible状态和位置
|
||||
this.state.value = {
|
||||
...currentState,
|
||||
items,
|
||||
view
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 否则正常显示菜单
|
||||
this.state.value = {
|
||||
visible: true,
|
||||
position: { x: clientX, y: clientY },
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
|
||||
import { markdownPreviewManager } from './manager';
|
||||
import { createMarkdownRenderer } from './renderer';
|
||||
import { updateMermaidTheme } from '@/common/markdown-it/plugins/markdown-it-mermaid';
|
||||
import { useThemeStore } from '@/stores/themeStore';
|
||||
import { createDebounce } from '@/common/utils/debounce';
|
||||
import { morphHTML } from '@/common/utils/domDiff';
|
||||
import * as runtime from '@wailsio/runtime';
|
||||
|
||||
import './github-markdown.css';
|
||||
|
||||
const state = markdownPreviewManager.useState();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const panelRef = ref<HTMLDivElement | null>(null);
|
||||
const contentRef = ref<HTMLDivElement | null>(null);
|
||||
const resizeHandleRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const isVisible = computed(() => state.value.visible);
|
||||
const content = computed(() => state.value.content);
|
||||
const height = computed(() => state.value.position.height);
|
||||
|
||||
// Markdown 渲染器
|
||||
let md = createMarkdownRenderer();
|
||||
|
||||
// 渲染的 HTML
|
||||
const renderedHtml = ref('');
|
||||
let lastRenderedContent = '';
|
||||
let isDestroyed = false;
|
||||
|
||||
/**
|
||||
* 使用 DOM Diff 渲染内容
|
||||
*/
|
||||
function renderWithDiff(markdownContent: string): void {
|
||||
if (isDestroyed || !contentRef.value) return;
|
||||
|
||||
try {
|
||||
const newHtml = md.render(markdownContent);
|
||||
|
||||
// 首次渲染或内容为空,直接设置
|
||||
if (!lastRenderedContent || contentRef.value.children.length === 0) {
|
||||
renderedHtml.value = newHtml;
|
||||
} else {
|
||||
// 使用 DOM Diff 增量更新
|
||||
morphHTML(contentRef.value, newHtml);
|
||||
}
|
||||
|
||||
lastRenderedContent = markdownContent;
|
||||
} catch (error) {
|
||||
console.warn('Markdown render error:', error);
|
||||
renderedHtml.value = `<div class="markdown-error">Render failed: ${error}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步渲染大内容
|
||||
*/
|
||||
function renderLargeContentAsync(markdownContent: string): void {
|
||||
if (isDestroyed || !isVisible.value) return;
|
||||
|
||||
// 首次渲染显示加载状态
|
||||
if (!lastRenderedContent) {
|
||||
renderedHtml.value = '<div class="markdown-loading">Rendering...</div>';
|
||||
}
|
||||
|
||||
// 使用 requestIdleCallback 在浏览器空闲时渲染
|
||||
const callback = window.requestIdleCallback || ((cb: IdleRequestCallback) => setTimeout(cb, 1));
|
||||
|
||||
callback(() => {
|
||||
// 再次检查状态,防止异步回调时预览已关闭
|
||||
if (isDestroyed || !isVisible.value || !contentRef.value) return;
|
||||
|
||||
try {
|
||||
const newHtml = md.render(markdownContent);
|
||||
|
||||
// 首次渲染或内容为空
|
||||
if (!lastRenderedContent || contentRef.value.children.length === 0) {
|
||||
// 使用 DocumentFragment 减少 DOM 操作
|
||||
const fragment = document.createRange().createContextualFragment(newHtml);
|
||||
if (contentRef.value) {
|
||||
contentRef.value.innerHTML = '';
|
||||
contentRef.value.appendChild(fragment);
|
||||
}
|
||||
} else {
|
||||
// 使用 DOM Diff 增量更新
|
||||
morphHTML(contentRef.value, newHtml);
|
||||
}
|
||||
|
||||
lastRenderedContent = markdownContent;
|
||||
} catch (error) {
|
||||
console.warn('Large content render error:', error);
|
||||
if (isVisible.value) {
|
||||
renderedHtml.value = `<div class="markdown-error">Render failed: ${error}</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Markdown 内容
|
||||
*/
|
||||
function renderMarkdown(markdownContent: string): void {
|
||||
if (!markdownContent || markdownContent === lastRenderedContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 大内容使用异步渲染
|
||||
if (markdownContent.length > 1000) {
|
||||
renderLargeContentAsync(markdownContent);
|
||||
} else {
|
||||
renderWithDiff(markdownContent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新创建渲染器
|
||||
*/
|
||||
function resetRenderer(): void {
|
||||
md = createMarkdownRenderer();
|
||||
const currentTheme = themeStore.isDarkMode ? 'dark' : 'default';
|
||||
updateMermaidTheme(currentTheme);
|
||||
|
||||
lastRenderedContent = '';
|
||||
if (content.value) {
|
||||
renderMarkdown(content.value);
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 data-theme 属性值
|
||||
const dataTheme = computed(() => themeStore.isDarkMode ? 'dark' : 'light');
|
||||
|
||||
// 拖动相关状态
|
||||
let startY = 0;
|
||||
let startHeight = 0;
|
||||
let currentHandle: HTMLDivElement | null = null;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const delta = startY - e.clientY; // 向上拖动增加高度
|
||||
|
||||
// 获取编辑器容器高度作为最大限制
|
||||
const editorView = state.value.view;
|
||||
const maxHeight = editorView
|
||||
? (editorView.dom.parentElement?.clientHeight || editorView.dom.clientHeight)
|
||||
: 9999;
|
||||
|
||||
const newHeight = Math.max(10, Math.min(maxHeight, startHeight + delta));
|
||||
markdownPreviewManager.updateHeight(newHeight);
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
if (currentHandle) {
|
||||
currentHandle.classList.remove('dragging');
|
||||
}
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
startY = e.clientY;
|
||||
startHeight = height.value;
|
||||
currentHandle = resizeHandleRef.value;
|
||||
if (currentHandle) {
|
||||
currentHandle.classList.add('dragging');
|
||||
}
|
||||
document.body.style.cursor = 'ns-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理拖动事件
|
||||
*/
|
||||
function cleanupResize(): void {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理链接点击
|
||||
*/
|
||||
function handleLinkClick(e: MouseEvent): void {
|
||||
const target = e.target as HTMLElement;
|
||||
const anchor = target.closest('a');
|
||||
if (!anchor) return;
|
||||
|
||||
const href = anchor.getAttribute('href');
|
||||
|
||||
// 处理锚点跳转
|
||||
if (href && href.startsWith('#')) {
|
||||
e.preventDefault();
|
||||
const targetId = href.substring(1);
|
||||
const targetElement = document.getElementById(targetId);
|
||||
if (targetElement && contentRef.value?.contains(targetElement)) {
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理外部链接
|
||||
if (href && !href.startsWith('#')) {
|
||||
e.preventDefault();
|
||||
if (isValidUrl(href)) {
|
||||
runtime.Browser.OpenURL(href);
|
||||
} else {
|
||||
console.warn('Invalid or relative link:', href);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// data-href 属性处理
|
||||
const dataHref = anchor.getAttribute('data-href');
|
||||
if (dataHref) {
|
||||
e.preventDefault();
|
||||
if (isValidUrl(dataHref)) {
|
||||
runtime.Browser.OpenURL(dataHref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 URL 是否有效
|
||||
*/
|
||||
function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
if (url.match(/^[a-zA-Z][a-zA-Z\d+\-.]*:/)) {
|
||||
const parsedUrl = new URL(url);
|
||||
const allowedProtocols = ['http:', 'https:', 'mailto:', 'file:', 'ftp:'];
|
||||
return allowedProtocols.includes(parsedUrl.protocol);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 面板样式
|
||||
const panelStyle = computed(() => ({
|
||||
height: `${height.value}px`
|
||||
}));
|
||||
|
||||
// 创建防抖渲染函数
|
||||
const { debouncedFn: debouncedRender, cancel: cancelDebounce } = createDebounce(
|
||||
(newContent: string) => {
|
||||
if (isVisible.value && newContent) {
|
||||
renderMarkdown(newContent);
|
||||
}
|
||||
},
|
||||
{ delay: 500 }
|
||||
);
|
||||
|
||||
// 监听内容变化
|
||||
watch(
|
||||
content,
|
||||
(newContent) => {
|
||||
if (isVisible.value && newContent) {
|
||||
debouncedRender(newContent);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听主题变化
|
||||
watch(() => themeStore.isDarkMode, resetRenderer);
|
||||
|
||||
// 监听可见性变化,初始化/清理拖动
|
||||
watch(isVisible, async (visible) => {
|
||||
if (visible) {
|
||||
await nextTick();
|
||||
// 初始化拖动手柄
|
||||
const handle = resizeHandleRef.value;
|
||||
if (handle) {
|
||||
handle.addEventListener('mousedown', onMouseDown);
|
||||
}
|
||||
if (content.value) {
|
||||
renderMarkdown(content.value);
|
||||
}
|
||||
} else {
|
||||
// 清理拖动事件
|
||||
const handle = resizeHandleRef.value;
|
||||
if (handle) {
|
||||
handle.removeEventListener('mousedown', onMouseDown);
|
||||
}
|
||||
cleanupResize();
|
||||
|
||||
cancelDebounce();
|
||||
renderedHtml.value = '';
|
||||
lastRenderedContent = '';
|
||||
}
|
||||
});
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
isDestroyed = true;
|
||||
cancelDebounce();
|
||||
cleanupResize();
|
||||
lastRenderedContent = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition name="preview-slide">
|
||||
<div
|
||||
v-if="isVisible"
|
||||
ref="panelRef"
|
||||
class="cm-markdown-preview-panel"
|
||||
:style="panelStyle"
|
||||
>
|
||||
<!-- 拖动调整手柄 -->
|
||||
<div ref="resizeHandleRef" class="cm-preview-resize-handle">
|
||||
<div class="resize-indicator"></div>
|
||||
</div>
|
||||
|
||||
<!-- 预览内容 -->
|
||||
<div
|
||||
ref="contentRef"
|
||||
class="cm-preview-content markdown-body"
|
||||
:data-theme="dataTheme"
|
||||
@click="handleLinkClick"
|
||||
v-html="renderedHtml"
|
||||
></div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cm-markdown-preview-panel {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-height: 10px;
|
||||
background: var(--bg-primary);
|
||||
border-top: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.cm-preview-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 8px;
|
||||
cursor: ns-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
background: transparent;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.cm-preview-resize-handle:hover {
|
||||
background: var(--bg-hover, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.cm-preview-resize-handle.dragging {
|
||||
background: var(--bg-hover, rgba(66, 133, 244, 0.1));
|
||||
}
|
||||
|
||||
.resize-indicator {
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--border-color, rgba(255, 255, 255, 0.2));
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.cm-preview-resize-handle:hover .resize-indicator {
|
||||
background: var(--text-muted, rgba(255, 255, 255, 0.4));
|
||||
}
|
||||
|
||||
.cm-preview-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 45px;
|
||||
box-sizing: border-box;
|
||||
position: relative; /* 为绝对定位的 loading/error 提供定位上下文 */
|
||||
}
|
||||
|
||||
/* ========== macOS 窗口风格代码块(主题适配)========== */
|
||||
.cm-preview-content.markdown-body {
|
||||
:deep(pre) {
|
||||
position: relative;
|
||||
padding-top: 40px !important;
|
||||
}
|
||||
|
||||
/* 暗色主题 */
|
||||
&[data-theme="dark"] {
|
||||
:deep(pre) {
|
||||
/* macOS 窗口顶部栏 */
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 28px;
|
||||
background-color: #1c1c1e;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
|
||||
border-radius: 6px 6px 0 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* macOS 三个控制按钮 */
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 12px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: #ec6a5f;
|
||||
box-shadow: 18px 0 0 0 #f4bf4f, 36px 0 0 0 #61c554;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 亮色主题 */
|
||||
&[data-theme="light"] {
|
||||
:deep(pre) {
|
||||
/* macOS 窗口顶部栏 */
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 28px;
|
||||
background-color: #e8e8e8;
|
||||
border-bottom: 1px solid #d1d1d6;
|
||||
border-radius: 6px 6px 0 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* macOS 三个控制按钮 */
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 12px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: #ff5f57;
|
||||
box-shadow: 18px 0 0 0 #febc2e, 36px 0 0 0 #28c840;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading 和 Error 状态 - 居中显示 */
|
||||
.cm-preview-content :deep(.markdown-loading),
|
||||
.cm-preview-content :deep(.markdown-error) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted, #7d8590);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cm-preview-content :deep(.markdown-error) {
|
||||
color: #f85149;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 过渡动画 - 从下往上弹起 */
|
||||
.preview-slide-enter-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.preview-slide-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.6, 1);
|
||||
}
|
||||
|
||||
.preview-slide-enter-from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.preview-slide-enter-to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.preview-slide-leave-from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.preview-slide-leave-to {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,14 @@
|
||||
/**
|
||||
* Markdown 预览扩展主入口
|
||||
*/
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { Compartment } from "@codemirror/state";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import { usePanelStore } from "@/stores/panelStore";
|
||||
import { EditorView, ViewUpdate, ViewPlugin } from "@codemirror/view";
|
||||
import { Extension } from "@codemirror/state";
|
||||
import { useDocumentStore } from "@/stores/documentStore";
|
||||
import { getActiveNoteBlock } from "../codeblock/state";
|
||||
import { createMarkdownPreviewTheme } from "./styles";
|
||||
import { previewPanelState, previewPanelPlugin, togglePreview, closePreviewWithAnimation } from "./state";
|
||||
import { markdownPreviewManager } from "./manager";
|
||||
|
||||
/**
|
||||
* 切换预览面板的命令
|
||||
*/
|
||||
export function toggleMarkdownPreview(view: EditorView): boolean {
|
||||
const panelStore = usePanelStore();
|
||||
const documentStore = useDocumentStore();
|
||||
const currentState = view.state.field(previewPanelState, false);
|
||||
const activeBlock = getActiveNoteBlock(view.state as any);
|
||||
|
||||
// 如果当前没有激活的 Markdown 块,不执行操作
|
||||
@@ -30,53 +22,84 @@ export function toggleMarkdownPreview(view: EditorView): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果预览面板已打开(无论预览的是不是当前块),关闭预览
|
||||
if (panelStore.markdownPreview.isOpen && !panelStore.markdownPreview.isClosing) {
|
||||
// 使用带动画的关闭函数
|
||||
closePreviewWithAnimation(view);
|
||||
// 切换预览状态
|
||||
if (markdownPreviewManager.isVisible()) {
|
||||
markdownPreviewManager.hide();
|
||||
} else {
|
||||
// 否则,打开当前块的预览
|
||||
view.dispatch({
|
||||
effects: togglePreview.of({
|
||||
documentId: currentDocumentId,
|
||||
blockFrom: activeBlock.content.from,
|
||||
blockTo: activeBlock.content.to
|
||||
})
|
||||
});
|
||||
|
||||
// 注意:store 状态由 ViewPlugin 在面板创建成功后更新
|
||||
markdownPreviewManager.show(
|
||||
view,
|
||||
currentDocumentId,
|
||||
activeBlock.content.from,
|
||||
activeBlock.content.to
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览同步插件
|
||||
*/
|
||||
const previewSyncPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
constructor(private view: EditorView) {}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
// 只在预览可见时处理
|
||||
if (!markdownPreviewManager.isVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocumentId = documentStore.currentDocumentId;
|
||||
const previewDocId = markdownPreviewManager.getCurrentDocumentId();
|
||||
|
||||
// 如果切换了文档,关闭预览
|
||||
if (currentDocumentId !== previewDocId) {
|
||||
markdownPreviewManager.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// 文档内容改变时,更新预览
|
||||
if (update.docChanged) {
|
||||
const activeBlock = getActiveNoteBlock(update.state as any);
|
||||
|
||||
// 如果不再是 Markdown 块,关闭预览
|
||||
if (!activeBlock || activeBlock.language.name.toLowerCase() !== 'md') {
|
||||
markdownPreviewManager.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const range = markdownPreviewManager.getCurrentBlockRange();
|
||||
|
||||
// 如果切换到其他块,关闭预览
|
||||
if (range && activeBlock.content.from !== range.from) {
|
||||
markdownPreviewManager.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新预览内容
|
||||
const newContent = update.state.doc.sliceString(
|
||||
activeBlock.content.from,
|
||||
activeBlock.content.to
|
||||
);
|
||||
markdownPreviewManager.updateContent(
|
||||
newContent,
|
||||
activeBlock.content.from,
|
||||
activeBlock.content.to
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
markdownPreviewManager.destroy();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 导出 Markdown 预览扩展
|
||||
*/
|
||||
const previewThemeCompartment = new Compartment();
|
||||
|
||||
const buildPreviewTheme = () => {
|
||||
const themeStore = useThemeStore();
|
||||
const colors = themeStore.currentColors;
|
||||
return colors ? createMarkdownPreviewTheme(colors) : EditorView.baseTheme({});
|
||||
};
|
||||
|
||||
export function markdownPreviewExtension() {
|
||||
return [
|
||||
previewPanelState,
|
||||
previewPanelPlugin,
|
||||
previewThemeCompartment.of(buildPreviewTheme())
|
||||
];
|
||||
}
|
||||
|
||||
export function updateMarkdownPreviewTheme(view: EditorView): void {
|
||||
if (!view?.dispatch) return;
|
||||
|
||||
try {
|
||||
view.dispatch({
|
||||
effects: previewThemeCompartment.reconfigure(buildPreviewTheme())
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update markdown preview theme", error);
|
||||
}
|
||||
export function markdownPreviewExtension(): Extension {
|
||||
return [previewSyncPlugin];
|
||||
}
|
||||
|
||||
161
frontend/src/views/editor/extensions/markdownPreview/manager.ts
Normal file
161
frontend/src/views/editor/extensions/markdownPreview/manager.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { shallowRef, type ShallowRef } from 'vue';
|
||||
|
||||
/**
|
||||
* 预览面板位置配置
|
||||
*/
|
||||
interface PreviewPosition {
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览状态
|
||||
*/
|
||||
interface PreviewState {
|
||||
visible: boolean;
|
||||
position: PreviewPosition;
|
||||
content: string;
|
||||
blockFrom: number;
|
||||
blockTo: number;
|
||||
documentId: number | null;
|
||||
view: EditorView | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown 预览管理器类
|
||||
*/
|
||||
class MarkdownPreviewManager {
|
||||
private state: ShallowRef<PreviewState> = shallowRef({
|
||||
visible: false,
|
||||
position: { height: 300 },
|
||||
content: '',
|
||||
blockFrom: 0,
|
||||
blockTo: 0,
|
||||
documentId: null,
|
||||
view: null
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取状态(供 Vue 组件使用)
|
||||
*/
|
||||
useState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示预览面板
|
||||
*/
|
||||
show(view: EditorView, documentId: number, blockFrom: number, blockTo: number): void {
|
||||
const content = view.state.doc.sliceString(blockFrom, blockTo);
|
||||
|
||||
// 计算初始高度(编辑器容器高度的 50%)
|
||||
const containerHeight = view.dom.parentElement?.clientHeight || view.dom.clientHeight;
|
||||
const defaultHeight = Math.floor(containerHeight * 0.5);
|
||||
|
||||
this.state.value = {
|
||||
visible: true,
|
||||
position: { height: Math.max(10, defaultHeight) },
|
||||
content,
|
||||
blockFrom,
|
||||
blockTo,
|
||||
documentId,
|
||||
view
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新预览内容(文档编辑时调用)
|
||||
*/
|
||||
updateContent(content: string, blockFrom: number, blockTo: number): void {
|
||||
if (!this.state.value.visible) return;
|
||||
|
||||
this.state.value = {
|
||||
...this.state.value,
|
||||
content,
|
||||
blockFrom,
|
||||
blockTo
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新面板高度
|
||||
*/
|
||||
updateHeight(height: number): void {
|
||||
if (!this.state.value.visible) return;
|
||||
|
||||
this.state.value = {
|
||||
...this.state.value,
|
||||
position: { height: Math.max(10, height) }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏预览面板
|
||||
*/
|
||||
hide(): void {
|
||||
if (!this.state.value.visible) return;
|
||||
|
||||
const view = this.state.value.view;
|
||||
|
||||
this.state.value = {
|
||||
visible: false,
|
||||
position: { height: 300 },
|
||||
content: '',
|
||||
blockFrom: 0,
|
||||
blockTo: 0,
|
||||
documentId: null,
|
||||
view: null
|
||||
};
|
||||
|
||||
// 关闭后聚焦编辑器
|
||||
if (view) {
|
||||
view.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查预览是否可见
|
||||
*/
|
||||
isVisible(): boolean {
|
||||
return this.state.value.visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前预览的文档 ID
|
||||
*/
|
||||
getCurrentDocumentId(): number | null {
|
||||
return this.state.value.documentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前预览的块范围
|
||||
*/
|
||||
getCurrentBlockRange(): { from: number; to: number } | null {
|
||||
if (!this.state.value.visible) return null;
|
||||
return {
|
||||
from: this.state.value.blockFrom,
|
||||
to: this.state.value.blockTo
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
destroy(): void {
|
||||
this.state.value = {
|
||||
visible: false,
|
||||
position: { height: 300 },
|
||||
content: '',
|
||||
blockFrom: 0,
|
||||
blockTo: 0,
|
||||
documentId: null,
|
||||
view: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出单例
|
||||
*/
|
||||
export const markdownPreviewManager = new MarkdownPreviewManager();
|
||||
|
||||
@@ -1,393 +0,0 @@
|
||||
/**
|
||||
* Markdown 预览面板 UI 组件
|
||||
*/
|
||||
import {EditorView, Panel, ViewUpdate} from "@codemirror/view";
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import * as runtime from "@wailsio/runtime";
|
||||
import {previewPanelState} from "./state";
|
||||
import {createMarkdownRenderer} from "./markdownRenderer";
|
||||
import {updateMermaidTheme} from "@/common/markdown-it/plugins/markdown-it-mermaid";
|
||||
import {useThemeStore} from "@/stores/themeStore";
|
||||
import {usePanelStore} from "@/stores/panelStore";
|
||||
import {watch} from "vue";
|
||||
import {createDebounce} from "@/common/utils/debounce";
|
||||
import {morphHTML} from "@/common/utils/domDiff";
|
||||
|
||||
/**
|
||||
* Markdown 预览面板类
|
||||
*/
|
||||
export class MarkdownPreviewPanel {
|
||||
private md: MarkdownIt;
|
||||
private readonly dom: HTMLDivElement;
|
||||
private readonly resizeHandle: HTMLDivElement;
|
||||
private readonly content: HTMLDivElement;
|
||||
private view: EditorView;
|
||||
private themeUnwatchers: Array<() => void> = [];
|
||||
private lastRenderedContent: string = "";
|
||||
private readonly debouncedUpdate: ReturnType<typeof createDebounce>;
|
||||
private isDestroyed: boolean = false; // 标记面板是否已销毁
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.view = view;
|
||||
this.md = createMarkdownRenderer();
|
||||
|
||||
// 创建防抖更新函数
|
||||
this.debouncedUpdate = createDebounce(() => {
|
||||
this.updateContentInternal();
|
||||
}, { delay: 500 });
|
||||
|
||||
// 监听主题变化
|
||||
const themeStore = useThemeStore();
|
||||
this.themeUnwatchers.push(
|
||||
watch(() => themeStore.isDarkMode, (isDark) => {
|
||||
const newTheme = isDark ? "dark" : "default";
|
||||
updateMermaidTheme(newTheme);
|
||||
this.resetPreviewContent();
|
||||
})
|
||||
);
|
||||
this.themeUnwatchers.push(
|
||||
watch(
|
||||
() => themeStore.currentColors,
|
||||
() => {
|
||||
this.resetPreviewContent();
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
);
|
||||
|
||||
// 创建 DOM 结构
|
||||
this.dom = document.createElement("div");
|
||||
this.dom.className = "cm-markdown-preview-panel";
|
||||
|
||||
this.resizeHandle = document.createElement("div");
|
||||
this.resizeHandle.className = "cm-preview-resize-handle";
|
||||
|
||||
this.content = document.createElement("div");
|
||||
this.content.className = "cm-preview-content";
|
||||
|
||||
this.dom.appendChild(this.resizeHandle);
|
||||
this.dom.appendChild(this.content);
|
||||
|
||||
// 设置默认高度为编辑器高度的一半
|
||||
const defaultHeight = Math.floor(this.view.dom.clientHeight / 2);
|
||||
this.dom.style.height = `${defaultHeight}px`;
|
||||
|
||||
// 初始化拖动功能
|
||||
this.initResize();
|
||||
|
||||
// 初始化链接点击处理
|
||||
this.initLinkHandler();
|
||||
|
||||
// 初始渲染
|
||||
this.updateContentInternal();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化链接点击处理(事件委托)
|
||||
*/
|
||||
private initLinkHandler(): void {
|
||||
this.content.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// 查找最近的 <a> 标签
|
||||
let linkElement = target;
|
||||
while (linkElement && linkElement !== this.content) {
|
||||
if (linkElement.tagName === 'A') {
|
||||
const anchor = linkElement as HTMLAnchorElement;
|
||||
const href = anchor.getAttribute('href');
|
||||
|
||||
// 处理脚注内部锚点链接
|
||||
if (href && href.startsWith('#')) {
|
||||
e.preventDefault();
|
||||
|
||||
// 在预览面板内查找目标元素
|
||||
const targetId = href.substring(1);
|
||||
|
||||
// 使用 getElementById 而不是 querySelector,因为 ID 可能包含特殊字符(如冒号)
|
||||
const targetElement = document.getElementById(targetId);
|
||||
|
||||
if (targetElement && this.content.contains(targetElement)) {
|
||||
// 平滑滚动到目标元素
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理带 data-href 的外部链接
|
||||
if (anchor.hasAttribute('data-href')) {
|
||||
e.preventDefault();
|
||||
const url = anchor.getAttribute('data-href');
|
||||
if (url && this.isValidUrl(url)) {
|
||||
runtime.Browser.OpenURL(url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理其他链接
|
||||
if (href && !href.startsWith('#')) {
|
||||
e.preventDefault();
|
||||
|
||||
// 只有有效的 URL(http/https/mailto/file 等)才用浏览器打开
|
||||
if (this.isValidUrl(href)) {
|
||||
runtime.Browser.OpenURL(href);
|
||||
} else {
|
||||
// 相对路径或无效链接,显示提示
|
||||
console.warn('Invalid or relative link in preview:', href);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
linkElement = linkElement.parentElement as HTMLElement;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是有效的 URL(包含协议)
|
||||
*/
|
||||
private isValidUrl(url: string): boolean {
|
||||
try {
|
||||
// 检查是否包含协议
|
||||
if (url.match(/^[a-zA-Z][a-zA-Z\d+\-.]*:/)) {
|
||||
const parsedUrl = new URL(url);
|
||||
// 允许的协议列表
|
||||
const allowedProtocols = ['http:', 'https:', 'mailto:', 'file:', 'ftp:'];
|
||||
return allowedProtocols.includes(parsedUrl.protocol);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化拖动调整高度功能
|
||||
*/
|
||||
private initResize(): void {
|
||||
let startY = 0;
|
||||
let startHeight = 0;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const delta = startY - e.clientY;
|
||||
const maxHeight = this.getMaxHeight();
|
||||
const newHeight = Math.max(100, Math.min(maxHeight, startHeight + delta));
|
||||
this.dom.style.height = `${newHeight}px`;
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
this.resizeHandle.classList.remove("dragging");
|
||||
// 恢复 body 样式
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
this.resizeHandle.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
startY = e.clientY;
|
||||
startHeight = this.dom.offsetHeight;
|
||||
this.resizeHandle.classList.add("dragging");
|
||||
// 设置 body 样式,防止拖动时光标闪烁
|
||||
document.body.style.cursor = "ns-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态计算最大高度(编辑器高度)
|
||||
*/
|
||||
private getMaxHeight(): number {
|
||||
return this.view.dom.clientHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部更新预览内容(带缓存 + DOM Diff 优化)
|
||||
*/
|
||||
private updateContentInternal(): void {
|
||||
// 如果面板已销毁,直接返回
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const state = this.view.state;
|
||||
const currentPreviewState = state.field(previewPanelState, false);
|
||||
|
||||
if (!currentPreviewState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blockContent = state.doc.sliceString(
|
||||
currentPreviewState.blockFrom,
|
||||
currentPreviewState.blockTo
|
||||
);
|
||||
|
||||
if (!blockContent || blockContent.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 缓存检查:如果内容没变,不重新渲染
|
||||
if (blockContent === this.lastRenderedContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 对于大内容,使用异步渲染避免阻塞主线程
|
||||
if (blockContent.length > 1000) {
|
||||
this.renderLargeContentAsync(blockContent);
|
||||
} else {
|
||||
// 小内容使用 DOM Diff 优化渲染
|
||||
this.renderWithDiff(blockContent);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn("Error updating preview content:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 DOM Diff 渲染内容(保留未变化的节点)
|
||||
*/
|
||||
private renderWithDiff(content: string): void {
|
||||
// 如果面板已销毁,直接返回
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newHtml = this.md.render(content);
|
||||
|
||||
// 如果是首次渲染或内容为空,直接设置 innerHTML
|
||||
if (!this.lastRenderedContent || this.content.children.length === 0) {
|
||||
this.content.innerHTML = newHtml;
|
||||
} else {
|
||||
// 使用 DOM Diff 增量更新
|
||||
morphHTML(this.content, newHtml);
|
||||
}
|
||||
|
||||
this.lastRenderedContent = content;
|
||||
} catch (error) {
|
||||
console.warn("Error rendering with diff:", error);
|
||||
// 降级到直接设置 innerHTML
|
||||
if (!this.isDestroyed) {
|
||||
this.content.innerHTML = this.md.render(content);
|
||||
this.lastRenderedContent = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步渲染大内容(使用 DOM Diff 优化)
|
||||
*/
|
||||
private renderLargeContentAsync(content: string): void {
|
||||
// 如果面板已销毁,直接返回
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是首次渲染,显示加载状态
|
||||
if (!this.lastRenderedContent) {
|
||||
this.content.innerHTML = '<div class="markdown-loading">Rendering...</div>';
|
||||
}
|
||||
|
||||
// 使用 requestIdleCallback 在浏览器空闲时渲染
|
||||
const callback = window.requestIdleCallback || ((cb: IdleRequestCallback) => setTimeout(cb, 1));
|
||||
|
||||
callback(() => {
|
||||
// 再次检查是否已销毁(异步回调时可能已经关闭)
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const html = this.md.render(content);
|
||||
|
||||
// 如果是首次渲染或之前内容为空,直接设置
|
||||
if (!this.lastRenderedContent || this.content.children.length === 0) {
|
||||
// 使用 DocumentFragment 减少 DOM 操作
|
||||
const fragment = document.createRange().createContextualFragment(html);
|
||||
this.content.innerHTML = '';
|
||||
this.content.appendChild(fragment);
|
||||
} else {
|
||||
// 使用 DOM Diff 增量更新(保留滚动位置和未变化的节点)
|
||||
morphHTML(this.content, html);
|
||||
}
|
||||
|
||||
this.lastRenderedContent = content;
|
||||
} catch (error) {
|
||||
console.warn("Error rendering large content:", error);
|
||||
if (!this.isDestroyed) {
|
||||
this.content.innerHTML = '<div class="markdown-error">Render failed</div>';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resetPreviewContent(): void {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.md = createMarkdownRenderer();
|
||||
this.lastRenderedContent = "";
|
||||
this.updateContentInternal();
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应编辑器更新
|
||||
*/
|
||||
public update(update: ViewUpdate): void {
|
||||
if (update.docChanged) {
|
||||
// 文档改变时使用防抖更新
|
||||
this.debouncedUpdate.debouncedFn();
|
||||
} else if (update.selectionSet) {
|
||||
// 光标移动时不触发更新
|
||||
// 如果需要根据光标位置更新,可以在这里处理
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
public destroy(): void {
|
||||
// 标记为已销毁,防止异步回调继续执行
|
||||
this.isDestroyed = true;
|
||||
|
||||
// 清理防抖
|
||||
if (this.debouncedUpdate) {
|
||||
this.debouncedUpdate.cancel();
|
||||
}
|
||||
|
||||
// 清空缓存
|
||||
this.lastRenderedContent = "";
|
||||
|
||||
if (this.themeUnwatchers.length) {
|
||||
this.themeUnwatchers.forEach(unwatch => unwatch());
|
||||
this.themeUnwatchers = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 CodeMirror Panel 对象
|
||||
*/
|
||||
public getPanel(): Panel {
|
||||
return {
|
||||
top: false,
|
||||
dom: this.dom,
|
||||
update: (update: ViewUpdate) => this.update(update),
|
||||
destroy: () => this.destroy()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建预览面板
|
||||
*/
|
||||
export function createPreviewPanel(view: EditorView): Panel {
|
||||
const panel = new MarkdownPreviewPanel(view);
|
||||
return panel.getPanel();
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* Markdown 预览面板的 CodeMirror 状态管理
|
||||
*/
|
||||
import { EditorView, showPanel, ViewUpdate, ViewPlugin } from "@codemirror/view";
|
||||
import { StateEffect, StateField } from "@codemirror/state";
|
||||
import { getActiveNoteBlock } from "../codeblock/state";
|
||||
import { usePanelStore } from "@/stores/panelStore";
|
||||
import { createPreviewPanel } from "./panel";
|
||||
import type { PreviewState } from "./types";
|
||||
|
||||
/**
|
||||
* 定义切换预览面板的 Effect
|
||||
*/
|
||||
export const togglePreview = StateEffect.define<PreviewState | null>();
|
||||
|
||||
/**
|
||||
* 关闭面板(带动画)
|
||||
*/
|
||||
export function closePreviewWithAnimation(view: EditorView): void {
|
||||
const panelStore = usePanelStore();
|
||||
|
||||
// 标记开始关闭
|
||||
panelStore.startClosingMarkdownPreview();
|
||||
|
||||
const panelElement = view.dom.querySelector('.cm-panels.cm-panels-bottom') as HTMLElement;
|
||||
if (panelElement) {
|
||||
panelElement.style.animation = 'panelSlideDown 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
// 等待动画完成后再关闭面板
|
||||
setTimeout(() => {
|
||||
view.dispatch({
|
||||
effects: togglePreview.of(null)
|
||||
});
|
||||
panelStore.closeMarkdownPreview();
|
||||
}, 280);
|
||||
} else {
|
||||
view.dispatch({
|
||||
effects: togglePreview.of(null)
|
||||
});
|
||||
panelStore.closeMarkdownPreview();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义预览面板的状态字段
|
||||
*/
|
||||
export const previewPanelState = StateField.define<PreviewState | null>({
|
||||
create: () => null,
|
||||
update(value, tr) {
|
||||
const panelStore = usePanelStore();
|
||||
|
||||
for (let e of tr.effects) {
|
||||
if (e.is(togglePreview)) {
|
||||
value = e.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有预览状态,智能管理预览生命周期
|
||||
if (value && !value.closing) {
|
||||
const activeBlock = getActiveNoteBlock(tr.state as any);
|
||||
|
||||
// 关键修复:检查预览状态是否属于当前文档
|
||||
// 如果 panelStore 中没有当前文档的预览状态(说明切换了文档),
|
||||
// 则不执行关闭逻辑,保持其他文档的预览状态
|
||||
if (!panelStore.markdownPreview.isOpen) {
|
||||
// 当前文档没有预览,不处理
|
||||
return value;
|
||||
}
|
||||
|
||||
// 场景1:离开 Markdown 块或无激活块 → 关闭预览
|
||||
if (!activeBlock || activeBlock.language.name.toLowerCase() !== 'md') {
|
||||
if (!panelStore.markdownPreview.isClosing) {
|
||||
return { ...value, closing: true };
|
||||
}
|
||||
}
|
||||
// 场景2:切换到其他块(起始位置变化)→ 关闭预览
|
||||
else if (activeBlock.content.from !== value.blockFrom) {
|
||||
if (!panelStore.markdownPreview.isClosing) {
|
||||
return { ...value, closing: true };
|
||||
}
|
||||
}
|
||||
// 场景3:还在同一个块内编辑(只有结束位置变化)→ 更新范围,实时预览
|
||||
else if (activeBlock.content.to !== value.blockTo) {
|
||||
// 更新 panelStore 中的预览范围
|
||||
panelStore.updatePreviewRange(value.blockFrom, activeBlock.content.to);
|
||||
|
||||
return {
|
||||
documentId: value.documentId,
|
||||
blockFrom: value.blockFrom,
|
||||
blockTo: activeBlock.content.to,
|
||||
closing: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
provide: f => showPanel.from(f, state => state ? createPreviewPanel : null)
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建监听插件
|
||||
*/
|
||||
export const previewPanelPlugin = ViewPlugin.fromClass(class {
|
||||
private lastState: PreviewState | null | undefined = null;
|
||||
private panelStore = usePanelStore();
|
||||
|
||||
constructor(private view: EditorView) {
|
||||
this.lastState = view.state.field(previewPanelState, false);
|
||||
this.panelStore.setEditorView(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
const currentState = update.state.field(previewPanelState, false);
|
||||
|
||||
// 检测到面板打开(从 null 变为有值,且不是 closing)
|
||||
if (currentState && !currentState.closing && !this.lastState) {
|
||||
// 验证面板 DOM 是否真正创建成功
|
||||
requestAnimationFrame(() => {
|
||||
const panelElement = this.view.dom.querySelector('.cm-markdown-preview-panel');
|
||||
if (panelElement) {
|
||||
// 面板创建成功,更新 store 状态
|
||||
this.panelStore.openMarkdownPreview(currentState.blockFrom, currentState.blockTo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 检测到状态变为 closing
|
||||
if (currentState?.closing && !this.lastState?.closing) {
|
||||
// 触发关闭动画
|
||||
closePreviewWithAnimation(this.view);
|
||||
}
|
||||
|
||||
this.lastState = currentState;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// 不调用 reset(),因为那会清空所有文档的预览状态
|
||||
// 只清理编辑器视图引用
|
||||
this.panelStore.setEditorView(null);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,384 +0,0 @@
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { ThemeColors } from "@/views/editor/theme/types";
|
||||
|
||||
/**
|
||||
* 创建 Markdown 预览面板的主题样式
|
||||
*/
|
||||
export function createMarkdownPreviewTheme(colors: ThemeColors) {
|
||||
// GitHub 官方颜色变量
|
||||
const isDark = colors.dark;
|
||||
|
||||
// GitHub Light 主题颜色
|
||||
const lightColors = {
|
||||
fg: {
|
||||
default: "#1F2328",
|
||||
muted: "#656d76",
|
||||
subtle: "#6e7781"
|
||||
},
|
||||
border: {
|
||||
default: "#d0d7de",
|
||||
muted: "#d8dee4"
|
||||
},
|
||||
canvas: {
|
||||
default: "#ffffff",
|
||||
subtle: "#f6f8fa"
|
||||
},
|
||||
accent: {
|
||||
fg: "#0969da",
|
||||
emphasis: "#0969da"
|
||||
}
|
||||
};
|
||||
|
||||
// GitHub Dark 主题颜色
|
||||
const darkColors = {
|
||||
fg: {
|
||||
default: "#e6edf3",
|
||||
muted: "#7d8590",
|
||||
subtle: "#6e7681"
|
||||
},
|
||||
border: {
|
||||
default: "#30363d",
|
||||
muted: "#21262d"
|
||||
},
|
||||
canvas: {
|
||||
default: "#0d1117",
|
||||
subtle: "#161b22"
|
||||
},
|
||||
accent: {
|
||||
fg: "#2f81f7",
|
||||
emphasis: "#2f81f7"
|
||||
}
|
||||
};
|
||||
|
||||
const ghColors = isDark ? darkColors : lightColors;
|
||||
|
||||
return EditorView.theme({
|
||||
// 面板容器
|
||||
".cm-markdown-preview-panel": {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden"
|
||||
},
|
||||
|
||||
// 拖动调整大小的手柄
|
||||
".cm-preview-resize-handle": {
|
||||
width: "100%",
|
||||
height: "3px",
|
||||
backgroundColor: colors.borderColor,
|
||||
cursor: "ns-resize",
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
transition: "background-color 0.2s ease",
|
||||
"&:hover": {
|
||||
backgroundColor: colors.selection
|
||||
},
|
||||
"&.dragging": {
|
||||
backgroundColor: colors.selection
|
||||
}
|
||||
},
|
||||
|
||||
// 面板动画效果
|
||||
'.cm-panels.cm-panels-top': {
|
||||
borderBottom: '2px solid black'
|
||||
},
|
||||
'.cm-panels.cm-panels-bottom': {
|
||||
animation: 'panelSlideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
},
|
||||
'@keyframes panelSlideUp': {
|
||||
from: {
|
||||
transform: 'translateY(100%)',
|
||||
opacity: '0'
|
||||
},
|
||||
to: {
|
||||
transform: 'translateY(0)',
|
||||
opacity: '1'
|
||||
}
|
||||
},
|
||||
'@keyframes panelSlideDown': {
|
||||
from: {
|
||||
transform: 'translateY(0)',
|
||||
opacity: '1'
|
||||
},
|
||||
to: {
|
||||
transform: 'translateY(100%)',
|
||||
opacity: '0'
|
||||
}
|
||||
},
|
||||
|
||||
// 内容区域
|
||||
".cm-preview-content": {
|
||||
flex: 1,
|
||||
padding: "45px",
|
||||
overflow: "auto",
|
||||
fontSize: "16px",
|
||||
lineHeight: "1.5",
|
||||
color: ghColors.fg.default,
|
||||
wordWrap: "break-word",
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'",
|
||||
boxSizing: "border-box",
|
||||
|
||||
// Loading state
|
||||
"& .markdown-loading, & .markdown-error": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "200px",
|
||||
fontSize: "14px",
|
||||
color: ghColors.fg.muted
|
||||
},
|
||||
|
||||
"& .markdown-error": {
|
||||
color: "#f85149"
|
||||
},
|
||||
|
||||
// ========== 标题样式 ==========
|
||||
"& h1, & h2, & h3, & h4, & h5, & h6": {
|
||||
marginTop: "24px",
|
||||
marginBottom: "16px",
|
||||
fontWeight: "600",
|
||||
lineHeight: "1.25",
|
||||
color: ghColors.fg.default
|
||||
},
|
||||
"& h1": {
|
||||
fontSize: "2em",
|
||||
borderBottom: `1px solid ${ghColors.border.muted}`,
|
||||
paddingBottom: "0.3em"
|
||||
},
|
||||
"& h2": {
|
||||
fontSize: "1.5em",
|
||||
borderBottom: `1px solid ${ghColors.border.muted}`,
|
||||
paddingBottom: "0.3em"
|
||||
},
|
||||
"& h3": {
|
||||
fontSize: "1.25em"
|
||||
},
|
||||
"& h4": {
|
||||
fontSize: "1em"
|
||||
},
|
||||
"& h5": {
|
||||
fontSize: "0.875em"
|
||||
},
|
||||
"& h6": {
|
||||
fontSize: "0.85em",
|
||||
color: ghColors.fg.muted
|
||||
},
|
||||
|
||||
// ========== 段落和文本 ==========
|
||||
"& p": {
|
||||
marginTop: "0",
|
||||
marginBottom: "16px"
|
||||
},
|
||||
"& strong": {
|
||||
fontWeight: "600"
|
||||
},
|
||||
"& em": {
|
||||
fontStyle: "italic"
|
||||
},
|
||||
"& del": {
|
||||
textDecoration: "line-through",
|
||||
opacity: "0.7"
|
||||
},
|
||||
|
||||
// ========== 列表 ==========
|
||||
"& ul, & ol": {
|
||||
paddingLeft: "2em",
|
||||
marginTop: "0",
|
||||
marginBottom: "16px"
|
||||
},
|
||||
"& ul ul, & ul ol, & ol ol, & ol ul": {
|
||||
marginTop: "0",
|
||||
marginBottom: "0"
|
||||
},
|
||||
"& li": {
|
||||
wordWrap: "break-all"
|
||||
},
|
||||
"& li > p": {
|
||||
marginTop: "16px"
|
||||
},
|
||||
"& li + li": {
|
||||
marginTop: "0.25em"
|
||||
},
|
||||
|
||||
// 任务列表
|
||||
"& .task-list-item": {
|
||||
listStyleType: "none",
|
||||
position: "relative",
|
||||
paddingLeft: "1.5em"
|
||||
},
|
||||
"& .task-list-item + .task-list-item": {
|
||||
marginTop: "3px"
|
||||
},
|
||||
"& .task-list-item input[type='checkbox']": {
|
||||
font: "inherit",
|
||||
overflow: "visible",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "inherit",
|
||||
lineHeight: "inherit",
|
||||
boxSizing: "border-box",
|
||||
padding: "0",
|
||||
margin: "0 0.2em 0.25em -1.6em",
|
||||
verticalAlign: "middle",
|
||||
cursor: "pointer"
|
||||
},
|
||||
|
||||
// ========== 代码块 ==========
|
||||
"& code, & tt": {
|
||||
fontFamily: "SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace",
|
||||
fontSize: "85%",
|
||||
padding: "0.2em 0.4em",
|
||||
margin: "0",
|
||||
backgroundColor: isDark ? "rgba(110, 118, 129, 0.4)" : "rgba(27, 31, 35, 0.05)",
|
||||
borderRadius: "3px"
|
||||
},
|
||||
|
||||
"& pre": {
|
||||
position: "relative",
|
||||
backgroundColor: isDark ? "#161b22" : "#f6f8fa",
|
||||
padding: "40px 16px 16px 16px",
|
||||
borderRadius: "6px",
|
||||
overflow: "auto",
|
||||
margin: "16px 0",
|
||||
fontSize: "85%",
|
||||
lineHeight: "1.45",
|
||||
wordWrap: "normal",
|
||||
|
||||
// macOS 窗口样式 - 使用伪元素创建顶部栏
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
right: "0",
|
||||
height: "28px",
|
||||
backgroundColor: isDark ? "#1c1c1e" : "#e8e8e8",
|
||||
borderBottom: `1px solid ${ghColors.border.default}`,
|
||||
borderRadius: "6px 6px 0 0"
|
||||
},
|
||||
|
||||
// macOS 三个控制按钮
|
||||
"&::after": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
top: "10px",
|
||||
left: "12px",
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: isDark ? "#ec6a5f" : "#ff5f57",
|
||||
boxShadow: `
|
||||
18px 0 0 0 ${isDark ? "#f4bf4f" : "#febc2e"},
|
||||
36px 0 0 0 ${isDark ? "#61c554" : "#28c840"}
|
||||
`
|
||||
}
|
||||
},
|
||||
|
||||
"& pre code, & pre tt": {
|
||||
display: "inline",
|
||||
maxWidth: "auto",
|
||||
padding: "0",
|
||||
margin: "0",
|
||||
overflow: "visible",
|
||||
lineHeight: "inherit",
|
||||
wordWrap: "normal",
|
||||
backgroundColor: "transparent",
|
||||
border: "0",
|
||||
fontSize: "100%",
|
||||
color: ghColors.fg.default,
|
||||
wordBreak: "normal",
|
||||
whiteSpace: "pre"
|
||||
},
|
||||
|
||||
// ========== 引用块 ==========
|
||||
"& blockquote": {
|
||||
margin: "16px 0",
|
||||
padding: "0 1em",
|
||||
color: isDark ? "#7d8590" : "#6a737d",
|
||||
borderLeft: isDark ? "0.25em solid #3b434b" : "0.25em solid #dfe2e5"
|
||||
},
|
||||
"& blockquote > :first-child": {
|
||||
marginTop: "0"
|
||||
},
|
||||
"& blockquote > :last-child": {
|
||||
marginBottom: "0"
|
||||
},
|
||||
|
||||
// ========== 分割线 ==========
|
||||
"& hr": {
|
||||
height: "0.25em",
|
||||
padding: "0",
|
||||
margin: "24px 0",
|
||||
backgroundColor: isDark ? "#21262d" : "#e1e4e8",
|
||||
border: "0",
|
||||
overflow: "hidden",
|
||||
boxSizing: "content-box"
|
||||
},
|
||||
|
||||
// ========== 表格 ==========
|
||||
"& table": {
|
||||
borderSpacing: "0",
|
||||
borderCollapse: "collapse",
|
||||
display: "block",
|
||||
width: "100%",
|
||||
overflow: "auto",
|
||||
marginTop: "0",
|
||||
marginBottom: "16px"
|
||||
},
|
||||
"& table tr": {
|
||||
backgroundColor: isDark ? "#0d1117" : "#ffffff",
|
||||
borderTop: isDark ? "1px solid #21262d" : "1px solid #c6cbd1"
|
||||
},
|
||||
"& table th, & table td": {
|
||||
padding: "6px 13px",
|
||||
border: isDark ? "1px solid #30363d" : "1px solid #dfe2e5"
|
||||
},
|
||||
"& table th": {
|
||||
fontWeight: "600"
|
||||
},
|
||||
|
||||
// ========== 链接 ==========
|
||||
"& a, & .markdown-link": {
|
||||
color: isDark ? "#58a6ff" : "#0366d6",
|
||||
textDecoration: "none",
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
textDecoration: "underline"
|
||||
}
|
||||
},
|
||||
|
||||
// ========== 图片 ==========
|
||||
"& img": {
|
||||
maxWidth: "100%",
|
||||
height: "auto",
|
||||
borderRadius: "4px",
|
||||
margin: "16px 0"
|
||||
},
|
||||
|
||||
// ========== 其他元素 ==========
|
||||
"& kbd": {
|
||||
display: "inline-block",
|
||||
padding: "3px 5px",
|
||||
fontSize: "11px",
|
||||
lineHeight: "10px",
|
||||
color: ghColors.fg.default,
|
||||
verticalAlign: "middle",
|
||||
backgroundColor: ghColors.canvas.subtle,
|
||||
border: `solid 1px ${isDark ? "rgba(110, 118, 129, 0.4)" : "rgba(175, 184, 193, 0.2)"}`,
|
||||
borderBottom: `solid 2px ${isDark ? "rgba(110, 118, 129, 0.4)" : "rgba(175, 184, 193, 0.2)"}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: "inset 0 -1px 0 rgba(175, 184, 193, 0.2)"
|
||||
},
|
||||
|
||||
// 首个子元素去除上边距
|
||||
"& > *:first-child": {
|
||||
marginTop: "0 !important"
|
||||
},
|
||||
|
||||
// 最后一个子元素去除下边距
|
||||
"& > *:last-child": {
|
||||
marginBottom: "0 !important"
|
||||
}
|
||||
}
|
||||
}, { dark: colors.dark });
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Markdown 预览面板相关类型定义
|
||||
*/
|
||||
|
||||
// 预览面板状态
|
||||
export interface PreviewState {
|
||||
documentId: number; // 预览所属的文档ID
|
||||
blockFrom: number;
|
||||
blockTo: number;
|
||||
closing?: boolean; // 标记面板正在关闭
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ const rainbowBracketsPlugin = ViewPlugin.fromClass(RainbowBracketsView, {
|
||||
decorations: (v) => v.decorations,
|
||||
});
|
||||
|
||||
export default function rainbowBracketsExtension() {
|
||||
export default function index() {
|
||||
return [
|
||||
rainbowBracketsPlugin,
|
||||
EditorView.baseTheme({
|
||||
@@ -0,0 +1,481 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, onUnmounted, ref, watch} from 'vue';
|
||||
import {translatorManager} from './manager';
|
||||
import {useTranslationStore} from '@/stores/translationStore';
|
||||
|
||||
const props = defineProps<{
|
||||
portalTarget?: HTMLElement | null;
|
||||
}>();
|
||||
|
||||
const state = translatorManager.useState();
|
||||
const translationStore = useTranslationStore();
|
||||
|
||||
const dialogRef = ref<HTMLDivElement | null>(null);
|
||||
const adjustedPosition = ref({ x: 0, y: 0 });
|
||||
|
||||
const isVisible = computed(() => state.value.visible);
|
||||
const sourceText = computed(() => state.value.sourceText);
|
||||
const position = computed(() => state.value.position);
|
||||
const teleportTarget = computed<HTMLElement | string>(() => props.portalTarget ?? 'body');
|
||||
|
||||
const sourceLangSelector = ref('');
|
||||
const targetLangSelector = ref('');
|
||||
const translatorSelector = ref('');
|
||||
const translatedText = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
const isDragging = ref(false);
|
||||
const dragStart = ref({ x: 0, y: 0 });
|
||||
|
||||
// 监听可见性变化
|
||||
watch(isVisible, async (visible) => {
|
||||
if (visible) {
|
||||
adjustedPosition.value = { ...position.value };
|
||||
await nextTick();
|
||||
adjustDialogPosition();
|
||||
await initializeTranslation();
|
||||
await nextTick();
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
isDragging.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 清理
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
});
|
||||
|
||||
const dialogStyle = computed(() => ({
|
||||
left: `${adjustedPosition.value.x}px`,
|
||||
top: `${adjustedPosition.value.y}px`
|
||||
}));
|
||||
|
||||
const availableLanguages = computed(() => {
|
||||
const languageMap = translationStore.translatorLanguages[translatorSelector.value];
|
||||
if (!languageMap) return [];
|
||||
return Object.entries(languageMap).map(([code, info]: [string, any]) => ({
|
||||
code,
|
||||
name: info.Name || info.name || code
|
||||
}));
|
||||
});
|
||||
|
||||
const availableTranslators = computed(() => translationStore.translators);
|
||||
|
||||
function adjustDialogPosition() {
|
||||
const dialogEl = dialogRef.value;
|
||||
const container = props.portalTarget;
|
||||
if (!dialogEl || !container) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const dialogRect = dialogEl.getBoundingClientRect();
|
||||
|
||||
let x = adjustedPosition.value.x;
|
||||
let y = adjustedPosition.value.y;
|
||||
|
||||
// 限制在容器范围内
|
||||
x = Math.max(containerRect.left, Math.min(x, containerRect.right - dialogRect.width - 8));
|
||||
y = Math.max(containerRect.top, Math.min(y, containerRect.bottom - dialogRect.height - 8));
|
||||
|
||||
adjustedPosition.value = { x, y };
|
||||
}
|
||||
|
||||
function clampPosition(x: number, y: number) {
|
||||
const container = props.portalTarget;
|
||||
const dialogEl = dialogRef.value;
|
||||
if (!container || !dialogEl) return { x, y };
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const dialogRect = dialogEl.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
x: Math.max(containerRect.left, Math.min(x, containerRect.right - dialogRect.width)),
|
||||
y: Math.max(containerRect.top, Math.min(y, containerRect.bottom - dialogRect.height))
|
||||
};
|
||||
}
|
||||
|
||||
async function initializeTranslation() {
|
||||
isLoading.value = true;
|
||||
translatedText.value = '';
|
||||
|
||||
try {
|
||||
await loadTranslators();
|
||||
await translate();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize translation:', error);
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTranslators() {
|
||||
const translators = translationStore.translators;
|
||||
if (translators.length > 0) {
|
||||
translatorSelector.value = translators[0];
|
||||
}
|
||||
resetLanguageSelectors();
|
||||
}
|
||||
|
||||
function resetLanguageSelectors() {
|
||||
const languageMap = translationStore.translatorLanguages[translatorSelector.value];
|
||||
if (!languageMap) return;
|
||||
|
||||
const languages = Object.keys(languageMap);
|
||||
if (languages.length > 0) {
|
||||
sourceLangSelector.value = languages[0];
|
||||
targetLangSelector.value = languages[0];
|
||||
}
|
||||
}
|
||||
|
||||
function handleTranslatorChange() {
|
||||
resetLanguageSelectors();
|
||||
translate();
|
||||
}
|
||||
|
||||
function swapLanguages() {
|
||||
const temp = sourceLangSelector.value;
|
||||
sourceLangSelector.value = targetLangSelector.value;
|
||||
targetLangSelector.value = temp;
|
||||
translate();
|
||||
}
|
||||
|
||||
async function translate() {
|
||||
const sourceLang = sourceLangSelector.value;
|
||||
const targetLang = targetLangSelector.value;
|
||||
const translatorType = translatorSelector.value;
|
||||
|
||||
if (!sourceLang || !targetLang || !translatorType) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
translatedText.value = '';
|
||||
|
||||
try {
|
||||
const result = await translationStore.translateText(
|
||||
sourceText.value,
|
||||
sourceLang,
|
||||
targetLang,
|
||||
translatorType
|
||||
);
|
||||
|
||||
translatedText.value = result.translatedText || result.error || '';
|
||||
} catch (err) {
|
||||
console.error('Translation failed:', err);
|
||||
translatedText.value = 'Translation failed';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startDrag(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('select, button')) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const rect = dialogRef.value!.getBoundingClientRect();
|
||||
dragStart.value = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
};
|
||||
|
||||
isDragging.value = true;
|
||||
document.addEventListener('mousemove', onDrag);
|
||||
document.addEventListener('mouseup', endDrag);
|
||||
}
|
||||
|
||||
function onDrag(e: MouseEvent) {
|
||||
adjustedPosition.value = clampPosition(
|
||||
e.clientX - dragStart.value.x,
|
||||
e.clientY - dragStart.value.y
|
||||
);
|
||||
}
|
||||
|
||||
function endDrag(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
isDragging.value = false;
|
||||
document.removeEventListener('mousemove', onDrag);
|
||||
document.removeEventListener('mouseup', endDrag);
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(translatedText.value);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy text:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (isDragging.value) return;
|
||||
if (dialogRef.value?.contains(e.target as Node)) return;
|
||||
translatorManager.hide();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="teleportTarget">
|
||||
<template v-if="isVisible">
|
||||
<div
|
||||
ref="dialogRef"
|
||||
class="cm-translation-tooltip"
|
||||
:class="{ 'cm-translation-dragging': isDragging }"
|
||||
:style="dialogStyle"
|
||||
@mousedown="startDrag"
|
||||
@keydown.esc="translatorManager.hide"
|
||||
@contextmenu.prevent
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="cm-translation-header">
|
||||
<div class="cm-translation-controls">
|
||||
<select
|
||||
v-model="sourceLangSelector"
|
||||
class="cm-translation-select"
|
||||
@change="translate"
|
||||
@mousedown.stop
|
||||
>
|
||||
<option v-for="lang in availableLanguages" :key="lang.code" :value="lang.code">
|
||||
{{ lang.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<button class="cm-translation-swap" @click="swapLanguages" @mousedown.stop title="交换语言">
|
||||
<svg viewBox="0 0 24 24" width="11" height="11">
|
||||
<path fill="currentColor" d="M7.5 21L3 16.5L7.5 12L9 13.5L7 15.5H15V13H17V17.5H7L9 19.5L7.5 21M16.5 3L21 7.5L16.5 12L15 10.5L17 8.5H9V11H7V6.5H17L15 4.5L16.5 3Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<select
|
||||
v-model="targetLangSelector"
|
||||
class="cm-translation-select"
|
||||
@change="translate"
|
||||
@mousedown.stop
|
||||
>
|
||||
<option v-for="lang in availableLanguages" :key="lang.code" :value="lang.code">
|
||||
{{ lang.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
v-model="translatorSelector"
|
||||
class="cm-translation-select"
|
||||
@change="handleTranslatorChange"
|
||||
@mousedown.stop
|
||||
>
|
||||
<option v-for="translator in availableTranslators" :key="translator" :value="translator">
|
||||
{{ translator }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cm-translation-scroll-container">
|
||||
<div v-if="isLoading" class="cm-translation-loading">
|
||||
Translation...
|
||||
</div>
|
||||
|
||||
<div v-else class="cm-translation-result">
|
||||
<div class="cm-translation-result-wrapper">
|
||||
<button
|
||||
v-if="translatedText"
|
||||
class="cm-translation-copy-btn"
|
||||
@click="copyToClipboard"
|
||||
@mousedown.stop
|
||||
title="复制"
|
||||
>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="cm-translation-target">{{ translatedText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cm-translation-tooltip {
|
||||
position: fixed;
|
||||
background: var(--settings-card-bg, #fff);
|
||||
color: var(--text-primary, #333);
|
||||
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3), 0 0 1px rgba(0, 0, 0, 0.2);
|
||||
padding: 6px;
|
||||
max-width: 240px;
|
||||
max-height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
font-family: var(--voidraft-font-mono, system-ui, -apple-system, sans-serif), serif;
|
||||
font-size: 10px;
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
z-index: 10000;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cm-translation-dragging {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15), 0 0 1px rgba(0, 0, 0, 0.2);
|
||||
z-index: 10001;
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
.cm-translation-header {
|
||||
margin-bottom: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cm-translation-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.cm-translation-select {
|
||||
padding: 2px 3px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
|
||||
background: var(--bg-primary, #f8f8f8);
|
||||
font-size: 10px;
|
||||
color: var(--text-primary, #333);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 65px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cm-translation-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-color, rgba(66, 133, 244, 0.5));
|
||||
}
|
||||
|
||||
.cm-translation-swap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
|
||||
background: var(--bg-primary, transparent);
|
||||
color: var(--text-muted, #666);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.cm-translation-swap:hover {
|
||||
background: var(--bg-hover, rgba(66, 133, 244, 0.08));
|
||||
border-color: var(--border-color, rgba(66, 133, 244, 0.3));
|
||||
}
|
||||
|
||||
.cm-translation-scroll-container {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.cm-translation-scroll-container::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.cm-translation-scroll-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.cm-translation-scroll-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.cm-translation-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cm-translation-result-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cm-translation-copy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
|
||||
background: var(--bg-primary, rgba(255, 255, 255, 0.9));
|
||||
color: var(--text-muted, #666);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
z-index: 2;
|
||||
opacity: 0.6;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.cm-translation-copy-btn:hover {
|
||||
background: var(--bg-hover, rgba(66, 133, 244, 0.1));
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.cm-translation-copy-btn svg {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.cm-translation-target {
|
||||
padding: 5px;
|
||||
padding-right: 24px;
|
||||
background: var(--bg-primary, rgba(66, 133, 244, 0.03));
|
||||
color: var(--text-primary, #333);
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.cm-translation-loading {
|
||||
padding: 6px;
|
||||
text-align: center;
|
||||
color: var(--text-muted, #666);
|
||||
font-size: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.cm-translation-loading::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--text-muted, rgba(0, 0, 0, 0.2));
|
||||
border-top-color: var(--text-muted, #666);
|
||||
animation: cm-translation-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cm-translation-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,116 +1,29 @@
|
||||
import { Extension, StateField, StateEffect, StateEffectType } from '@codemirror/state';
|
||||
import { Extension, StateField } from '@codemirror/state';
|
||||
import { EditorView, showTooltip, Tooltip } from '@codemirror/view';
|
||||
import { createTranslationTooltip } from './tooltip';
|
||||
import {
|
||||
TranslatorConfig,
|
||||
DEFAULT_TRANSLATION_CONFIG,
|
||||
TRANSLATION_ICON_SVG
|
||||
} from '@/common/constant/translation';
|
||||
|
||||
|
||||
class TranslatorExtension {
|
||||
private config: TranslatorConfig;
|
||||
private setTranslationTooltip: StateEffectType<Tooltip | null>;
|
||||
private translationTooltipField: StateField<readonly Tooltip[]>;
|
||||
private translationButtonField: StateField<readonly Tooltip[]>;
|
||||
|
||||
constructor(config?: Partial<TranslatorConfig>) {
|
||||
// 初始化配置
|
||||
this.config = {
|
||||
minSelectionLength: DEFAULT_TRANSLATION_CONFIG.minSelectionLength,
|
||||
maxTranslationLength: DEFAULT_TRANSLATION_CONFIG.maxTranslationLength,
|
||||
...config
|
||||
};
|
||||
|
||||
// 初始化状态效果
|
||||
this.setTranslationTooltip = StateEffect.define<Tooltip | null>();
|
||||
|
||||
// 初始化翻译气泡状态字段
|
||||
this.translationTooltipField = StateField.define<readonly Tooltip[]>({
|
||||
create: () => [],
|
||||
update: (tooltips, tr) => {
|
||||
// 检查是否有特定的状态效果来更新tooltips
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(this.setTranslationTooltip)) {
|
||||
return effect.value ? [effect.value] : [];
|
||||
}
|
||||
}
|
||||
|
||||
// 如果文档或选择变化,隐藏气泡
|
||||
if (tr.docChanged || tr.selection) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return tooltips;
|
||||
},
|
||||
provide: field => showTooltip.computeN([field], state => state.field(field))
|
||||
});
|
||||
|
||||
// 初始化翻译按钮状态字段
|
||||
this.translationButtonField = StateField.define<readonly Tooltip[]>({
|
||||
create: (state) => this.getTranslationButtonTooltips(state),
|
||||
update: (tooltips, tr) => {
|
||||
// 如果文档或选择变化,重新计算tooltip
|
||||
if (tr.docChanged || tr.selection) {
|
||||
return this.getTranslationButtonTooltips(tr.state);
|
||||
}
|
||||
|
||||
// 检查是否有翻译气泡显示,如果有则不显示按钮
|
||||
if (tr.state.field(this.translationTooltipField).length > 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return tooltips;
|
||||
},
|
||||
provide: field => showTooltip.computeN([field], state => state.field(field))
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前选择获取翻译按钮tooltip
|
||||
*/
|
||||
private getTranslationButtonTooltips(state: any): readonly Tooltip[] {
|
||||
// 如果气泡已显示,则不显示按钮
|
||||
if (state.field(this.translationTooltipField).length > 0) return [];
|
||||
import { translatorManager } from './manager';
|
||||
import { TRANSLATION_ICON_SVG } from '@/common/constant/translation';
|
||||
|
||||
function TranslationTooltips(state: any): readonly Tooltip[] {
|
||||
const selection = state.selection.main;
|
||||
|
||||
// 如果没有选中文本,不显示按钮
|
||||
if (selection.empty) return [];
|
||||
|
||||
// 获取选中的文本
|
||||
const selectedText = state.sliceDoc(selection.from, selection.to);
|
||||
if (!selectedText.trim()) return [];
|
||||
|
||||
// 检查文本是否只包含空格
|
||||
if (!selectedText.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 检查文本长度条件
|
||||
if (selectedText.length < this.config.minSelectionLength ||
|
||||
selectedText.length > this.config.maxTranslationLength) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 返回翻译按钮tooltip配置
|
||||
return [{
|
||||
pos: selection.to,
|
||||
above: false,
|
||||
strictSide: true,
|
||||
arrow: false,
|
||||
create: (view) => {
|
||||
// 创建按钮DOM
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-translator-button';
|
||||
dom.innerHTML = TRANSLATION_ICON_SVG;
|
||||
|
||||
// 点击事件
|
||||
dom.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 显示翻译气泡
|
||||
this.showTranslationTooltip(view);
|
||||
showTranslatorDialog(view);
|
||||
});
|
||||
|
||||
return { dom };
|
||||
@@ -118,37 +31,33 @@ class TranslatorExtension {
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示翻译气泡
|
||||
*/
|
||||
private showTranslationTooltip(view: EditorView) {
|
||||
// 直接从当前选择获取文本
|
||||
function showTranslatorDialog(view: EditorView) {
|
||||
const selection = view.state.selection.main;
|
||||
if (selection.empty) return;
|
||||
|
||||
const selectedText = view.state.sliceDoc(selection.from, selection.to);
|
||||
if (!selectedText.trim()) return;
|
||||
|
||||
// 创建翻译气泡
|
||||
const tooltip = createTranslationTooltip(view, selectedText);
|
||||
const coords = view.coordsAtPos(selection.to);
|
||||
if (!coords) return;
|
||||
|
||||
// 更新状态以显示气泡
|
||||
view.dispatch({
|
||||
effects: this.setTranslationTooltip.of(tooltip)
|
||||
});
|
||||
translatorManager.show(view, coords.left, coords.bottom + 5, selectedText);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建扩展
|
||||
*/
|
||||
createExtension(): Extension {
|
||||
return [
|
||||
// 翻译按钮tooltip
|
||||
this.translationButtonField,
|
||||
// 翻译气泡tooltip
|
||||
this.translationTooltipField,
|
||||
const translationButtonField = StateField.define<readonly Tooltip[]>({
|
||||
create: (state) => TranslationTooltips(state),
|
||||
update: (tooltips, tr) => {
|
||||
if (tr.docChanged || tr.selection) {
|
||||
return TranslationTooltips(tr.state);
|
||||
}
|
||||
return tooltips;
|
||||
},
|
||||
provide: field => showTooltip.computeN([field], state => state.field(field))
|
||||
});
|
||||
|
||||
// 添加基础样式
|
||||
export function createTranslatorExtension(): Extension {
|
||||
return [
|
||||
translationButtonField,
|
||||
EditorView.baseTheme({
|
||||
".cm-translator-button": {
|
||||
display: "flex",
|
||||
@@ -167,189 +76,9 @@ class TranslatorExtension {
|
||||
"&:hover": {
|
||||
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))"
|
||||
}
|
||||
},
|
||||
|
||||
// 翻译气泡样式
|
||||
".cm-translation-tooltip": {
|
||||
background: "var(--bg-secondary, #fff)",
|
||||
color: "var(--text-primary, #333)",
|
||||
border: "1px solid var(--border-color, #dadce0)",
|
||||
borderRadius: "3px",
|
||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
|
||||
padding: "8px",
|
||||
maxWidth: "300px",
|
||||
maxHeight: "200px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
fontFamily: "var(--font-family, system-ui, -apple-system, sans-serif)",
|
||||
fontSize: "11px",
|
||||
userSelect: "none",
|
||||
cursor: "grab"
|
||||
},
|
||||
|
||||
// 拖拽状态样式
|
||||
".cm-translation-dragging": {
|
||||
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.2)",
|
||||
zIndex: "1000",
|
||||
cursor: "grabbing !important"
|
||||
},
|
||||
|
||||
".cm-translation-header": {
|
||||
marginBottom: "8px",
|
||||
flexShrink: "0"
|
||||
},
|
||||
|
||||
".cm-translation-controls": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
flexWrap: "nowrap"
|
||||
},
|
||||
|
||||
".cm-translation-select": {
|
||||
padding: "2px 4px",
|
||||
borderRadius: "3px",
|
||||
border: "1px solid var(--border-color, #dadce0)",
|
||||
background: "var(--bg-primary, #f5f5f5)",
|
||||
fontSize: "11px",
|
||||
color: "var(--text-primary, #333)",
|
||||
flex: "1",
|
||||
minWidth: "0",
|
||||
maxWidth: "80px"
|
||||
},
|
||||
|
||||
".cm-translation-swap": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
borderRadius: "3px",
|
||||
border: "1px solid var(--border-color, #dadce0)",
|
||||
background: "var(--bg-primary, transparent)",
|
||||
color: "var(--text-muted, #666)",
|
||||
cursor: "pointer",
|
||||
padding: "0",
|
||||
flexShrink: "0",
|
||||
"&:hover": {
|
||||
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))"
|
||||
}
|
||||
},
|
||||
|
||||
// 滚动容器
|
||||
".cm-translation-scroll-container": {
|
||||
overflowY: "auto",
|
||||
flex: "1",
|
||||
minHeight: "0"
|
||||
},
|
||||
|
||||
".cm-translation-result": {
|
||||
display: "flex",
|
||||
flexDirection: "column"
|
||||
},
|
||||
|
||||
".cm-translation-result-header": {
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginBottom: "4px"
|
||||
},
|
||||
|
||||
".cm-translation-result-wrapper": {
|
||||
position: "relative",
|
||||
width: "100%"
|
||||
},
|
||||
|
||||
".cm-translation-copy-btn": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
borderRadius: "3px",
|
||||
border: "1px solid var(--border-color, #dadce0)",
|
||||
background: "var(--bg-primary, transparent)",
|
||||
color: "var(--text-muted, #666)",
|
||||
cursor: "pointer",
|
||||
padding: "0",
|
||||
position: "absolute",
|
||||
top: "4px",
|
||||
right: "4px",
|
||||
zIndex: "2",
|
||||
opacity: "0.7",
|
||||
"&:hover": {
|
||||
background: "var(--bg-hover, rgba(66, 133, 244, 0.1))",
|
||||
opacity: "1"
|
||||
},
|
||||
"&.copied": {
|
||||
background: "var(--bg-success, #4caf50)",
|
||||
color: "white",
|
||||
border: "1px solid var(--bg-success, #4caf50)",
|
||||
opacity: "1"
|
||||
}
|
||||
},
|
||||
|
||||
".cm-translation-target": {
|
||||
padding: "6px",
|
||||
paddingRight: "28px", // 为复制按钮留出空间
|
||||
background: "var(--bg-primary, rgba(66, 133, 244, 0.05))",
|
||||
color: "var(--text-primary, #333)",
|
||||
borderRadius: "3px",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word"
|
||||
},
|
||||
|
||||
".cm-translation-notice": {
|
||||
fontSize: "10px",
|
||||
color: "var(--text-muted, #888)",
|
||||
padding: "2px 0",
|
||||
fontStyle: "italic",
|
||||
textAlign: "center",
|
||||
marginBottom: "2px"
|
||||
},
|
||||
|
||||
".cm-translation-error": {
|
||||
color: "var(--text-danger, #d32f2f)",
|
||||
fontStyle: "italic"
|
||||
},
|
||||
|
||||
".cm-translation-loading": {
|
||||
padding: "8px",
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted, #666)",
|
||||
fontSize: "11px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "6px"
|
||||
},
|
||||
|
||||
".cm-translation-loading::before": {
|
||||
content: "''",
|
||||
display: "inline-block",
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "50%",
|
||||
border: "2px solid var(--text-muted, #666)",
|
||||
borderTopColor: "transparent",
|
||||
animation: "cm-translation-spin 1s linear infinite"
|
||||
},
|
||||
|
||||
"@keyframes cm-translation-spin": {
|
||||
"0%": { transform: "rotate(0deg)" },
|
||||
"100%": { transform: "rotate(360deg)" }
|
||||
}
|
||||
})
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建翻译扩展
|
||||
*/
|
||||
export function createTranslatorExtension(config?: Partial<TranslatorConfig>): Extension {
|
||||
const translatorExtension = new TranslatorExtension(config);
|
||||
return translatorExtension.createExtension();
|
||||
}
|
||||
|
||||
export default createTranslatorExtension;
|
||||
66
frontend/src/views/editor/extensions/translator/manager.ts
Normal file
66
frontend/src/views/editor/extensions/translator/manager.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { readonly, shallowRef, type ShallowRef } from 'vue';
|
||||
|
||||
interface TranslatorPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface TranslatorState {
|
||||
visible: boolean;
|
||||
position: TranslatorPosition;
|
||||
sourceText: string;
|
||||
view: EditorView | null;
|
||||
}
|
||||
|
||||
class TranslatorManager {
|
||||
private state: ShallowRef<TranslatorState> = shallowRef({
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
sourceText: '',
|
||||
view: null
|
||||
});
|
||||
|
||||
useState() {
|
||||
return readonly(this.state);
|
||||
}
|
||||
|
||||
show(view: EditorView, clientX: number, clientY: number, text: string): void {
|
||||
this.state.value = {
|
||||
visible: true,
|
||||
position: { x: clientX, y: clientY },
|
||||
sourceText: text,
|
||||
view
|
||||
};
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
if (!this.state.value.visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const view = this.state.value.view;
|
||||
this.state.value = {
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
sourceText: '',
|
||||
view: null
|
||||
};
|
||||
|
||||
if (view) {
|
||||
view.focus();
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.state.value = {
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
sourceText: '',
|
||||
view: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const translatorManager = new TranslatorManager();
|
||||
|
||||
@@ -1,598 +0,0 @@
|
||||
import {EditorView, Tooltip, TooltipView} from '@codemirror/view';
|
||||
import {useTranslationStore} from '@/stores/translationStore';
|
||||
|
||||
/**
|
||||
* 翻译气泡弹窗类
|
||||
* 提供文本翻译功能的交互式界面
|
||||
*/
|
||||
export class TranslationTooltip implements TooltipView {
|
||||
// ===== 核心属性 =====
|
||||
dom!: HTMLElement;
|
||||
sourceText: string;
|
||||
translationStore: ReturnType<typeof useTranslationStore>;
|
||||
|
||||
// ===== UI 元素 =====
|
||||
private translatorSelector!: HTMLSelectElement;
|
||||
private sourceLangSelector!: HTMLSelectElement;
|
||||
private targetLangSelector!: HTMLSelectElement;
|
||||
private resultContainer!: HTMLDivElement;
|
||||
private loadingIndicator!: HTMLDivElement;
|
||||
private swapButton!: HTMLButtonElement;
|
||||
|
||||
// ===== 状态管理 =====
|
||||
private translatedText: string = '';
|
||||
private eventListeners: Array<{element: HTMLElement | Document, event: string, handler: EventListener}> = [];
|
||||
|
||||
// ===== 拖拽状态 =====
|
||||
private isDragging: boolean = false;
|
||||
private dragOffset: { x: number; y: number } = { x: 0, y: 0 };
|
||||
|
||||
constructor(_view: EditorView, text: string) {
|
||||
this.sourceText = text;
|
||||
this.translationStore = useTranslationStore();
|
||||
|
||||
this.initializeDOM();
|
||||
this.setupEventListeners();
|
||||
this.initializeTranslation();
|
||||
}
|
||||
|
||||
// ===== DOM 初始化 =====
|
||||
|
||||
/**
|
||||
* 初始化DOM结构
|
||||
*/
|
||||
private initializeDOM(): void {
|
||||
this.dom = this.createElement('div', 'cm-translation-tooltip');
|
||||
// 设置为绝对定位,允许拖拽移动
|
||||
this.dom.style.position = 'absolute';
|
||||
|
||||
const header = this.createHeader();
|
||||
const scrollContainer = this.createScrollContainer();
|
||||
|
||||
this.dom.appendChild(header);
|
||||
this.dom.appendChild(scrollContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建头部控制区域
|
||||
*/
|
||||
private createHeader(): HTMLElement {
|
||||
const header = this.createElement('div', 'cm-translation-header');
|
||||
|
||||
const controlsContainer = this.createElement('div', 'cm-translation-controls');
|
||||
|
||||
// 创建所有控制元素
|
||||
this.sourceLangSelector = this.createSelector('cm-translation-select');
|
||||
this.swapButton = this.createSwapButton();
|
||||
this.targetLangSelector = this.createSelector('cm-translation-select');
|
||||
this.translatorSelector = this.createTranslatorSelector();
|
||||
|
||||
// 添加到控制容器
|
||||
controlsContainer.appendChild(this.sourceLangSelector);
|
||||
controlsContainer.appendChild(this.swapButton);
|
||||
controlsContainer.appendChild(this.targetLangSelector);
|
||||
controlsContainer.appendChild(this.translatorSelector);
|
||||
|
||||
header.appendChild(controlsContainer);
|
||||
return header;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建滚动容器
|
||||
*/
|
||||
private createScrollContainer(): HTMLElement {
|
||||
const scrollContainer = this.createElement('div', 'cm-translation-scroll-container');
|
||||
|
||||
this.loadingIndicator = this.createElement('div', 'cm-translation-loading') as HTMLDivElement;
|
||||
this.loadingIndicator.textContent = 'Translation...';
|
||||
this.loadingIndicator.style.display = 'none';
|
||||
|
||||
this.resultContainer = this.createElement('div', 'cm-translation-result') as HTMLDivElement;
|
||||
|
||||
scrollContainer.appendChild(this.loadingIndicator);
|
||||
scrollContainer.appendChild(this.resultContainer);
|
||||
|
||||
return scrollContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建选择器元素
|
||||
*/
|
||||
private createSelector(className: string): HTMLSelectElement {
|
||||
const select = this.createElement('select', className) as HTMLSelectElement;
|
||||
return select;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建语言交换按钮
|
||||
*/
|
||||
private createSwapButton(): HTMLButtonElement {
|
||||
const button = this.createElement('button', 'cm-translation-swap') as HTMLButtonElement;
|
||||
button.innerHTML = `<svg viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M7.5 21L3 16.5L7.5 12L9 13.5L7 15.5H15V13H17V17.5H7L9 19.5L7.5 21M16.5 3L21 7.5L16.5 12L15 10.5L17 8.5H9V11H7V6.5H17L15 4.5L16.5 3Z"/></svg>`;
|
||||
return button;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建翻译器选择器
|
||||
*/
|
||||
private createTranslatorSelector(): HTMLSelectElement {
|
||||
const select = this.createSelector('cm-translation-select');
|
||||
const tempOption = this.createElement('option') as HTMLOptionElement;
|
||||
tempOption.textContent = 'Loading...';
|
||||
select.appendChild(tempOption);
|
||||
return select;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用DOM元素创建方法
|
||||
*/
|
||||
private createElement(tag: string, className?: string): HTMLElement {
|
||||
const element = document.createElement(tag);
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
// ===== 事件管理 =====
|
||||
|
||||
/**
|
||||
* 设置事件监听器
|
||||
*/
|
||||
private setupEventListeners(): void {
|
||||
this.addEventListenerWithCleanup(this.sourceLangSelector, 'change', () => {
|
||||
this.handleLanguageChange();
|
||||
});
|
||||
|
||||
this.addEventListenerWithCleanup(this.targetLangSelector, 'change', () => {
|
||||
this.handleLanguageChange();
|
||||
});
|
||||
|
||||
this.addEventListenerWithCleanup(this.swapButton, 'click', () => {
|
||||
this.swapLanguages();
|
||||
});
|
||||
|
||||
// 添加拖拽事件监听器
|
||||
this.setupDragListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加事件监听器并记录以便清理
|
||||
*/
|
||||
private addEventListenerWithCleanup(element: HTMLElement | Document, event: string, handler: EventListener): void {
|
||||
element.addEventListener(event, handler);
|
||||
this.eventListeners.push({ element, event, handler });
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有事件监听器
|
||||
*/
|
||||
private cleanupEventListeners(): void {
|
||||
this.eventListeners.forEach(({ element, event, handler }) => {
|
||||
element.removeEventListener(event, handler);
|
||||
});
|
||||
this.eventListeners = [];
|
||||
}
|
||||
|
||||
// ===== 初始化和生命周期 =====
|
||||
|
||||
/**
|
||||
* 初始化翻译功能
|
||||
*/
|
||||
private async initializeTranslation(): Promise<void> {
|
||||
this.showLoading();
|
||||
this.resultContainer.innerHTML = '<div class="cm-translation-loading">Loading...</div>';
|
||||
|
||||
try {
|
||||
await this.loadTranslators();
|
||||
await this.translate();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize translation:', error);
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 语言管理 =====
|
||||
|
||||
/**
|
||||
* 设置拖拽事件监听器
|
||||
*/
|
||||
private setupDragListeners(): void {
|
||||
// 在整个翻译框上监听鼠标按下事件
|
||||
this.addEventListenerWithCleanup(this.dom, 'mousedown', (e: Event) => {
|
||||
const mouseEvent = e as MouseEvent;
|
||||
const target = mouseEvent.target as HTMLElement;
|
||||
|
||||
// 如果点击的是交互元素(按钮、选择框等),不启动拖拽
|
||||
if (target.tagName === 'SELECT' || target.tagName === 'BUTTON' ||
|
||||
target.tagName === 'OPTION' || target.closest('select') || target.closest('button')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.startDrag(mouseEvent);
|
||||
});
|
||||
|
||||
// 鼠标移动
|
||||
this.addEventListenerWithCleanup(document, 'mousemove', (e: Event) => {
|
||||
const mouseEvent = e as MouseEvent;
|
||||
this.onDrag(mouseEvent);
|
||||
});
|
||||
|
||||
// 鼠标释放结束拖拽
|
||||
this.addEventListenerWithCleanup(document, 'mouseup', () => {
|
||||
this.endDrag();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始拖拽
|
||||
*/
|
||||
private startDrag(e: MouseEvent): void {
|
||||
e.preventDefault();
|
||||
this.isDragging = true;
|
||||
|
||||
const rect = this.dom.getBoundingClientRect();
|
||||
this.dragOffset = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
};
|
||||
|
||||
// 添加拖拽状态样式
|
||||
this.dom.classList.add('cm-translation-dragging');
|
||||
this.dom.style.cursor = 'grabbing';
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽过程中
|
||||
*/
|
||||
private onDrag(e: MouseEvent): void {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const newX = e.clientX - this.dragOffset.x;
|
||||
const newY = e.clientY - this.dragOffset.y;
|
||||
|
||||
// 确保不会拖拽到视窗外
|
||||
const maxX = window.innerWidth - this.dom.offsetWidth;
|
||||
const maxY = window.innerHeight - this.dom.offsetHeight;
|
||||
|
||||
const clampedX = Math.max(0, Math.min(newX, maxX));
|
||||
const clampedY = Math.max(0, Math.min(newY, maxY));
|
||||
|
||||
this.dom.style.left = `${clampedX}px`;
|
||||
this.dom.style.top = `${clampedY}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束拖拽
|
||||
*/
|
||||
private endDrag(): void {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
this.isDragging = false;
|
||||
|
||||
// 移除拖拽状态样式
|
||||
this.dom.classList.remove('cm-translation-dragging');
|
||||
this.dom.style.cursor = 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理语言变更
|
||||
*/
|
||||
private handleLanguageChange(): void {
|
||||
// 语言变更后重新翻译,具体的语言限制逻辑在store中处理
|
||||
this.translate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 交换源语言和目标语言
|
||||
*/
|
||||
private swapLanguages(): void {
|
||||
const temp = this.sourceLangSelector.value;
|
||||
this.sourceLangSelector.value = this.targetLangSelector.value;
|
||||
this.targetLangSelector.value = temp;
|
||||
this.translate();
|
||||
}
|
||||
|
||||
// ===== 翻译器管理 =====
|
||||
|
||||
/**
|
||||
* 加载翻译器选项
|
||||
*/
|
||||
private async loadTranslators(): Promise<boolean> {
|
||||
try {
|
||||
this.clearSelectOptions(this.translatorSelector);
|
||||
|
||||
const translators = this.translationStore.translators;
|
||||
this.populateTranslatorOptions(translators);
|
||||
|
||||
// 添加翻译器变更事件监听
|
||||
this.addEventListenerWithCleanup(this.translatorSelector, 'change', () => {
|
||||
this.handleTranslatorChange();
|
||||
});
|
||||
|
||||
await this.updateLanguageSelectors();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load translators:', error);
|
||||
this.loadDefaultTranslators();
|
||||
await this.updateLanguageSelectors();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充翻译器选项
|
||||
*/
|
||||
private populateTranslatorOptions(translators: string[]): void {
|
||||
translators.forEach((translator, index) => {
|
||||
const option = this.createElement('option') as HTMLOptionElement;
|
||||
option.value = translator;
|
||||
option.textContent = translator;
|
||||
option.selected = index === 0; // 选择第一个翻译器
|
||||
this.translatorSelector.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载默认翻译器
|
||||
*/
|
||||
private loadDefaultTranslators(): void {
|
||||
this.clearSelectOptions(this.translatorSelector);
|
||||
|
||||
// 使用从后端获取的翻译器列表
|
||||
const translators = this.translationStore.translators;
|
||||
this.populateTranslatorOptions(translators);
|
||||
|
||||
this.addEventListenerWithCleanup(this.translatorSelector, 'change', () => {
|
||||
this.handleTranslatorChange();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理翻译器选择变化
|
||||
*/
|
||||
private async handleTranslatorChange(): Promise<void> {
|
||||
await this.updateLanguageSelectors();
|
||||
this.translate();
|
||||
}
|
||||
|
||||
// ===== 语言选择器管理 =====
|
||||
|
||||
/**
|
||||
* 更新语言选择器
|
||||
*/
|
||||
private async updateLanguageSelectors(): Promise<void> {
|
||||
const currentTranslator = this.translatorSelector.value;
|
||||
|
||||
// 保存当前选中的语言
|
||||
const currentSourceLang = this.sourceLangSelector.value || '';
|
||||
const currentTargetLang = this.targetLangSelector.value;
|
||||
|
||||
// 清空选择器
|
||||
this.clearSelectOptions(this.sourceLangSelector);
|
||||
this.clearSelectOptions(this.targetLangSelector);
|
||||
|
||||
// 直接使用预加载的语言映射
|
||||
const languageMap = this.translationStore.translatorLanguages[currentTranslator];
|
||||
|
||||
if (!languageMap || Object.keys(languageMap).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加语言选项
|
||||
Object.entries(languageMap).forEach(([code, langInfo]) => {
|
||||
this.addLanguageOption(code, langInfo);
|
||||
});
|
||||
|
||||
// 恢复之前的语言选择
|
||||
this.restoreLanguageSelection(currentSourceLang, currentTargetLang);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空选择器选项
|
||||
*/
|
||||
private clearSelectOptions(selector: HTMLSelectElement): void {
|
||||
while (selector.firstChild) {
|
||||
selector.removeChild(selector.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加语言选项到选择器
|
||||
*/
|
||||
private addLanguageOption(code: string, langInfo: any): void {
|
||||
const displayName = langInfo.Name || langInfo.name || code;
|
||||
|
||||
// 添加源语言选项
|
||||
const sourceOption = this.createElement('option') as HTMLOptionElement;
|
||||
sourceOption.value = code;
|
||||
sourceOption.textContent = displayName;
|
||||
this.sourceLangSelector.appendChild(sourceOption);
|
||||
|
||||
// 添加目标语言选项
|
||||
const targetOption = this.createElement('option') as HTMLOptionElement;
|
||||
targetOption.value = code;
|
||||
targetOption.textContent = displayName;
|
||||
this.targetLangSelector.appendChild(targetOption);
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复语言选择
|
||||
*/
|
||||
private restoreLanguageSelection(sourceLang: string, targetLang: string): void {
|
||||
// 设置源语言
|
||||
if (sourceLang && this.hasLanguageOption(this.sourceLangSelector, sourceLang)) {
|
||||
this.sourceLangSelector.value = sourceLang;
|
||||
} else if (this.sourceLangSelector.options.length > 0) {
|
||||
this.sourceLangSelector.selectedIndex = 0;
|
||||
}
|
||||
|
||||
// 设置目标语言
|
||||
if (targetLang && this.hasLanguageOption(this.targetLangSelector, targetLang)) {
|
||||
this.targetLangSelector.value = targetLang;
|
||||
} else if (this.targetLangSelector.options.length > 0) {
|
||||
this.targetLangSelector.selectedIndex = 0;
|
||||
}
|
||||
|
||||
// 确保源语言和目标语言不同
|
||||
this.handleLanguageChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查选择器是否有指定语言选项
|
||||
*/
|
||||
private hasLanguageOption(selector: HTMLSelectElement, langCode: string): boolean {
|
||||
return Array.from(selector.options).some(option => option.value === langCode);
|
||||
}
|
||||
|
||||
// ===== 翻译功能 =====
|
||||
|
||||
|
||||
/**
|
||||
* 执行翻译
|
||||
*/
|
||||
private async translate(): Promise<void> {
|
||||
const sourceLang = this.sourceLangSelector.value;
|
||||
const targetLang = this.targetLangSelector.value;
|
||||
const translatorType = this.translatorSelector.value;
|
||||
|
||||
this.showLoading();
|
||||
this.resultContainer.innerHTML = '';
|
||||
|
||||
try {
|
||||
const result = await this.translationStore.translateText(
|
||||
this.sourceText,
|
||||
sourceLang,
|
||||
targetLang,
|
||||
translatorType
|
||||
);
|
||||
|
||||
this.displayTranslationResult(result);
|
||||
} catch (err) {
|
||||
console.error('Translation failed:', err);
|
||||
this.displayError('Translation failed');
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== UI 状态管理 =====
|
||||
|
||||
/**
|
||||
* 显示加载状态
|
||||
*/
|
||||
private showLoading(): void {
|
||||
this.loadingIndicator.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏加载状态
|
||||
*/
|
||||
private hideLoading(): void {
|
||||
this.loadingIndicator.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示错误信息
|
||||
*/
|
||||
private displayError(message: string): void {
|
||||
this.resultContainer.innerHTML = '';
|
||||
this.translatedText = '';
|
||||
|
||||
const errorElement = this.createElement('div', 'cm-translation-error');
|
||||
errorElement.textContent = message;
|
||||
this.resultContainer.appendChild(errorElement);
|
||||
}
|
||||
|
||||
// ===== 结果显示 =====
|
||||
|
||||
/**
|
||||
* 显示翻译结果
|
||||
*/
|
||||
private displayTranslationResult(result: any): void {
|
||||
this.resultContainer.innerHTML = '';
|
||||
|
||||
const resultWrapper = this.createElement('div', 'cm-translation-result-wrapper');
|
||||
const translatedTextElem = this.createElement('div', 'cm-translation-target');
|
||||
|
||||
if (result.error) {
|
||||
translatedTextElem.classList.add('cm-translation-error');
|
||||
translatedTextElem.textContent = result.error;
|
||||
this.translatedText = '';
|
||||
} else {
|
||||
this.translatedText = result.translatedText || '';
|
||||
translatedTextElem.textContent = this.translatedText;
|
||||
}
|
||||
|
||||
// 添加复制按钮
|
||||
if (this.translatedText) {
|
||||
const copyButton = this.createCopyButton();
|
||||
resultWrapper.appendChild(copyButton);
|
||||
}
|
||||
|
||||
resultWrapper.appendChild(translatedTextElem);
|
||||
this.resultContainer.appendChild(resultWrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建复制按钮
|
||||
*/
|
||||
private createCopyButton(): HTMLButtonElement {
|
||||
const copyButton = this.createElement('button', 'cm-translation-copy-btn') as HTMLButtonElement;
|
||||
copyButton.innerHTML = `<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>`;
|
||||
|
||||
this.addEventListenerWithCleanup(copyButton, 'click', () => {
|
||||
this.copyToClipboard(copyButton);
|
||||
});
|
||||
|
||||
return copyButton;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制文本到剪贴板
|
||||
*/
|
||||
private async copyToClipboard(button: HTMLButtonElement): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.translatedText);
|
||||
this.showCopySuccess(button);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy text:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示复制成功状态
|
||||
*/
|
||||
private showCopySuccess(button: HTMLButtonElement): void {
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = `<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>`;
|
||||
button.classList.add('copied');
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
button.classList.remove('copied');
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// ===== 生命周期管理 =====
|
||||
|
||||
/**
|
||||
* 销毁组件时的清理工作
|
||||
*/
|
||||
destroy(): void {
|
||||
this.cleanupEventListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// 创建翻译气泡
|
||||
export function createTranslationTooltip(view: EditorView, text: string): Tooltip {
|
||||
return {
|
||||
pos: view.state.selection.main.to, // 紧贴文本末尾
|
||||
above: false,
|
||||
strictSide: false,
|
||||
arrow: true,
|
||||
create: () => new TranslationTooltip(view, text)
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import {deleteLineCommand} from '../extensions/codeblock/deleteLine';
|
||||
import {moveLineDown, moveLineUp} from '../extensions/codeblock/moveLines';
|
||||
import {transposeChars} from '../extensions/codeblock';
|
||||
import {copyCommand, cutCommand, pasteCommand} from '../extensions/codeblock/copyPaste';
|
||||
import {textHighlightToggleCommand} from '../extensions/textHighlight/textHighlightExtension';
|
||||
import {textHighlightToggleCommand} from '../extensions/textHighlight';
|
||||
import {
|
||||
copyLineDown,
|
||||
copyLineUp,
|
||||
|
||||
@@ -3,8 +3,8 @@ import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||
import i18n from '@/i18n';
|
||||
import {ExtensionDefinition} from './types';
|
||||
|
||||
import rainbowBracketsExtension from '../extensions/rainbowBracket/rainbowBracketsExtension';
|
||||
import {createTextHighlighter} from '../extensions/textHighlight/textHighlightExtension';
|
||||
import index from '../extensions/rainbowBracket';
|
||||
import {createTextHighlighter} from '../extensions/textHighlight';
|
||||
import {color} from '../extensions/colorSelector';
|
||||
import {hyperLink} from '../extensions/hyperlink';
|
||||
import {minimap} from '../extensions/minimap';
|
||||
@@ -28,7 +28,7 @@ const defineExtension = (create: (config: any) => any, defaultConfig: Record<str
|
||||
|
||||
const EXTENSION_REGISTRY: Record<RegisteredExtensionID, ExtensionEntry> = {
|
||||
[ExtensionID.ExtensionRainbowBrackets]: {
|
||||
definition: defineExtension(() => rainbowBracketsExtension()),
|
||||
definition: defineExtension(() => index()),
|
||||
displayNameKey: 'extensions.rainbowBrackets.name',
|
||||
descriptionKey: 'extensions.rainbowBrackets.description'
|
||||
},
|
||||
@@ -43,13 +43,7 @@ const EXTENSION_REGISTRY: Record<RegisteredExtensionID, ExtensionEntry> = {
|
||||
descriptionKey: 'extensions.colorSelector.description'
|
||||
},
|
||||
[ExtensionID.ExtensionTranslator]: {
|
||||
definition: defineExtension((config: any) => createTranslatorExtension({
|
||||
minSelectionLength: config?.minSelectionLength ?? 2,
|
||||
maxTranslationLength: config?.maxTranslationLength ?? 5000
|
||||
}), {
|
||||
minSelectionLength: 2,
|
||||
maxTranslationLength: 5000
|
||||
}),
|
||||
definition: defineExtension(() => createTranslatorExtension()),
|
||||
displayNameKey: 'extensions.translator.name',
|
||||
descriptionKey: 'extensions.translator.description'
|
||||
},
|
||||
|
||||
14
go.mod
14
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.40
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.41
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/text v0.31.0
|
||||
modernc.org/sqlite v1.40.0
|
||||
modernc.org/sqlite v1.40.1
|
||||
resty.dev/v3 v3.0.0-beta.3
|
||||
)
|
||||
|
||||
@@ -29,7 +29,7 @@ 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.6.0 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // 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
|
||||
@@ -42,7 +42,7 @@ require (
|
||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/go-github/v30 v30.1.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
@@ -73,19 +73,19 @@ require (
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
github.com/xanzy/go-gitlab v0.115.0 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
|
||||
golang.org/x/image v0.33.0 // indirect
|
||||
golang.org/x/oauth2 v0.33.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/libc v1.67.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
48
go.sum
48
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.6.0 h1:BtGB77njd6SVO6VztOHfPxKitJvd/VPT+OFBFMOi1Is=
|
||||
github.com/cyphar/filepath-securejoin v0.6.0/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/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=
|
||||
@@ -62,8 +62,8 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
|
||||
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
@@ -87,6 +87,8 @@ github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVU
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||
@@ -162,12 +164,12 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
||||
github.com/wailsapp/go-webview2 v1.0.23/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.40 h1:LY0hngVwihlSXveshL5LM8ivjLTHAN6VDjOSF6szI9k=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.40/go.mod h1:7i8tSuA74q97zZ5qEJlcVZdnO+IR7LT2KU8UpzYMPsw=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.41 h1:DYcC1/vtO862sxnoyCOMfLLypbzpFWI257fR6zDYY+Y=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.41/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=
|
||||
@@ -178,12 +180,12 @@ golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
||||
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
@@ -220,8 +222,8 @@ golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -236,18 +238,20 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/libc v1.67.0 h1:QzL4IrKab2OFmxA3/vRYl0tLXrIamwrhD6CKD4WBVjQ=
|
||||
modernc.org/libc v1.67.0/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -256,8 +260,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.40.0 h1:bNWEDlYhNPAUdUdBzjAvn8icAs/2gaKlj4vM+tQ6KdQ=
|
||||
modernc.org/sqlite v1.40.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
|
||||
modernc.org/sqlite v1.40.1/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=
|
||||
|
||||
Reference in New Issue
Block a user