Full-Featured Editor
A complete Notion-style editor with all features enabled.
This example shows a production-ready Blokhaus editor with every available plugin and UI component. It includes formatting toolbar, slash menu, drag-and-drop blocks, image and video uploads, AI content generation, mentions, tables, code blocks, callouts, toggles, emoji picker, text color, text direction, block selection, word count, and state persistence.
Complete editor page
"use client";
import { useState, useCallback } from "react";
import {
// Core
EditorRoot,
useEditorState,
useWordCount,
// UI Components
FloatingToolbar,
SlashMenu,
OverlayPortal,
MobileToolbar,
// Plugins
InputRulePlugin,
ImagePlugin,
VideoPlugin,
AIPlugin,
MentionPlugin,
PastePlugin,
LinkPlugin,
ListPlugin,
TablePlugin,
TableActionMenu,
TableHoverActions,
TogglePlugin,
CalloutPlugin,
EmojiPickerPlugin,
ColorPlugin,
DirectionPlugin,
BlockSelectionPlugin,
// Helpers
$createMentionNode,
} from "@blokhaus/core";
import type {
AIProvider,
MentionProvider,
UploadHandler,
} from "@blokhaus/core";
// ---------------------------------------------------------------------------
// Upload handler -- replace with your real upload endpoint
// ---------------------------------------------------------------------------
const uploadHandler: UploadHandler = async (file: File): Promise<string> => {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`);
}
const { url } = await response.json();
return url;
};
// ---------------------------------------------------------------------------
// AI provider -- connects to your /api/ai/generate route
// ---------------------------------------------------------------------------
const aiProvider: AIProvider = {
name: "My AI",
generate: async ({ prompt, context, config }) => {
const response = await fetch("/api/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt,
context,
temperature: config?.temperature ?? 0.7,
maxTokens: config?.maxTokens ?? 2000,
systemPrompt: config?.systemPrompt,
}),
});
if (!response.ok) {
throw new Error(`AI request failed: ${response.status}`);
}
const reader = response.body!.getReader();
const decoder = new TextDecoder();
return new ReadableStream<string>({
async pull(controller) {
const { done, value } = await reader.read();
if (done) {
controller.close();
return;
}
controller.enqueue(decoder.decode(value, { stream: true }));
},
});
},
};
// ---------------------------------------------------------------------------
// Mention providers -- @users and #tags
// ---------------------------------------------------------------------------
const userMentionProvider: MentionProvider = {
trigger: "@",
onSearch: async (query) => {
// Replace with your actual user search API
const users = [
{
id: "u1",
label: "Alice Johnson",
meta: "alice@company.com",
icon: "/avatars/alice.jpg",
},
{
id: "u2",
label: "Bob Smith",
meta: "bob@company.com",
icon: "/avatars/bob.jpg",
},
{ id: "u3", label: "Charlie Brown", meta: "charlie@company.com" },
{ id: "u4", label: "Diana Prince", meta: "diana@company.com" },
{ id: "u5", label: "Eve Williams", meta: "eve@company.com" },
];
if (!query) return users;
return users.filter((u) =>
u.label.toLowerCase().includes(query.toLowerCase()),
);
},
renderItem: (item) => (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{item.icon ? (
<img
src={item.icon}
alt=""
style={{ width: 20, height: 20, borderRadius: "50%" }}
/>
) : (
<span
style={{
width: 20,
height: 20,
borderRadius: "50%",
background: "#ddd",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontSize: 10,
}}
>
{item.label[0]}
</span>
)}
<div>
<div style={{ fontWeight: 500 }}>{item.label}</div>
{item.meta && (
<div style={{ fontSize: 12, opacity: 0.6 }}>{item.meta}</div>
)}
</div>
</div>
),
onSelect: (item) =>
$createMentionNode({ id: item.id, label: item.label, trigger: "@" }),
};
const tagMentionProvider: MentionProvider = {
trigger: "#",
onSearch: async (query) => {
const tags = [
{ id: "t1", label: "documentation" },
{ id: "t2", label: "feature-request" },
{ id: "t3", label: "bug" },
{ id: "t4", label: "enhancement" },
{ id: "t5", label: "design" },
{ id: "t6", label: "performance" },
];
if (!query) return tags;
return tags.filter((t) =>
t.label.toLowerCase().includes(query.toLowerCase()),
);
},
renderItem: (item) => (
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ opacity: 0.5 }}>#</span>
{item.label}
</span>
),
onSelect: (item) =>
$createMentionNode({ id: item.id, label: item.label, trigger: "#" }),
};
// ---------------------------------------------------------------------------
// Editor state management (inner component -- must be inside EditorRoot)
// ---------------------------------------------------------------------------
function EditorState({ onSave }: { onSave: (json: string) => void }) {
const { serializedState } = useEditorState({
debounceMs: 300,
onChange: onSave,
});
return null;
}
function WordCounter() {
const { words, characters } = useWordCount();
return (
<div className="flex items-center gap-4 text-xs text-gray-400 px-4 py-2 border-t">
<span>{words} words</span>
<span>{characters} characters</span>
</div>
);
}
// ---------------------------------------------------------------------------
// Page component
// ---------------------------------------------------------------------------
export default function FullFeaturedEditorPage() {
const [initialState] = useState(() => {
if (typeof window === "undefined") return null;
return localStorage.getItem("blokhaus-full-featured");
});
const handleSave = useCallback((json: string) => {
localStorage.setItem("blokhaus-full-featured", json);
}, []);
return (
<main className="max-w-3xl mx-auto py-12 px-4">
<h1 className="text-2xl font-bold mb-2">Full-Featured Editor</h1>
<p className="text-gray-500 mb-6">
Every Blokhaus plugin enabled. Type <code>/</code> for commands,{" "}
<code>@</code> to mention someone, or <code>#</code> for tags.
</p>
<div className="border rounded-lg overflow-hidden">
<EditorRoot
namespace="full-featured"
initialState={initialState}
className="relative min-h-[500px] p-4"
placeholder="Start writing something amazing..."
>
{/* State management */}
<EditorState onSave={handleSave} />
{/* UI Components */}
<FloatingToolbar />
<SlashMenu />
<OverlayPortal namespace="full-featured" />
<MobileToolbar />
{/* Core plugins */}
<InputRulePlugin />
<PastePlugin />
<LinkPlugin />
<ListPlugin />
{/* Media */}
<ImagePlugin
uploadHandler={uploadHandler}
maxFileSize={10 * 1024 * 1024}
onUploadError={(file, error) => {
console.error(`Upload failed for ${file.name}:`, error);
}}
/>
<VideoPlugin uploadHandler={uploadHandler} />
{/* AI */}
<AIPlugin
provider={aiProvider}
config={{
generate: {
temperature: 0.7,
maxTokens: 2000,
systemPrompt:
"You are a helpful writing assistant. Respond in Markdown.",
},
labels: {
header: "AI Assistant",
streaming: "Writing...",
accept: "Insert",
discard: "Cancel",
},
contextWindowSize: 5,
onAccept: (content) => {
console.log("AI content accepted:", content.length, "chars");
},
}}
/>
{/* Mentions */}
<MentionPlugin
providers={[userMentionProvider, tagMentionProvider]}
/>
{/* Block types */}
<TablePlugin hasCellMerge hasTabHandler />
<TableActionMenu />
<TableHoverActions />
<TogglePlugin />
<CalloutPlugin />
{/* Formatting */}
<EmojiPickerPlugin />
<ColorPlugin />
<DirectionPlugin />
<BlockSelectionPlugin />
{/* Word count -- rendered inside EditorRoot for context access */}
<WordCounter />
</EditorRoot>
</div>
</main>
);
}API route for AI generation
The AI provider above expects a streaming endpoint at /api/ai/generate. Here is a reference implementation using the OpenAI SDK:
import { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const { prompt, context, temperature, maxTokens, systemPrompt } =
await request.json();
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: "gpt-4o",
stream: true,
temperature: temperature ?? 0.7,
max_tokens: maxTokens ?? 2000,
messages: [
{
role: "system",
content:
systemPrompt ??
"You are a helpful writing assistant. Respond in Markdown.",
},
{
role: "user",
content: `Context:\n${context}\n\nTask:\n${prompt}`,
},
],
}),
});
return new Response(response.body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
});
}API route for file uploads
The upload handler expects a /api/upload endpoint. Here is a minimal example that saves to the local filesystem (replace with S3, R2, or your storage provider in production):
import { NextRequest, NextResponse } from "next/server";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const uploadDir = join(process.cwd(), "public", "uploads");
await mkdir(uploadDir, { recursive: true });
const filename = `${Date.now()}-${file.name}`;
const filepath = join(uploadDir, filename);
await writeFile(filepath, buffer);
return NextResponse.json({ url: `/uploads/${filename}` });
}What each component does
UI Components
| Component | Purpose |
|---|---|
FloatingToolbar | Appears on text selection with Bold, Italic, Underline, Strikethrough, Code, Link buttons |
SlashMenu | Command palette triggered by / at the start of an empty paragraph |
OverlayPortal | Notion-style drag handles on hover (desktop only) |
MobileToolbar | Bottom-anchored formatting toolbar on touch devices |
Headless Plugins
| Plugin | Purpose |
|---|---|
InputRulePlugin | Markdown shortcuts (# , > , - , etc.) |
PastePlugin | Sanitizes HTML from external paste sources |
LinkPlugin | URL detection and link editing |
ListPlugin | Ordered, unordered, and task list behaviors |
ImagePlugin | Drag/drop and paste image upload with optimistic UI |
VideoPlugin | Video embed (YouTube, Vimeo, Loom) and file upload |
AIPlugin | AI content generation with streaming preview |
MentionPlugin | @user and #tag mentions with async search |
TablePlugin | Table insertion and cell navigation |
TableActionMenu | Right-click context menu for table operations |
TableHoverActions | Row/column handles and resize |
TogglePlugin | Collapsible toggle blocks |
CalloutPlugin | Styled callout blocks with emoji and color presets |
EmojiPickerPlugin | Emoji search and insertion via the slash menu |
ColorPlugin | Text and background color formatting |
DirectionPlugin | LTR/RTL text direction |
BlockSelectionPlugin | Multi-block selection with keyboard shortcuts |
Hooks
| Hook | Purpose |
|---|---|
useEditorState | Debounced serialized JSON state with onChange callback |
useWordCount | Live word and character count |
Removing features
Because every feature is an independent child component, removing a feature is as simple as deleting the corresponding line. Want an editor without AI? Remove <AIPlugin />. No mentions? Remove <MentionPlugin />. No drag-and-drop? Remove <OverlayPortal />. The remaining features continue to work without any changes.
Next steps
- Read-Only Viewer -- Display saved content without editing
- Custom AI Provider -- Connect to any LLM backend
- Custom Upload Handler -- Integrate with S3, R2, or Supabase
- Custom Slash Items -- Extend the command palette