@loud-owls/editor
v0.5.8
Published
Block editor for React — headless core, React bindings and basic blocks in one package
Maintainers
Readme
@loud-owls/editor
A lightweight, plugin-first block editor for React. Headless core with beautiful defaults — bring your own styles or use the built-in theme.
Features
- Block-based document model — flat JSON tree, easy to persist and sync
- Plugin architecture — every block type, command, keymap and slash menu item comes from a plugin
- Headless core — zero opinion on styling; apply your own CSS or use the default theme
- Built-in header — dark/light mode toggle always included; optional locale switcher, JSON viewer, and Markdown viewer via boolean props
- Rich inline marks — bold, italic, underline, strikethrough, inline code, links, and text highlight with color picker
- Slash menu — type
/to insert any block type with fuzzy search - @mention — type
@to open a user picker; inserts an atomic inline chip with keyboard navigation and avatar support - Floating selection toolbar — format selected text instantly
- Block handle — drag to reorder blocks, click for color and delete options
- Built-in blocks — paragraphs, headings (H1–H3), bullet list, numbered list, checklist, blockquote, code block (with syntax highlighting), image (resizable), YouTube embed (resizable), divider
- AI Write — optional
aiPluginadds a slash menu command that prompts the user for instructions and calls your backend to generate blocks; API keys stay server-side - RTL support — Arabic, Hebrew, Farsi — layout and handles flip automatically
- i18n — English, French, German, Punjabi, Arabic out of the box; extend for any locale
- Undo / redo — full history with 100-entry depth and typing coalescing
- Read-only mode — toggle editable at runtime
- TypeScript — fully typed API, strict mode
Installation
npm install @loud-owls/editor
# or
bun add @loud-owls/editorPeer dependencies — make sure these are already in your project:
npm install react react-domQuick start
import { OwlsEditor, useEditor, basicBlocks } from "@loud-owls/editor";
import "@loud-owls/editor/index.css";
export default function App() {
const editor = useEditor({
plugins: [basicBlocks()],
});
return <OwlsEditor editor={editor} className="owls-root" />;
}That's it. The editor is ready with all basic blocks, slash menu, toolbar and drag handles.
Built-in header
The editor ships with a header bar that always includes a dark/light mode toggle. Additional controls are opt-in via props:
import { OwlsEditor, useEditor, basicBlocks } from "@loud-owls/editor";
import type { LocaleOption } from "@loud-owls/editor";
import "@loud-owls/editor/index.css";
const locales: LocaleOption[] = [
{ code: "en", label: "English" },
{ code: "ar", label: "العربية" },
{ code: "fr", label: "Français" },
];
export default function App() {
const editor = useEditor({ plugins: [basicBlocks()] });
return (
<OwlsEditor
editor={editor}
className="owls-root"
showJSON // show JSON export panel
showMarkdown // show Markdown export panel
showLanguagePicker // show locale switcher
availableLocales={locales}
/>
);
}- Dark/light toggle — always present; manages
.darkscoped to the editor wrapper (does not affect the rest of your page) - JSON panel — expands below the header with formatted JSON and a one-click Copy button
- Markdown panel — same for Markdown
- Locale picker — dropdown that calls
editor.setLocale(), flipping RTL layout automatically when applicable
Dark mode
The built-in header toggle manages dark mode scoped to the editor wrapper. If you prefer to control dark mode from outside (e.g. with next-themes or Tailwind), add a .dark class to any ancestor — it takes precedence and both approaches work together.
// next-themes example — dark mode across the whole app
import { ThemeProvider } from "next-themes";
<ThemeProvider attribute="class">
<App />
</ThemeProvider>Syntax highlighting
Pass any highlighter function to basicBlocks. The example below uses highlight.js:
import hljs from "highlight.js/lib/common";
import "highlight.js/styles/github-dark.css";
function highlight(code: string, language: string): string {
if (!language || language === "plaintext") return code;
try {
return hljs.highlight(code, { language, ignoreIllegals: true }).value;
} catch {
return code;
}
}
const editor = useEditor({
plugins: [basicBlocks({ highlight })],
});Image uploads
Provide an onUpload callback to handle file uploads for the image block:
async function uploadFile(file: File): Promise<string> {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: form });
const { url } = await res.json();
return url;
}
const editor = useEditor({
plugins: [basicBlocks()],
onUpload: uploadFile,
});Loading content from an API
Use useAsyncEditor when initial blocks come from a server. Pass a fetchBlocks function — the editor shows an animated skeleton until the data arrives, then renders the content.
import { OwlsEditor, useAsyncEditor, basicBlocks } from "@loud-owls/editor";
export default function App() {
const { editor, loading } = useAsyncEditor({
plugins: [basicBlocks()],
fetchBlocks: async () => {
const res = await fetch("/api/doc/123");
const data = await res.json();
return data.blocks; // Block[]
},
});
return (
<OwlsEditor
editor={editor}
loading={loading} // shows skeleton while fetching
className="owls-root"
/>
);
}For static / already-loaded data, use the existing useEditor with initialBlocks:
const editor = useEditor({
plugins: [basicBlocks()],
initialBlocks: myBlocks, // Block[] you already have
});Reading and writing content
import { toJSON, toMarkdown } from "@loud-owls/editor";
// Get current state
const state = editor.getState();
// Export as JSON (source of truth)
const json = toJSON(state); // string
// Export as Markdown
const defs = new Map(
editor.getPlugins().flatMap((p) =>
(p.blocks ?? []).map((b) => [b.type, b])
)
);
const markdown = toMarkdown(state, defs);
// Load saved content
const editor = useEditor({
plugins: [basicBlocks()],
initialBlocks: JSON.parse(savedJson).blocks,
});Locale / RTL
// Switch locale at runtime
editor.setLocale("ar"); // Arabic — layout flips to RTL automatically
// Supported out of the box: "en", "fr", "de", "pa", "ar"CSS variables
Override any design token to match your brand:
:root {
--owls-page-bg: #ffffff;
--owls-page-fg: #18181b;
--owls-link: #7c3aed;
--owls-code-bg: #f3f4f6;
--owls-toolbar-bg: #18181b;
--owls-toolbar-fg: #fafafa;
}AI Write
The aiPlugin adds an AI Write entry to the slash menu (type /ai). When selected, an inline prompt card appears — the user types their instructions and clicks Generate. The editor POSTs to your backend and inserts the returned blocks into the document.
API keys never leave your server — the editor only calls your endpoint, not an AI provider directly.
import { basicBlocks, aiPlugin } from "@loud-owls/editor";
const editor = useEditor({
plugins: [
basicBlocks({ highlight }),
aiPlugin({ endpoint: "/api/ai/generate" }),
],
});Backend contract — just return text or markdown:
POST /api/ai/generate
Content-Type: application/json
{ "prompt": "Write a blog intro about React hooks" }
200 OK
{ "text": "# React Hooks\n\nHooks let you use state and other React features..." }The editor automatically converts the returned markdown into the correct block types — headings, paragraphs, bullet lists, numbered lists, quotes, code blocks, checkboxes, dividers — including inline formatting (bold, italic, strikethrough, inline code, links). Your backend only needs to return the AI text, nothing else.
The editor automatically sends a systemPrompt in the request body that tells the AI to respond in markdown — your backend just passes it through. You don't need to write any formatting instructions yourself.
Example Next.js route handler:
// app/api/ai/generate/route.ts
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic(); // uses ANTHROPIC_API_KEY env var
export async function POST(req: Request) {
const { prompt, systemPrompt } = await req.json();
const message = await client.messages.create({
model: "claude-opus-4-7",
max_tokens: 2048,
system: systemPrompt, // passed from the editor — no extra work needed
messages: [{ role: "user", content: prompt }],
});
const text = message.content[0]?.type === "text" ? message.content[0].text : "";
return Response.json({ text });
}Works the same with OpenAI:
const completion = await openai.chat.completions.create({
model: "gpt-4o",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: prompt },
],
});
return Response.json({ text: completion.choices[0]?.message.content ?? "" });Your backend is just a thin proxy — it receives { prompt, systemPrompt } and returns { text }. The editor handles the rest.
Markdown → blocks mapping:
| Markdown | Block type |
|---|---|
| # Heading | heading-1 |
| ## Heading | heading-2 |
| ### Heading | heading-3 |
| - item | bullet-item |
| 1. item | numbered-item |
| > quote | quote |
| - [ ] task | check-item |
| ```code``` | code-block |
| --- | divider |
| anything else | paragraph |
Keyboard shortcuts in the prompt card:
Cmd+Enter— generateEscape— cancel (reverts to paragraph)
@mention
Pass a mentions array to <OwlsEditor> and typing @ anywhere in a text block opens a live-filtered user picker. Selecting a user (mouse click or keyboard) inserts an atomic @Name chip inline.
import { OwlsEditor, useEditor, basicBlocks, type MentionItem } from "@loud-owls/editor";
import "@loud-owls/editor/index.css";
const members: MentionItem[] = [
{ id: "usr_1", label: "Alice Johnson", avatar: "https://example.com/alice.jpg" },
{ id: "usr_2", label: "Bob Smith" }, // avatar is optional — falls back to initials
{ id: "usr_3", label: "Carol White" },
];
export default function App() {
const editor = useEditor({ plugins: [basicBlocks()] });
return (
<OwlsEditor
editor={editor}
className="owls-root"
mentions={members}
/>
);
}How it works
| Trigger | Behaviour |
|---|---|
| Type @ | Opens the picker showing all users |
| Type @jo | Filters to names containing "jo" (case-insensitive) |
| ↑ / ↓ | Move between items |
| Enter | Insert the highlighted user as a chip |
| Esc | Close without inserting |
| Click an item | Insert that user as a chip |
| Backspace next to a chip | Removes the entire chip in one keystroke |
MentionItem
interface MentionItem {
id: string; // unique ID stored in the document (e.g. database user ID)
label: string; // display name shown in the picker and the chip
avatar?: string; // optional avatar URL; omit to show a coloured initial badge
}Reading mentions from saved content
Mention chips are stored as regular inline spans in the block's content array. Each mention inline has a mention field alongside text:
// block.content entry for an @mention chip:
{
text: "@Alice Johnson",
mention: { id: "usr_1", label: "Alice Johnson" }
}To extract all mentioned user IDs from a document:
const mentionedIds = editor
.getState()
.blocks
.flatMap((block) => block.content)
.filter((inline) => inline.mention != null)
.map((inline) => inline.mention!.id);Styling
The chip and picker use .owls-* CSS variables so they adapt to your theme automatically. Override to customise:
/* Chip colour */
.owls-mention {
background: rgba(139, 92, 246, 0.12); /* violet tint */
color: #7c3aed;
}
/* Picker width */
.owls-mention-menu {
width: 280px;
}Writing a custom block plugin
import type { Plugin } from "@loud-owls/editor";
import type { ReactBlockDefinition } from "@loud-owls/editor";
const calloutBlock: ReactBlockDefinition = {
type: "callout",
label: "Callout",
defaultProps: { emoji: "💡" },
render: ({ block, editor }) => (
<div className="callout" data-block-id={block.id} data-block-type={block.type}>
<span>{block.props.emoji}</span>
<span>{block.content[0]?.text}</span>
</div>
),
toMarkdown: (b) => `> ${b.content[0]?.text ?? ""}`,
};
const calloutPlugin: Plugin = {
name: "callout",
blocks: [calloutBlock],
slashMenu: [
{
id: "callout",
label: "Callout",
description: "Highlighted note or tip",
icon: "💡",
group: "Basic blocks",
keywords: ["callout", "note", "tip", "info"],
action: (editor) => {
const sel = editor.getState().selection;
if (sel) editor.updateBlock(sel.blockId, { type: "callout", content: [], props: { emoji: "💡" } });
},
},
],
};
const editor = useEditor({ plugins: [basicBlocks(), calloutPlugin] });API reference
useEditor(options)
| Option | Type | Description |
|---|---|---|
| plugins | Plugin[] | Block plugins to load |
| initialBlocks | Block[] | Initial document content |
| onUpload | (file: File) => Promise<string> | File upload handler |
| locale | string | Initial locale (default: "en") |
| editable | boolean | Start in read-only mode (default: true) |
<OwlsEditor />
| Prop | Type | Default | Description |
|---|---|---|---|
| editor | Editor | — | Editor instance from useEditor |
| className | string | — | Class applied to the editing area (e.g. "owls-root") |
| style | CSSProperties | — | Inline styles applied to the outer wrapper (includes header) |
| onChange | (state: EditorState) => void | — | Fires on every state change |
| showJSON | boolean | false | Show a JSON export button in the header |
| showMarkdown | boolean | false | Show a Markdown export button in the header |
| showLanguagePicker | boolean | false | Show a locale switcher in the header |
| availableLocales | LocaleOption[] | [] | Locales for the picker: [{ code: "en", label: "English" }] |
| showThemeToggle | boolean | true | Show the dark/light toggle in the header. Pass false when the host app manages dark mode. When all header items are false, the header bar is hidden entirely |
| mentions | MentionItem[] | [] | Users available for @mention. Typing @ opens a picker filtered by this list |
| loading | boolean | false | Show an animated skeleton instead of editor content — use with useAsyncEditor while blocks are loading |
| rewriteEndpoint | string | — | Backend endpoint for the AI Rewrite feature in the selection toolbar |
Editor methods
editor.getState() // current EditorState
editor.setEditable(boolean) // toggle read-only
editor.setLocale(locale) // change language / direction
editor.undo() / editor.redo() // history
editor.insertBlockAfter(id, type) // add a block
editor.updateBlock(id, patch) // update block props or content
editor.removeBlock(id) // delete a block
editor.reorderBlock(id, toIndex) // drag-and-drop reorderLicense
MIT
