Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Rich text plugin

The rich-text control, its opt-out, the stored document and the media slot.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

How a richtext field looks in the entry form. Since 0.12.0 an author edits the body in place: a click puts the caret in it, the toolbar appears once it has focus, and a long body scrolls inside the field. Its Expand button opens the full TipTap editor in the record’s work area — text style, colours, alignment, lists, links, callouts, tables, columns, images and video — and Done brings you back. A reader without write access sees the body as it reads, with View to open the same view read-only.

Install

npm install @orthacms/wysiwyg-admin

Register

// apps/admin/src/plugins.ts
import { WysiwygPlugin } from '@orthacms/wysiwyg-admin';

WysiwygPlugin(); // after ContentPlugin() — it fills ENTRY_FIELD_CONTROL_SLOT

No server half, no configuration, no environment variables. richtext is a content field type; this plugin only changes its control, not the schema, the stored value or the API.

Which fields it claims

Every richtext field by default. Opt a field out to keep a plain textarea — for markup edited on purpose, such as an email template:

// apps/server/src/content/collections/newsletter.ts
import { collection, field } from '@orthacms/content-server/define';

export const newsletter = collection('newsletter', {
    label: 'Newsletter',
    groups: { advanced: { label: 'Advanced', collapsed: true } },
    fields: {
        body: field.richtext({ required: true, maxLength: 20_000 }), // the rich-text editor
        emailHtml: field.richtext({
            structure: 'off', // not a document: skip heading/table/link checks
            admin: { widget: 'textarea', group: 'advanced' } // a plain textarea
        })
    }
});
  • admin.widget: 'wysiwyg' (the default, stated explicitly) or 'textarea'. Both values are exported as WYSIWYG_WIDGET.
  • maxLength / minLength count the body’s text, not its markup.
  • structure: 'off' turns off the structural checks listed below.
  • Since 0.7.0, admin.group puts a field in one of the type’s collapsible groups, and every field is full width unless it sets admin.width: 'half'.

Structure checks

Under the document the editor lists the problems the server checks on save: a skipped heading level, a table with no header cells, link text that says nothing, an unparseable language tag. Same rules on both sides: an error here refuses the save; a warning does not. More formatting ▸ Language of this passage… marks text in another language.

What gets stored

A document — the editor’s node tree, not an HTML string:

{
    "type": "doc",
    "content": [
        {
            "type": "heading",
            "attrs": { "level": 2 },
            "content": [{ "type": "text", "text": "Spring launch" }]
        },
        {
            "type": "paragraph",
            "content": [
                { "type": "text", "text": "Read the " },
                {
                    "type": "text",
                    "text": "announcement",
                    "marks": [{ "type": "link", "attrs": { "href": "https://example.com/news" } }]
                }
            ]
        }
    ]
}
  • Node types include paragraph, heading, bulletList, orderedList, blockquote, codeBlock, image, table, container. Marks include link, bold, italic, code, highlight, language.
  • A body saved before the document format is still an HTML string. Opening it parses it into the editor, and the first save stores the document — content upgrades as it is edited.

See rich text for rendering the document in a delivery client.

Images and video

The editor holds images and video but knows nothing about the Media Library. Out of the box an author pastes a URL; the media plugin adds Media Library… and Upload files… through WYSIWYG_MEDIA_SLOT.

  • An inserted image is a URL plus display metadata (width, alignment, alt) — not a reference to a library asset. Later edits to the asset do not reach the body.
  • Only http(s) URLs and same-origin paths are accepted.
  • An image with no alt text and no decorative mark shows an Add alt text prompt.

Adding a media source

Fill WYSIWYG_MEDIA_SLOT to give the editor another place to get media from (a DAM, a stock-photo search):

import type { AdminPlugin } from '@orthacms/bootstrap-admin';
import {
    WYSIWYG_MEDIA_SLOT,
    type WysiwygMediaSourceContext
} from '@orthacms/wysiwyg-admin';

function StockPhotoSource({ open, onOpenChange, accept, onInsert }: WysiwygMediaSourceContext) {
    // Render your picker while `open`; then hand back what was chosen:
    const choose = (url: string, alt: string) =>
        onInsert([{ kind: 'image', src: url, alt }]); // kind must be in `accept`
    return <StockPhotoDialog open={open} onOpenChange={onOpenChange} onPick={choose} />;
}

export function StockPhotosPlugin(): AdminPlugin {
    return {
        name: 'stock-photos',
        slots: [
            {
                slot: WYSIWYG_MEDIA_SLOT,
                items: [
                    {
                        id: 'stock-photos',
                        label: { id: 'stock.source', defaultMessage: 'Stock photos…' },
                        order: 30,
                        Source: StockPhotoSource
                    }
                ]
            }
        ]
    };
}
  • An embed is { kind: 'image' | 'video', src, alt?, width? }.
  • onInsert places the embeds at the caret in order and does not close the source, so “insert and keep browsing” works.