npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@loud-owls/editor

v0.5.8

Published

Block editor for React — headless core, React bindings and basic blocks in one package

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.

npm version license


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 aiPlugin adds 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/editor

Peer dependencies — make sure these are already in your project:

npm install react react-dom

Quick 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 .dark scoped 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 — generate
  • Escape — 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 reorder

License

MIT