feat(web): add Tiptap WYSIWYG KnowledgeEditor component (#500)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #500.
This commit is contained in:
437
apps/web/src/components/knowledge/KnowledgeEditor.tsx
Normal file
437
apps/web/src/components/knowledge/KnowledgeEditor.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
"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 { common, createLowlight } from "lowlight";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
import "./KnowledgeEditor.css";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
export interface KnowledgeEditorProps {
|
||||
/** HTML content for the editor */
|
||||
content: string;
|
||||
/** Called when editor content changes (provides HTML) */
|
||||
onChange: (html: 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 }) => {
|
||||
onChange(e.getHTML());
|
||||
},
|
||||
[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,
|
||||
}),
|
||||
],
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user