@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
useOpenEditorControllerfor editor state, commands, document exports, and selection stateOpenEditorContentfor the editable Tiptap-backed content surfaceOpenEditorBubbleMenufor Tiptap-owned selection visibility and positioningOpenEditorViewerfor read-only React renderingOpenEditorPageHeaderfor host-backed page title and icon editinggetDefaultBlockPickerItemsfor host-owned block rails, pickers, and command palettesgetDefaultSlashMenuItemsfor 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.tabledomain API for selection-aware row, column, header, cell, and table commands OpenEditorBlockDragHandleanduseOpenEditorBlockInteractionfor headless block-handle UIsdefineOpenEditorReactNodefor consumer-owned React blocksdefineOpenEditorReactExtensionfor 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.
