fix: lock sidebar width and add rendered markdown preview

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.
This commit is contained in:
Tianyang 2026-08-01 18:30:50 +08:00
parent 954b9bede4
commit 39761b4ce7
8 changed files with 237 additions and 17 deletions

View File

@ -11,6 +11,8 @@
"@codemirror/lang-html": "^6.4.10", "@codemirror/lang-html": "^6.4.10",
"@codemirror/lang-markdown": "^6.4.0", "@codemirror/lang-markdown": "^6.4.0",
"@uiw/react-codemirror": "^4.25.11", "@uiw/react-codemirror": "^4.25.11",
"dompurify": "^3.4.12",
"marked": "^18.0.7",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1" "react-dom": "18.3.1"
}, },
@ -1672,6 +1674,13 @@
"@types/react": "*" "@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": { "node_modules/@uiw/codemirror-extensions-basic-setup": {
"version": "4.25.11", "version": "4.25.11",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.11.tgz", "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.11.tgz",
@ -2243,6 +2252,15 @@
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -2810,6 +2828,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@ -13,6 +13,8 @@
"@codemirror/lang-html": "^6.4.10", "@codemirror/lang-html": "^6.4.10",
"@codemirror/lang-markdown": "^6.4.0", "@codemirror/lang-markdown": "^6.4.0",
"@uiw/react-codemirror": "^4.25.11", "@uiw/react-codemirror": "^4.25.11",
"dompurify": "^3.4.12",
"marked": "^18.0.7",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1" "react-dom": "18.3.1"
}, },

View File

@ -3,6 +3,7 @@ import CodeMirror from '@uiw/react-codemirror';
import { markdown } from '@codemirror/lang-markdown'; import { markdown } from '@codemirror/lang-markdown';
import { html } from '@codemirror/lang-html'; import { html } from '@codemirror/lang-html';
import { deleteDocument, getDocument, saveDocument } from '../api/client'; import { deleteDocument, getDocument, saveDocument } from '../api/client';
import MarkdownPreview from './MarkdownPreview';
interface DocumentEditorProps { interface DocumentEditorProps {
documentKey: string; documentKey: string;
@ -21,16 +22,17 @@ export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const [isPreview, setIsPreview] = useState(true);
const isImage = contentType.startsWith('image/'); const isImage = contentType.startsWith('image/');
const extensions = useMemo( const isMarkdown = contentType === 'text/markdown';
() => [contentType === 'text/markdown' ? markdown() : html()], const extensions = useMemo(() => [isMarkdown ? markdown() : html()], [isMarkdown]);
[contentType],
);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
setLoadError(null); setLoadError(null);
setSaveError(null); setSaveError(null);
setIsPreview(true);
getDocument(documentKey) getDocument(documentKey)
.then((doc) => { .then((doc) => {
setContent(doc.content); setContent(doc.content);
@ -124,9 +126,14 @@ export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }
{deleting ? '删除中…' : '删除'} {deleting ? '删除中…' : '删除'}
</button> </button>
{!isImage && ( {!isImage && (
<button className="btn-save" onClick={handleSave} disabled={!isDirty || saving}> <>
{saving ? '保存中…' : '保存'} <button className="btn-secondary" onClick={() => setIsPreview((prev) => !prev)}>
</button> {isPreview ? '编辑' : '预览'}
</button>
<button className="btn-save" onClick={handleSave} disabled={!isDirty || saving}>
{saving ? '保存中…' : '保存'}
</button>
</>
)} )}
</div> </div>
</div> </div>
@ -134,6 +141,10 @@ export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }
<div className="image-preview-area" data-testid="image-preview"> <div className="image-preview-area" data-testid="image-preview">
<img src={`data:${contentType};base64,${content}`} alt={documentKey.split('/').pop()} /> <img src={`data:${contentType};base64,${content}`} alt={documentKey.split('/').pop()} />
</div> </div>
) : isPreview ? (
<div className="preview-area">
<MarkdownPreview content={content} isMarkdown={isMarkdown} />
</div>
) : ( ) : (
<div className="editor-area"> <div className="editor-area">
<CodeMirror <CodeMirror

View File

@ -96,7 +96,7 @@ function FolderNode({
<path d="M8 5v14l11-7z" /> <path d="M8 5v14l11-7z" />
</svg> </svg>
</span> </span>
{name} <span className="foldername">{name}</span>
</div> </div>
{expanded && ( {expanded && (
<div className="tree-children"> <div className="tree-children">

View File

@ -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 (
<div
className="markdown-preview"
data-testid="markdown-preview"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}

View File

@ -192,9 +192,12 @@ body {
/* ---------- Sidebar ---------- */ /* ---------- Sidebar ---------- */
.sidebar { .sidebar {
width: 272px; width: 272px;
min-width: 272px;
max-width: 272px;
background: var(--paper-alt); background: var(--paper-alt);
border-right: 1px solid var(--rule); border-right: 1px solid var(--rule);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
padding: 16px 10px; padding: 16px 10px;
flex-shrink: 0; flex-shrink: 0;
} }
@ -228,6 +231,13 @@ body {
white-space: nowrap; white-space: nowrap;
border-radius: 5px; border-radius: 5px;
user-select: none; user-select: none;
min-width: 0;
}
.tree-item .foldername {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
} }
.tree-item:hover { .tree-item:hover {
@ -454,6 +464,103 @@ body {
border-radius: var(--radius); 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 ---------- */
.search-overlay { .search-overlay {
position: absolute; position: absolute;

View File

@ -74,6 +74,8 @@ test('selecting a file from the tree loads it into the editor', async () => {
const fileRow = await screen.findByText('架构设计.md'); const fileRow = await screen.findByText('架构设计.md');
await user.click(fileRow); await user.click(fileRow);
await screen.findByTestId('markdown-preview');
await user.click(screen.getByRole('button', { name: '编辑' }));
const textarea = await screen.findByTestId('codemirror-mock'); const textarea = await screen.findByTestId('codemirror-mock');
expect(textarea).toHaveValue('# 架构设计'); expect(textarea).toHaveValue('# 架构设计');
}); });
@ -108,6 +110,8 @@ test('searching shows the results overlay, and clicking a result opens the docum
await waitFor(() => { await waitFor(() => {
expect(screen.queryByTestId('search-results')).not.toBeInTheDocument(); 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'); const textarea = await screen.findByTestId('codemirror-mock');
expect(textarea).toHaveValue('# 架构设计'); expect(textarea).toHaveValue('# 架构设计');
}); });
@ -180,6 +184,8 @@ test('switching files while the current document is dirty prompts for confirmati
render(<App />); render(<App />);
await user.click(await screen.findByText('文档A.md')); 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'); const textarea = await screen.findByTestId('codemirror-mock');
await user.type(textarea, '修改内容'); await user.type(textarea, '修改内容');
@ -206,13 +212,15 @@ test('confirming the discard prompt proceeds to switch files', async () => {
render(<App />); render(<App />);
await user.click(await screen.findByText('文档A.md')); 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'); const textarea = await screen.findByTestId('codemirror-mock');
await user.type(textarea, '修改内容'); await user.type(textarea, '修改内容');
await user.click(screen.getByText('文档B.md')); await user.click(screen.getByText('文档B.md'));
await waitFor(async () => { await waitFor(async () => {
expect(await screen.findByTestId('codemirror-mock')).toHaveValue('内容-文档B.md'); expect(screen.getByTestId('markdown-preview')).toHaveTextContent('内容-文档B.md');
}); });
}); });

View File

@ -25,6 +25,45 @@ beforeEach(() => {
vi.mocked(deleteDocument).mockReset(); vi.mocked(deleteDocument).mockReset();
}); });
async function switchToEditMode(user: ReturnType<typeof userEvent.setup>) {
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(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
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(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
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 () => { test('shows an image preview instead of the code editor for image content types', async () => {
vi.mocked(getDocument).mockResolvedValue({ vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/图片.png', 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(img).toHaveAttribute('src', 'data:image/png;base64,iVBORw0KGgo=');
expect(screen.queryByTestId('codemirror-mock')).not.toBeInTheDocument(); expect(screen.queryByTestId('codemirror-mock')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '保存' })).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 () => { 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(); expect(screen.getByTestId('document-editor-loading')).toBeInTheDocument();
const textarea = await screen.findByTestId('codemirror-mock'); await screen.findByTestId('markdown-preview');
expect(textarea).toHaveValue('# 架构设计');
expect(getDocument).toHaveBeenCalledWith('产品文档/架构设计.md'); expect(getDocument).toHaveBeenCalledWith('产品文档/架构设计.md');
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档'); expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
}); });
@ -75,7 +114,7 @@ test('typing marks the document dirty and saving clears the dirty state', async
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />); render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
const textarea = await screen.findByTestId('codemirror-mock'); const textarea = await switchToEditMode(user);
await user.clear(textarea); await user.clear(textarea);
await user.type(textarea, '# 新内容'); await user.type(textarea, '# 新内容');
@ -108,7 +147,7 @@ test('save failure keeps the editor and unsaved content visible, shows an error,
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />); render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
const textarea = await screen.findByTestId('codemirror-mock'); const textarea = await switchToEditMode(user);
await user.clear(textarea); await user.clear(textarea);
await user.type(textarea, '# 未保存的修改'); await user.type(textarea, '# 未保存的修改');
@ -142,7 +181,7 @@ test('calls onDirtyChange when dirty state changes', async () => {
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDirtyChange={onDirtyChange} onDeleted={vi.fn()} />); render(<DocumentEditor documentKey="产品文档/架构设计.md" onDirtyChange={onDirtyChange} onDeleted={vi.fn()} />);
const textarea = await screen.findByTestId('codemirror-mock'); const textarea = await switchToEditMode(user);
await waitFor(() => { await waitFor(() => {
expect(onDirtyChange).toHaveBeenLastCalledWith(false); expect(onDirtyChange).toHaveBeenLastCalledWith(false);
@ -169,7 +208,7 @@ test('clicking delete confirms, calls deleteDocument, and calls onDeleted on suc
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />); render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
await screen.findByTestId('codemirror-mock'); await screen.findByTestId('markdown-preview');
const deleteButton = screen.getByRole('button', { name: '删除' }); const deleteButton = screen.getByRole('button', { name: '删除' });
await user.click(deleteButton); await user.click(deleteButton);
@ -195,7 +234,7 @@ test('clicking delete does nothing if the confirmation is declined', async () =>
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />); render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
await screen.findByTestId('codemirror-mock'); await screen.findByTestId('markdown-preview');
const deleteButton = screen.getByRole('button', { name: '删除' }); const deleteButton = screen.getByRole('button', { name: '删除' });
await user.click(deleteButton); await user.click(deleteButton);
@ -217,7 +256,7 @@ test('shows an inline error and does not call onDeleted when delete fails', asyn
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />); render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
await screen.findByTestId('codemirror-mock'); await screen.findByTestId('markdown-preview');
const deleteButton = screen.getByRole('button', { name: '删除' }); const deleteButton = screen.getByRole('button', { name: '删除' });
await user.click(deleteButton); await user.click(deleteButton);