Skip to content

Commit d2e5f48

Browse files
committed
feat: 支持多编辑器实例(每个 tab 一个),现在切换更流畅了
1 parent c1324a6 commit d2e5f48

8 files changed

Lines changed: 187 additions & 167 deletions

File tree

src/renderer/App.vue

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
<script setup lang="ts">
22
import emitter from "@/renderer/events";
3-
import useContent from "@/renderer/hooks/useContent";
43
import { useContext } from "@/renderer/hooks/useContext";
54
import useFont from "@/renderer/hooks/useFont";
65
import useOtherConfig from "@/renderer/hooks/useOtherConfig";
@@ -19,16 +18,16 @@ import TitleBar from "./components/menu/TitleBar.vue";
1918
import Outline from "./components/outline/Outline.vue";
2019
2120
// ✅ 应用级事件协调器(仅负责事件监听和协调)
22-
const { editorKey } = useContext();
21+
useContext();
2322
2423
// ✅ 直接使用各个hooks(而不是通过useContext转发)
25-
const { markdown } = useContent();
2624
const { init: initTheme } = useTheme();
2725
const { init: initFont } = useFont();
2826
const { init: initOtherConfig } = useOtherConfig();
2927
const { isShowSource } = useSourceCode(); // 用于控制大纲显示
3028
const { init: initSpellCheck } = useSpellCheck();
31-
const { currentTab, close, saveCurrentTab, getUnsavedTabs, switchToTab } = useTab();
29+
const { currentTab, tabs, activeTabId, close, saveCurrentTab, getUnsavedTabs, switchToTab } =
30+
useTab();
3231
const {
3332
isDialogVisible,
3433
dialogType,
@@ -185,19 +184,25 @@ const handleInstall = async () => {
185184
<template>
186185
<TitleBar />
187186
<div id="fontRoot">
188-
<!--使用key属性来重建编辑器,当editorKey变化时Vue会自动重建组件 -->
189-
<div :key="editorKey" ref="editorAreaRef" class="editorArea" :class="outlineClass">
187+
<!--多编辑器实例:每个 tab 拥有独立的编辑器,v-show 保持 DOM 存活 -->
188+
<div ref="editorAreaRef" class="editorArea" :class="outlineClass">
190189
<div class="outlineBox">
191190
<Outline />
192191
</div>
193192
<div class="editorBox" @transitionend="onOutlineTransitionEnd">
194-
<!-- Milkup 编辑器(新内核,支持源码模式) -->
195-
<MilkupEditor v-model="markdown" :read-only="currentTab?.readOnly" />
193+
<!-- Milkup 编辑器(每个 tab 独立实例) -->
194+
<MilkupEditor
195+
v-for="tab in tabs"
196+
:key="tab.id"
197+
v-show="tab.id === activeTabId"
198+
:tab="tab"
199+
:is-active="tab.id === activeTabId"
200+
/>
196201
</div>
197202
</div>
198203
</div>
199204
<StatusBar
200-
:content="markdown"
205+
:content="currentTab?.content ?? ''"
201206
:update-status="updateStatus"
202207
:download-progress="downloadProgress"
203208
:is-update-dialog-visible="isUpdateDialogVisible"

src/renderer/components/editor/MilkupEditor.vue

Lines changed: 110 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
/**
33
* Milkup 编辑器 Vue 组件
44
* 基于自研 ProseMirror 内核的即时渲染 Markdown 编辑器
5+
* 每个 tab 拥有独立的编辑器实例(v-for + v-show 模式)
56
*/
7+
import type { Tab } from "@/types/tab";
68
import { ref, onMounted, onUnmounted, watch, nextTick } from "vue";
79
import {
810
MilkupEditor,
@@ -16,24 +18,15 @@ import { AIService } from "@/renderer/services/ai";
1618
import { useAIConfig } from "@/renderer/hooks/useAIConfig";
1719
import { useConfig } from "@/renderer/hooks/useConfig";
1820
import emitter from "@/renderer/events";
19-
import useTab from "@/renderer/hooks/useTab";
2021
import "@/core/styles/milkup.css";
2122
2223
interface Props {
23-
modelValue: string;
24-
readOnly?: boolean;
24+
tab: Tab;
25+
isActive: boolean;
2526
}
2627
27-
const props = withDefaults(defineProps<Props>(), {
28-
modelValue: "",
29-
readOnly: false,
30-
});
31-
32-
const emit = defineEmits<{
33-
"update:modelValue": [value: string];
34-
}>();
28+
const props = defineProps<Props>();
3529
36-
const { currentTab } = useTab();
3730
const { config: aiConfig, isEnabled: aiEnabled } = useAIConfig();
3831
const { config: appConfig, watchConf } = useConfig();
3932
@@ -49,6 +42,16 @@ let editor: MilkupEditor | null = null;
4942
const lastEmittedValue = ref<string | null>(null);
5043
let isSourceViewToggling = false;
5144
45+
// isNewlyLoaded 归一化清理定时器
46+
let newlyLoadedTimer: ReturnType<typeof setTimeout> | null = null;
47+
function scheduleNewlyLoadedCleanup() {
48+
if (newlyLoadedTimer) clearTimeout(newlyLoadedTimer);
49+
newlyLoadedTimer = setTimeout(() => {
50+
newlyLoadedTimer = null;
51+
props.tab.isNewlyLoaded = false;
52+
}, 150);
53+
}
54+
5255
// 更新滚动比例(rAF 节流)
5356
let scrollRafId: number | null = null;
5457
function updateScrollRatio(e: Event) {
@@ -59,9 +62,7 @@ function updateScrollRatio(e: Event) {
5962
const scrollTop = target.scrollTop;
6063
const scrollHeight = target.scrollHeight - target.clientHeight;
6164
const ratio = scrollHeight === 0 ? 0 : scrollTop / scrollHeight;
62-
if (currentTab.value) {
63-
currentTab.value.scrollRatio = ratio;
64-
}
65+
props.tab.scrollRatio = ratio;
6566
});
6667
}
6768
@@ -98,7 +99,7 @@ function emitOutlineUpdate() {
9899
}
99100
outlineTimer = setTimeout(() => {
100101
outlineTimer = null;
101-
if (!editor) return;
102+
if (!editor || !props.isActive) return;
102103
103104
const doc = editor.getDoc();
104105
const headings: Array<{ level: number; text: string; id: string; pos: number }> = [];
@@ -126,20 +127,20 @@ function emitOutlineUpdate() {
126127
}, 150);
127128
}
128129
129-
onMounted(async () => {
130+
function createEditorInstance() {
130131
if (!containerRef.value) return;
131132
132-
await nextTick();
133-
134133
// 设置全局文件路径供插件使用
135-
(window as any).__currentFilePath = currentTab.value?.filePath || null;
134+
if (props.isActive) {
135+
(window as any).__currentFilePath = props.tab.filePath || null;
136+
}
136137
137138
// 预处理内容
138-
const contentForRendering = preprocessContent(props.modelValue);
139+
const contentForRendering = preprocessContent(props.tab.content);
139140
140141
const config: MilkupConfig = {
141142
content: contentForRendering,
142-
readonly: props.readOnly,
143+
readonly: props.tab.readOnly,
143144
sourceView: false,
144145
placeholder: "写点什么吧...",
145146
pasteConfig: {
@@ -210,65 +211,88 @@ onMounted(async () => {
210211
if (isSourceViewToggling) return;
211212
const restoredMarkdown = postprocessContent(markdown);
212213
lastEmittedValue.value = restoredMarkdown;
213-
emit("update:modelValue", restoredMarkdown);
214+
215+
// 直接写入 tab 对象
216+
const tab = props.tab;
217+
tab.content = restoredMarkdown;
218+
219+
if (tab.readOnly) {
220+
tab.isModified = false;
221+
return;
222+
}
223+
224+
// 刚加载的 tab,吸收编辑器归一化产生的变化
225+
if (tab.isNewlyLoaded) {
226+
tab.originalContent = restoredMarkdown;
227+
tab.isModified = false;
228+
// 归一化每步都可能触发 change,重置定时器等待全部完成
229+
scheduleNewlyLoadedCleanup();
230+
return;
231+
}
232+
233+
tab.isModified = restoredMarkdown !== tab.originalContent;
214234
emitOutlineUpdate();
215235
});
216236
217237
// 监听选区变更
218238
editor.on("selectionChange", (data: { from: number; to: number }) => {
219-
if (currentTab.value) {
220-
currentTab.value.milkdownCursorOffset = data.from;
221-
// 计算源码偏移量
222-
const markdown = editor?.getMarkdown() || "";
223-
currentTab.value.codeMirrorCursorOffset =
224-
markdown.length > 0 ? Math.min(data.from, markdown.length) : 0;
225-
}
239+
props.tab.milkdownCursorOffset = data.from;
240+
// 计算源码偏移量
241+
const markdown = editor?.getMarkdown() || "";
242+
props.tab.codeMirrorCursorOffset =
243+
markdown.length > 0 ? Math.min(data.from, markdown.length) : 0;
226244
});
227245
228-
// 初始化大纲
229-
emitOutlineUpdate();
246+
// 初始化大纲(仅活跃编辑器)
247+
if (props.isActive) {
248+
emitOutlineUpdate();
249+
}
230250
231251
// 恢复光标位置
232-
if (currentTab.value?.milkdownCursorOffset) {
233-
editor.setCursorOffset(currentTab.value.milkdownCursorOffset);
252+
if (props.tab.milkdownCursorOffset) {
253+
editor.setCursorOffset(props.tab.milkdownCursorOffset);
234254
}
235255
236256
// 恢复滚动位置
237257
nextTick(() => {
238-
if (scrollViewRef.value && currentTab.value) {
239-
const scrollRatio = currentTab.value.scrollRatio ?? 0;
258+
if (scrollViewRef.value) {
259+
const scrollRatio = props.tab.scrollRatio ?? 0;
240260
const targetScrollTop =
241261
scrollRatio * (scrollViewRef.value.scrollHeight - scrollViewRef.value.clientHeight);
242262
scrollViewRef.value.scrollTop = targetScrollTop;
243263
}
244264
});
265+
}
266+
267+
onMounted(async () => {
268+
if (!containerRef.value) return;
269+
await nextTick();
270+
createEditorInstance();
245271
});
246272
247273
onUnmounted(() => {
248274
editor?.destroy();
249275
editor = null;
250-
// 移除事件监听
276+
if (newlyLoadedTimer) clearTimeout(newlyLoadedTimer);
277+
if (outlineTimer) clearTimeout(outlineTimer);
251278
emitter.off("sourceView:toggle", handleSourceViewToggle);
252279
emitter.off("outline:scrollTo", handleOutlineScrollTo);
280+
emitter.off("editor:reload", handleEditorReload);
253281
});
254282
255-
// 处理源码模式切换事件
283+
// 处理源码模式切换事件(仅活跃编辑器响应)
256284
function handleSourceViewToggle() {
257-
if (editor) {
258-
isSourceViewToggling = true;
259-
editor.toggleSourceView();
260-
isSourceViewToggling = false;
261-
// 通知状态变化
262-
emitter.emit("sourceView:changed", editor.isSourceViewEnabled());
263-
}
285+
if (!props.isActive || !editor) return;
286+
isSourceViewToggling = true;
287+
editor.toggleSourceView();
288+
isSourceViewToggling = false;
289+
emitter.emit("sourceView:changed", editor.isSourceViewEnabled());
264290
}
265-
266-
// 监听源码模式切换事件
267291
emitter.on("sourceView:toggle", handleSourceViewToggle);
268292
269-
// 处理大纲点击滚动
293+
// 处理大纲点击滚动(仅活跃编辑器响应)
270294
function handleOutlineScrollTo(pos: unknown) {
271-
if (!editor || typeof pos !== "number") return;
295+
if (!props.isActive || !editor || typeof pos !== "number") return;
272296
const view = editor.view;
273297
const dom = view.domAtPos(pos + 1);
274298
if (dom.node) {
@@ -278,30 +302,40 @@ function handleOutlineScrollTo(pos: unknown) {
278302
}
279303
emitter.on("outline:scrollTo", handleOutlineScrollTo);
280304
281-
// 监听 modelValue 变化
305+
// 处理编辑器重载事件(仅活跃编辑器响应)
306+
function handleEditorReload() {
307+
if (!props.isActive || !containerRef.value) return;
308+
editor?.destroy();
309+
editor = null;
310+
// 清空容器
311+
if (containerRef.value) {
312+
containerRef.value.innerHTML = "";
313+
}
314+
createEditorInstance();
315+
}
316+
emitter.on("editor:reload", handleEditorReload);
317+
318+
// 监听 tab.content 变化(处理外部内容更新,如文件 watcher、useFile 打开文件等)
282319
watch(
283-
() => props.modelValue,
320+
() => props.tab.content,
284321
(newValue) => {
285322
if (newValue === lastEmittedValue.value) {
286323
return;
287324
}
288325
if (editor && newValue !== undefined) {
289326
requestAnimationFrame(() => {
290327
// 更新全局文件路径
291-
(window as any).__currentFilePath = currentTab.value?.filePath || null;
328+
if (props.isActive) {
329+
(window as any).__currentFilePath = props.tab.filePath || null;
330+
}
292331
293332
const contentForRendering = preprocessContent(newValue);
294333
editor?.setMarkdown(contentForRendering);
295-
// 注意:不要在 setMarkdown 之后覆盖 lastEmittedValue。
296-
// setMarkdown 会同步触发 change 事件,change handler 已经将
297-
// lastEmittedValue 设置为序列化后的值。如果这里再覆盖为 newValue,
298-
// 当序列化结果与 newValue 不同时(如引用块归一化),会导致 watch
299-
// 守卫失效,触发无限循环。
300334
301335
// 恢复滚动位置
302336
nextTick(() => {
303-
if (scrollViewRef.value && currentTab.value) {
304-
const scrollRatio = currentTab.value.scrollRatio ?? 0;
337+
if (scrollViewRef.value) {
338+
const scrollRatio = props.tab.scrollRatio ?? 0;
305339
const targetScrollTop =
306340
scrollRatio * (scrollViewRef.value.scrollHeight - scrollViewRef.value.clientHeight);
307341
scrollViewRef.value.scrollTop = targetScrollTop;
@@ -312,14 +346,29 @@ watch(
312346
}
313347
);
314348
315-
// 监听 readOnly 变化
349+
// 监听 tab.readOnly 变化
316350
watch(
317-
() => props.readOnly,
351+
() => props.tab.readOnly,
318352
(newValue) => {
319353
editor?.updateConfig({ readonly: newValue });
320354
}
321355
);
322356
357+
// 监听 isActive 变化:激活时同步全局状态
358+
watch(
359+
() => props.isActive,
360+
(isActive) => {
361+
if (isActive) {
362+
// 更新全局文件路径
363+
(window as any).__currentFilePath = props.tab.filePath || null;
364+
// 发送大纲更新
365+
emitOutlineUpdate();
366+
// 通知源码模式状态
367+
emitter.emit("sourceView:changed", editor?.isSourceViewEnabled() ?? false);
368+
}
369+
}
370+
);
371+
323372
// 暴露方法
324373
defineExpose({
325374
getEditor: () => editor,

src/renderer/events/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type Events = {
2828
"update:available": { version: string; url: string; notes: string }; // Triggered when an update is available
2929
"sourceView:toggle": void; // Triggered when source view mode is toggled
3030
"sourceView:changed": boolean; // Triggered when source view mode state changes
31+
"editor:reload": void; // Triggered when active editor should reload its internal ProseMirror instance
3132
} & Record<string, unknown>;
3233

3334
const emitter = mitt<Events>();

0 commit comments

Comments
 (0)