feat(web): add markdown round-trip and replace textarea with Tiptap (#501)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #501.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef } from "react";
|
||||
import { LinkAutocomplete } from "./LinkAutocomplete";
|
||||
import React from "react";
|
||||
import { KnowledgeEditor } from "./KnowledgeEditor";
|
||||
|
||||
interface EntryEditorProps {
|
||||
content: string;
|
||||
@@ -9,57 +9,21 @@ interface EntryEditorProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* EntryEditor - Markdown editor with live preview and link autocomplete
|
||||
* EntryEditor - WYSIWYG editor for knowledge entries.
|
||||
* Wraps KnowledgeEditor (Tiptap) with markdown round-trip.
|
||||
* Content is stored as markdown; the editor provides rich text editing.
|
||||
*/
|
||||
export function EntryEditor({ content, onChange }: EntryEditorProps): React.JSX.Element {
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
return (
|
||||
<div className="entry-editor relative">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Content (Markdown)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowPreview(!showPreview);
|
||||
}}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{showPreview ? "Edit" : "Preview"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showPreview ? (
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert p-4 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 min-h-[300px]">
|
||||
<div className="whitespace-pre-wrap">{content}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
className="w-full min-h-[300px] p-4 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Write your content here... (Markdown supported)"
|
||||
/>
|
||||
<LinkAutocomplete
|
||||
textareaRef={textareaRef}
|
||||
onInsert={(newContent) => {
|
||||
onChange(newContent);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Supports Markdown formatting. Type <code className="text-xs">[[</code> to insert links to
|
||||
other entries.
|
||||
</p>
|
||||
<div className="entry-editor">
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: "var(--text-2)" }}>
|
||||
Content
|
||||
</label>
|
||||
<KnowledgeEditor
|
||||
content={content}
|
||||
onChange={onChange}
|
||||
placeholder="Write your content here... Supports markdown formatting."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,18 +11,20 @@ import { TableCell } from "@tiptap/extension-table-cell";
|
||||
import { TableHeader } from "@tiptap/extension-table-header";
|
||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { MarkdownStorage } from "tiptap-markdown";
|
||||
|
||||
import "./KnowledgeEditor.css";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
export interface KnowledgeEditorProps {
|
||||
/** HTML content for the editor */
|
||||
/** Markdown content for the editor */
|
||||
content: string;
|
||||
/** Called when editor content changes (provides HTML) */
|
||||
onChange: (html: string) => void;
|
||||
/** Called when editor content changes (provides markdown) */
|
||||
onChange: (markdown: string) => void;
|
||||
/** Placeholder text when editor is empty */
|
||||
placeholder?: string;
|
||||
/** Whether the editor is editable */
|
||||
@@ -366,7 +368,11 @@ export function KnowledgeEditor({
|
||||
}: KnowledgeEditorProps): ReactElement {
|
||||
const handleUpdate = useCallback(
|
||||
({ editor: e }: { editor: Editor }) => {
|
||||
onChange(e.getHTML());
|
||||
const s = e.storage as unknown as Record<string, MarkdownStorage>;
|
||||
const mdStorage = s.markdown;
|
||||
if (mdStorage) {
|
||||
onChange(mdStorage.getMarkdown());
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
@@ -395,6 +401,13 @@ export function KnowledgeEditor({
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
breaks: false,
|
||||
tightLists: true,
|
||||
transformPastedText: true,
|
||||
transformCopiedText: true,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
editable,
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { EntryEditor } from "../EntryEditor";
|
||||
|
||||
// Mock the LinkAutocomplete component
|
||||
vi.mock("../LinkAutocomplete", () => ({
|
||||
LinkAutocomplete: (): React.JSX.Element => (
|
||||
<div data-testid="link-autocomplete">LinkAutocomplete</div>
|
||||
// Mock KnowledgeEditor since Tiptap requires a full DOM
|
||||
vi.mock("../KnowledgeEditor", () => ({
|
||||
KnowledgeEditor: ({
|
||||
content,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
content: string;
|
||||
onChange: (md: string) => void;
|
||||
placeholder?: string;
|
||||
}): React.JSX.Element => (
|
||||
<div data-testid="knowledge-editor" data-content={content} data-placeholder={placeholder}>
|
||||
<button
|
||||
data-testid="trigger-change"
|
||||
onClick={(): void => {
|
||||
onChange("updated content");
|
||||
}}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -21,133 +37,50 @@ describe("EntryEditor", (): void => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render textarea in edit mode by default", (): void => {
|
||||
it("should render KnowledgeEditor component", (): void => {
|
||||
render(<EntryEditor {...defaultProps} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Write your content here/);
|
||||
expect(textarea).toBeInTheDocument();
|
||||
expect(textarea.tagName).toBe("TEXTAREA");
|
||||
expect(screen.getByTestId("knowledge-editor")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display current content in textarea", (): void => {
|
||||
it("should have a content label", (): void => {
|
||||
render(<EntryEditor {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should pass content to KnowledgeEditor", (): void => {
|
||||
const content = "# Test Content\n\nThis is a test.";
|
||||
render(<EntryEditor {...defaultProps} content={content} />);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
const textarea = screen.getByPlaceholderText(/Write your content here/) as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe(content);
|
||||
const editor = screen.getByTestId("knowledge-editor");
|
||||
expect(editor).toHaveAttribute("data-content", content);
|
||||
});
|
||||
|
||||
it("should call onChange when content is modified", async (): Promise<void> => {
|
||||
it("should pass placeholder to KnowledgeEditor", (): void => {
|
||||
render(<EntryEditor {...defaultProps} />);
|
||||
|
||||
const editor = screen.getByTestId("knowledge-editor");
|
||||
expect(editor).toHaveAttribute(
|
||||
"data-placeholder",
|
||||
"Write your content here... Supports markdown formatting."
|
||||
);
|
||||
});
|
||||
|
||||
it("should forward onChange to KnowledgeEditor", async (): Promise<void> => {
|
||||
const { default: userEvent } = await import("@testing-library/user-event");
|
||||
const user = userEvent.setup();
|
||||
const onChangeMock = vi.fn();
|
||||
|
||||
render(<EntryEditor {...defaultProps} onChange={onChangeMock} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Write your content here/);
|
||||
await user.type(textarea, "Hello");
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalled();
|
||||
await user.click(screen.getByTestId("trigger-change"));
|
||||
expect(onChangeMock).toHaveBeenCalledWith("updated content");
|
||||
});
|
||||
|
||||
it("should toggle between edit and preview modes", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
const content = "# Test\n\nPreview this content.";
|
||||
it("should render with entry-editor wrapper class", (): void => {
|
||||
const { container } = render(<EntryEditor {...defaultProps} />);
|
||||
|
||||
render(<EntryEditor {...defaultProps} content={content} />);
|
||||
|
||||
// Initially in edit mode
|
||||
expect(screen.getByPlaceholderText(/Write your content here/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Preview")).toBeInTheDocument();
|
||||
|
||||
// Switch to preview mode
|
||||
const previewButton = screen.getByText("Preview");
|
||||
await user.click(previewButton);
|
||||
|
||||
// Should show preview
|
||||
expect(screen.queryByPlaceholderText(/Write your content here/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Edit")).toBeInTheDocument();
|
||||
// Check for partial content (newlines may split text across elements)
|
||||
expect(screen.getByText(/Test/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Preview this content/)).toBeInTheDocument();
|
||||
|
||||
// Switch back to edit mode
|
||||
const editButton = screen.getByText("Edit");
|
||||
await user.click(editButton);
|
||||
|
||||
// Should show textarea again
|
||||
expect(screen.getByPlaceholderText(/Write your content here/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Preview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render LinkAutocomplete component in edit mode", (): void => {
|
||||
render(<EntryEditor {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("link-autocomplete")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render LinkAutocomplete in preview mode", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<EntryEditor {...defaultProps} />);
|
||||
|
||||
// LinkAutocomplete should be present in edit mode
|
||||
expect(screen.getByTestId("link-autocomplete")).toBeInTheDocument();
|
||||
|
||||
// Switch to preview mode
|
||||
const previewButton = screen.getByText("Preview");
|
||||
await user.click(previewButton);
|
||||
|
||||
// LinkAutocomplete should not be in preview mode
|
||||
expect(screen.queryByTestId("link-autocomplete")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show help text about wiki-link syntax", (): void => {
|
||||
render(<EntryEditor {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/Type/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/\[\[/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/to insert links/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should maintain content when toggling between modes", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
const content = "# My Content\n\nThis should persist.";
|
||||
|
||||
render(<EntryEditor {...defaultProps} content={content} />);
|
||||
|
||||
// Verify content in edit mode
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
const textarea = screen.getByPlaceholderText(/Write your content here/) as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe(content);
|
||||
|
||||
// Toggle to preview
|
||||
await user.click(screen.getByText("Preview"));
|
||||
// Check for partial content (newlines may split text across elements)
|
||||
expect(screen.getByText(/My Content/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/This should persist/)).toBeInTheDocument();
|
||||
|
||||
// Toggle back to edit
|
||||
await user.click(screen.getByText("Edit"));
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
const textareaAfter = screen.getByPlaceholderText(
|
||||
/Write your content here/
|
||||
) as HTMLTextAreaElement;
|
||||
expect(textareaAfter.value).toBe(content);
|
||||
});
|
||||
|
||||
it("should apply correct styling classes", (): void => {
|
||||
render(<EntryEditor {...defaultProps} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Write your content here/);
|
||||
expect(textarea).toHaveClass("font-mono");
|
||||
expect(textarea).toHaveClass("text-sm");
|
||||
expect(textarea).toHaveClass("min-h-[300px]");
|
||||
});
|
||||
|
||||
it("should have label for content field", (): void => {
|
||||
render(<EntryEditor {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Content (Markdown)")).toBeInTheDocument();
|
||||
expect(container.querySelector(".entry-editor")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user