blokhaus

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

app/editor/page.tsx
"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:

app/api/ai/generate/route.ts
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):

app/api/upload/route.ts
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

ComponentPurpose
FloatingToolbarAppears on text selection with Bold, Italic, Underline, Strikethrough, Code, Link buttons
SlashMenuCommand palette triggered by / at the start of an empty paragraph
OverlayPortalNotion-style drag handles on hover (desktop only)
MobileToolbarBottom-anchored formatting toolbar on touch devices

Headless Plugins

PluginPurpose
InputRulePluginMarkdown shortcuts (# , > , - , etc.)
PastePluginSanitizes HTML from external paste sources
LinkPluginURL detection and link editing
ListPluginOrdered, unordered, and task list behaviors
ImagePluginDrag/drop and paste image upload with optimistic UI
VideoPluginVideo embed (YouTube, Vimeo, Loom) and file upload
AIPluginAI content generation with streaming preview
MentionPlugin@user and #tag mentions with async search
TablePluginTable insertion and cell navigation
TableActionMenuRight-click context menu for table operations
TableHoverActionsRow/column handles and resize
TogglePluginCollapsible toggle blocks
CalloutPluginStyled callout blocks with emoji and color presets
EmojiPickerPluginEmoji search and insertion via the slash menu
ColorPluginText and background color formatting
DirectionPluginLTR/RTL text direction
BlockSelectionPluginMulti-block selection with keyboard shortcuts

Hooks

HookPurpose
useEditorStateDebounced serialized JSON state with onChange callback
useWordCountLive 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