feat(KB-226): add markdown preview to FileEditor
- Add markdown preview toggle to FileEditor with live preview pane - Implement syntax-highlighted code blocks and responsive layout - Add comprehensive tests for preview toggle and content rendering - Update dashboard README with FileEditor usage documentation - Add changeset for markdown preview feature - Clean up obsolete changeset files
This commit is contained in:
@@ -6,7 +6,6 @@ describe("FileEditor", () => {
|
||||
it("renders textarea with correct class names", () => {
|
||||
render(<FileEditor content="" onChange={vi.fn()} />);
|
||||
const textarea = screen.getByRole("textbox");
|
||||
expect(textarea.classList.contains("file-editor-container")).toBe(true);
|
||||
expect(textarea.classList.contains("file-editor-textarea")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -57,4 +56,125 @@ describe("FileEditor", () => {
|
||||
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
expect(textarea.readOnly).toBe(false);
|
||||
});
|
||||
|
||||
describe("markdown preview", () => {
|
||||
it("shows edit/preview toggle for .md files", () => {
|
||||
render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="readme.md" />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /preview/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows edit/preview toggle for .markdown files", () => {
|
||||
render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="readme.markdown" />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /preview/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows edit/preview toggle for .mdx files", () => {
|
||||
render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="page.mdx" />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /preview/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show edit/preview toggle for non-markdown files", () => {
|
||||
render(<FileEditor content="const x = 1;" onChange={vi.fn()} filePath="script.ts" />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /edit/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /preview/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show edit/preview toggle when filePath is not provided", () => {
|
||||
render(<FileEditor content="some content" onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /edit/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /preview/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults to edit mode for markdown files", () => {
|
||||
render(<FileEditor content="# Hello World" onChange={vi.fn()} filePath="readme.md" />);
|
||||
|
||||
// Textarea should be visible
|
||||
const textarea = screen.getByRole("textbox");
|
||||
expect(textarea).toBeInTheDocument();
|
||||
expect(textarea.tagName.toLowerCase()).toBe("textarea");
|
||||
});
|
||||
|
||||
it("switches to preview mode when preview button is clicked", () => {
|
||||
render(<FileEditor content="# Hello World" onChange={vi.fn()} filePath="readme.md" />);
|
||||
|
||||
// Click preview button
|
||||
const previewButton = screen.getByRole("button", { name: /preview/i });
|
||||
fireEvent.click(previewButton);
|
||||
|
||||
// Preview should be visible (no textarea)
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
expect(document.querySelector(".file-editor-preview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches back to edit mode when edit button is clicked", () => {
|
||||
render(<FileEditor content="# Hello World" onChange={vi.fn()} filePath="readme.md" />);
|
||||
|
||||
// Switch to preview first
|
||||
const previewButton = screen.getByRole("button", { name: /preview/i });
|
||||
fireEvent.click(previewButton);
|
||||
|
||||
// Then switch back to edit
|
||||
const editButton = screen.getByRole("button", { name: /edit/i });
|
||||
fireEvent.click(editButton);
|
||||
|
||||
// Textarea should be visible again
|
||||
const textarea = screen.getByRole("textbox");
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders markdown content in preview mode", () => {
|
||||
render(<FileEditor content="# Hello World" onChange={vi.fn()} filePath="readme.md" />);
|
||||
|
||||
// Switch to preview
|
||||
const previewButton = screen.getByRole("button", { name: /preview/i });
|
||||
fireEvent.click(previewButton);
|
||||
|
||||
// Check that the markdown is rendered (heading should be present)
|
||||
expect(document.querySelector(".file-editor-preview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides edit button in readOnly mode for markdown files", () => {
|
||||
render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="readme.md" readOnly />);
|
||||
|
||||
// Edit button should not be visible
|
||||
expect(screen.queryByRole("button", { name: /edit/i })).not.toBeInTheDocument();
|
||||
// Preview button should still be visible
|
||||
expect(screen.getByRole("button", { name: /preview/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults to preview mode in readOnly mode for markdown files", () => {
|
||||
render(<FileEditor content="# Hello World" onChange={vi.fn()} filePath="readme.md" readOnly />);
|
||||
|
||||
// Preview should be active by default (no textarea in readOnly)
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
expect(document.querySelector(".file-editor-preview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preview button is disabled when already in preview mode", () => {
|
||||
render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="readme.md" />);
|
||||
|
||||
// Switch to preview
|
||||
const previewButton = screen.getByRole("button", { name: /preview/i });
|
||||
fireEvent.click(previewButton);
|
||||
|
||||
// Preview button should now be disabled
|
||||
expect(previewButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("edit button is disabled when already in edit mode", () => {
|
||||
render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="readme.md" />);
|
||||
|
||||
const editButton = screen.getByRole("button", { name: /edit/i });
|
||||
// Edit button should be disabled in edit mode
|
||||
expect(editButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { FileEdit, Eye } from "lucide-react";
|
||||
|
||||
interface FileEditorProps {
|
||||
content: string;
|
||||
onChange: (content: string) => void;
|
||||
@@ -5,15 +10,72 @@ interface FileEditorProps {
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
function isMarkdownFile(filePath?: string): boolean {
|
||||
if (!filePath) return false;
|
||||
const lowerPath = filePath.toLowerCase();
|
||||
return lowerPath.endsWith(".md") || lowerPath.endsWith(".markdown") || lowerPath.endsWith(".mdx");
|
||||
}
|
||||
|
||||
export function FileEditor({ content, onChange, readOnly, filePath }: FileEditorProps) {
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const isMarkdown = isMarkdownFile(filePath);
|
||||
|
||||
// For markdown files in readOnly mode, default to preview
|
||||
const effectiveShowPreview = isMarkdown && (readOnly ? true : showPreview);
|
||||
|
||||
const handleEditClick = useCallback(() => {
|
||||
setShowPreview(false);
|
||||
}, []);
|
||||
|
||||
const handlePreviewClick = useCallback(() => {
|
||||
setShowPreview(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
className="file-editor-container file-editor-textarea"
|
||||
value={content}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
readOnly={readOnly}
|
||||
spellCheck={false}
|
||||
aria-label={filePath ? `Editor for ${filePath}` : "File editor"}
|
||||
/>
|
||||
<div className="file-editor-container">
|
||||
{isMarkdown && (
|
||||
<div className="file-editor-toolbar">
|
||||
<div className="file-editor-mode-toggle">
|
||||
{!readOnly && (
|
||||
<button
|
||||
className={`btn btn-sm ${!effectiveShowPreview ? "btn-primary" : ""}`}
|
||||
onClick={handleEditClick}
|
||||
disabled={!effectiveShowPreview}
|
||||
aria-label="Edit mode"
|
||||
>
|
||||
<FileEdit size={14} />
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`btn btn-sm ${effectiveShowPreview ? "btn-primary" : ""}`}
|
||||
onClick={handlePreviewClick}
|
||||
disabled={effectiveShowPreview}
|
||||
aria-label="Preview mode"
|
||||
>
|
||||
<Eye size={14} />
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveShowPreview ? (
|
||||
<div className="file-editor-preview markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
className="file-editor-textarea"
|
||||
value={content}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
readOnly={readOnly}
|
||||
spellCheck={false}
|
||||
aria-label={filePath ? `Editor for ${filePath}` : "File editor"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6194,6 +6194,8 @@ html .column.drag-over * {
|
||||
/* File Editor Container */
|
||||
.file-editor-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
@@ -6202,6 +6204,37 @@ html .column.drag-over * {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* File Editor Toolbar */
|
||||
.file-editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-editor-mode-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.file-editor-mode-toggle .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* File Editor Preview */
|
||||
.file-editor-preview {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Textarea-based file editor */
|
||||
.file-editor-textarea {
|
||||
width: 100%;
|
||||
@@ -6438,7 +6471,9 @@ html .column.drag-over * {
|
||||
.file-browser-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.file-browser-empty {
|
||||
|
||||
Reference in New Issue
Block a user