Skip to content

Schema Customization

Jayden Smith edited this page Sep 7, 2026 · 6 revisions

Schema Customization

A schema is the document contract for a handle. It determines valid JSON and HTML, empty-document construction, commands, serialized output, viewer rendering, and collaboration snapshot compatibility. Choose it before creating a NativeEditorDocumentHandle; it cannot be changed for that handle later.

Built-in schemas and migration

defaultSchema is the default for handles and viewers. It is the same object as prosemirrorSchema, uses ProseMirror snake_case list and void-node names, and retains codeBlock for code blocks. tiptapCompatibleSchema preserves the former camelCase list and void-node names for existing stored content.

Preset Lists Void nodes
defaultSchema / prosemirrorSchema bullet_list, ordered_list, list_item hard_break, horizontal_rule, image
tiptapCompatibleSchema bulletList, orderedList, listItem hardBreak, horizontalRule, image

Both presets expose doc, paragraph, heading, blockquote, codeBlock, and text, plus the marks bold, italic, underline, strike, and link. A heading is represented publicly as { type: 'heading', attrs: { level: 1..6 } }; the schema projection maps it to the native h1 through h6 variants.

The old root export tiptapSchema was replaced by tiptapCompatibleSchema. Existing camelCase documents must pass that schema explicitly to every handle and viewer. New documents can omit schema or use defaultSchema.

Each compiled preset also has a keyed authoring form: defaultSchemaSpec, prosemirrorSchemaSpec, and tiptapCompatibleSchemaSpec.

Both presets now declare codeBlock.attrs.language with a default of null. Set it in JSON to record the code language; optional syntax coloring is configured through an addon, not through a different node type. See Code Syntax Highlighting.

Adding language changes the compiled schema fingerprint even for documents with no code blocks. Existing JSON can be loaded under the new schema after validating the content, but collaboration snapshots created with the old schema must not be treated as compatible. Migrate persisted content into a new compatible room/snapshot and decide how to handle old history, or keep the previous schema explicitly until every peer and stored snapshot is migrated. Coordinate custom attribute constraints the same way.

Do not rename stored node types and assume the result is equivalent. Node names, mark names, projections, attributes, and HTML rules are part of schema identity and the collaboration fingerprint.

Author a keyed schema

defineSchema() accepts a ProseMirror-shaped SchemaSpec keyed by public JSON node and mark names and compiles it into the serializable SchemaDefinition used by native code.

import {
  defineSchema,
  resolveDocumentDescriptor,
} from '@apollohg/react-native-rich-text-editor';

const articleSchema = defineSchema({
  nodes: {
    article: { content: 'block+', role: 'doc' },
    paragraph: {
      content: 'inline*',
      group: 'block',
      parseDOM: [{ tag: 'p' }],
      toDOM: ['p', 0],
    },
    callout: {
      content: 'inline*',
      group: 'block',
      attrs: { tone: { default: 'info' } },
      parseDOM: [{ tag: 'aside' }],
      toDOM: ['aside', 0],
    },
    text: { group: 'inline' },
  },
  marks: {
    highlight: {
      parseDOM: [{ tag: 'mark' }],
      toDOM: ['mark', 0],
    },
  },
});

const articleDescriptor = resolveDocumentDescriptor(articleSchema);

articleDescriptor.documentNodeName is article. Pass articleSchema to the handle and every RichTextViewer that renders its content.

SchemaSpec

Field Contract
nodes Required record keyed by the public JSON node type.
marks Optional record keyed by the public JSON mark type.
atoms Optional AtomNodeDefinition[] composed into the compiled schema. See Custom Atom Nodes.

SchemaNodeSpec

Field Contract
content ProseMirror-style content expression. Defaults to an empty leaf.
group Space-separated content groups such as block or inline.
attrs Declared persisted attributes. An AttrSpec without default is required.
role Optional native semantic role. Common doc, paragraph, and text shapes are inferred; use heading for an attribute-projected heading.
parseDOM Declarative input tag rules with optional fixed attrs.
toDOM One static [tag] or [tag, 0] output, or an attribute-driven output projection.
html NodeHtmlRules for lossless custom void-node attributes.
isVoid Marks a node with no editable content.
allowUndeclaredAttrs Explicit pass-through opt-in for undeclared JSON metadata.

SchemaMarkSpec supports attrs, excludes, parseDOM, toDOM, and allowUndeclaredAttrs. Custom mark HTML output is restricted to inert supported tags: span, strong, em, u, s, code, a, sub, sup, and mark.

content supports node or group symbols, sequences, alternatives, parentheses, and ?, *, +, {n}, and {min,max} quantifiers. A valid schema has exactly one document role and one text role and can construct a valid empty document.

Attribute-driven node projections

Use an AttributeDOMOutputSpec when one public JSON node type maps to several native HTML variants. The built-in heading is the canonical example:

const heading = {
  content: 'inline*',
  group: 'block',
  role: 'heading' as const,
  attrs: { level: { default: 1 } },
  parseDOM: [1, 2, 3, 4, 5, 6].map((level) => ({
    tag: `h${level}`,
    attrs: { level },
  })),
  toDOM: {
    switchOn: 'level',
    cases: {
      1: ['h1', 0],
      2: ['h2', 0],
      3: ['h3', 0],
      4: ['h4', 0],
      5: ['h5', 0],
      6: ['h6', 0],
    },
  },
};

The switch attribute must be declared and use one consistent scalar type. When parseDOM is supplied, its rules must map one-to-one to the output cases. The compiled native variants retain a JSON projection back to the public type and discriminator attribute.

The related public types are ParseDOMRule, DOMOutputSpec, AttributeDOMOutputSpec, and NodeJSONProjection.

Declarative HTML rules for void nodes

NodeHtmlRules supports lossless attributes on custom void nodes:

interface NodeHtmlRules {
  tag: string;
  staticAttrs?: Record<string, string>;
  attrMap?: Record<string, string>;
}

staticAttrs must provide a discriminator that identifies the void node when parsing. attrMap maps every declared JSON attribute to a unique HTML attribute without colliding with the discriminator. Prefer defineAtomNode() for component-backed atoms because it validates safe tags, fills default data-* mappings, and rejects incomplete, ambiguous, or colliding rules. See Custom Atom Nodes.

Compiled schema types

SchemaDefinition, NodeSpec, and MarkSpec are the lower-level serializable forms sent to the native engine. defineSchema() produces them and should be the ordinary authoring path.

Type Important fields
SchemaDefinition nodes: NodeSpec[], marks: MarkSpec[]
NodeSpec name, content, role, optional group, attrs, htmlTag, html, json, isVoid, allowUndeclaredAttrs
MarkSpec name, optional attrs, excludes, htmlTag, allowUndeclaredAttrs
NodeJSONProjection Public type and optional fixed attrs for a compiled native variant.
AttrSpec Optional default, type, enum, min, and max; omitting default makes the attribute required.

Attribute types are string, number, boolean, object, or array. Bounds require a compatible explicit type and apply to numeric values, Unicode string lengths, or array lengths. Enum values must share one JSON type. Constraints validate defaults, imports, and mutation candidates and contribute to the schema fingerprint. For inferred component and fragment types, see Custom Atom Nodes.

allowUndeclaredAttrs is an escape hatch, not a convenience setting. Use it only when arbitrary persisted metadata is a deliberate part of the node or mark contract. Atom definitions do not allow it.

Mention, image, and atom extensions

Use extension helpers instead of copying shipped node specs:

import {
  defaultSchema,
  withAtomsSchema,
  withImagesSchema,
  withMentionsSchema,
} from '@apollohg/react-native-rich-text-editor';

const messageSchema = withAtomsSchema(
  withMentionsSchema(withImagesSchema(defaultSchema)),
  atomDefinitions
);

withImagesSchema() and withMentionsSchema() return the input unchanged when it already has the standard image or mention node. The presets already include image. withAtomsSchema() similarly adds each definition once but rejects a conflicting node with the same name or ambiguous atom HTML rules.

The image helpers remain IMAGE_NODE_NAME, ImageNodeAttributes, imageNodeSpec(), withImagesSchema(), and buildImageFragmentJson(). The standard image has required src and nullable alt, title, width, and height attributes.

Build fragments against the active root

insertContentJson() accepts a document fragment. For a custom root, resolve its descriptor and reuse it:

const descriptor = resolveDocumentDescriptor(articleSchema);

const callout = buildDocumentFragmentJson(
  [
    {
      type: 'callout',
      attrs: { tone: 'warning' },
      content: [{ type: 'text', text: 'Check the result.' }],
    },
  ],
  descriptor
);

const image = buildImageFragmentJson(
  { src: 'https://cdn.example.com/report.png', alt: 'Report chart' },
  descriptor
);

The helpers set the document-root type; they do not make an undeclared node valid.

Compatibility rules

  • Create a handle with the final schema, fragment name, policy, and resource limits. An editor prop cannot override them.
  • Use the same schema for stored content, viewers, and every participant in a collaboration room.
  • A room snapshot records the schema fingerprint and fragment name. A changed schema requires an explicit content migration and compatible history strategy.
  • Invalid or unconstructible schemas are rejected with schema errors; public handle or viewer creation does not silently substitute a fallback schema.
  • Keep maxSchemaNodes and maxSchemaExpressionBytes large enough for the schema while staying within the hard bounds in Production Limits and Errors.

For handle construction and schema exports, see Document API Reference.

Clone this wiki locally