feat(web): add Tiptap WYSIWYG KnowledgeEditor component (#500)
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

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:
2026-02-24 01:23:57 +00:00
committed by jason.woltje
parent ff5a09c3fb
commit a81c4a5edd
5 changed files with 1429 additions and 11 deletions

View File

@@ -22,12 +22,23 @@
"@mosaic/shared": "workspace:*",
"@mosaic/ui": "workspace:*",
"@tanstack/react-query": "^5.90.20",
"@tiptap/extension-code-block-lowlight": "^3.20.0",
"@tiptap/extension-link": "^3.20.0",
"@tiptap/extension-placeholder": "^3.20.0",
"@tiptap/extension-table": "^3.20.0",
"@tiptap/extension-table-cell": "^3.20.0",
"@tiptap/extension-table-header": "^3.20.0",
"@tiptap/extension-table-row": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/react": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"@types/dompurify": "^3.2.0",
"@xyflow/react": "^12.5.3",
"better-auth": "^1.4.17",
"date-fns": "^4.1.0",
"dompurify": "^3.3.1",
"elkjs": "^0.9.3",
"lowlight": "^3.3.0",
"lucide-react": "^0.563.0",
"mermaid": "^11.4.1",
"next": "^16.1.6",

View File

@@ -0,0 +1,245 @@
/* KnowledgeEditor — Tiptap/ProseMirror styles
Uses CSS variables for theme compatibility */
.knowledge-editor-content .tiptap {
min-height: 300px;
padding: 16px 20px;
outline: none;
color: var(--text);
font-family: var(--font);
font-size: 0.92rem;
line-height: 1.7;
}
/* Placeholder */
.knowledge-editor-content .tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--muted);
opacity: 0.6;
pointer-events: none;
height: 0;
}
/* Headings */
.knowledge-editor-content .tiptap h1 {
font-size: 1.75rem;
font-weight: 700;
margin: 1.5em 0 0.5em;
color: var(--text);
line-height: 1.3;
}
.knowledge-editor-content .tiptap h2 {
font-size: 1.35rem;
font-weight: 700;
margin: 1.3em 0 0.4em;
color: var(--text);
line-height: 1.35;
}
.knowledge-editor-content .tiptap h3 {
font-size: 1.1rem;
font-weight: 600;
margin: 1.2em 0 0.3em;
color: var(--text);
line-height: 1.4;
}
.knowledge-editor-content .tiptap h1:first-child,
.knowledge-editor-content .tiptap h2:first-child,
.knowledge-editor-content .tiptap h3:first-child {
margin-top: 0;
}
/* Paragraphs */
.knowledge-editor-content .tiptap p {
margin: 0.5em 0;
}
/* Bold / Italic / Strikethrough */
.knowledge-editor-content .tiptap strong {
font-weight: 700;
}
.knowledge-editor-content .tiptap em {
font-style: italic;
}
.knowledge-editor-content .tiptap s {
text-decoration: line-through;
opacity: 0.7;
}
/* Inline code */
.knowledge-editor-content .tiptap code {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 1px 5px;
font-family: var(--mono);
font-size: 0.85em;
color: var(--primary-l);
}
/* Code blocks */
.knowledge-editor-content .tiptap pre {
background: var(--bg-deep);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 12px 16px;
margin: 0.75em 0;
overflow-x: auto;
}
.knowledge-editor-content .tiptap pre code {
background: none;
border: none;
padding: 0;
font-family: var(--mono);
font-size: 0.83rem;
color: var(--text-2);
line-height: 1.6;
}
/* Lists */
.knowledge-editor-content .tiptap ul {
list-style-type: disc;
padding-left: 1.5em;
margin: 0.5em 0;
}
.knowledge-editor-content .tiptap ol {
list-style-type: decimal;
padding-left: 1.5em;
margin: 0.5em 0;
}
.knowledge-editor-content .tiptap li {
margin: 0.2em 0;
}
.knowledge-editor-content .tiptap li > p {
margin: 0;
}
/* Blockquote */
.knowledge-editor-content .tiptap blockquote {
border-left: 3px solid var(--primary);
padding: 4px 16px;
margin: 0.75em 0;
color: var(--text-2);
background: var(--surface-2);
border-radius: 0 var(--r-sm) var(--r-sm) 0;
}
.knowledge-editor-content .tiptap blockquote p {
margin: 0.25em 0;
}
/* Horizontal rule */
.knowledge-editor-content .tiptap hr {
border: none;
border-top: 1px solid var(--border);
margin: 1.5em 0;
}
/* Links */
.knowledge-editor-content .tiptap a,
.knowledge-editor-content .tiptap .knowledge-editor-link {
color: var(--primary-l);
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
/* Tables */
.knowledge-editor-content .tiptap table {
border-collapse: collapse;
width: 100%;
margin: 0.75em 0;
overflow: hidden;
border-radius: var(--r-sm);
}
.knowledge-editor-content .tiptap th,
.knowledge-editor-content .tiptap td {
border: 1px solid var(--border);
padding: 6px 10px;
text-align: left;
vertical-align: top;
min-width: 80px;
}
.knowledge-editor-content .tiptap th {
background: var(--surface-2);
font-weight: 600;
font-size: 0.85rem;
}
.knowledge-editor-content .tiptap td {
font-size: 0.88rem;
}
/* Table selected cell highlight */
.knowledge-editor-content .tiptap .selectedCell::after {
content: "";
position: absolute;
inset: 0;
background: var(--primary);
opacity: 0.08;
pointer-events: none;
z-index: 1;
}
.knowledge-editor-content .tiptap th.selectedCell::after,
.knowledge-editor-content .tiptap td.selectedCell::after {
content: "";
position: absolute;
inset: 0;
background: var(--primary);
opacity: 0.08;
pointer-events: none;
}
/* Table cell relative positioning for selection overlay */
.knowledge-editor-content .tiptap th,
.knowledge-editor-content .tiptap td {
position: relative;
}
/* Column resize handle */
.knowledge-editor-content .tiptap .column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: 0;
width: 4px;
background: var(--primary);
cursor: col-resize;
z-index: 2;
}
.knowledge-editor-content .tiptap .tableWrapper {
overflow-x: auto;
}
/* Syntax highlighting tokens (lowlight/highlight.js) */
.knowledge-editor-content .tiptap pre .hljs-keyword { color: var(--ms-purple-400); }
.knowledge-editor-content .tiptap pre .hljs-string { color: var(--ms-teal-400); }
.knowledge-editor-content .tiptap pre .hljs-number { color: var(--ms-amber-400); }
.knowledge-editor-content .tiptap pre .hljs-comment { color: var(--muted); font-style: italic; }
.knowledge-editor-content .tiptap pre .hljs-function { color: var(--ms-blue-400); }
.knowledge-editor-content .tiptap pre .hljs-title { color: var(--ms-blue-400); }
.knowledge-editor-content .tiptap pre .hljs-params { color: var(--text-2); }
.knowledge-editor-content .tiptap pre .hljs-built_in { color: var(--ms-cyan-500); }
.knowledge-editor-content .tiptap pre .hljs-literal { color: var(--ms-amber-400); }
.knowledge-editor-content .tiptap pre .hljs-type { color: var(--ms-teal-400); }
.knowledge-editor-content .tiptap pre .hljs-attr { color: var(--ms-purple-400); }
.knowledge-editor-content .tiptap pre .hljs-selector-class { color: var(--ms-blue-400); }
.knowledge-editor-content .tiptap pre .hljs-selector-tag { color: var(--ms-red-400); }
.knowledge-editor-content .tiptap pre .hljs-variable { color: var(--text); }
.knowledge-editor-content .tiptap pre .hljs-meta { color: var(--muted); }
.knowledge-editor-content .tiptap pre .hljs-tag { color: var(--ms-red-400); }
.knowledge-editor-content .tiptap pre .hljs-name { color: var(--ms-red-400); }
.knowledge-editor-content .tiptap pre .hljs-attribute { color: var(--ms-purple-400); }

View 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>
);
}