"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 ( 1 2 3 ); } 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 && }
); }