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

@openeditor/react

v0.0.38

Published

Headless React bindings for OpenEditor's web editor runtime.

Downloads

4,863

Readme

@openeditor/react

Headless React bindings for OpenEditor's web editor runtime.

Pass imageRuntime and attachmentRuntime to useOpenEditorController for host-backed media and to OpenEditorViewer for identity-based URL resolution. Progress, cancellation, retry input, and errors remain transient node-view state and are never emitted through document JSON.

An inserted image starts as an empty image node—never a fake placeholder URL. The built-in node view offers upload, drag/drop, URL embedding, replacement, alternative-text editing, progress, retry, and removal. The upload button is shown only when the host supplies selectImage and uploadImage:

const imageRuntime = {
  selectImage: () => chooseImageFromYourPicker(),
  validateImage: (input) => input.size && input.size > 15_000_000
    ? { accepted: false, message: "Image is too large." }
    : { accepted: true },
  uploadImage: (input, { signal, onProgress } = {}) =>
    uploadToYourStorage(input.source, { signal, onProgress }),
  resolveImage: (imageId, { signal } = {}) =>
    resolveImageFromYourStorage(imageId, { signal }),
  replaceImage: (imageId, input, options) =>
    replaceImageInYourStorage(imageId, input.source, options),
};

const controller = useOpenEditorController({ imageRuntime });

uploadImage and replaceImage return an OpenEditorImageSnapshot. Store a durable imageId, public src, or both; resolveImage may return a temporary owner-preview URL such as a blob: URL without serializing it.

Public surface

  • useOpenEditorController for editor state, commands, document exports, and selection state
  • OpenEditorContent for the editable Tiptap-backed content surface
  • OpenEditorBubbleMenu for Tiptap-owned selection visibility and positioning
  • OpenEditorViewer for read-only React rendering
  • OpenEditorPageHeader for host-backed page title and icon editing
  • getDefaultBlockPickerItems for host-owned block rails, pickers, and command palettes
  • getDefaultSlashMenuItems for UI packages that want the built-in insert actions
  • stable-identity block targeting and transaction-backed copy, duplicate, move, and delete commands
  • a first-class controller.table domain API for selection-aware row, column, header, cell, and table commands
  • OpenEditorBlockDragHandle and useOpenEditorBlockInteraction for headless block-handle UIs
  • defineOpenEditorReactNode for consumer-owned React blocks
  • defineOpenEditorReactExtension for advanced integrations that contribute multiple Tiptap extensions

This package intentionally does not import CSS and does not render styled menus. Use @openeditor/ui for the optional styled components.

Read-only rendering and URL policy

OpenEditorViewer renders the portable document through an explicit registry of built-in and structural renderers. Unknown nodes are preserved for forward readability but carry data-openeditor-unknown-node so unsupported content never silently appears to be a fully supported block. Built-in viewer output and safe HTML export share stable oe-* classes and data-openeditor-* hooks for document semantics such as columns, tasks, toggles, callouts, tables, pages, and files.

Viewer URLs use openEditorPublicUrlPolicy by default. The policy covers links, images, pages, and attachments, including snapshots returned by page and attachment runtimes. It accepts HTTP(S) and ordinary relative references, plus mailto: and tel: for text links, while rejecting executable and local-preview schemes. Owner previews can opt into a narrowly scoped policy:

import {
  OpenEditorViewer,
  openEditorPublicUrlPolicy,
  type OpenEditorUrlPolicy,
} from "@openeditor/react";

const previewUrlPolicy: OpenEditorUrlPolicy = (value, context) => {
  if (context === "attachment" && value.startsWith("blob:")) return value;
  return openEditorPublicUrlPolicy(value, context);
};

<OpenEditorViewer document={document} urlPolicy={previewUrlPolicy} />;

Custom viewer renderers receive both urlPolicy and a typed resolveUrl helper. The host remains responsible for arbitrary custom renderer output and for actions performed inside openPage or openAttachment callbacks.

Block actions

Every addressable node has a persistent openeditor-id. The block interaction snapshot publishes the stable block reference under the handle without putting pointer-frequency state into the editor controller. Mutating commands resolve that identity against the current ProseMirror document immediately before dispatch, preserving history and avoiding stale positions:

const { activeBlock } = useOpenEditorBlockInteraction(controller);
if (activeBlock) {
  controller.selectBlock(activeBlock);
  await controller.copyBlock(activeBlock);
  controller.duplicateBlock(activeBlock);
  controller.moveBlock(1, activeBlock);
  controller.deleteBlock(activeBlock);
}

Pass blockActions to contribute product-specific actions. The optional OpenEditorBlockMenu from @openeditor/ui combines those contributions with Move Up, Move Down, Copy, Duplicate, and Delete. Action contexts receive context.block, never a ProseMirror position or DOM rectangle. Clipboard copies use a versioned OpenEditor block envelope so blocks can move between independent editor instances and documents while receiving fresh identities.

Handle ownership is structural, not inferred from pointer proximity. Callouts, quotes, tables, and consumer blocks own a single handle for their content by default. Lists, toggles, and columns expose their independent nested blocks. Set blockHandle: "nested" on a consumer extension whose child blocks should receive their own handles. Placement always follows the live DOM element for the resolved ProseMirror node, including through scrolling, resizing, and reflow.

Hover targeting is owned by OpenEditor rather than the Tiptap drag-handle extension. The editor's interaction surface is one continuous hover region: content, the reserved left gutter, and the handle itself. Pointer coordinates resolve to the deepest structurally addressable block, with collapsed outer margins included so gaps between blocks do not become dead zones. A short hide delay lets the pointer cross from content to the handle, while pressing, dragging, or opening the block menu freezes the active target. Pointer movement is coalesced to one resolution per animation frame and identity changes are the only changes published to React.

The gutter is part of layout, not a collision workaround. Its reservation is continuous rather than breakpoint-based: the editor borrows only the portion of the 42px gutter that physically fits to its left and reserves the remainder inside its width. Floating placement may correct vertical overflow but must never shift horizontally into block content.

Host-owned block pickers

Use getDefaultBlockPickerItems when blocks need to be discoverable outside the slash menu. The catalog contains built-in insert presets and registered custom blocks, but deliberately excludes slash-only actions such as moving a block or converting a whole document. Use sortBlockPickerItems when merging additional host-owned entries into the catalog.

import {
  getDefaultBlockPickerItems,
  OpenEditorContent,
  useOpenEditorController,
} from "@openeditor/react";

export function EditorWithBlockRail() {
  const controller = useOpenEditorController({ extensions });
  const items = getDefaultBlockPickerItems(controller);

  return (
    <>
      <aside aria-label="Blocks">
        {items.map((item) => (
          <button
            key={item.key}
            onMouseDown={(event) => event.preventDefault()}
            onClick={item.insert}
            type="button"
          >
            {item.label}
          </button>
        ))}
      </aside>
      <OpenEditorContent controller={controller} />
    </>
  );
}

item.insert() restores focus and inserts at the controller's current editor selection. Preventing the button's mouse-down default keeps the caret visibly anchored while the user moves from the editor to a host-owned picker.

The built-in web catalog includes editable Toggle Lists, rich-content Callouts, and Mermaid-powered Diagrams. Diagrams use beautiful-mermaid for synchronous, themeable SVG rendering and retain their canonical Mermaid source in the document.

Quick Start

import { OpenEditorContent, useOpenEditorController } from "@openeditor/react";

export function Example() {
  const controller = useOpenEditorController({ initialDocument });

  return <OpenEditorContent controller={controller} />;
}

Editors are uncontrolled on both web and native. initialDocument is read once; use controller.setContent(document) for an undoable programmatic replacement. It emits onChange and preserves the current selection where possible. Runtime changes to callbacks, editable, and placeholder are applied without remounting.

Exports are lazy. controller.export("html") returns publishing-safe built-in HTML. controller.exportUnsafe("html") opts into trusted custom extension HTML.

Pass enabledBlocks to restrict authoring without removing nodes from the schema. Existing disabled blocks remain readable. Runtime-dependent insertion is automatically disabled unless page creation or attachment upload is wired.

Custom blocks

Custom blocks belong to the consuming product. OpenEditor hosts them without requiring a fork or a change to its built-in block catalog. A web block owns:

  • a portable block identity and default JSON node;
  • its Tiptap schema and editable React node view;
  • slash-menu discovery;
  • a read-only React renderer;
  • HTML and plain-text exporters;
  • an honest web/native support declaration.

Keep the extension array referentially stable. Defining extensions at module scope, as below, is the simplest option.

import { HugeiconsIcon } from "@hugeicons/react";
import { PackageOpenIcon } from "@hugeicons/core-free-icons";
import {
  defineOpenEditorReactNode,
  NodeViewWrapper,
  OpenEditorContent,
  type OpenEditorNodeViewProps,
  useOpenEditorController,
} from "@openeditor/react";

function ProductCardNode({ node, updateAttributes, editor }: OpenEditorNodeViewProps) {
  return (
    <NodeViewWrapper data-product-card contentEditable={false}>
      <input
        disabled={!editor.isEditable}
        value={String(node.attrs.title ?? "")}
        onChange={(event) => updateAttributes({ title: event.target.value })}
      />
    </NodeViewWrapper>
  );
}

const productCard = defineOpenEditorReactNode({
  block: {
    name: "acme.productCard",
    nodeType: "acmeProductCard",
    label: "Product Card",
    group: "embed",
    defaultNode: () => ({
      type: "acmeProductCard",
      attrs: { productId: null, title: "Untitled product" },
    }),
    support: { web: "supported", native: "unsupported" },
  },
  node: {
    group: "block",
    atom: true,
    draggable: true,
    addAttributes: () => ({
      productId: { default: null },
      title: { default: "Untitled product" },
    }),
    parseHTML: () => [{ tag: "article[data-product-card]" }],
    renderHTML: ({ HTMLAttributes }) => [
      "article",
      { ...HTMLAttributes, "data-product-card": "" },
    ],
  },
  component: ProductCardNode,
  insertMenu: {
    icon: (props) => <HugeiconsIcon {...props} icon={PackageOpenIcon} />,
    keywords: ["product", "commerce", "card"],
    order: 250,
  },
  viewer: ({ node, resolveUrl }) => (
    <article>
      <a href={resolveUrl(node.attrs?.href, "link")}>{String(node.attrs?.title ?? "")}</a>
    </article>
  ),
  exporters: {
    html: {
      acmeProductCard: ({ node, escapeHtml }) =>
        `<article>${escapeHtml(String(node.attrs?.title ?? ""))}</article>`,
    },
    text: {
      acmeProductCard: ({ node }) => String(node.attrs?.title ?? ""),
    },
  },
});

const extensions = [productCard] as const;

export function ProductEditor() {
  const controller = useOpenEditorController({ extensions });
  return <OpenEditorContent controller={controller} />;
}

Pass the same extension array to OpenEditorViewer. The controller uses registered exporters only through the explicit controller.exportUnsafe(...) path, and registered blocks appear in getDefaultSlashMenuItems, which powers @openeditor/ui.

Insert-menu entries accept an optional React icon component and numeric order. OpenEditor assigns built-in entries orders in increments of 100, so consumer blocks can be placed between them (for example, order: 250 appears between Heading 1 and Heading 2). Entries with the same order retain their declaration order, and entries without an order appear after ordered entries. This metadata is shared by slash menus and getDefaultBlockPickerItems; use insertMenu: false to keep a block out of both discovery surfaces. The same fields are available on direct slashMenuItems; a slash menu's explicit items prop is also sorted by this rule.

Block name is the stable public identifier. Namespace consumer blocks to avoid collisions. nodeType is the serialized ProseMirror node type and defaults to name; use an identifier without punctuation when interoperability requires it. Duplicate names and node types fail immediately during editor construction.

Pages

The first-party page block models a Notion-style child page as a reference to a separately persisted document. Supply pageRuntime to connect creation, resolution, metadata updates, and navigation to your application:

const controller = useOpenEditorController({
  pageRuntime: {
    createPage: async ({ title, icon }) => api.pages.create({ title, icon }),
    resolvePage: async (pageId) => api.pages.get(pageId),
    updatePage: async (pageId, update) => api.pages.update(pageId, update),
    openPage: (page) => router.push(`/pages/${page.pageId}`),
  },
});

Render OpenEditorPageHeader on the opened page surface to provide the canonical editable title and icon. Both fields call pageRuntime.updatePage; page references resolve the same page entity instead of owning independent metadata.

The document stores only the stable reference and cached presentation. Page contents, hierarchy, permissions, routing, and deletion remain host-owned.

For an externally stored object, persist a stable ID in node attributes rather than embedding the entire application record. The React node view can resolve that ID using the consumer's own data layer. OpenEditor deliberately has no knowledge of that service.