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-adminRegister
// apps/admin/src/plugins.ts
import { WysiwygPlugin } from '@orthacms/wysiwyg-admin';
WysiwygPlugin(); // after ContentPlugin() — it fills ENTRY_FIELD_CONTROL_SLOTNo 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 asWYSIWYG_WIDGET.maxLength/minLengthcount the body’s text, not its markup.structure: 'off'turns off the structural checks listed below.- Since 0.7.0,
admin.groupputs a field in one of the type’s collapsiblegroups, and every field is full width unless it setsadmin.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 includelink,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? }. onInsertplaces the embeds at the caret in order and does not close the source, so “insert and keep browsing” works.