From 39761b4ce7c38ddd59806bb7e0c55e40f9558554 Mon Sep 17 00:00:00 2001 From: Tianyang Date: Sat, 1 Aug 2026 18:30:50 +0800 Subject: [PATCH] fix: lock sidebar width and add rendered markdown preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar overflowed horizontally on long file/folder names, which broke vertical scrolling too — locked width with min/max-width plus overflow-x: hidden, and truncate folder names with ellipsis. Documents now open in a rendered preview (marked + DOMPurify) by default, with an 编辑/预览 toggle to switch to the CodeMirror source view. --- frontend/package-lock.json | 30 +++++ frontend/package.json | 2 + frontend/src/components/DocumentEditor.tsx | 25 ++-- frontend/src/components/FileExplorer.tsx | 2 +- frontend/src/components/MarkdownPreview.tsx | 23 ++++ frontend/src/styles.css | 107 ++++++++++++++++++ frontend/tests/App.test.tsx | 10 +- .../tests/components/DocumentEditor.test.tsx | 55 +++++++-- 8 files changed, 237 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/MarkdownPreview.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 41a7f37..e965457 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,8 @@ "@codemirror/lang-html": "^6.4.10", "@codemirror/lang-markdown": "^6.4.0", "@uiw/react-codemirror": "^4.25.11", + "dompurify": "^3.4.12", + "marked": "^18.0.7", "react": "18.3.1", "react-dom": "18.3.1" }, @@ -1672,6 +1674,13 @@ "@types/react": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@uiw/codemirror-extensions-basic-setup": { "version": "4.25.11", "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.11.tgz", @@ -2243,6 +2252,15 @@ "license": "MIT", "peer": true }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2810,6 +2828,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.0.7", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.7.tgz", + "integrity": "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 84e4bea..23baea2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,8 @@ "@codemirror/lang-html": "^6.4.10", "@codemirror/lang-markdown": "^6.4.0", "@uiw/react-codemirror": "^4.25.11", + "dompurify": "^3.4.12", + "marked": "^18.0.7", "react": "18.3.1", "react-dom": "18.3.1" }, diff --git a/frontend/src/components/DocumentEditor.tsx b/frontend/src/components/DocumentEditor.tsx index dbe5f9b..f03a610 100644 --- a/frontend/src/components/DocumentEditor.tsx +++ b/frontend/src/components/DocumentEditor.tsx @@ -3,6 +3,7 @@ import CodeMirror from '@uiw/react-codemirror'; import { markdown } from '@codemirror/lang-markdown'; import { html } from '@codemirror/lang-html'; import { deleteDocument, getDocument, saveDocument } from '../api/client'; +import MarkdownPreview from './MarkdownPreview'; interface DocumentEditorProps { documentKey: string; @@ -21,16 +22,17 @@ export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted } const [saveError, setSaveError] = useState(null); const [deleteError, setDeleteError] = useState(null); + const [isPreview, setIsPreview] = useState(true); + const isImage = contentType.startsWith('image/'); - const extensions = useMemo( - () => [contentType === 'text/markdown' ? markdown() : html()], - [contentType], - ); + const isMarkdown = contentType === 'text/markdown'; + const extensions = useMemo(() => [isMarkdown ? markdown() : html()], [isMarkdown]); useEffect(() => { setLoading(true); setLoadError(null); setSaveError(null); + setIsPreview(true); getDocument(documentKey) .then((doc) => { setContent(doc.content); @@ -124,9 +126,14 @@ export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted } {deleting ? '删除中…' : '删除'} {!isImage && ( - + <> + + + )} @@ -134,6 +141,10 @@ export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }
{documentKey.split('/').pop()}
+ ) : isPreview ? ( +
+ +
) : (
- {name} + {name}
{expanded && (
diff --git a/frontend/src/components/MarkdownPreview.tsx b/frontend/src/components/MarkdownPreview.tsx new file mode 100644 index 0000000..27bce67 --- /dev/null +++ b/frontend/src/components/MarkdownPreview.tsx @@ -0,0 +1,23 @@ +import { useMemo } from 'react'; +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; + +interface MarkdownPreviewProps { + content: string; + isMarkdown: boolean; +} + +export default function MarkdownPreview({ content, isMarkdown }: MarkdownPreviewProps) { + const html = useMemo(() => { + const rawHtml = isMarkdown ? (marked.parse(content, { async: false }) as string) : content; + return DOMPurify.sanitize(rawHtml); + }, [content, isMarkdown]); + + return ( +
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 926e767..147b26b 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -192,9 +192,12 @@ body { /* ---------- Sidebar ---------- */ .sidebar { width: 272px; + min-width: 272px; + max-width: 272px; background: var(--paper-alt); border-right: 1px solid var(--rule); overflow-y: auto; + overflow-x: hidden; padding: 16px 10px; flex-shrink: 0; } @@ -228,6 +231,13 @@ body { white-space: nowrap; border-radius: 5px; user-select: none; + min-width: 0; +} + +.tree-item .foldername { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; } .tree-item:hover { @@ -454,6 +464,103 @@ body { border-radius: var(--radius); } +.preview-area { + flex: 1; + overflow-y: auto; + background: var(--paper); + padding: 32px 40px; +} + +.markdown-preview { + max-width: 720px; + margin: 0 auto; + color: var(--ink); + line-height: 1.7; + font-size: 14px; +} + +.markdown-preview h1, +.markdown-preview h2, +.markdown-preview h3, +.markdown-preview h4 { + font-family: 'Fraunces', serif; + font-weight: 600; + color: var(--ink); + margin: 1.4em 0 0.6em; +} + +.markdown-preview h1 { + font-size: 26px; +} + +.markdown-preview h2 { + font-size: 21px; +} + +.markdown-preview h3 { + font-size: 17px; +} + +.markdown-preview p { + margin: 0.8em 0; +} + +.markdown-preview a { + color: var(--pine); +} + +.markdown-preview code { + font-family: 'IBM Plex Mono', monospace; + font-size: 12.5px; + background: var(--paper-alt); + padding: 2px 5px; + border-radius: 4px; +} + +.markdown-preview pre { + background: var(--paper-alt); + border: 1px solid var(--rule); + border-radius: var(--radius); + padding: 14px 16px; + overflow-x: auto; +} + +.markdown-preview pre code { + background: none; + padding: 0; +} + +.markdown-preview ul, +.markdown-preview ol { + padding-left: 1.6em; + margin: 0.8em 0; +} + +.markdown-preview blockquote { + border-left: 3px solid var(--rule); + color: var(--ash); + margin: 0.8em 0; + padding: 2px 0 2px 16px; +} + +.markdown-preview img { + max-width: 100%; + border-radius: var(--radius); +} + +.markdown-preview table { + border-collapse: collapse; + width: 100%; + margin: 0.8em 0; +} + +.markdown-preview th, +.markdown-preview td { + border: 1px solid var(--rule); + padding: 6px 10px; + text-align: left; +} + /* ---------- Search overlay ---------- */ .search-overlay { position: absolute; diff --git a/frontend/tests/App.test.tsx b/frontend/tests/App.test.tsx index e37bfd5..ff3c9a6 100644 --- a/frontend/tests/App.test.tsx +++ b/frontend/tests/App.test.tsx @@ -74,6 +74,8 @@ test('selecting a file from the tree loads it into the editor', async () => { const fileRow = await screen.findByText('架构设计.md'); await user.click(fileRow); + await screen.findByTestId('markdown-preview'); + await user.click(screen.getByRole('button', { name: '编辑' })); const textarea = await screen.findByTestId('codemirror-mock'); expect(textarea).toHaveValue('# 架构设计'); }); @@ -108,6 +110,8 @@ test('searching shows the results overlay, and clicking a result opens the docum await waitFor(() => { expect(screen.queryByTestId('search-results')).not.toBeInTheDocument(); }); + await screen.findByTestId('markdown-preview'); + await user.click(screen.getByRole('button', { name: '编辑' })); const textarea = await screen.findByTestId('codemirror-mock'); expect(textarea).toHaveValue('# 架构设计'); }); @@ -180,6 +184,8 @@ test('switching files while the current document is dirty prompts for confirmati render(); await user.click(await screen.findByText('文档A.md')); + await screen.findByTestId('markdown-preview'); + await user.click(screen.getByRole('button', { name: '编辑' })); const textarea = await screen.findByTestId('codemirror-mock'); await user.type(textarea, '修改内容'); @@ -206,13 +212,15 @@ test('confirming the discard prompt proceeds to switch files', async () => { render(); await user.click(await screen.findByText('文档A.md')); + await screen.findByTestId('markdown-preview'); + await user.click(screen.getByRole('button', { name: '编辑' })); const textarea = await screen.findByTestId('codemirror-mock'); await user.type(textarea, '修改内容'); await user.click(screen.getByText('文档B.md')); await waitFor(async () => { - expect(await screen.findByTestId('codemirror-mock')).toHaveValue('内容-文档B.md'); + expect(screen.getByTestId('markdown-preview')).toHaveTextContent('内容-文档B.md'); }); }); diff --git a/frontend/tests/components/DocumentEditor.test.tsx b/frontend/tests/components/DocumentEditor.test.tsx index 9e666df..ad6fcaf 100644 --- a/frontend/tests/components/DocumentEditor.test.tsx +++ b/frontend/tests/components/DocumentEditor.test.tsx @@ -25,6 +25,45 @@ beforeEach(() => { vi.mocked(deleteDocument).mockReset(); }); +async function switchToEditMode(user: ReturnType) { + await screen.findByTestId('markdown-preview'); + await user.click(screen.getByRole('button', { name: '编辑' })); + return screen.findByTestId('codemirror-mock'); +} + +test('shows a rendered markdown preview by default', async () => { + vi.mocked(getDocument).mockResolvedValue({ + key: '产品文档/架构设计.md', + content: '# 架构设计', + contentType: 'text/markdown', + encoding: 'utf-8', + }); + + render(); + + const preview = await screen.findByTestId('markdown-preview'); + expect(preview.querySelector('h1')).toHaveTextContent('架构设计'); + expect(screen.queryByTestId('codemirror-mock')).not.toBeInTheDocument(); +}); + +test('clicking 编辑 switches to the code editor and back to 预览 restores the rendered view', async () => { + vi.mocked(getDocument).mockResolvedValue({ + key: '产品文档/架构设计.md', + content: '# 架构设计', + contentType: 'text/markdown', + encoding: 'utf-8', + }); + const user = userEvent.setup(); + + render(); + + const textarea = await switchToEditMode(user); + expect(textarea).toHaveValue('# 架构设计'); + + await user.click(screen.getByRole('button', { name: '预览' })); + expect(await screen.findByTestId('markdown-preview')).toBeInTheDocument(); +}); + test('shows an image preview instead of the code editor for image content types', async () => { vi.mocked(getDocument).mockResolvedValue({ key: '产品文档/图片.png', @@ -40,6 +79,7 @@ test('shows an image preview instead of the code editor for image content types' expect(img).toHaveAttribute('src', 'data:image/png;base64,iVBORw0KGgo='); expect(screen.queryByTestId('codemirror-mock')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '编辑' })).not.toBeInTheDocument(); }); test('shows a loading state, then loads and displays document content', async () => { @@ -54,8 +94,7 @@ test('shows a loading state, then loads and displays document content', async () expect(screen.getByTestId('document-editor-loading')).toBeInTheDocument(); - const textarea = await screen.findByTestId('codemirror-mock'); - expect(textarea).toHaveValue('# 架构设计'); + await screen.findByTestId('markdown-preview'); expect(getDocument).toHaveBeenCalledWith('产品文档/架构设计.md'); expect(screen.getByTestId('save-status')).toHaveTextContent('已归档'); }); @@ -75,7 +114,7 @@ test('typing marks the document dirty and saving clears the dirty state', async render(); - const textarea = await screen.findByTestId('codemirror-mock'); + const textarea = await switchToEditMode(user); await user.clear(textarea); await user.type(textarea, '# 新内容'); @@ -108,7 +147,7 @@ test('save failure keeps the editor and unsaved content visible, shows an error, render(); - const textarea = await screen.findByTestId('codemirror-mock'); + const textarea = await switchToEditMode(user); await user.clear(textarea); await user.type(textarea, '# 未保存的修改'); @@ -142,7 +181,7 @@ test('calls onDirtyChange when dirty state changes', async () => { render(); - const textarea = await screen.findByTestId('codemirror-mock'); + const textarea = await switchToEditMode(user); await waitFor(() => { expect(onDirtyChange).toHaveBeenLastCalledWith(false); @@ -169,7 +208,7 @@ test('clicking delete confirms, calls deleteDocument, and calls onDeleted on suc render(); - await screen.findByTestId('codemirror-mock'); + await screen.findByTestId('markdown-preview'); const deleteButton = screen.getByRole('button', { name: '删除' }); await user.click(deleteButton); @@ -195,7 +234,7 @@ test('clicking delete does nothing if the confirmation is declined', async () => render(); - await screen.findByTestId('codemirror-mock'); + await screen.findByTestId('markdown-preview'); const deleteButton = screen.getByRole('button', { name: '删除' }); await user.click(deleteButton); @@ -217,7 +256,7 @@ test('shows an inline error and does not call onDeleted when delete fails', asyn render(); - await screen.findByTestId('codemirror-mock'); + await screen.findByTestId('markdown-preview'); const deleteButton = screen.getByRole('button', { name: '删除' }); await user.click(deleteButton);