Documentation
Node Registry
How components are decoupled and registered.
PaperCast fundamentally separates the structure of a document (JSON Schema) from its visual representation (React Components). Because of PaperCast's Headless Architecture, this separation is achieved through two distinct registries: the Schema Registry in the core engine and the Node Registry (Component Registry) in the React headless binding.
The Dual Registry System
1. Schema Registry (@papercast/engine)
This registry stores the logic for how a node behaves physically (its height and how it splits). It has no knowledge of React or the DOM.
export interface SchemaTypeDefinition<TNode extends AnyNode = AnyNode> {
type: string;
measure: (node: TNode, ctx: MeasureContext) => number;
split?: (
node: TNode,
remainingHeight: number,
ctx: SplitContext
) => [TNode, TNode | null, number?] | null;
}
2. Node Registry (@papercast/react)
This is the React-specific registry that extends the Schema Registry. It maps the node type to an actual React component. When you register a node here, it automatically proxies the measure and split definitions down to the core engine's SchemaRegistry.
API Reference: ComponentTypeDefinition
When you create a new widget or node type in PaperCast, you register it with the React NodeRegistry using this interface:
import { SchemaTypeDefinition } from "@papercast/engine";
export interface ComponentTypeDefinition<
TNode extends AnyNode = AnyNode,
> extends SchemaTypeDefinition<TNode> {
render: React.ComponentType<{
node: TNode;
path?: string;
pageContext?: { pageNumber: number; pageCount: number };
injectedProps?: React.HTMLAttributes<HTMLDivElement> & {
"data-selected"?: boolean;
};
}>;
}
Why is measuring separate from rendering?
Because PaperCast runs OffscreenMeasurer.tsx silently before doing any
pagination, the engine needs a lightweight way to ask a node how tall it will
be given a specific width constraint, without actually rendering the heavy
React tree to the screen.
Registering a Node
Here is an example of registering a simple "divider" node:
import { NodeRegistry } from "@papercast/react";
NodeRegistry.register({
type: "divider",
measure: () => 10, // A divider is always 10px tall
render: ({ node }) => (
<div style={{ height: "1px", background: "black", margin: "4px 0" }} />
),
});
Tutorial: Creating a Custom Signature Widget
Let's say you want to add a "Signature Box" to your document builder.
1. Create the React Component First, build the component that will render the signature line.
// src/components/renderer/nodes/SignatureNode.tsx
import React from "react";
import { AnyNode } from "@papercast/core";
export const SignatureNode = ({ node }: { node: AnyNode }) => {
return (
<div className="mt-8 flex flex-col w-64">
<div className="border-b border-black h-12" />
<span className="text-sm text-slate-500 mt-2 font-italic">Sign Here</span>
</div>
);
};
2. Register it with the React Engine
In your widget registration file (e.g., packages/react/src/widgets/basic.ts or wherever your UI widgets are defined):
import { NodeRegistry } from "@papercast/react";
import { SignatureNode } from "./SignatureNode";
NodeRegistry.register({
type: "signature",
// A signature box is roughly 70px tall (48px line + 20px text)
measure: () => 70,
render: SignatureNode,
});
3. Add it to the DnD Widget Menu (App Layer) Now, define the default JSON payload that should be injected into the AST when a user drags the widget from the sidebar into the document:
// Inside your app's widget definitions
export const signatureWidgetDef = {
id: "signature-widget",
name: "Signature Box",
icon: "PenTool", // Lucide icon
defaultNode: {
type: "signature",
layout: {
marginTop: 20,
},
},
};
That's it! The core engine will handle pagination logic via the proxied schema registry, while @papercast/react will render your custom Signature node.