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.