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:
parent
954b9bede4
commit
39761b4ce7
30
frontend/package-lock.json
generated
30
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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<string | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(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 ? '删除中…' : '删除'}
|
||||
</button>
|
||||
{!isImage && (
|
||||
<button className="btn-save" onClick={handleSave} disabled={!isDirty || saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
<>
|
||||
<button className="btn-secondary" onClick={() => setIsPreview((prev) => !prev)}>
|
||||
{isPreview ? '编辑' : '预览'}
|
||||
</button>
|
||||
<button className="btn-save" onClick={handleSave} disabled={!isDirty || saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -134,6 +141,10 @@ export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }
|
||||
<div className="image-preview-area" data-testid="image-preview">
|
||||
<img src={`data:${contentType};base64,${content}`} alt={documentKey.split('/').pop()} />
|
||||
</div>
|
||||
) : isPreview ? (
|
||||
<div className="preview-area">
|
||||
<MarkdownPreview content={content} isMarkdown={isMarkdown} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="editor-area">
|
||||
<CodeMirror
|
||||
|
||||
@ -96,7 +96,7 @@ function FolderNode({
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
{name}
|
||||
<span className="foldername">{name}</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="tree-children">
|
||||
|
||||
23
frontend/src/components/MarkdownPreview.tsx
Normal file
23
frontend/src/components/MarkdownPreview.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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(<App />);
|
||||
|
||||
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(<App />);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -25,6 +25,45 @@ beforeEach(() => {
|
||||
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 () => {
|
||||
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(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
||||
|
||||
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(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
||||
|
||||
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(<DocumentEditor documentKey="产品文档/架构设计.md" onDirtyChange={onDirtyChange} onDeleted={vi.fn()} />);
|
||||
|
||||
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(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
|
||||
|
||||
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(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
|
||||
|
||||
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(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
|
||||
|
||||
await screen.findByTestId('codemirror-mock');
|
||||
await screen.findByTestId('markdown-preview');
|
||||
const deleteButton = screen.getByRole('button', { name: '删除' });
|
||||
await user.click(deleteButton);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user