Rich Text Editor¶
pf-rich-editor.js turns a <textarea data-pf-editor="rich"> into a WYSIWYG editor built on
TipTap. The textarea keeps the HTML, so the form posts what it would
without JavaScript. It ships with every other assets/js/pf-*.js — init and
project:resync --js copy it to the web root — and it is loaded only by a page that asks for it.
// app/app.php — as the mass-message editor (see the Email guide)
'messaging' => ['editor' => 'rich', 'rich' => [
'upload' => adminUrl('Media/upload'),
'links' => adminUrl('search'),
'block_toolbar' => true, // the toolbar over the block at the cursor; off by default
]],
<textarea name="body" data-pf-editor="rich"><?php echo $e($html); ?></textarea>
<?php echo \Pramnos\Html\MessageEditor::scripts(\Pramnos\Html\MessageEditor::RICH); ?>
MessageEditor::scripts('rich') prints the editor's scoped stylesheet, the application's
defaults — the controls' words through t(), and the upload and links addresses from
messaging.rich — and the script.
What it does¶
- Text: bold, italic, two heading levels, bulleted and numbered lists, quotes, undo and redo.
- Links: the link at the cursor shows its address, to open, change or remove. With a
linksaddress the panel searches the site for a page to point it at. The selected words stay marked while the panel has the focus. - Pictures are
<figure><img alt><figcaption>: clicking one shows its alternative text and caption to edit. A bare<img>is read as a picture too. - Uploading: with an
uploadaddress, a picture dropped lands where it is dropped, one pasted at the cursor, and the toolbar's Picture chooses a file. It is POSTed asfilewith the page's CSRF token; the answer is{"url": "…"}. A paste that carries text as well stays text — Word pastes a picture of the selection beside it. - Blocks move up and down with Alt+↑ / Alt+↓, or the toolbar's ▲ ▼.
- The block toolbar, when the editor is set up with it: a small bar floating over the block at
the cursor while the text has the focus — move the block, make it a paragraph or a heading,
bold, italic, link. Off by default;
'block_toolbar' => trueinmessaging.richturns it on for the application,data-pf-rich-block-toolbar="1"(or"0") for one field,blockToolbarforcreate()and the Svelte component. A plugin's button withblock: trueis on it too. - Embeds: an address alone in a paragraph — YouTube, Vimeo, or a provider of the site's own — is previewed under it. The address is what is saved; how it is embedded on the published page is the site's business.
On a message form the editor is reached by the composer's tools — saved messages, placeholders, writing with AI, the signature — like the other editors; see the Email guide.
A field's own settings¶
| Attribute | |
|---|---|
data-pf-rich-upload="/admin/Media/upload" |
where this field's pictures go, instead of the default |
data-pf-rich-links="/admin/search" |
where its link panel searches |
data-pf-rich-plugins="embeds,mentions" |
only these plugins; every one registered without it. embeds is named like a plugin, so a field can leave previews out |
data-pf-rich-labels='{"bold":"Έντονα"}' |
the controls' words |
data-pf-rich-block-toolbar="1" |
the block toolbar for this field ("0": none), whatever the application's default |
data-pf-rich-header="#title" |
an element of the page moved into the text's column — under the toolbar, above the text — as an article's title sits; header in create() |
readonly / disabled |
the text shown, not editable |
pfRichEditor.enhance(textarea) // a field added after the page loaded
pfRichEditor.for(textarea) // its editor: { editor, getHTML(), setHTML(), setReadonly(), destroy() }
pfRichEditor.plain(textarea, true) // the bare textarea, for a text-only channel; false gives the editor back
pfRichEditor.create(element, options) // an editor that is not a field
Adding a site's own¶
Everything a site adds is a plugin, registered before the page has loaded — a plain script
after pf-rich-editor.js, no build:
pfRichEditor.use({
name: 'highlight',
// TipTap extensions: marks, nodes, keymaps, decorations. `tiptap` is the toolkit the editor
// runs on — Editor, Extension, Node, Mark, Plugin, PluginKey, Decoration, DecorationSet, …
extensions: (tiptap) => [tiptap.Mark.create({
name: 'highlight',
parseHTML: () => [{ tag: 'mark' }],
renderHTML: () => ['mark', 0],
})],
// Buttons, after the built-in ones; `block: true` puts one on the block toolbar as well.
buttons: [{
name: 'highlight', label: 'H!', title: 'Highlight', block: true,
run: (editor, ui) => editor.chain().focus().toggleMark('highlight').run(),
active: (editor) => editor.isActive('highlight'),
}],
// Anything else: a panel of its own (`ui.addPanel(element)`), listeners. Return a teardown.
setup(ui) { /* ui.editor, ui.wrapper, ui.toolbar, ui.labels */ },
});
A plugin under a name already registered replaces it. Plugins apply to editors made after they are registered.
An embed provider of the site's own is asked before the built-in ones:
pfRichEditor.embeds.add({
name: 'podcast',
match(url) {
const m = /^https:\/\/podcasts\.example\/ep\/(\d+)/.exec(url);
return m ? { src: 'https://podcasts.example/player/' + m[1], label: 'Podcast' } : null;
},
});
match answers { src, label, video } for an address it embeds — src is drawn in a frame, a
provider without one is shown by name — or null.
In a Svelte SPA¶
The same core, imported rather than bundled:
That writes lib/rich-editor/core.js, extensions.js and rich-editor.css — the framework's,
refreshed by every project:resync after — and components/RichEditor.svelte, which is the
project's: change it as you like, it is kept. The TipTap packages are added to package.json
beside the ones the project already has — in dependencies if it keeps them there, else in
devDependencies — leaving a version the project already chose. The header snippet sits in the
text's column, under the toolbar.
<RichEditor value={article.body} onchange={(html) => (article.body = html)}
upload={(file) => api.upload(file).then((r) => r.url)}
findLinks={(text) => api.get('/search', { q: text })}
plugins={[mentions]} embeds={{ providers: [podcast, ...embedProviders] }} blockToolbar>
{#snippet header()}<input class="title" bind:value={article.title}>{/snippet}
</RichEditor>
Plugins are the same objects as on a server page; in a SPA a plugin may import from @tiptap/*
directly, or use the tiptap toolkit lib/rich-editor/core.js exports. embedProviders is
exported too, to extend rather than replace.
Building it¶
The sources are ES modules in resources/js/rich-editor/: core.js (the editor and its
toolbar, createRichEditor()), extensions.js (the picture, the kept selection, moving blocks,
embeds), page.js (the textarea wiring and window.pfRichEditor). npm run build:editor bundles
them with TipTap into scaffolding/assets/js/pf-rich-editor.js, whose first line records the
TipTap version and a hash of the sources; a test fails when the sources changed and the file was
not rebuilt.