From 5f8ed95f1f0961d86f11ad3c71c0139a955ca9fe Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Sat, 5 Sep 2026 18:15:36 +0800 Subject: [PATCH 1/2] fix(editor): restore document editing and source-preserving undo --- .../src/infrastructure/markdown/Markdown.scss | 20 +- src/web-ui/src/locales/en-US/tools.json | 17 +- src/web-ui/src/locales/zh-CN/tools.json | 17 +- src/web-ui/src/locales/zh-TW/tools.json | 17 +- src/web-ui/src/tools/editor/AGENTS.md | 67 ++ .../components/MarkdownEditor.appearance.ts | 2 +- .../editor/components/MarkdownEditor.test.tsx | 11 +- .../editor/components/MarkdownEditor.tsx | 161 +---- .../editor/meditor/components/EditArea.tsx | 2 +- .../meditor/components/MEditor.test.tsx | 314 ++++++++- .../editor/meditor/components/MEditor.tsx | 37 +- .../meditor/components/TiptapEditor.scss | 651 ++++-------------- .../meditor/components/TiptapEditor.tsx | 169 ++--- .../extensions/MarkdownImageExtension.ts | 141 +++- .../extensions/MarkdownTableExtensions.ts | 3 +- .../meditor/extensions/RawHtmlExtensions.ts | 240 +++++-- .../tools/editor/meditor/hooks/useEditor.ts | 5 +- .../meditor/utils/embeddedSource.test.ts | 21 + .../editor/meditor/utils/embeddedSource.ts | 34 + .../meditor/utils/tiptapMarkdown.test.ts | 46 +- .../editor/meditor/utils/tiptapMarkdown.ts | 93 ++- src/web-ui/tests/e2e/image.svg | 1 + src/web-ui/tests/e2e/markdown-editor.html | 5 + src/web-ui/tests/e2e/markdown-editor.tsx | 41 ++ tests/e2e/AGENTS.md | 15 + tests/e2e/browser/MarkdownEditorPage.ts | 29 + tests/e2e/browser/markdown-editor.spec.ts | 191 +++++ tests/e2e/browser/markdown-fixture.md | 50 ++ tests/e2e/config/wdio.markdown-browser.ts | 70 ++ tests/e2e/config/wdio.markdown-native.ts | 33 + tests/e2e/specs/markdown-native.spec.ts | 181 +++++ 31 files changed, 1793 insertions(+), 891 deletions(-) create mode 100644 src/web-ui/src/tools/editor/AGENTS.md create mode 100644 src/web-ui/src/tools/editor/meditor/utils/embeddedSource.test.ts create mode 100644 src/web-ui/src/tools/editor/meditor/utils/embeddedSource.ts create mode 100644 src/web-ui/tests/e2e/image.svg create mode 100644 src/web-ui/tests/e2e/markdown-editor.html create mode 100644 src/web-ui/tests/e2e/markdown-editor.tsx create mode 100644 tests/e2e/browser/MarkdownEditorPage.ts create mode 100644 tests/e2e/browser/markdown-editor.spec.ts create mode 100644 tests/e2e/browser/markdown-fixture.md create mode 100644 tests/e2e/config/wdio.markdown-browser.ts create mode 100644 tests/e2e/config/wdio.markdown-native.ts create mode 100644 tests/e2e/specs/markdown-native.spec.ts diff --git a/src/web-ui/src/infrastructure/markdown/Markdown.scss b/src/web-ui/src/infrastructure/markdown/Markdown.scss index 431bf21e75..a929fd909c 100644 --- a/src/web-ui/src/infrastructure/markdown/Markdown.scss +++ b/src/web-ui/src/infrastructure/markdown/Markdown.scss @@ -402,7 +402,8 @@ margin-bottom: 0; } -.markdown-renderer details { +.markdown-renderer details, +.markdown-renderer [data-type='details'] { margin: calc(var(--markdown-block-gap) * 1.2) 0; padding: 0.62rem 0.72rem; border: 1px solid var(--openbitfun-color-border-subtle); @@ -421,6 +422,10 @@ margin-bottom: var(--markdown-block-gap); } +.markdown-renderer [data-type='details'] [data-type='detailsContent'] { + padding-top: var(--markdown-block-gap); +} + .markdown-renderer hr { height: 1px; margin: calc(var(--markdown-block-gap) * 1.8) 0; @@ -543,6 +548,19 @@ background: var(--markdown-table-row-hover); } +// Tiptap keeps the header in tbody; offset striping to match rendered data rows. +.markdown-renderer.ProseMirror table[data-type='markdown-table'] tbody tr:nth-child(2n) { + background: var(--markdown-table-row-base); +} + +.markdown-renderer.ProseMirror table[data-type='markdown-table'] tbody tr:nth-child(2n + 3) { + background: var(--markdown-table-row-stripe); +} + +.markdown-renderer.ProseMirror table[data-type='markdown-table'] tbody tr:hover { + background: var(--markdown-table-row-hover); +} + .markdown-renderer a, .markdown-renderer .file-link, .markdown-renderer .visualization-link, diff --git a/src/web-ui/src/locales/en-US/tools.json b/src/web-ui/src/locales/en-US/tools.json index 573ea1defa..583e114aa8 100644 --- a/src/web-ui/src/locales/en-US/tools.json +++ b/src/web-ui/src/locales/en-US/tools.json @@ -68,6 +68,19 @@ "switchToSideBySide": "Switch to side-by-side view" }, "markdownEditor": { + "blockTypes": { + "math": "Equation", + "code": "Code block", + "reference": "Link reference", + "footnote": "Footnote" + }, + "finishBlockEdit": "Done editing", + "editImage": "Edit image", + "imageAddress": "Image address", + "imageAltText": "Alternative text", + "imageTitle": "Image title", + "richText": "Document", + "editBlockSource": "Edit block source", "loadingFile": "Loading file...", "placeholder": "Start writing Markdown...", "source": "Source", @@ -75,9 +88,9 @@ "preview": "Preview", "copyMarkdown": "Copy Markdown", "copiedMarkdown": "Copied Markdown", - "viewModeLabel": "Markdown and preview mode", + "viewModeLabel": "Document and source mode", "notice": { - "sourcePreviewFallback": "This document contains syntax that is not safely editable in visual mode. Edit in source mode or switch to preview." + "sourcePreviewFallback": "Rich text could not load. You can continue editing the Markdown source." } }, "planViewer": { diff --git a/src/web-ui/src/locales/zh-CN/tools.json b/src/web-ui/src/locales/zh-CN/tools.json index 371358e7ab..b9df75e061 100644 --- a/src/web-ui/src/locales/zh-CN/tools.json +++ b/src/web-ui/src/locales/zh-CN/tools.json @@ -68,6 +68,19 @@ "switchToSideBySide": "切换到并排视图" }, "markdownEditor": { + "blockTypes": { + "math": "公式", + "code": "代码块", + "reference": "链接引用", + "footnote": "脚注" + }, + "finishBlockEdit": "完成编辑", + "editImage": "编辑图片", + "imageAddress": "图片地址", + "imageAltText": "替代文本", + "imageTitle": "图片标题", + "richText": "文档", + "editBlockSource": "编辑此块源码", "loadingFile": "正在加载文件...", "placeholder": "开始编写 Markdown 内容...", "source": "源码", @@ -75,9 +88,9 @@ "preview": "预览", "copyMarkdown": "复制 Markdown", "copiedMarkdown": "已复制 Markdown", - "viewModeLabel": "Markdown 与预览模式", + "viewModeLabel": "文档与源码模式", "notice": { - "sourcePreviewFallback": "该文档包含无法在可视化模式中安全编辑的语法。请在源码模式中编辑,或切换到预览模式查看。" + "sourcePreviewFallback": "富文本加载失败,你可以继续编辑 Markdown 源码。" } }, "planViewer": { diff --git a/src/web-ui/src/locales/zh-TW/tools.json b/src/web-ui/src/locales/zh-TW/tools.json index b65c2a7149..711eef6e4d 100644 --- a/src/web-ui/src/locales/zh-TW/tools.json +++ b/src/web-ui/src/locales/zh-TW/tools.json @@ -68,6 +68,19 @@ "switchToSideBySide": "切換到並排視圖" }, "markdownEditor": { + "blockTypes": { + "math": "公式", + "code": "程式碼區塊", + "reference": "連結參照", + "footnote": "註腳" + }, + "finishBlockEdit": "完成編輯", + "editImage": "編輯圖片", + "imageAddress": "圖片位址", + "imageAltText": "替代文字", + "imageTitle": "圖片標題", + "richText": "文件", + "editBlockSource": "編輯此區塊原始碼", "loadingFile": "正在載入檔案...", "placeholder": "開始編寫 Markdown 內容...", "source": "源碼", @@ -75,9 +88,9 @@ "preview": "預覽", "copyMarkdown": "複製 Markdown", "copiedMarkdown": "已複製 Markdown", - "viewModeLabel": "Markdown 與預覽模式", + "viewModeLabel": "文件與源碼模式", "notice": { - "sourcePreviewFallback": "該文檔包含無法在可視化模式中安全編輯的語法。請在源碼模式中編輯,或切換到預覽模式查看。" + "sourcePreviewFallback": "富文字載入失敗,你可以繼續編輯 Markdown 原始碼。" } }, "planViewer": { diff --git a/src/web-ui/src/tools/editor/AGENTS.md b/src/web-ui/src/tools/editor/AGENTS.md new file mode 100644 index 0000000000..0253d77474 --- /dev/null +++ b/src/web-ui/src/tools/editor/AGENTS.md @@ -0,0 +1,67 @@ +# Editor + +This directory follows `src/web-ui/AGENTS.md`. + +## Markdown editing + +- File editing exposes rich text (`ir`) and source (`edit`). Pure preview is + reserved for explicit viewer consumers, never an automatic compatibility mode. +- Keep ordinary blocks editable when a document contains special syntax. + Unsupported Markdown regions use source-backed embedded blocks; preserve their + original Markdown when parsing and serializing. HTML rendering uses the shared + sanitized Markdown renderer. +- Rich text reuses the existing MarkdownRenderer typography and component styles. + Preserve the pre-existing preview appearance; do not add an editor-specific + visual redesign. +- Outside local editing, embeds keep their original rendered appearance. Show + additional source-editing labels and completion controls only while that embed + is being edited. +- Embedded changes participate in the document's dirty state, save shortcuts, + undo/redo, and explicit readonly policy. Changing modes or editability must + not clear unsaved changes or emit a document edit. +- Keep imported Markdown bytes associated with their rich document state. Undo + back to that state must restore the exact source; do not hide source-formatting + edits by weakening the owner's comparison with the last saved bytes. +- File access, conflict dialogs, local image loading, and peer disk synchronization + continue through existing infrastructure adapters. + +## Focused verification + +Run from the repository root after Markdown editor changes: + +```bash +pnpm --dir src/web-ui run test:run src/tools/editor/components/MarkdownEditor.test.tsx src/tools/editor/meditor/components/MEditor.test.tsx src/tools/editor/meditor/utils/tiptapMarkdown.test.ts src/tools/editor/meditor/utils/embeddedSource.test.ts src/tools/editor/meditor/utils/markdownFrontmatter.test.ts src/tools/editor/meditor/components/Preview.test.tsx +``` + +For UI, types, and theme contracts, also follow the parent guide's `check:web` +command. Local DOM tests do not establish remote workspace, Remote Connect, +Peer Device Mode, or Detached Dispatch behavior. + +For rich-text interaction changes, run the focused browser E2E from the root: + +```bash +pnpm --dir tests/e2e exec wdio run ./config/wdio.markdown-browser.ts +``` + +This runs Chrome against the production MarkdownEditor with a test filesystem +adapter backed by a temporary file. It checks local block editing, live rendering, +keyboard completion, undo/redo, rich/source switching, readonly and save/reload. +The runner owns localhost ports 1447/1450, isolates browser state, removes its +temporary file, and writes screenshots to `tests/e2e/reports/markdown-browser`. +It does not test Tauri, relay, SSH, peer transport, or detached dispatch. + +For actual desktop integration, build the current desktop and frontend, then run: + +```bash +cargo build -p openbitfun-desktop +pnpm run build:web +pnpm --dir tests/e2e exec wdio run ./config/wdio.markdown-native.ts +``` + +The native spec opens a temporary workspace in OpenBitFun, opens its Markdown file +from the file tree, edits native and embedded blocks, saves via the production +Tauri transport, and closes/reopens the file. Packaged frontend mode prevents +accidentally testing another checkout's Vite server. The focused runner uses a +fresh application profile on every run and removes it on completion. Screenshots +go to `tests/e2e/reports/screenshots`. +This covers a local desktop workspace; remote scenarios still require live hosts. diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.appearance.ts b/src/web-ui/src/tools/editor/components/MarkdownEditor.appearance.ts index 4740ca3b2f..e4bdf9529e 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.appearance.ts +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.appearance.ts @@ -6,7 +6,7 @@ export const markdownEditorAppearanceDescriptor: AppearanceSurfaceDescriptor = { { id: 'root' }, { id: 'loading' }, { id: 'error' }, { id: 'toolbar' }, { id: 'actions' }, { id: 'body' }, ], - facets: [{ id: 'view', attribute: 'data-openbitfun-view', values: ['preview', 'markdown', 'source'] }], + facets: [{ id: 'view', attribute: 'data-openbitfun-view', values: ['preview', 'markdown', 'source', 'ir'] }], states: [ { id: 'loading', selector: { kind: 'self', suffix: '[data-openbitfun-state~="loading"]' } }, { id: 'error', selector: { kind: 'self', suffix: '[data-openbitfun-state~="error"]' } }, diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.test.tsx b/src/web-ui/src/tools/editor/components/MarkdownEditor.test.tsx index 6ae31eb26c..2270cfbef6 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.test.tsx +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.test.tsx @@ -129,19 +129,22 @@ describe('MarkdownEditor', () => { expect(html).toContain('data-component="icon-button"'); }); - it('uses preview mode for markdown rendering', () => { + it('opens Mermaid documents in rich text mode', () => { const html = renderToStaticMarkup( , ); - expect(html).toContain('data-mode="preview"'); + expect(html).toContain('data-mode="ir"'); }); - it('does not show the IR fallback warning in the preview/source file UI', () => { + it('offers only rich text and source modes', () => { const html = renderToStaticMarkup( , ); - expect(html).not.toContain('IR fallback warning'); + expect(html).toContain('editor.markdownEditor.richText'); + expect(html).toContain('editor.markdownEditor.source'); + expect(html).not.toContain('editor.markdownEditor.preview'); + expect(html.match(/role="radio"/g)).toHaveLength(2); }); }); diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx index 0485ec523b..e811fb57d1 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx @@ -9,7 +9,6 @@ import { Button, Icon, IconButton, SegmentedControl } from '@openbitfun/ui'; import React, { useEffect, useState, useCallback, useRef } from 'react'; import { MEditor } from '../meditor'; import type { EditorInstance } from '../meditor'; -import { analyzeMarkdownEditability, type MarkdownEditabilityAnalysis } from '../meditor/utils/tiptapMarkdown'; import { AlertCircle } from 'lucide-react'; import { createLogger } from '@/shared/utils/logger'; import { sendDebugProbe } from '@/shared/utils/debugProbe'; @@ -22,7 +21,6 @@ import { } from '@/infrastructure/peer-device/peerModeFlag'; import { LoadingState } from '@openbitfun/ui'; import { useI18n } from '@/infrastructure/i18n'; -import CodeEditor from './CodeEditor'; import { diskVersionFromMetadata, diskVersionsDiffer, @@ -81,7 +79,6 @@ const MarkdownEditor: React.FC = ({ filePath, initialContent = '', workspacePath, - fileName, readOnly = false, className = '', onContentChange, @@ -94,12 +91,10 @@ const MarkdownEditor: React.FC = ({ const { t } = useI18n('tools'); 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 [viewMode, setViewMode] = useState<'ir' | 'source'>('ir'); const [loading, setLoading] = useState(!!filePath); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); - const [editability, setEditability] = useState(() => analyzeMarkdownEditability(initialContent)); const editorRef = useRef(null); const isUnmountedRef = useRef(false); const diskVersionRef = useRef(null); @@ -109,8 +104,6 @@ const MarkdownEditor: React.FC = ({ const onContentChangeRef = useRef(onContentChange); const contentRef = useRef(content); const lastReportedDirtyRef = useRef(null); - const unsafeViewModeRef = useRef(unsafeViewMode); - unsafeViewModeRef.current = unsafeViewMode; const lastReportedMissingRef = useRef(undefined); const reportFileMissingFromDisk = useCallback( @@ -118,21 +111,13 @@ const MarkdownEditor: React.FC = ({ if (!onFileMissingFromDiskChange) { return; } - const isUnsafeSplit = - !!filePath && - (editability.mode === 'unsafe' || - editability.containsRenderOnlyBlocks || - editability.containsRawHtmlInlines); - if (isUnsafeSplit && unsafeViewModeRef.current === 'source') { - return; - } if (lastReportedMissingRef.current === missing) { return; } lastReportedMissingRef.current = missing; onFileMissingFromDiskChange(missing); }, - [editability.containsRawHtmlInlines, editability.containsRenderOnlyBlocks, editability.mode, filePath, onFileMissingFromDiskChange] + [onFileMissingFromDiskChange] ); onContentChangeRef.current = onContentChange; @@ -142,13 +127,6 @@ const MarkdownEditor: React.FC = ({ hasChangesRef.current = hasChanges; }, [hasChanges]); - const toNormalizedMarkdown = useCallback((raw: string) => { - const nextEditability = analyzeMarkdownEditability(raw); - const nextContent = - nextEditability.mode === 'unsafe' ? raw : nextEditability.canonicalMarkdown; - return { nextEditability, nextContent }; - }, []); - const basePath = React.useMemo(() => { if (!filePath) return undefined; const normalizedPath = filePath.replace(/\\/g, '/'); @@ -169,8 +147,7 @@ const MarkdownEditor: React.FC = ({ }, []); useEffect(() => { - setViewMode('preview'); - setUnsafeViewMode('source'); + setViewMode('ir'); }, [filePath, initialContent]); const fetchFileMetadata = useCallback(async () => { @@ -212,9 +189,8 @@ const MarkdownEditor: React.FC = ({ } if (!isUnmountedRef.current) { - const { nextEditability, nextContent } = toNormalizedMarkdown(fileContent); + const nextContent = fileContent; - setEditability(nextEditability); setContent(nextContent); setHasChanges(false); lastReportedDirtyRef.current = false; @@ -245,7 +221,7 @@ const MarkdownEditor: React.FC = ({ setLoading(false); } } - }, [fetchFileMetadata, filePath, reportFileMissingFromDisk, t, toNormalizedMarkdown]); + }, [fetchFileMetadata, filePath, reportFileMissingFromDisk, t]); // Initial file load - only run once when filePath changes const loadFileContentCalledRef = useRef(false); @@ -262,12 +238,8 @@ const MarkdownEditor: React.FC = ({ loadFileContent(); } } else if (initialContent !== undefined) { - const nextEditability = analyzeMarkdownEditability(initialContent); - const nextContent = nextEditability.mode === 'unsafe' - ? initialContent - : nextEditability.canonicalMarkdown; + const nextContent = initialContent; - setEditability(nextEditability); setContent(nextContent); setHasChanges(false); lastReportedDirtyRef.current = false; @@ -328,7 +300,7 @@ const MarkdownEditor: React.FC = ({ outcome = 'editor-changed-before-read'; return; } - const { nextEditability, nextContent } = toNormalizedMarkdown(raw); + const nextContent = raw; if (nextContent === contentRef.current) { diskVersionRef.current = currentVersion; outcome = 'content-match'; @@ -352,7 +324,6 @@ const MarkdownEditor: React.FC = ({ } if (!isUnmountedRef.current) { - setEditability(nextEditability); setContent(nextContent); contentRef.current = nextContent; setHasChanges(false); @@ -397,21 +368,14 @@ const MarkdownEditor: React.FC = ({ } isCheckingDiskRef.current = false; } - }, [fetchFileMetadata, filePath, isActiveTab, reportFileMissingFromDisk, t, toNormalizedMarkdown]); + }, [fetchFileMetadata, filePath, isActiveTab, reportFileMissingFromDisk, t]); const checkMarkdownDisk = useCallback(async () => { await syncMarkdownFromDisk('poll'); }, [syncMarkdownFromDisk]); - const isUnsafeSplitUi = - !!filePath && - (editability.mode === 'unsafe' || - editability.containsRenderOnlyBlocks || - editability.containsRawHtmlInlines); - const pollMarkdownDisk = !isUnsafeSplitUi || unsafeViewMode !== 'source'; - useEffect(() => { - if (!filePath || !isActiveTab || !pollMarkdownDisk) { + if (!filePath || !isActiveTab) { return; } const tick = () => { @@ -443,10 +407,10 @@ const MarkdownEditor: React.FC = ({ } window.removeEventListener('peer-mode:changed', onPeerModeChanged); }; - }, [checkMarkdownDisk, filePath, isActiveTab, pollMarkdownDisk]); + }, [checkMarkdownDisk, filePath, isActiveTab]); useEffect(() => { - if (!filePath || !pollMarkdownDisk) { + if (!filePath) { return; } @@ -456,7 +420,7 @@ const MarkdownEditor: React.FC = ({ } void syncMarkdownFromDisk('event'); }); - }, [filePath, pollMarkdownDisk, syncMarkdownFromDisk]); + }, [filePath, syncMarkdownFromDisk]); const saveFileContent = useCallback(async () => { if (!hasChanges || isUnmountedRef.current) return; @@ -487,9 +451,8 @@ const MarkdownEditor: React.FC = ({ }); if (!overwrite) { const raw = await workspaceAPI.readFileContent(filePath); - const { nextEditability, nextContent } = toNormalizedMarkdown(raw); + const nextContent = raw; if (!isUnmountedRef.current) { - setEditability(nextEditability); setContent(nextContent); contentRef.current = nextContent; setHasChanges(false); @@ -553,7 +516,7 @@ const MarkdownEditor: React.FC = ({ setError(t('editor.common.saveFailedWithMessage', { message: errorMessage })); } } - }, [content, fetchFileMetadata, filePath, hasChanges, onSave, reportFileMissingFromDisk, t, toNormalizedMarkdown, workspacePath]); + }, [content, fetchFileMetadata, filePath, hasChanges, onSave, reportFileMissingFromDisk, t, workspacePath]); const handleContentChange = useCallback((newContent: string) => { contentRef.current = newContent; @@ -618,12 +581,6 @@ const MarkdownEditor: React.FC = ({ return () => clearTimeout(timer); }, [jumpToLine, jumpToColumn, filePath, loading, content]); - const shouldUseSourcePreviewFallback = !!filePath && ( - editability.mode === 'unsafe' || - editability.containsRenderOnlyBlocks || - editability.containsRawHtmlInlines - ); - if (loading) { return (
@@ -648,88 +605,6 @@ const MarkdownEditor: React.FC = ({ ); } - if (shouldUseSourcePreviewFallback) { - return ( -
-
- setUnsafeViewMode(value as 'source' | 'preview')} - /> -
- void handleCopyMarkdown()} - aria-label={copied - ? t('editor.markdownEditor.copiedMarkdown') - : t('editor.markdownEditor.copyMarkdown')} - icon={copied ? : } - title={copied - ? t('editor.markdownEditor.copiedMarkdown') - : t('editor.markdownEditor.copyMarkdown')} - /> -
-
-
- {unsafeViewMode === 'source' ? ( - { - contentRef.current = newContent; - setContent(newContent); - setHasChanges(dirty); - if (lastReportedDirtyRef.current === dirty) { - return; - } - - lastReportedDirtyRef.current = dirty; - onContentChangeRef.current?.(newContent, dirty); - }} - onSave={(_savedContent) => { - setHasChanges(false); - lastReportedDirtyRef.current = false; - onContentChangeRef.current?.(contentRef.current, false); - }} - /> - ) : ( - - )} -
-
- ); - } - return (
@@ -737,11 +612,11 @@ const MarkdownEditor: React.FC = ({ className="openbitfun-markdown-editor__mode-toggle" aria-label={t('editor.markdownEditor.viewModeLabel')} options={[ - { value: 'preview', label: t('editor.markdownEditor.preview') }, - { value: 'markdown', label: t('editor.markdownEditor.markdown') }, + { value: 'ir', label: t('editor.markdownEditor.richText') }, + { value: 'source', label: t('editor.markdownEditor.source') }, ]} value={viewMode} - onValueChange={(value) => setViewMode(value as 'preview' | 'markdown')} + onValueChange={(value) => setViewMode(value as 'ir' | 'source')} />
= ({ onChange={handleContentChange} onSave={handleSave} onDirtyChange={handleDirtyChange} - mode={viewMode === 'preview' ? 'preview' : 'edit'} + mode={viewMode === 'ir' ? 'ir' : 'edit'} height="100%" width="100%" placeholder={t('editor.markdownEditor.placeholder')} diff --git a/src/web-ui/src/tools/editor/meditor/components/EditArea.tsx b/src/web-ui/src/tools/editor/meditor/components/EditArea.tsx index c9a853312c..626f50a7b7 100644 --- a/src/web-ui/src/tools/editor/meditor/components/EditArea.tsx +++ b/src/web-ui/src/tools/editor/meditor/components/EditArea.tsx @@ -24,7 +24,7 @@ export const EditArea = forwardRef( } const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Tab') { + if (!readonly && e.key === 'Tab') { e.preventDefault() const textarea = e.currentTarget const start = textarea.selectionStart diff --git a/src/web-ui/src/tools/editor/meditor/components/MEditor.test.tsx b/src/web-ui/src/tools/editor/meditor/components/MEditor.test.tsx index f3540a18ee..2b747e3e83 100644 --- a/src/web-ui/src/tools/editor/meditor/components/MEditor.test.tsx +++ b/src/web-ui/src/tools/editor/meditor/components/MEditor.test.tsx @@ -1,6 +1,9 @@ // @vitest-environment jsdom -import React, { act } from 'react' +import React, { act, createRef, useState } from 'react' +import type { Editor } from '@tiptap/core' +import { closeHistory } from '@tiptap/pm/history' +import type { EditorInstance } from '../types' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -8,6 +11,10 @@ const mocks = vi.hoisted(() => ({ logError: vi.fn(), })) +vi.mock('@/infrastructure/markdown', () => ({ + MarkdownRenderer: ({ content }: { content: string }) =>
{content}
, +})) + vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ error: mocks.logError, @@ -30,6 +37,8 @@ describe('MEditorErrorBoundary', () => { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true + Range.prototype.getClientRects = () => [] as unknown as DOMRectList; + Range.prototype.getBoundingClientRect = () => new DOMRect(); container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -68,21 +77,300 @@ describe('MEditorErrorBoundary', () => { ) }) - it('shows the compatibility warning only when IR mode requests a fallback', () => { + const sourceMarkdown = '# Before\n\n```mermaid\ngraph TD\n A-->B\n```\n\nAfter'; + + function getEditor(): Editor { + return (container.querySelector('.ProseMirror') as HTMLElement & { editor: Editor }).editor; + } + + async function renderEditor(markdown = sourceMarkdown, readonly = false) { + const ref = createRef(); + const onDirtyChange = vi.fn(); + const onSave = vi.fn(); + let setDocumentValue: (value: string) => void; + function Document() { + const [value, setValue] = useState(markdown); + setDocumentValue = setValue; + const [mode, setMode] = useState<'ir' | 'edit'>('ir'); + const [locked, setLocked] = useState(readonly); + return <> + + + + + ; + } + await act(async () => root.render()); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + return { ref, onDirtyChange, onSave, setDocumentValue: (next: string) => setDocumentValue(next) }; + } + + it.each([ + sourceMarkdown, + '# Before\n\n
HTML
\n\nAfter', + '# Before\n\nMix inline HTML.\n\nAfter', + '# Before\n\nFootnote[^a].\n\n[^a]: Definition\n\nAfter', + '# Before\n\n$$\nx^2\n$$\n\nAfter', + ])('keeps surrounding paragraphs editable and special content in source-backed blocks: %s', async markdown => { + await renderEditor(markdown); + expect(container.querySelector('.m-editor-mode-ir')).not.toBeNull(); + expect(container.querySelector('.m-editor-mode-split, .m-editor-mode-preview')).toBeNull(); + expect(getEditor().isEditable).toBe(true); + expect(getEditor().getJSON().content?.[0].type).toBe('heading'); + expect(getEditor().getJSON().content?.at(-1)?.type).toBe('paragraph'); + expect(container.querySelector('.m-editor-source-block-action')).not.toBeNull(); + }); + + it('edits an embedded block, saves with the document shortcut, and supports undo/redo', async () => { + const { ref, onDirtyChange, onSave } = await renderEditor(); + const action = container.querySelector('.m-editor-source-block-action')!; + expect(container.querySelector('.m-editor-embed-toolbar')?.hidden).toBe(true); + act(() => container.querySelector('[data-testid="md-embed-preview"]')!.click()); + const textarea = container.querySelector('.m-editor-render-only-block__textarea')!; + expect(textarea.closest('[data-editing]')?.getAttribute('data-editing')).toBe('true'); act(() => { - root.render( - inline HTML.'} mode="preview" /> - ) - }) - expect(container.textContent).not.toContain('IR fallback warning') + textarea.value = 'graph TD\n A-->C'; + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }); + expect(ref.current?.getValue()).toContain('A-->C'); + expect(onDirtyChange).toHaveBeenLastCalledWith(true); + act(() => textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true }))); + expect(onSave).toHaveBeenLastCalledWith(sourceMarkdown.replace('A-->B', 'A-->C')); + act(() => action.click()); + expect(textarea.closest('[data-editing]')?.getAttribute('data-editing')).toBe('false'); + expect(container.querySelector('[data-testid="block-renderer"]')?.textContent).toContain('A-->C'); + expect(container.querySelector('.m-editor-embed-toolbar')?.hidden).toBe(true); + act(() => { ref.current?.undo?.(); }); + expect(ref.current?.getValue()).toBe(sourceMarkdown); + act(() => { ref.current?.redo?.(); }); + expect(ref.current?.getValue()).toContain('A-->C'); + }); + it('edits inline math without replacing the surrounding paragraph', async () => { + const { ref } = await renderEditor('Before $x^2$ after.'); + expect(getEditor().getJSON().content?.[0].type).toBe('paragraph'); + const preview = container.querySelector('.m-editor-inline-math__preview')!; + act(() => preview.click()); + const source = container.querySelector('.m-editor-inline-math__textarea')!; + expect(source.value).toBe('x^2'); act(() => { - root.render( - inline HTML.'} mode="ir" /> - ) - }) - expect(container.textContent).toContain('IR fallback warning') - }) + source.value = 'y^2'; + source.dispatchEvent(new Event('input', { bubbles: true })); + }); + expect(ref.current?.getValue()).toBe('Before $y^2$ after.'); + act(() => document.body.dispatchEvent(new Event('click', { bubbles: true }))); + expect(preview.closest('[data-editing]')?.getAttribute('data-editing')).toBe('false'); + }); + + it('completes a final embed with the keyboard and creates an editable following paragraph', async () => { + const { ref } = await renderEditor('$$\nx^2\n$$'); + act(() => container.querySelector('[data-testid="md-embed-preview"]')!.click()); + const source = container.querySelector('[data-testid="md-embed-source"]')!; + expect(source.value).toBe('x^2'); + act(() => source.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true }))); + expect(getEditor().state.selection.$from.parent.type.name).toBe('paragraph'); + act(() => { getEditor().commands.insertContent('Following text'); }); + expect(ref.current?.getValue()).toContain('$$\nx^2\n$$\n\nFollowing text'); + }); + + it('preserves dirty state through source/rich text switches', async () => { + const { ref, onDirtyChange } = await renderEditor('# Original'); + act(() => getEditor().commands.insertContentAt(1, 'Changed ')); + const edited = ref.current?.getValue(); + expect(ref.current?.isDirty).toBe(true); + act(() => container.querySelector('#mode')!.click()); + expect(container.querySelector('textarea')?.value).toBe(edited); + await act(async () => container.querySelector('#mode')!.click()); + expect(ref.current?.getValue()).toBe(edited); + expect(ref.current?.isDirty).toBe(true); + expect(onDirtyChange).toHaveBeenLastCalledWith(true); + }); + + it('reports the first edit after an external reload', async () => { + const { ref, onDirtyChange } = await renderEditor('# Original'); + // The document owner supplies new content and explicitly marks its disk baseline. + await act(async () => container.querySelector('#reload')!.click()); + act(() => { ref.current?.setInitialContent?.('# Reloaded'); }); + act(() => getEditor().commands.insertContentAt(1, 'First ')); + expect(ref.current?.getValue()).toBe('# First Reloaded'); + expect(ref.current?.isDirty).toBe(true); + expect(onDirtyChange).toHaveBeenLastCalledWith(true); + }); + + it.each([ + '# Undo test\n\n* item\n', + '# Undo test\n\n\nText\n\n', + '# Undo test\r\n\r\nText\r\n', + '# Undo test\n\n__bold__ and _italic_\n', + ])('restores the exact imported source and clean state after undo: %s', async raw => { + const { ref, onDirtyChange } = await renderEditor(raw); + act(() => getEditor().commands.insertContentAt(1, 'Changed ')); + const edited = ref.current!.getValue(); + expect(ref.current?.isDirty).toBe(true); + act(() => { ref.current?.undo?.(); }); + expect(ref.current?.getValue()).toBe(raw); + expect(ref.current?.isDirty).toBe(false); + expect(onDirtyChange).toHaveBeenLastCalledWith(false); + act(() => { ref.current?.redo?.(); }); + expect(ref.current?.getValue()).toBe(edited); + expect(ref.current?.isDirty).toBe(true); + }); + + it('compares undo and redo with the latest successful save', async () => { + const raw = '# Undo test\n\n* item\n'; + const { ref } = await renderEditor(raw); + act(() => getEditor().commands.insertContentAt(1, 'Changed ')); + const saved = ref.current!.getValue(); + act(() => { ref.current?.markSaved?.(); }); + expect(ref.current?.isDirty).toBe(false); + act(() => { ref.current?.undo?.(); }); + expect(ref.current?.getValue()).toBe(raw); + expect(ref.current?.isDirty).toBe(true); + act(() => { ref.current?.redo?.(); }); + expect(ref.current?.getValue()).toBe(saved); + expect(ref.current?.isDirty).toBe(false); + }); + + it('stays dirty after partial undo and clears only after all content changes are undone', async () => { + const raw = '# Undo test\n\n* item\n'; + const { ref } = await renderEditor(raw); + act(() => getEditor().commands.insertContentAt(1, 'First ')); + const firstEdit = ref.current!.getValue(); + act(() => { + const editor = getEditor(); + editor.view.dispatch(closeHistory(editor.state.tr)); + editor.commands.insertContentAt(editor.state.doc.content.size, { + type: 'paragraph', content: [{ type: 'text', text: 'New block' }], + }); + }); + act(() => { ref.current?.undo?.(); }); + expect(ref.current?.getValue()).toBe(firstEdit); + expect(ref.current?.isDirty).toBe(true); + act(() => { ref.current?.undo?.(); }); + expect(ref.current?.getValue()).toBe(raw); + expect(ref.current?.isDirty).toBe(false); + }); + + it('preserves source-only changes through rich editing and undo', async () => { + const raw = '# Undo test\n\n- item\n'; + const changed = '# Undo test\n\n* item\n\n'; + const { ref, setDocumentValue } = await renderEditor(raw); + act(() => container.querySelector('#mode')!.click()); + act(() => { setDocumentValue(changed); }); + await act(async () => container.querySelector('#mode')!.click()); + expect(ref.current?.isDirty).toBe(true); + act(() => getEditor().commands.insertContentAt(1, 'Changed ')); + act(() => { ref.current?.undo?.(); }); + expect(ref.current?.getValue()).toBe(changed); + expect(ref.current?.isDirty).toBe(true); + act(() => { ref.current?.markSaved?.(); }); + act(() => getEditor().commands.insertContentAt(1, 'Again ')); + act(() => { ref.current?.undo?.(); }); + expect(ref.current?.getValue()).toBe(changed); + expect(ref.current?.isDirty).toBe(false); + }); + + it('replaces the source snapshot when disk content is reloaded', async () => { + const raw = '# Reloaded\n\n* item\n\n'; + const { ref, setDocumentValue } = await renderEditor('# Original'); + act(() => { setDocumentValue(raw); ref.current?.setInitialContent?.(raw); }); + act(() => getEditor().commands.insertContentAt(1, 'Changed ')); + act(() => { ref.current?.undo?.(); }); + expect(ref.current?.getValue()).toBe(raw); + expect(ref.current?.isDirty).toBe(false); + }); + + it.each([ + '**Token** and *emphasis* with `code`.', + '> Token', + '- Token\n- Another item', + '1. Token\n2. Another item', + '- [ ] Token\n- [x] Done', + '| Header |\n| --- |\n| Token |', + '```typescript\nconst Token = 1;\n```', + '
\nTitle\n\nToken\n\n
', + '[Token](https://example.com)', + ])('edits native rich text without turning the document into an embed: %s', async markdown => { + const { ref } = await renderEditor(markdown); + let position = -1; + getEditor().state.doc.descendants((node, pos) => { + if (node.isText && node.text?.includes('Token')) position = pos + node.text.indexOf('Token'); + }); + expect(position).toBeGreaterThanOrEqual(0); + act(() => getEditor().commands.insertContentAt(position, 'Updated ')); + expect(ref.current?.getValue()).toContain('Updated Token'); + expect(ref.current?.isDirty).toBe(true); + expect(container.querySelector('.m-editor-mode-preview, .m-editor-mode-split')).toBeNull(); + }); + + it('edits image attributes in place and includes them in document saves', async () => { + const { ref, onSave } = await renderEditor('Before ![Original](https://example.com/photo.png) after.'); + act(() => container.querySelector('.m-editor-image img')!.click()); + const fields = container.querySelector('.m-editor-image-fields')!; + expect(fields.hasAttribute('hidden')).toBe(false); + const alt = fields.querySelectorAll('input')[1]; + act(() => { + alt.value = 'Updated description'; + alt.dispatchEvent(new Event('input', { bubbles: true })); + }); + expect(ref.current?.getValue()).toBe('Before ![Updated description](https://example.com/photo.png) after.'); + act(() => alt.dispatchEvent(new KeyboardEvent('keydown', { key: 's', metaKey: true, bubbles: true }))); + expect(onSave).toHaveBeenLastCalledWith(ref.current?.getValue()); + act(() => { ref.current?.undo?.(); }); + expect(ref.current?.getValue()).toContain('![Original]'); + }); + + it('edits frontmatter without losing its delimiters or surrounding content', async () => { + const { ref } = await renderEditor('---\ntitle: Original\n---\n\n# Body'); + act(() => container.querySelector('.m-editor-frontmatter [data-testid="md-embed-preview"]')!.click()); + const textarea = container.querySelector('.m-editor-frontmatter__textarea')!; + act(() => { + textarea.value = 'title: Updated'; + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }); + expect(ref.current?.getValue()).toBe('---\ntitle: Updated\n---\n\n# Body'); + }); + + it('keeps the document editable if its renderer fails', async () => { + const onChange = vi.fn(); + act(() => root.render( + + + , + )); + const textarea = container.querySelector('textarea')!; + expect(textarea.readOnly).toBe(false); + }); + + it('does not rewrite or dirty a document just by opening it or changing permissions', async () => { + const raw = '# Original\n\nText\n\n'; + const { ref } = await renderEditor(raw); + expect(ref.current?.getValue()).toBe(raw); + expect(ref.current?.isDirty).toBe(false); + await act(async () => container.querySelector('#readonly')!.click()); + expect(ref.current?.getValue()).toBe(raw); + expect(ref.current?.isDirty).toBe(false); + }); + + it('updates embedded block permissions when the document becomes readonly', async () => { + await renderEditor(); + await act(async () => container.querySelector('#readonly')!.click()); + expect(getEditor().isEditable).toBe(false); + expect(container.querySelector('.m-editor-source-block-action')?.hidden).toBe(true); + expect(container.querySelector('.m-editor-render-only-block__textarea')?.readOnly).toBe(true); + await act(async () => container.querySelector('#readonly')!.click()); + expect(container.querySelector('.m-editor-source-block-action')?.hidden).toBe(false); + expect(container.querySelector('.m-editor-render-only-block__textarea')?.readOnly).toBe(false); + }); + + it('honors explicit readonly documents without enabling embedded edits', async () => { + await renderEditor(sourceMarkdown, true); + expect(getEditor().isEditable).toBe(false); + expect(container.querySelector('.m-editor-source-block-action')?.hidden).toBe(true); + expect(container.querySelector('.m-editor-render-only-block__textarea')?.readOnly).toBe(true); + }); + }) vi.mock('@/infrastructure/i18n', () => ({ diff --git a/src/web-ui/src/tools/editor/meditor/components/MEditor.tsx b/src/web-ui/src/tools/editor/meditor/components/MEditor.tsx index 0da7d984f6..298496fb2d 100644 --- a/src/web-ui/src/tools/editor/meditor/components/MEditor.tsx +++ b/src/web-ui/src/tools/editor/meditor/components/MEditor.tsx @@ -4,7 +4,6 @@ import React, { useCallback, useEffect, useImperativeHandle, - useMemo, useRef, useState, type ErrorInfo, @@ -19,8 +18,6 @@ import { TiptapEditor, TiptapEditorHandle } from './TiptapEditor' import { Preview } from './Preview' import type { EditorOptions, EditorInstance } from '../types' import { useI18n } from '@/infrastructure/i18n' -import { analyzeMarkdownEditability } from '../utils/tiptapMarkdown' -import { AlertCircle } from 'lucide-react' import './MEditor.scss' const log = createLogger('MEditor') @@ -51,6 +48,7 @@ function executeTextareaAction( } const MEditorSourceFallback = forwardRef((props, ref) => { + const { t } = useI18n('tools') const { value: controlledValue, defaultValue = '', @@ -147,6 +145,9 @@ const MEditorSourceFallback = forwardRef((props, r } }} > +
+ {t('editor.markdownEditor.notice.sourcePreviewFallback')} +