Files
stack/apps/web/src/components/knowledge/KnowledgeEditor.tsx
Jason Woltje d5ecc0b107
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
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>
2026-02-24 01:40:34 +00:00

451 lines
11 KiB
TypeScript

"use client";
import { useCallback } from "react";
import type { ReactElement } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row";
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 {
/** Markdown content for the editor */
content: string;
/** Called when editor content changes (provides markdown) */
onChange: (markdown: string) => void;
/** Placeholder text when editor is empty */
placeholder?: string;
/** Whether the editor is editable */
editable?: boolean;
}
/** Toolbar button helper */
function ToolbarButton({
onClick,
active,
disabled,
title,
children,
}: {
onClick: () => void;
active?: boolean;
disabled?: boolean;
title: string;
children: React.ReactNode;
}): ReactElement {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
borderRadius: "var(--r-sm)",
border: "none",
background: active ? "var(--primary)" : "transparent",
color: active ? "#fff" : "var(--text-2)",
cursor: disabled ? "default" : "pointer",
opacity: disabled ? 0.4 : 1,
fontSize: "0.82rem",
fontWeight: 600,
transition: "all 0.12s ease",
lineHeight: 1,
}}
>
{children}
</button>
);
}
/** Separator between toolbar groups */
function ToolbarSep(): ReactElement {
return (
<div
style={{
width: 1,
height: 20,
background: "var(--border)",
margin: "0 4px",
flexShrink: 0,
}}
/>
);
}
/** Link insertion handler — prompts for URL */
function toggleLink(editor: Editor): void {
if (editor.isActive("link")) {
editor.chain().focus().unsetLink().run();
return;
}
const url = window.prompt("Enter URL:");
if (url) {
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
}
}
/* SVG icon components for toolbar */
function BulletListIcon(): ReactElement {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="3" cy="4" r="1.5" />
<rect x="6" y="3" width="8" height="2" rx="0.5" />
<circle cx="3" cy="8" r="1.5" />
<rect x="6" y="7" width="8" height="2" rx="0.5" />
<circle cx="3" cy="12" r="1.5" />
<rect x="6" y="11" width="8" height="2" rx="0.5" />
</svg>
);
}
function OrderedListIcon(): ReactElement {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<text x="1" y="5.5" fontSize="5" fontWeight="bold">
1
</text>
<rect x="6" y="3" width="8" height="2" rx="0.5" />
<text x="1" y="9.5" fontSize="5" fontWeight="bold">
2
</text>
<rect x="6" y="7" width="8" height="2" rx="0.5" />
<text x="1" y="13.5" fontSize="5" fontWeight="bold">
3
</text>
<rect x="6" y="11" width="8" height="2" rx="0.5" />
</svg>
);
}
function QuoteIcon(): ReactElement {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M3 3h2l-1 4h2v6H2V7l1-4zm7 0h2l-1 4h2v6H9V7l1-4z" />
</svg>
);
}
function CodeBlockIcon(): ReactElement {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<polyline points="5,3 1,8 5,13" />
<polyline points="11,3 15,8 11,13" />
<line x1="9" y1="2" x2="7" y2="14" />
</svg>
);
}
function LinkIcon(): ReactElement {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M6.5 9.5l3-3" />
<path d="M9 6l1.5-1.5a2.12 2.12 0 013 3L12 9" />
<path d="M7 10l-1.5 1.5a2.12 2.12 0 01-3-3L4 7" />
</svg>
);
}
function TableIcon(): ReactElement {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
>
<rect x="1" y="2" width="14" height="12" rx="1" />
<line x1="1" y1="6" x2="15" y2="6" />
<line x1="1" y1="10" x2="15" y2="10" />
<line x1="6" y1="2" x2="6" y2="14" />
<line x1="11" y1="2" x2="11" y2="14" />
</svg>
);
}
/** Editor toolbar component */
function EditorToolbar({ editor }: { editor: Editor }): ReactElement {
return (
<div
style={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 2,
padding: "6px 8px",
borderBottom: "1px solid var(--border)",
background: "var(--surface-2)",
borderRadius: "var(--r-lg) var(--r-lg) 0 0",
}}
>
{/* Headings */}
<ToolbarButton
onClick={(): void => {
editor.chain().focus().toggleHeading({ level: 1 }).run();
}}
active={editor.isActive("heading", { level: 1 })}
title="Heading 1"
>
H1
</ToolbarButton>
<ToolbarButton
onClick={(): void => {
editor.chain().focus().toggleHeading({ level: 2 }).run();
}}
active={editor.isActive("heading", { level: 2 })}
title="Heading 2"
>
H2
</ToolbarButton>
<ToolbarButton
onClick={(): void => {
editor.chain().focus().toggleHeading({ level: 3 }).run();
}}
active={editor.isActive("heading", { level: 3 })}
title="Heading 3"
>
H3
</ToolbarButton>
<ToolbarSep />
{/* Text formatting */}
<ToolbarButton
onClick={(): void => {
editor.chain().focus().toggleBold().run();
}}
active={editor.isActive("bold")}
title="Bold (Ctrl+B)"
>
B
</ToolbarButton>
<ToolbarButton
onClick={(): void => {
editor.chain().focus().toggleItalic().run();
}}
active={editor.isActive("italic")}
title="Italic (Ctrl+I)"
>
<span style={{ fontStyle: "italic" }}>I</span>
</ToolbarButton>
<ToolbarButton
onClick={(): void => {
editor.chain().focus().toggleStrike().run();
}}
active={editor.isActive("strike")}
title="Strikethrough"
>
<span style={{ textDecoration: "line-through" }}>S</span>
</ToolbarButton>
<ToolbarButton
onClick={(): void => {
editor.chain().focus().toggleCode().run();
}}
active={editor.isActive("code")}
title="Inline Code"
>
{"<>"}
</ToolbarButton>
<ToolbarSep />
{/* Lists */}
<ToolbarButton
onClick={(): void => {
editor.chain().focus().toggleBulletList().run();
}}
active={editor.isActive("bulletList")}
title="Bullet List"
>
<BulletListIcon />
</ToolbarButton>
<ToolbarButton
onClick={(): void => {
editor.chain().focus().toggleOrderedList().run();
}}
active={editor.isActive("orderedList")}
title="Ordered List"
>
<OrderedListIcon />
</ToolbarButton>
<ToolbarButton
onClick={(): void => {
editor.chain().focus().toggleBlockquote().run();
}}
active={editor.isActive("blockquote")}
title="Blockquote"
>
<QuoteIcon />
</ToolbarButton>
<ToolbarSep />
{/* Code block */}
<ToolbarButton
onClick={(): void => {
editor.chain().focus().toggleCodeBlock().run();
}}
active={editor.isActive("codeBlock")}
title="Code Block"
>
<CodeBlockIcon />
</ToolbarButton>
{/* Link */}
<ToolbarButton
onClick={(): void => {
toggleLink(editor);
}}
active={editor.isActive("link")}
title="Insert Link"
>
<LinkIcon />
</ToolbarButton>
{/* Table */}
<ToolbarButton
onClick={(): void => {
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
}}
disabled={editor.isActive("table")}
title="Insert Table"
>
<TableIcon />
</ToolbarButton>
<ToolbarSep />
{/* Horizontal rule */}
<ToolbarButton
onClick={(): void => {
editor.chain().focus().setHorizontalRule().run();
}}
title="Horizontal Rule"
>
</ToolbarButton>
</div>
);
}
export function KnowledgeEditor({
content,
onChange,
placeholder = "Start writing...",
editable = true,
}: KnowledgeEditorProps): ReactElement {
const handleUpdate = useCallback(
({ editor: e }: { editor: Editor }) => {
const s = e.storage as unknown as Record<string, MarkdownStorage>;
const mdStorage = s.markdown;
if (mdStorage) {
onChange(mdStorage.getMarkdown());
}
},
[onChange]
);
const editor = useEditor({
extensions: [
StarterKit.configure({
codeBlock: false,
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
rel: "noopener noreferrer",
class: "knowledge-editor-link",
},
}),
Table.configure({
resizable: true,
}),
TableRow,
TableCell,
TableHeader,
CodeBlockLowlight.configure({
lowlight,
}),
Placeholder.configure({
placeholder,
}),
Markdown.configure({
html: true,
breaks: false,
tightLists: true,
transformPastedText: true,
transformCopiedText: true,
}),
],
content,
editable,
onUpdate: handleUpdate,
});
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- useEditor returns null during SSR/init
if (!editor) {
return (
<div
style={{
minHeight: 300,
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span style={{ color: "var(--muted)", fontSize: "0.85rem" }}>Loading editor...</span>
</div>
);
}
return (
<div
className="knowledge-editor"
style={{
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
overflow: "hidden",
background: "var(--surface)",
}}
>
{editable && <EditorToolbar editor={editor} />}
<EditorContent editor={editor} className="knowledge-editor-content" />
</div>
);
}