feat(web): add markdown round-trip and replace textarea with Tiptap (#501)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful

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:
2026-02-24 01:40:34 +00:00
committed by jason.woltje
parent a81c4a5edd
commit d5ecc0b107
6 changed files with 132 additions and 182 deletions

View File

@@ -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>
);
}

View File

@@ -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,

View File

@@ -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();
});
});