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-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",
|
||||||
|
|||||||
@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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-secondary" onClick={() => setIsPreview((prev) => !prev)}>
|
||||||
|
{isPreview ? '编辑' : '预览'}
|
||||||
|
</button>
|
||||||
<button className="btn-save" onClick={handleSave} disabled={!isDirty || saving}>
|
<button className="btn-save" onClick={handleSave} disabled={!isDirty || saving}>
|
||||||
{saving ? '保存中…' : '保存'}
|
{saving ? '保存中…' : '保存'}
|
||||||
</button>
|
</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
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
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 ---------- */
|
||||||
.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;
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user