Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
451 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|