From 9ede722682d5785b6dadd816301a58c072cb2684 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 10 Apr 2026 17:05:31 +0800 Subject: [PATCH] Fix markdown editor preview mode and dirty close behavior --- .../content-canvas/editor-area/EditorArea.tsx | 8 +- .../content-canvas/hooks/useTabLifecycle.ts | 40 +++-- .../mission-control/MissionControl.tsx | 4 +- src/web-ui/src/locales/en-US/tools.json | 3 +- src/web-ui/src/locales/zh-CN/tools.json | 3 +- .../editor/components/MarkdownEditor.scss | 4 +- .../editor/components/MarkdownEditor.tsx | 32 +++- .../editor/meditor/components/MEditor.tsx | 39 ++++- .../meditor/components/TiptapEditor.tsx | 151 +++++++++++++++++- 9 files changed, 242 insertions(+), 42 deletions(-) diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx index 2fc751ea..cb4ac2f3 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx @@ -66,16 +66,16 @@ export const EditorArea: React.FC = ({ const handleTabClose = useCallback((groupId: EditorGroupId) => async (tabId: string) => { if (onTabCloseWithDirtyCheck) { - const shouldClose = await onTabCloseWithDirtyCheck(tabId, groupId); - if (!shouldClose) return; + await onTabCloseWithDirtyCheck(tabId, groupId); + return; } closeTab(tabId, groupId); }, [closeTab, onTabCloseWithDirtyCheck]); const handleCloseAllTabs = useCallback((groupId: EditorGroupId) => async () => { if (onTabCloseAllWithDirtyCheck) { - const shouldClose = await onTabCloseAllWithDirtyCheck(groupId); - if (!shouldClose) return; + await onTabCloseAllWithDirtyCheck(groupId); + return; } closeAllTabs(groupId); }, [closeAllTabs, onTabCloseAllWithDirtyCheck]); diff --git a/src/web-ui/src/app/components/panels/content-canvas/hooks/useTabLifecycle.ts b/src/web-ui/src/app/components/panels/content-canvas/hooks/useTabLifecycle.ts index 6491d91c..90cc7e36 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/hooks/useTabLifecycle.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/hooks/useTabLifecycle.ts @@ -14,6 +14,7 @@ import type { EditorGroupId, PanelContent, CreateTabEventDetail } from '../types import { TAB_EVENTS } from '../types'; import { useI18n } from '@/infrastructure/i18n'; import { drainPendingTabs } from '@/shared/services/pendingTabQueue'; +import { confirmDialog } from '@/component-library/components/ConfirmDialog/confirmService'; interface UseTabLifecycleOptions { /** App mode / target canvas */ mode?: 'agent' | 'project' | 'git'; @@ -45,6 +46,12 @@ interface UseTabLifecycleReturn { export const useTabLifecycle = (options: UseTabLifecycleOptions = {}): UseTabLifecycleReturn => { const { mode = 'agent' } = options; const { t } = useI18n('components'); + const canvasStoreApi = + mode === 'project' + ? useProjectCanvasStore + : mode === 'git' + ? useGitCanvasStore + : useAgentCanvasStore; const { addTab, @@ -55,8 +62,6 @@ export const useTabLifecycle = (options: UseTabLifecycleOptions = {}): UseTabLif updateTabContent, closeTab, closeAllTabs, - primaryGroup, - secondaryGroup, activeGroupId, layout, setSplitMode, @@ -123,7 +128,8 @@ export const useTabLifecycle = (options: UseTabLifecycleOptions = {}): UseTabLif * Dirty check before closing a tab. */ const handleCloseWithDirtyCheck = useCallback(async (tabId: string, groupId: EditorGroupId): Promise => { - const group = groupId === 'primary' ? primaryGroup : secondaryGroup; + const { primaryGroup: latestPrimaryGroup, secondaryGroup: latestSecondaryGroup } = canvasStoreApi.getState(); + const group = groupId === 'primary' ? latestPrimaryGroup : latestSecondaryGroup; const tab = group.tabs.find(t => t.id === tabId); if (!tab) { @@ -131,10 +137,12 @@ export const useTabLifecycle = (options: UseTabLifecycleOptions = {}): UseTabLif } if (tab.isDirty) { - // Show confirmation and ensure correct return handling - const result = window.confirm( - t('tabs.confirmCloseWithDirty', { title: tab.title }) - ); + const result = await confirmDialog({ + title: t('tabs.unsaved'), + message: t('tabs.confirmCloseWithDirty', { title: tab.title }), + type: 'warning', + confirmDanger: true, + }); if (!result) { return false; @@ -143,13 +151,14 @@ export const useTabLifecycle = (options: UseTabLifecycleOptions = {}): UseTabLif closeTab(tabId, groupId); return true; - }, [primaryGroup, secondaryGroup, closeTab, t]); + }, [canvasStoreApi, closeTab, t]); /** * Dirty check before closing all tabs. */ const handleCloseAllWithDirtyCheck = useCallback(async (groupId: EditorGroupId): Promise => { - const group = groupId === 'primary' ? primaryGroup : secondaryGroup; + const { primaryGroup: latestPrimaryGroup, secondaryGroup: latestSecondaryGroup } = canvasStoreApi.getState(); + const group = groupId === 'primary' ? latestPrimaryGroup : latestSecondaryGroup; const dirtyTabs = group.tabs.filter(t => t.isDirty); if (dirtyTabs.length === 0) { @@ -157,11 +166,14 @@ export const useTabLifecycle = (options: UseTabLifecycleOptions = {}): UseTabLif return true; } - // Show confirmation with list of dirty files const fileList = dirtyTabs.map(t => ` - ${t.title}`).join('\n'); - const result = window.confirm( - t('tabs.confirmCloseAllWithDirty', { count: dirtyTabs.length, fileList }) - ); + const result = await confirmDialog({ + title: t('tabs.unsaved'), + message: t('tabs.confirmCloseAllWithDirty', { count: dirtyTabs.length, fileList }), + type: 'warning', + confirmDanger: true, + preview: fileList, + }); if (!result) { return false; @@ -169,7 +181,7 @@ export const useTabLifecycle = (options: UseTabLifecycleOptions = {}): UseTabLif closeAllTabs(groupId); return true; - }, [primaryGroup, secondaryGroup, closeAllTabs, t]); + }, [canvasStoreApi, closeAllTabs, t]); /** * Listen for left-panel terminal close events to sync right-panel tabs. diff --git a/src/web-ui/src/app/components/panels/content-canvas/mission-control/MissionControl.tsx b/src/web-ui/src/app/components/panels/content-canvas/mission-control/MissionControl.tsx index a748948f..a311d434 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/mission-control/MissionControl.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/mission-control/MissionControl.tsx @@ -129,8 +129,8 @@ export const MissionControl: React.FC = ({ // Handle tab close const handleTabClose = useCallback(async (tabId: string, groupId: EditorGroupId) => { if (handleCloseWithDirtyCheck) { - const shouldClose = await handleCloseWithDirtyCheck(tabId, groupId); - if (!shouldClose) return; + await handleCloseWithDirtyCheck(tabId, groupId); + return; } closeTab(tabId, groupId); }, [closeTab, handleCloseWithDirtyCheck]); diff --git a/src/web-ui/src/locales/en-US/tools.json b/src/web-ui/src/locales/en-US/tools.json index 6907b815..fa2257d0 100644 --- a/src/web-ui/src/locales/en-US/tools.json +++ b/src/web-ui/src/locales/en-US/tools.json @@ -70,8 +70,9 @@ "loadingFile": "Loading file...", "placeholder": "Start writing Markdown...", "source": "Source", + "markdown": "Markdown", "preview": "Preview", - "viewModeLabel": "Markdown source and preview mode", + "viewModeLabel": "Markdown and preview mode", "notice": { "sourcePreviewFallback": "This document contains HTML fragments that are not safely editable in visual mode. Edit in source mode or switch to preview." } diff --git a/src/web-ui/src/locales/zh-CN/tools.json b/src/web-ui/src/locales/zh-CN/tools.json index e8f7096f..3688718c 100644 --- a/src/web-ui/src/locales/zh-CN/tools.json +++ b/src/web-ui/src/locales/zh-CN/tools.json @@ -70,8 +70,9 @@ "loadingFile": "正在加载文件...", "placeholder": "开始编写 Markdown 内容...", "source": "源码", + "markdown": "Markdown", "preview": "预览", - "viewModeLabel": "Markdown 源码与预览模式", + "viewModeLabel": "Markdown 与预览模式", "notice": { "sourcePreviewFallback": "该文档包含无法在可视化模式中安全编辑的 HTML 片段。请在源码模式中编辑,或切换到预览查看效果。" } diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.scss b/src/web-ui/src/tools/editor/components/MarkdownEditor.scss index ce3594dd..9f6c115e 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.scss +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.scss @@ -41,7 +41,7 @@ } } -.bitfun-markdown-editor__unsafe-toolbar { +.bitfun-markdown-editor__mode-toolbar { display: flex; justify-content: flex-end; padding: $size-gap-3 $size-gap-4; @@ -50,7 +50,7 @@ flex-shrink: 0; } -.bitfun-markdown-editor__unsafe-toggle { +.bitfun-markdown-editor__mode-toggle { display: inline-flex; align-items: center; gap: $size-gap-2; diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx index 41485c87..12a9a516 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx @@ -89,6 +89,7 @@ const MarkdownEditor: React.FC = ({ const { isLight } = useTheme(); const [content, setContent] = useState(initialContent); const [hasChanges, setHasChanges] = useState(false); + const [viewMode, setViewMode] = useState<'preview' | 'markdown'>('preview'); const [unsafeViewMode, setUnsafeViewMode] = useState<'source' | 'preview'>('source'); const [loading, setLoading] = useState(!!filePath); const [error, setError] = useState(null); @@ -162,6 +163,7 @@ const MarkdownEditor: React.FC = ({ }, []); useEffect(() => { + setViewMode('preview'); setUnsafeViewMode('source'); }, [filePath, initialContent]); @@ -630,8 +632,8 @@ const MarkdownEditor: React.FC = ({ )} -
-
+
+
)} +
+
+ + +
+
((props, ref) => const placeholder = placeholderProp ?? t('editor.meditor.placeholder') const containerRef = useRef(null) const textareaTargetIdRef = useRef(`markdown-textarea-${++markdownTextareaTargetCounter}`) + const initialEditorValue = controlledValue ?? defaultValue + const savedValueRef = useRef(initialEditorValue) + const currentValueRef = useRef(initialEditorValue) const { value, @@ -82,6 +85,10 @@ export const MEditor = forwardRef((props, ref) => ? (readonly ? 'preview' : 'split') : mode + useEffect(() => { + currentValueRef.current = value + }, [value]) + useEffect(() => { if (effectiveMode === 'ir' || effectiveMode === 'preview') { return @@ -115,9 +122,11 @@ export const MEditor = forwardRef((props, ref) => useEffect(() => { if (controlledValue !== undefined && controlledValue !== value) { - setValue(controlledValue) + currentValueRef.current = controlledValue + editorInstance.setValue(controlledValue) + onDirtyChange?.(controlledValue !== savedValueRef.current) } - }, [controlledValue, value, setValue]) + }, [controlledValue, editorInstance, onDirtyChange, value]) useEffect(() => { if (initialMode) { @@ -131,6 +140,12 @@ export const MEditor = forwardRef((props, ref) => } }, [initialTheme, setTheme]) + const handleEditorChange = useCallback((nextValue: string) => { + currentValueRef.current = nextValue + setValue(nextValue) + onDirtyChange?.(nextValue !== savedValueRef.current) + }, [onDirtyChange, setValue]) + useImperativeHandle(ref, () => ({ ...editorInstance, scrollToLine: (line: number, highlight?: boolean) => { @@ -172,19 +187,29 @@ export const MEditor = forwardRef((props, ref) => if (effectiveMode === 'ir' && tiptapEditorRef.current) { tiptapEditorRef.current.markSaved() } + savedValueRef.current = currentValueRef.current + onDirtyChange?.(false) }, setInitialContent: (content: string) => { if (effectiveMode === 'ir' && tiptapEditorRef.current) { tiptapEditorRef.current.setInitialContent(content) + currentValueRef.current = content + savedValueRef.current = content + onDirtyChange?.(false) + return } + currentValueRef.current = content + savedValueRef.current = content + editorInstance.setValue(content) + onDirtyChange?.(false) }, get isDirty() { if (effectiveMode === 'ir' && tiptapEditorRef.current) { return tiptapEditorRef.current.isDirty } - return false + return currentValueRef.current !== savedValueRef.current } - }), [editorInstance, effectiveMode, textareaRef]) + }), [editorInstance, effectiveMode, onDirtyChange, textareaRef]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { @@ -249,7 +274,7 @@ export const MEditor = forwardRef((props, ref) => ((props, ref) => ((props, ref) => { + if (child.isText) { + if ((child.text ?? '').trim().length > 0) { + hasMeaningfulContent = true; + return false; + } + return; + } + + if (child.isInline) { + hasMeaningfulContent = true; + return false; + } + }); + + return !hasMeaningfulContent; +} + +function isEffectivelyEmptyMarkdownTable(node: ProseMirrorNode): boolean { + if (node.type.name !== 'markdownTable') { + return false; + } + + let hasCells = false; + let hasMeaningfulContent = false; + + node.descendants((child) => { + if (!isMarkdownTableCellNode(child)) { + return; + } + + hasCells = true; + if (!isEffectivelyEmptyMarkdownTableCell(child)) { + hasMeaningfulContent = true; + return false; + } + }); + + return hasCells && !hasMeaningfulContent; +} + +function deleteEmptyMarkdownTableAtSelection(instance: TiptapEditorInstance): boolean { + const { selection } = instance.state; + if (!selection.empty) { + return false; + } + + const { $from } = selection; + let cellDepth = -1; + + for (let depth = $from.depth; depth > 0; depth -= 1) { + if (isMarkdownTableCellNode($from.node(depth))) { + cellDepth = depth; + break; + } + } + + if (cellDepth < 0) { + return false; + } + + const cellNode = $from.node(cellDepth); + if ($from.parentOffset !== 0 || !isEffectivelyEmptyMarkdownTableCell(cellNode)) { + return false; + } + + let tableDepth = -1; + for (let depth = cellDepth - 1; depth >= 0; depth -= 1) { + if ($from.node(depth).type.name === 'markdownTable') { + tableDepth = depth; + break; + } + } + + if (tableDepth < 0) { + return false; + } + + const tableNode = $from.node(tableDepth); + if (!isEffectivelyEmptyMarkdownTable(tableNode)) { + return false; + } + + const tablePos = $from.before(tableDepth); + const tr = instance.state.tr.deleteRange(tablePos, tablePos + tableNode.nodeSize); + + if (tr.doc.childCount === 0) { + const paragraph = tr.doc.type.schema.nodes.paragraph?.create(); + if (paragraph) { + tr.insert(0, paragraph); + } + } + + const nextSelectionPos = Math.min(tablePos, tr.doc.content.size); + tr.setSelection(Selection.near(tr.doc.resolve(nextSelectionPos), nextSelectionPos > 0 ? -1 : 1)); + instance.view.dispatch(tr.scrollIntoView()); + return true; +} + +function replaceEditorContentWithoutHistory( + instance: TiptapEditorInstance, + markdown: string, +): void { + instance + .chain() + .setMeta('addToHistory', false) + .setContent(markdownToTiptapDoc(markdown), { + emitUpdate: false, + }) + .run(); +} + export const TiptapEditor = React.forwardRef(({ value, onChange, @@ -493,11 +622,23 @@ export const TiptapEditor = React.forwardRef