"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 (
);
}
/** Separator between toolbar groups */
function ToolbarSep(): ReactElement {
return (
);
}
/** 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 (
);
}
function OrderedListIcon(): ReactElement {
return (
);
}
function QuoteIcon(): ReactElement {
return (
);
}
function CodeBlockIcon(): ReactElement {
return (
);
}
function LinkIcon(): ReactElement {
return (
);
}
function TableIcon(): ReactElement {
return (
);
}
/** Editor toolbar component */
function EditorToolbar({ editor }: { editor: Editor }): ReactElement {
return (
{/* Headings */}
{
editor.chain().focus().toggleHeading({ level: 1 }).run();
}}
active={editor.isActive("heading", { level: 1 })}
title="Heading 1"
>
H1
{
editor.chain().focus().toggleHeading({ level: 2 }).run();
}}
active={editor.isActive("heading", { level: 2 })}
title="Heading 2"
>
H2
{
editor.chain().focus().toggleHeading({ level: 3 }).run();
}}
active={editor.isActive("heading", { level: 3 })}
title="Heading 3"
>
H3
{/* Text formatting */}
{
editor.chain().focus().toggleBold().run();
}}
active={editor.isActive("bold")}
title="Bold (Ctrl+B)"
>
B
{
editor.chain().focus().toggleItalic().run();
}}
active={editor.isActive("italic")}
title="Italic (Ctrl+I)"
>
I
{
editor.chain().focus().toggleStrike().run();
}}
active={editor.isActive("strike")}
title="Strikethrough"
>
S
{
editor.chain().focus().toggleCode().run();
}}
active={editor.isActive("code")}
title="Inline Code"
>
{"<>"}
{/* Lists */}
{
editor.chain().focus().toggleBulletList().run();
}}
active={editor.isActive("bulletList")}
title="Bullet List"
>
{
editor.chain().focus().toggleOrderedList().run();
}}
active={editor.isActive("orderedList")}
title="Ordered List"
>
{
editor.chain().focus().toggleBlockquote().run();
}}
active={editor.isActive("blockquote")}
title="Blockquote"
>
{/* Code block */}
{
editor.chain().focus().toggleCodeBlock().run();
}}
active={editor.isActive("codeBlock")}
title="Code Block"
>
{/* Link */}
{
toggleLink(editor);
}}
active={editor.isActive("link")}
title="Insert Link"
>
{/* Table */}
{
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
}}
disabled={editor.isActive("table")}
title="Insert Table"
>
{/* Horizontal rule */}
{
editor.chain().focus().setHorizontalRule().run();
}}
title="Horizontal Rule"
>
—
);
}
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;
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 (
Loading editor...
);
}
return (
{editable && }
);
}