Compare commits
2 Commits
ad24d3a140
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| ac086db1ed | |||
| 6dff0181d2 |
@@ -8,7 +8,7 @@ import { getActiveNoteBlock } from '@/views/editor/extensions/codeblock/state';
|
|||||||
import { changeCurrentBlockLanguage } from '@/views/editor/extensions/codeblock/commands';
|
import { changeCurrentBlockLanguage } from '@/views/editor/extensions/codeblock/commands';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const editorStore = readonly(useEditorStore());
|
const editorStore = useEditorStore();
|
||||||
|
|
||||||
// 组件状态
|
// 组件状态
|
||||||
const showLanguageMenu = shallowRef(false);
|
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 {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode';
|
||||||
import {createDebounce} from '@/common/utils/debounce';
|
import {createDebounce} from '@/common/utils/debounce';
|
||||||
import {toggleMarkdownPreview} from '@/views/editor/extensions/markdownPreview';
|
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 editorStore = useEditorStore();
|
||||||
const configStore = readonly(useConfigStore());
|
const configStore = useConfigStore();
|
||||||
const updateStore = readonly(useUpdateStore());
|
const updateStore = useUpdateStore();
|
||||||
const windowStore = readonly(useWindowStore());
|
const windowStore = useWindowStore();
|
||||||
const systemStore = readonly(useSystemStore());
|
const systemStore = useSystemStore();
|
||||||
const panelStore = readonly(usePanelStore());
|
|
||||||
const {t} = useI18n();
|
const {t} = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@ const isCurrentWindowOnTop = computed(() => {
|
|||||||
|
|
||||||
// 当前文档的预览是否打开
|
// 当前文档的预览是否打开
|
||||||
const isCurrentBlockPreviewing = 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 {EditorState, Extension} from '@codemirror/state';
|
||||||
import {useConfigStore} from './configStore';
|
import {useConfigStore} from './configStore';
|
||||||
import {useDocumentStore} from './documentStore';
|
import {useDocumentStore} from './documentStore';
|
||||||
import {usePanelStore} from './panelStore';
|
|
||||||
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||||
import {DocumentService, ExtensionService} from '@/../bindings/voidraft/internal/services';
|
import {DocumentService, ExtensionService} from '@/../bindings/voidraft/internal/services';
|
||||||
import {ensureSyntaxTree} from "@codemirror/language";
|
import {ensureSyntaxTree} from "@codemirror/language";
|
||||||
@@ -30,7 +29,7 @@ import {generateContentHash} from "@/common/utils/hashUtils";
|
|||||||
import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
|
import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
|
||||||
import {EDITOR_CONFIG} from '@/common/constant/editor';
|
import {EDITOR_CONFIG} from '@/common/constant/editor';
|
||||||
import {createHttpClientExtension} from "@/views/editor/extensions/httpclient";
|
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';
|
import {createDebounce} from '@/common/utils/debounce';
|
||||||
|
|
||||||
export interface DocumentStats {
|
export interface DocumentStats {
|
||||||
@@ -642,12 +641,6 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 应用 Markdown 预览主题
|
|
||||||
const applyPreviewThemeSettings = () => {
|
|
||||||
editorCache.values().forEach(instance => {
|
|
||||||
updateMarkdownPreviewTheme(instance.view);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 应用Tab设置
|
// 应用Tab设置
|
||||||
const applyTabSettings = () => {
|
const applyTabSettings = () => {
|
||||||
@@ -701,10 +694,6 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
instance.view.destroy();
|
instance.view.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 清理 panelStore 状态(导航离开编辑器页面时)
|
|
||||||
const panelStore = usePanelStore();
|
|
||||||
panelStore.reset();
|
|
||||||
|
|
||||||
currentEditor.value = null;
|
currentEditor.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -790,7 +779,6 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
// 配置更新方法
|
// 配置更新方法
|
||||||
applyFontSettings,
|
applyFontSettings,
|
||||||
applyThemeSettings,
|
applyThemeSettings,
|
||||||
applyPreviewThemeSettings,
|
|
||||||
applyTabSettings,
|
applyTabSettings,
|
||||||
applyKeymapSettings,
|
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();
|
const editorStore = useEditorStore();
|
||||||
editorStore?.applyThemeSettings();
|
editorStore?.applyThemeSettings();
|
||||||
editorStore?.applyPreviewThemeSettings();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import ContextMenu from './contextMenu/ContextMenu.vue';
|
|||||||
import { contextMenuManager } from './contextMenu/manager';
|
import { contextMenuManager } from './contextMenu/manager';
|
||||||
import TranslatorDialog from './extensions/translator/TranslatorDialog.vue';
|
import TranslatorDialog from './extensions/translator/TranslatorDialog.vue';
|
||||||
import { translatorManager } from './extensions/translator/manager';
|
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 editorStore = useEditorStore();
|
||||||
const documentStore = useDocumentStore();
|
const documentStore = useDocumentStore();
|
||||||
@@ -37,6 +39,7 @@ onMounted(async () => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
contextMenuManager.destroy();
|
contextMenuManager.destroy();
|
||||||
translatorManager.destroy();
|
translatorManager.destroy();
|
||||||
|
markdownPreviewManager.destroy();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -46,8 +49,13 @@ onBeforeUnmount(() => {
|
|||||||
<transition name="loading-fade">
|
<transition name="loading-fade">
|
||||||
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT" />
|
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT" />
|
||||||
</transition>
|
</transition>
|
||||||
|
<!-- 编辑器和预览面板的容器 -->
|
||||||
|
<div class="editor-wrapper">
|
||||||
<!-- 编辑器区域 -->
|
<!-- 编辑器区域 -->
|
||||||
<div ref="editorElement" class="editor"></div>
|
<div ref="editorElement" class="editor"></div>
|
||||||
|
<!-- Markdown 预览面板 -->
|
||||||
|
<PreviewPanel />
|
||||||
|
</div>
|
||||||
<!-- 工具栏 -->
|
<!-- 工具栏 -->
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
<!-- 右键菜单 -->
|
<!-- 右键菜单 -->
|
||||||
@@ -66,9 +74,18 @@ onBeforeUnmount(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
.editor {
|
.editor-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
/**
|
import { EditorView, ViewUpdate, ViewPlugin } from "@codemirror/view";
|
||||||
* Markdown 预览扩展主入口
|
import { Extension } from "@codemirror/state";
|
||||||
*/
|
|
||||||
import { EditorView } from "@codemirror/view";
|
|
||||||
import { Compartment } from "@codemirror/state";
|
|
||||||
import { useThemeStore } from "@/stores/themeStore";
|
|
||||||
import { usePanelStore } from "@/stores/panelStore";
|
|
||||||
import { useDocumentStore } from "@/stores/documentStore";
|
import { useDocumentStore } from "@/stores/documentStore";
|
||||||
import { getActiveNoteBlock } from "../codeblock/state";
|
import { getActiveNoteBlock } from "../codeblock/state";
|
||||||
import { createMarkdownPreviewTheme } from "./styles";
|
import { markdownPreviewManager } from "./manager";
|
||||||
import { previewPanelState, previewPanelPlugin, togglePreview, closePreviewWithAnimation } from "./state";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换预览面板的命令
|
* 切换预览面板的命令
|
||||||
*/
|
*/
|
||||||
export function toggleMarkdownPreview(view: EditorView): boolean {
|
export function toggleMarkdownPreview(view: EditorView): boolean {
|
||||||
const panelStore = usePanelStore();
|
|
||||||
const documentStore = useDocumentStore();
|
const documentStore = useDocumentStore();
|
||||||
const currentState = view.state.field(previewPanelState, false);
|
|
||||||
const activeBlock = getActiveNoteBlock(view.state as any);
|
const activeBlock = getActiveNoteBlock(view.state as any);
|
||||||
|
|
||||||
// 如果当前没有激活的 Markdown 块,不执行操作
|
// 如果当前没有激活的 Markdown 块,不执行操作
|
||||||
@@ -30,53 +22,84 @@ export function toggleMarkdownPreview(view: EditorView): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果预览面板已打开(无论预览的是不是当前块),关闭预览
|
// 切换预览状态
|
||||||
if (panelStore.markdownPreview.isOpen && !panelStore.markdownPreview.isClosing) {
|
if (markdownPreviewManager.isVisible()) {
|
||||||
// 使用带动画的关闭函数
|
markdownPreviewManager.hide();
|
||||||
closePreviewWithAnimation(view);
|
|
||||||
} else {
|
} else {
|
||||||
// 否则,打开当前块的预览
|
markdownPreviewManager.show(
|
||||||
view.dispatch({
|
view,
|
||||||
effects: togglePreview.of({
|
currentDocumentId,
|
||||||
documentId: currentDocumentId,
|
activeBlock.content.from,
|
||||||
blockFrom: activeBlock.content.from,
|
activeBlock.content.to
|
||||||
blockTo: activeBlock.content.to
|
);
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
// 注意:store 状态由 ViewPlugin 在面板创建成功后更新
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
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 预览扩展
|
* 导出 Markdown 预览扩展
|
||||||
*/
|
*/
|
||||||
const previewThemeCompartment = new Compartment();
|
export function markdownPreviewExtension(): Extension {
|
||||||
|
return [previewSyncPlugin];
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
decorations: (v) => v.decorations,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function rainbowBracketsExtension() {
|
export default function index() {
|
||||||
return [
|
return [
|
||||||
rainbowBracketsPlugin,
|
rainbowBracketsPlugin,
|
||||||
EditorView.baseTheme({
|
EditorView.baseTheme({
|
||||||
@@ -3,7 +3,7 @@ import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
|||||||
import i18n from '@/i18n';
|
import i18n from '@/i18n';
|
||||||
import {ExtensionDefinition} from './types';
|
import {ExtensionDefinition} from './types';
|
||||||
|
|
||||||
import rainbowBracketsExtension from '../extensions/rainbowBracket/rainbowBracketsExtension';
|
import index from '../extensions/rainbowBracket';
|
||||||
import {createTextHighlighter} from '../extensions/textHighlight';
|
import {createTextHighlighter} from '../extensions/textHighlight';
|
||||||
import {color} from '../extensions/colorSelector';
|
import {color} from '../extensions/colorSelector';
|
||||||
import {hyperLink} from '../extensions/hyperlink';
|
import {hyperLink} from '../extensions/hyperlink';
|
||||||
@@ -28,7 +28,7 @@ const defineExtension = (create: (config: any) => any, defaultConfig: Record<str
|
|||||||
|
|
||||||
const EXTENSION_REGISTRY: Record<RegisteredExtensionID, ExtensionEntry> = {
|
const EXTENSION_REGISTRY: Record<RegisteredExtensionID, ExtensionEntry> = {
|
||||||
[ExtensionID.ExtensionRainbowBrackets]: {
|
[ExtensionID.ExtensionRainbowBrackets]: {
|
||||||
definition: defineExtension(() => rainbowBracketsExtension()),
|
definition: defineExtension(() => index()),
|
||||||
displayNameKey: 'extensions.rainbowBrackets.name',
|
displayNameKey: 'extensions.rainbowBrackets.name',
|
||||||
descriptionKey: 'extensions.rainbowBrackets.description'
|
descriptionKey: 'extensions.rainbowBrackets.description'
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user