22/**
33 * Milkup 编辑器 Vue 组件
44 * 基于自研 ProseMirror 内核的即时渲染 Markdown 编辑器
5+ * 每个 tab 拥有独立的编辑器实例(v-for + v-show 模式)
56 */
7+ import type { Tab } from " @/types/tab" ;
68import { ref , onMounted , onUnmounted , watch , nextTick } from " vue" ;
79import {
810 MilkupEditor ,
@@ -16,24 +18,15 @@ import { AIService } from "@/renderer/services/ai";
1618import { useAIConfig } from " @/renderer/hooks/useAIConfig" ;
1719import { useConfig } from " @/renderer/hooks/useConfig" ;
1820import emitter from " @/renderer/events" ;
19- import useTab from " @/renderer/hooks/useTab" ;
2021import " @/core/styles/milkup.css" ;
2122
2223interface 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 ();
3730const { config : aiConfig, isEnabled : aiEnabled } = useAIConfig ();
3831const { config : appConfig, watchConf } = useConfig ();
3932
@@ -49,6 +42,16 @@ let editor: MilkupEditor | null = null;
4942const lastEmittedValue = ref <string | null >(null );
5043let 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 节流)
5356let scrollRafId: number | null = null ;
5457function 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
247273onUnmounted (() => {
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+ // 处理源码模式切换事件(仅活跃编辑器响应)
256284function 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- // 监听源码模式切换事件
267291emitter .on (" sourceView:toggle" , handleSourceViewToggle );
268292
269- // 处理大纲点击滚动
293+ // 处理大纲点击滚动(仅活跃编辑器响应)
270294function 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}
279303emitter .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 打开文件等)
282319watch (
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 变化
316350watch (
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// 暴露方法
324373defineExpose ({
325374 getEditor : () => editor ,
0 commit comments