Search by

asignua / filament-rich-editor-toolkit

m2niac

Ten independent plugins for the Filament 5 RichEditor (TipTap): PasteCleanPlugin removes Word and Google Docs junk (mso-* styles, fonts, colour spans), can upload the pictures of such a paste instead of dropping them (SSRF-guarded) and, in strict mode, cuts a paste down to the field's toolbar, LinkO

Package info

github.com/asignua/filament-rich-editor-toolkit

pkg:composer/asignua/filament-rich-editor-toolkit

Statistics

Installs: 255

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 3

v1.1.0 2026-10-12 10:08 UTC

This package is auto-updated.

Last update: 2026-10-23 21:15:26 UTC


README

Stand With Ukraine Latest Version on Packagist Tests Total Downloads License Plumb score

Filament Rich Editor Toolkit: ten free plugins for the Filament 5 RichEditor

Ten free plugins for the Filament 5 RichEditor (TipTap) that fix the problems people keep reporting in Filament's GitHub Help:

Plugin Fixes
PasteCleanPlugin Pasting from Word / Google Docs drags in colours, fonts, mso-* junk and <span style> soup, and loses their pictures; optionally a paste is cut down to what the field's toolbar offers (#20484, #20048).
CustomAttributesPlugin class, id, style and data-* written in the source view are silently stripped, in the browser and on the server.
EmbedPlugin <iframe> / video embeds are removed by the schema and the sanitizer.
ImageUrlPlugin No way to insert an image that lives on another site.
ImagePropertiesPlugin No caption, no alignment, and no way to mark an image as decorative (alt="") (#8455, #16860, #20296).
LinkOptionsPlugin No way to set rel (nofollow, sponsored, ugc) on a link, no control over what a stored rel/target may contain, no noopener guarantee (#16829, #19478, #5705, #19028).
FullscreenPlugin No fullscreen mode for long content.
SourceCodePlugin No HTML source view (#17718, #19141).
CharacterCountPlugin + RichTextLength No counter; limits that count HTML tags, or only characters, not the text the reader sees.
StickyToolbarPlugin The toolbar scrolls away on long content (#17391, #16919, #11685).

Each plugin works alone; take only what you need.

Screenshots

Word HTML pasted and cleaned, structure kept

The Image properties dialog: alternative text, decorative switch, caption and alignment

An image with a centered caption in the editor

The Link options dialog: open in a new tab and rel checkboxes

The HTML source view of a short article

The editor in fullscreen with the character and word counter

The editor in dark mode

The Embed dialog accepting a YouTube link

The toolbar stays visible while scrolling a long document

Requirements

  • PHP 8.3+
  • Filament 5
  • Laravel 12 or 13

Installation

composer require asignua/filament-rich-editor-toolkit
php artisan filament:assets

filament:assets publishes the compiled editor modules. They are loadedOnRequest(): no <script> is printed on a page without a rich editor, the editor imports them by URL.

Optionally publish the config and the translations:

php artisan vendor:publish --tag=filament-rich-editor-toolkit-config
php artisan vendor:publish --tag=filament-rich-editor-toolkit-translations

Usage

use Asignua\RichEditorToolkit\Plugins\CustomAttributesPlugin;
use Asignua\RichEditorToolkit\Plugins\EmbedPlugin;
use Asignua\RichEditorToolkit\Plugins\FullscreenPlugin;
use Asignua\RichEditorToolkit\Plugins\ImagePropertiesPlugin;
use Asignua\RichEditorToolkit\Plugins\ImageUrlPlugin;
use Asignua\RichEditorToolkit\Plugins\LinkOptionsPlugin;
use Asignua\RichEditorToolkit\Plugins\PasteCleanPlugin;
use Asignua\RichEditorToolkit\Plugins\SourceCodePlugin;
use Filament\Forms\Components\RichEditor;

RichEditor::make('body')
    ->plugins([
        PasteCleanPlugin::make(),
        CustomAttributesPlugin::make(),
        EmbedPlugin::make(),
        ImageUrlPlugin::make(),
        ImagePropertiesPlugin::make(),
        LinkOptionsPlugin::make(),
        FullscreenPlugin::make(),
        SourceCodePlugin::make(),
    ])
    ->textLength(max: 5000, maxWords: 800) // validation rule + counter
    ->toolbarButtons([
        ['bold', 'italic', 'link', 'linkOptions', 'cleanFormat'],
        ['h2', 'h3', 'bulletList', 'orderedList'],
        ['embed', 'imageUrl', 'imageProperties', 'sourceCode', 'fullscreen'],
    ]);

A toolbar button is shown only when it is listed in toolbarButtons() (cleanFormat, embed, imageUrl, imageProperties, linkOptions, sourceCode, fullscreen); an unknown name makes Filament throw, so list only the plugins you installed.

PasteCleanPlugin

On Ctrl+V the structure stays (paragraphs, headings, lists, tables, links, bold/italic/underline) and colours, font sizes, backgrounds and every class/id/style they carry disappear. A pasted link keeps its href only for http(s):, mailto: and tel:; relative paths and #anchors (Word's #_Toc…, local file paths) become plain text. A paste from inside an editor is never touched (the data-pm-slice cutoff), so it composes with CustomAttributesPlugin. The marker is ProseMirror's, not the toolkit's: a paste copied from any ProseMirror/TipTap-based app (Confluence, GitLab, another CMS) skips the cleanup too and keeps its class/style.

The cleanFormat button runs the same cleanup on the selected fragment of existing content. Unlike Filament's clearFormatting it keeps headings and lists (that one runs clearNodes().unsetAllMarks()). What the editor itself put there survives untouched: custom blocks, embeds (<iframe>), images (also ones inserted by URL or stored on another host), code blocks, mentions and merge tags, grid, details and the lead paragraph. Every link stays too (relative paths, #anchors, tel:, and a link's target/rel), except the script schemes javascript:, vbscript:, data: and file:. The result goes back like a paste, so a few words picked out of a paragraph stay in it, and a table cell selection is left alone. A prefix you list is kept whatever its scheme:

PasteCleanPlugin::make()->keepLinkPrefixes(['/internal-link/'])

Images from Word and Google Docs

By default a picture pasted from Word or Google Docs is removed: the clipboard carries a file:///C:/…/clip_image001.png path (Word) or an https://lh7-rt.googleusercontent.com/… URL that a browser cannot read from script (Google Docs), and Filament's own paste handler uploads only data: images. Switch the recovery on per field or globally (paste_clean.upload_images):

RichEditor::make('body')
    ->toolbarButtons([['bold', 'attachFiles']]) // or ->fileAttachments()
    ->plugins([PasteCleanPlugin::make()->uploadImages()])

What happens:

  • Word. The picture bytes are on the clipboard too, as text/rtf ({\pict … \pngblip <hex>}). The browser reads them and swaps each file:/// source for a data: URL. The RTF has no names, so pictures are matched to the <img> tags by order; if the counts differ (a shape, a bullet picture, a missing RTF flavour) nothing is matched and the pictures are removed as before, because a wrong picture in the right place is worse than none. Only PNG and JPEG are recovered (EMF/WMF are not images a browser can show). Firefox and Safari may not expose text/rtf; then the pictures are removed.
  • Google Docs. The browser asks the server to download the picture (see below) and gets a data: URL back.
  • Then Filament takes over. The rewritten paste goes through Filament's own pasted-image upload: the field's accepted file types and maximum size are enforced, the file is stored by the field's disk/directory/saveUploadedFileAttachmentUsing and the image gets its data-id. Nothing is stored as base64.
  • What could not be recovered is removed and counted: the editor shows "Images that could not be imported: 2" (Filament's own file-validation message area) once the upload has finished.

It runs only on a field that takes file attachments (Filament's rule: the attachFiles button or ->fileAttachments()), and never on a paste from inside an editor or a pasted file.

How the paste is intercepted. ProseMirror runs transformPastedHTML (the cleanup) first and then offers the paste to the handlePaste of each plugin in turn; Filament's handler ignores that and re-reads event.clipboardData synchronously. The rewrite needs await, so the module has priority 1000 (Filament's localFiles has 100), stops the original event, converts asynchronously and then dispatches a new paste event on the editor whose clipboardData is a small stand-in holding the rewritten HTML. Filament and ProseMirror see an ordinary paste of data: images. If you type while the pictures load, the cursor is where you put it; otherwise the paste lands where it was made.

The server-side download (SSRF). The request URL comes from the browser, so every step is a gate (Support\RemoteImageFetcher):

  1. https only, port 443, no credentials, no whitespace/control characters/backslashes; a plain DNS name (no IP literal, localhost, or numeric forms such as 2130706433).
  2. The host must match paste_clean.remote_image_hosts (default ['*.googleusercontent.com']; *.x.y = any subdomain, never the bare domain; otherwise an exact name). A wildcard needs two labels after *. that are not a public suffix: *.com, *.co.uk, *.com.ua, *.github.io and *.cloudfront.net match nothing (a best-effort list of common second-level suffixes and shared-hosting domains, not the full Public Suffix List; the browser applies the same rule). Write an internationalised host name in punycode (xn--...), in the list as well as in the URL: the check compares ASCII names, in PHP and in the browser alike. An empty list turns Google Docs off and leaves Word.
  3. The name is resolved by the server and every address must be public: loopback, private, link-local (cloud metadata), CGNAT, documentation, multicast and the IPv4-wrapping IPv6 ranges (mapped, translated ::ffff:0:a.b.c.d, NAT64, 6to4, Teredo) plus 3fff::/20 and 5f00::/16 are refused. A name that resolves to a public and a private address is refused (the shape of a DNS-rebinding setup).
  4. The connection is pinned to the address that was checked (CURLOPT_RESOLVE), so a second lookup cannot return something else. The request is made without a proxy, whatever HTTPS_PROXY / ALL_PROXY / NO_PROXY say: the pin fixes where the client connects, and with a proxy that is the proxy, which would then resolve the name itself and could reach an internal address. A server that can only reach the internet through an egress proxy therefore cannot download Google Docs pictures (Word's keep working); allow direct https egress for this feature instead.
  5. No redirects (a 3xx is a failure).
  6. Timeouts, and a byte cap on the bytes that come off the wire (paste_clean.remote_image_max_kb, 5120 by default; the field's own fileAttachmentsMaxSize() wins when smaller): Content-Length, and a capped sink that takes none of the write crossing the cap (a short write, which makes libcurl abort the transfer at once, on Guzzle 7 and 8). The response is never decompressed (Accept-Encoding: identity, decode_content off) and a Content-Encoding: gzip answer is refused, so a few kilobytes of gzip cannot inflate into gigabytes.
  7. The type is decided by the file's magic bytes (png, jpeg, gif, webp). The remote Content-Type is ignored; SVG and HTML are refused.

Who may call it. The download is an editor action (asignuaFetchRemoteImage) that the browser mounts without a modal: the same Livewire request as every toolbar action. That gives it the page's session, CSRF protection and authorisation for free, and it answers only with a data: URL or a short error code, never an exception message. On top of that it refuses fields that are disabled or take no attachments.

  • Authorisation. By default it needs a logged-in user (auth()->check()); anonymous visitors get ['error' => 'unauthorized']. Allow guests with paste_clean.remote_image_guests => true, or decide per field: PasteCleanPlugin::make()->uploadImages()->authorizeRemoteImages(fn (RichEditor $component): bool => auth()->user()?->can('create', Post::class) ?? false) (also accepts a plain true / false). Without it, a public form with an editor would let anyone make your server fetch URLs.
  • Rate limits. Two buckets, both must have room: per viewer in total (paste_clean.remote_image_rate_limit, 60 a minute) and per viewer and field (remote_image_rate_limit_per_field, 30). The viewer is the user id, or the IP for a guest. Behind a reverse proxy or CDN the IP is the visitor's only when Laravel's TrustProxies is configured: without it every guest shares the proxy's address (one bucket for all), and a list that trusts everybody lets a guest choose his own address.

Why a data: URL and not a direct upload: Filament's pasted-image upload already does validation, storage and the attachment record, so reusing it keeps one code path and your saveUploadedFileAttachmentUsing; the cost is about a third more bytes on the wire for the (capped) picture.

Strict paste

Filament always loads the whole TipTap schema, whatever toolbarButtons() says: a field with only a Bold button still accepts a pasted table, an <h1> and links. Strict mode cuts a paste down to what the field's toolbar offers:

PasteCleanPlugin::make()->strict()

or for every field with the plugin, 'paste_clean' => ['strict' => true] in the config (a field's own strict(false) wins). What happens without the matching button:

Pasted Without the button
table each cell becomes a paragraph (table)
<h1>-<h6> a paragraph if no heading button exists at all; otherwise the nearest offered level, and on a tie the deeper one (h2/h4 offered, h3 pasted: h4: a heading is demoted, never promoted)
lists items become paragraphs (bulletList and orderedList both missing); with only one of them the other kind is converted to it
blockquote, code block, horizontal rule paragraphs, paragraphs (lines kept as line breaks), removed
bold, italic, underline, strike, subscript, superscript, code, highlight, link unwrapped: the text stays

Images, embeds, custom blocks, grid, details and mentions have no toolbar-controlled counterpart here and are left alone. Strict mode also applies to a paste from inside an editor (the data-pm-slice cutoff only skips the cleanup): the toolbar is the field's rule, and two editors on a page may differ. When it changes such a paste it drops the slice marker, because the marker describes the structure it changed.

How the browser knows the toolbar: a plugin cannot see the field, so the module reads the rendered toolbar. Every stock button carries the TipTap name it toggles in its Alpine class binding (isActive('bold', [])); that is the vocabulary needed here, it does not depend on the translated label, and dropdown groups and floating toolbars are read the same way. A test pins the markup shape, so a Filament change fails the suite instead of silently turning strict off. If the toolbar cannot be read (none rendered, no recognisable button) nothing is restricted: "cannot tell" never means "strip everything". Keyboard shortcuts (Ctrl+B) are not a paste and are untouched.

CustomAttributesPlugin

Keeps class, id, style and further attributes you list, on paragraphs, headings, lists, tables, images, links, details, grid, spans and generic <div> containers. A name the node declares itself belongs to the node and wins: an <img> keeps class and style but not id (Filament's image node owns id as its media key, stored as data-id), and an extra name such as title is not kept on a node that has its own title (image, iframe). It also stops legacy markup from collapsing: <div class="row"><img> <span>…</span></div> keeps its direct children, and <li><div>…</div></li> stays a list item. A <div> that wraps an <iframe> (a responsive embed wrapper) keeps it inside. A style/class that another extension writes itself is dropped only on its own tag (text-align on paragraphs and headings, width/height on images, lead and the grid classes on div); on a <td>, <table>, <iframe> or <p class="lead"> it is yours and stays.

// config/rich-editor-toolkit.php
'attributes' => ['data-track', 'aria-label', 'role'],
'types' => ['myCustomNode'],

The list is explicit because Symfony's HTML sanitizer, which Filament runs over every rendered rich text, cannot allow data-* as a pattern. on* handlers, URL attributes (href, src, srcset, srcdoc, action, formaction, ping, poster, ...), is, any name with : and framework directives (x-*, hx-*, v-*, ng-*, wire:*) are never accepted: the panel runs Alpine over the editor, so an allowed x-init would be stored XSS against every admin. That list is a backstop, not a whitelist; allow only what you need (data-*, aria-*, role, title).

->attributes([...]) / ->types([...]) on a plugin instance replace the config for that field only. A front-end request never builds the form, so pass the same lists to the renderer, or the values are saved and dropped on the page:

CustomAttributesPlugin::make()->attributes(['data-track']);                      // the form
RichEditorToolkit::renderer($post->body, attributes: ['data-track'])->toHtml();   // the page

EmbedPlugin

The "Embed" button takes a YouTube / Vimeo link (including youtu.be, Shorts, timecodes) or an https address from your allow-list, and inserts a bare <iframe>. A video link is rebuilt from provider and id (youtube-nocookie.com, no autoplay; an unlisted Vimeo video keeps its privacy hash), never copied. Pasted or hand-written iframes pass the same check at once in the browser and when the HTML is parsed on the server; an iframe from any other host does not exist as a node and is dropped. The check runs again when a node is rendered, so JSON content (RichEditor::json(), an array handed to the renderer) cannot smuggle one in either. The same goes for the admin editor: an iframe node whose src fails the list is drawn as an inert placeholder, and the configured sandbox, allow and referrerpolicy are forced there too. YouTube's own embed code (www.youtube.com/embed/ID) and watch?v= / youtu.be / vimeo.com iframes are rebuilt to the youtube-nocookie.com / player.vimeo.com form rather than dropped. A host/path entry matches on a segment boundary (the host is case-insensitive, the path is not), and a path with dot segments (/maps/embed/../../url), %2e, %2f or a backslash is refused.

'embed' => [
    'hosts' => ['www.google.com/maps/embed'],   // host or host/path/prefix, https only
    'sandbox' => 'allow-scripts allow-same-origin allow-presentation allow-popups',
],

sandbox, allow, referrerpolicy and loading="lazy" are forced onto every embed on render, whatever the stored HTML says.

ImageUrlPlugin

Inserts an <img> that is loaded from the other site, not copied. Only absolute http(s) URLs are accepted; ImageUrlPlugin::make()->hosts(['cdn.example.com']) restricts what the dialog accepts. It is not a content filter: an <img> typed in the source view or pasted is not checked against it. http:// is accepted (mixed content on an https site).

ImagePropertiesPlugin

Alt text, a caption and an alignment for an image, from a dialog. List imageProperties in toolbarButtons(): the button is enabled while an image is selected (click an image first) and opens a dialog with Alternative text, Decorative image, Caption and Alignment (default, left, center, right).

What Filament already does, and this does not repeat. The stock "Attach files" dialog has an alt field, and ->resizableImages() gives width and height. What it cannot do: an empty alt="" (an empty field means "no alt", and the server-side image node drops an empty one, so a screen reader reads the file name instead), a caption, and alignment. The plugin extends the existing image node; data-id (the attachment key), resizing, ImageUrlPlugin and the class/style that CustomAttributesPlugin keeps on the <img> work as before.

Markup. An image without a caption or alignment stays a bare <img>, byte for byte (no change to existing content). With either it becomes

<p><span class="asignua-rte-figure asignua-rte-align-center"><img src="..." alt="..."><span class="asignua-rte-caption">Caption text</span></span></p>

It is a <span>, not <figure>/<figcaption>, on purpose: Filament's image is an inline node (the editor stores it as <p><img></p>), and a <figure> inside a <p> is invalid HTML; browsers close the paragraph at <figure> and leave two empty ones. A <figure> with one <img> and an optional <figcaption> typed in the source view or pasted is read (alignment from an asignua-rte-align-* class or data-align) and written back in the span form; a <figure> around anything else is left alone. A caption is plain text: markup in a <figcaption> is flattened, and the text is escaped on output.

Put the button on the image. Filament shows a floating toolbar per node type; image is one of them:

RichEditor::make('body')
    ->plugins([ImagePropertiesPlugin::make()])
    ->floatingToolbars(fn (RichEditor $component): array => [
        ...$component->getDefaultFloatingToolbars(), // keep the grid and table toolbars
        'image' => ['imageProperties'],
    ]);

Front end. The plugin has a server half, so it is in RichEditorToolkit::plugins() / renderer() (add it to your own renderer the same way). The alignment classes need CSS; most sites style them themselves. A tiny optional stylesheet (centered block, left/right floats that stack on narrow screens, a small caption) ships with the package:

php artisan vendor:publish --tag=filament-rich-editor-toolkit-front-css
# -> public/vendor/filament-rich-editor-toolkit/image-properties.css

Alt text guidance. The alt text replaces the picture for someone who cannot see it, so describe what it says here, not what it is ("Chart: applications doubled between 2023 and 2025", not "image of a chart"). Leave out "image of"; keep it to a sentence; if the picture is repeated in the caption or the text around it, a short alt is enough. A picture that carries no information (a divider, a flourish) is Decorative image: the plugin stores alt="", which screen readers skip. A missing alt is the worst case (many readers announce the file name), so decide for every image: describe it, or mark it decorative. The sanitizer writes an empty attribute as a bare alt, which HTML reads as alt="".

LinkOptionsPlugin

rel and target of a link. List linkOptions in toolbarButtons(): the button is enabled while the cursor is in a link and opens a dialog with Open in a new tab and the rel tokens nofollow, sponsored and ugc.

What Filament already does: its link dialog has a URL and an "Open in new tab" checkbox (target="_blank"), the TipTap mark keeps target and rel, and the stock renderer passes both through. It never writes rel, and it checks nothing: target="_top" or rel="whatever opener" in stored HTML reach the page unchanged. The stock link action is built by a static factory with no seam for another field, and asignua/filament-rich-links writes its marker into the same link mark, so this is a second button that changes only target/rel of the link under the cursor (updateAttributes('link', …)), and leaves the address to the stock button or the rich-links one. A new tab always stores rel="noopener noreferrer".

The plugin has a server half: it is added by RichEditorToolkit::plugins() / renderer(), and the toolkit's renderer then normalises every <a> it outputs:

  • target is _blank or it is dropped (_self, _top, frame names);
  • rel keeps only whitelisted tokens (link_options.rel_tokens: the HTML link types without opener, so rel="me" survives);
  • a _blank link always gets noopener noreferrer, whatever the stored HTML says.

Optionally add rel tokens to links that leave the site, at render time only (the stored content is not changed):

// config/rich-editor-toolkit.php
'link_options' => ['external_rel' => ['nofollow'], 'internal_hosts' => ['shop.example.com']],

// or per renderer
LinkOptionsPlugin::make()->externalRel(['nofollow'])->internalHosts(['shop.example.com'])

"External" is an absolute http(s):// or protocol-relative URL whose host is not the host of app.url or an internal_hosts entry (a leading www. is ignored; a subdomain is another host unless listed). Relative links, #anchors, mailto:/tel: and the /ee-link/… markers of rich-links are never external. An editor cannot opt a single link out of external_rel.

FullscreenPlugin

A toolbar button that turns the field into a full-viewport overlay and back:

RichEditor::make('body')
    ->plugins([FullscreenPlugin::make()])
    ->toolbarButtons([['bold', 'link', 'fullscreen']]);

Esc leaves; the page scroll is locked while it is on and restored on exit; the toolbar stays on top while the content scrolls; a field's own maxHeight()/minHeight() are ignored in fullscreen. The button is a toggle (aria-pressed, active style). It works inside sections, tabs, repeaters and modals, in a panel or in a plain Livewire form, with or without StickyToolbarPlugin: the module links its own small stylesheet (php artisan filament:assets publishes it).

The overlay is a fixed element at z-index: 35: above Filament's topbar and sidebar (30), below its modals (40), so the link, embed and image dialogs opened from the fullscreen toolbar still open on top. Change it with FullscreenPlugin::make()->zIndex(50), the fullscreen.z_index config or the --asignua-rte-fullscreen-z variable. Esc is left to a modal that does not hold the editor, to an open toolbar dropdown and to any handler that already called preventDefault(). The Esc that does leave fullscreen stops there, so an editor inside a modal does not close the modal too.

SourceCodePlugin

A sourceCode toolbar button that swaps the editing area for a plain <textarea> with the document's HTML, and back:

RichEditor::make('body')
    ->plugins([SourceCodePlugin::make()])           // ->indent(4) for four spaces
    ->toolbarButtons([['bold', 'link', 'sourceCode']]);

The HTML is pretty-printed (one block per line, containers indented; <pre> is never re-indented; void elements such as <br> and <img> have no closing tag), monospace, always left-to-right, as tall as its content, or filling the screen together with FullscreenPlugin. The button is a toggle (aria-pressed) and the textarea has an aria-label.

The round trip goes through the editor's schema. The edited HTML is handed to editor.commands.setContent(html, { emitUpdate: true }): TipTap parses it with a DOMParser into a detached document and then with the schema, so nothing is ever assigned to a live element, the field state is updated by Filament's own update handler, and the server sanitizer is unchanged. The price is that markup the schema does not know is dropped (<mark>, <table> without the table button's extension, an attribute nobody registered). The toolkit tells the editor so: after leaving source mode a non-blocking notice, "Some markup is not supported by this editor and was removed.", appears above the content when the result differs materially from what was typed. Not material (no notice): whitespace and indentation, attribute order, comments, <b> for <strong> (<i>, <strike>, <del> likewise), attributes the editor added (a link's rel/target), a bare <div>/<span>, loose top-level text wrapped in <p>, a lone <p> inside a list item, cell or quote, <pre><code> for <pre>. Anything else (a lost element, attribute, class, style, text, a different heading level or list type) shows it. The check errs on the side of a false notice.

Saving while still in source mode keeps the source. The textarea is pushed into the editor 400 ms after the last keystroke, on blur/change (clicking Save blurs it first), synchronously on any Ctrl/Cmd keydown (Filament's save shortcut mod+s fires neither blur nor submit), in a capturing submit listener on the enclosing form, and when leaving. (There is no Livewire commit hook: Livewire builds a request's updates before it runs the hook and Filament copies the editor state on the next tick, so a flush from there would be too late to be sent.) If the document changes underneath the textarea (the server rewrites the attachment ids when the form is saved), the textarea is rebuilt from the editor, so the stale text can never overwrite it. Text typed and not yet pushed at that moment (typing while a save is in flight) is not thrown away and not pushed either, since it was written against the old document: it stays in the box, a status message says that it is not applied and will be discarded on leaving source mode (copy it first), and all syncs are suspended until you leave. While in source mode every toolbar tool except sourceCode and fullscreen is disabled.

class, id, style and the attributes you listed in attributes survive only if CustomAttributesPlugin is registered on the field; with it, source mode is how editors add them. Tab is not trapped in the textarea (a keyboard user could not leave it); type the indentation with spaces. Esc still leaves fullscreen. In a read-only editor the textarea is read-only. No server half: do not add the plugin to the front-end renderer. source_code.indent sets the default indent (0 to 8).

CharacterCountPlugin and RichTextLength

A counter under the editor and a validation rule that count the plain text, the same way:

RichEditor::make('body')->textLength(max: 500, maxWords: 80, min: 20);

textLength() is a macro that adds the rule and the counter (counter: false for the rule alone, showWords: true to show the word count without a word limit). Plugins added with plugins() are appended, so it composes with the ones you already set. Without the macro:

RichEditor::make('body')
    ->plugins([CharacterCountPlugin::make()->maxCharacters(500)->maxWords(80)->warnAt(0.9)])
    ->rule(new RichTextLength(max: 500, maxWords: 80, min: 20));

The counter shows "Characters: 123 / 500 · Words: 20 / 80", turns amber from warnAt() (default 90% of a limit) and red above it, and tells screen readers once when it flips to "limit exceeded" (the visible number is not a live region). It only displays: it never blocks typing and the browser is not trusted; the rule is the enforcement. Translatable messages (text_length_max, text_length_min, text_length_max_words).

What is counted (one definition, implemented in JS and in Support\PlainText, and tested against shared fixtures on both sides):

  • text blocks (paragraphs, headings, list items, table cells, code blocks) joined by a newline; a hard break is a newline; empty blocks dropped;
  • characters are Unicode code points with spaces; paragraph and line breaks are not counted;
  • words are runs of non-whitespace; a no-break space and the other Unicode spaces separate words;
  • images, embeds, custom blocks and mentions add nothing.

The rule accepts an HTML string or the TipTap JSON of a ->json() field. Null, '' and a document with no text pass, like Laravel's own size rules: an empty document is required's business, so an optional field that holds <p></p> can still be saved empty even with min.

Why not maxLength(): earlier Filament 5.x releases validated the raw HTML string (<p>Hello</p> was 12 characters, #17028); newer ones count tiptap-php's getText(). Either way it has no counter, no word limit and its number is not the one shown in the editor.

StickyToolbarPlugin

A panel plugin, CSS only:

use Asignua\RichEditorToolkit\StickyToolbarPlugin;

$panel->plugin(StickyToolbarPlugin::make()->offset('4rem'));

Filament 5 has RichEditor::stickyToolbar() per field; this makes it the default for every editor and fixes the case where it silently does nothing, because a section, tab or repeater item clips with overflow: hidden (which turns it into a scroll container that position: sticky cannot escape). Those containers are switched to overflow: clip. Run php artisan filament:assets so the stylesheet is published.

Offset precedence: a field's own ->stickyOffset(), then the plugin's offset(), then Filament's own default (the topbar, or inside a modal the height of its sticky header); the plugin offset does not apply inside a modal.

Opt a single field out with RichEditor::make('body')->withoutStickyToolbar() (it sets data-toolkit-sticky="off", which the stylesheet respects, even over Filament's native ->stickyToolbar()).

Rendering on the front end

RichContentRenderer parses the stored HTML with the same tiptap-php schema and then sanitizes it, and both steps drop what they do not know. Use the helper:

{!! \Asignua\RichEditorToolkit\RichEditorToolkit::renderer($post->body)->toHtml() !!}

or attach the plugins to a renderer you build yourself and render it through the toolkit:

$renderer = RichContentRenderer::make($post->body)
    ->customBlocks([...])
    ->plugins(RichEditorToolkit::plugins());

{!! RichEditorToolkit::toHtml($renderer) !!}

This adds CustomAttributesPlugin, EmbedPlugin, ImagePropertiesPlugin and LinkOptionsPlugin (the last two can be turned off, see Upgrading from 1.0) and sanitizes with the toolkit's own sanitizer: a copy of Filament's config plus your attribute allow-list and, when EmbedPlugin is attached, <iframe> whose src passes the embed allow-list. Filament's shared sanitizer is never changed, so TextColumn::html(), TextEntry::html(), notifications and the Markdown editor keep Filament's defaults. A renderer's own toHtml() uses that shared sanitizer, which is why RichEditorToolkit::toHtml($renderer) is needed (the same goes for HasRichContent models: RichEditorToolkit::toHtml($post->getRichContentAttribute('body')->getRenderer())). Do not add PasteCleanPlugin to the renderer: the cleanup happens once, at paste time.

Configuration

See config/rich-editor-toolkit.php: attributes, types, embed, paste_clean (keep_link_prefixes, strict, upload_images, remote_image_hosts, remote_image_max_kb, remote_image_rate_limit, remote_image_rate_limit_per_field, remote_image_guests), renderer (link_options, image_properties), link_options (rel_tokens, external_rel, internal_hosts), character_count (warn_at), fullscreen (z_index), source_code (indent), sticky_toolbar.

Upgrading from 1.0

RichEditorToolkit::plugins() / renderer() now also attach LinkOptionsPlugin and ImagePropertiesPlugin, so existing pages render differently after the upgrade, without any change to your code:

What Before Now
<a target rel> stored values passed through target is _blank or dropped; rel is filtered to link_options.rel_tokens (HTML link types, never opener); _blank forces noopener noreferrer
old <figure><img><figcaption> <figure> markup read as an image with a caption and written as <span class="asignua-rte-figure"><img><span class="asignua-rte-caption">; markup inside the figcaption is flattened to text
<img alt=""> the empty alt was dropped kept (a decorative image)

They stay on by default: the editor-side plugins cannot work without them (a caption or an alignment that is saved but not rendered would silently vanish from the page), so turning them off by default would trade a visible change for a silent loss. To keep the 1.0 output byte for byte, opt out, per call or globally:

RichEditorToolkit::renderer($post->body, links: false, images: false)->toHtml();
RichEditorToolkit::plugins(links: false, images: false);   // for a renderer you build yourself
// config/rich-editor-toolkit.php
'renderer' => ['link_options' => false, 'image_properties' => false],

Further changes to be aware of: the Google Docs picture download now requires a logged-in user by default (paste_clean.remote_image_guests, PasteCleanPlugin::authorizeRemoteImages()), runs without a proxy, and never decompresses; a wildcard host over a public suffix (*.co.uk) no longer matches. Run php artisan filament:assets (new modules).

Gotchas

  • Both halves, always. CustomAttributesPlugin, EmbedPlugin, ImagePropertiesPlugin and LinkOptionsPlugin must be in the editor and in the renderer. Miss the renderer and the data sits in the database and vanishes on the page.
  • Publish the assets. A missing filament:assets means a 404 for the module URL and a console.error from Filament's loader. Nothing else fails, the plugin is just absent.
  • Node types. An attribute is kept only on the node types in the list (Extensions\CustomAttributes::TYPES plus the types config). The PHP and the JS lists are identical on purpose; a test pins it.
  • A node's own attribute wins. id on an <img> and any listed name a node declares itself (title on image/iframe, width, src, ...) follow that node's rules, not the toolkit's; put the anchor on a wrapping element.
  • The sanitizer is the toolkit's own. Allowances apply only to RichEditorToolkit::renderer() and RichEditorToolkit::toHtml(); a plain RichContentRenderer::toHtml() and every ->html() column still drop <iframe> and the extra attributes.
  • toolbarButtons(). List cleanFormat, embed, imageUrl, imageProperties, linkOptions, fullscreen, sourceCode only when the matching plugin is registered.
  • Fullscreen is a fixed overlay, not the top layer. The top layer would paint above Filament's modals and hide the dialogs opened from the toolbar. The price: an ancestor with transform, filter or contain makes position: fixed fill that ancestor instead of the screen. overflow on a section, tab or repeater does not clip it.
  • The source view is not a raw editor. What you type is parsed through the schema, like a paste: unknown markup disappears (with a notice), and the textarea is rebuilt from the editor on the next visit. Use CustomAttributesPlugin for class/id/style/data-*.
  • Image properties live on the image. The caption and the alignment are node attributes: the wrapper <span> is built on output, and its own class/style/data-* are dropped when it is read back (the <img>'s are kept). Pasting a captioned image inside the editor keeps the caption and the alignment with it (Filament's paste handler splits the wrapper; the toolkit joins it again). A caption pasted from OUTSIDE the editor (another page, Word) is dropped with the wrapper. The imageProperties button is disabled until an image is selected. ImageUrlPlugin images are fine. left/right are floats: clear them below a section that must start on its own line.
  • Link options after the renderer change. With LinkOptionsPlugin in the renderer (it is in RichEditorToolkit::plugins()), a stored rel is filtered to the whitelist and target to _blank on every page, including old content. Add the tokens you rely on to link_options.rel_tokens. The linkOptions button is disabled outside a link; to change the address use the stock link button. Setting the address again with the stock button writes target only and removes the rel you set: use linkOptions last.
  • Pasted pictures need attachments, an RTF and a count that matches. uploadImages() does nothing on a field without file attachments; Word pictures are recovered only when the clipboard has text/rtf and as many recoverable pictures as <img> tags. Google Docs pictures are downloaded by the server: the host must be on remote_image_hosts, and app.url/DNS/egress firewall of the server must allow the lookup. A picture over the field's size or type limit is removed, not uploaded.
  • Counter and rule are separate. The counter does not validate and the rule does not draw anything; textLength() adds both. A browser can be bypassed, so keep the rule.
  • Strict paste is about pastes. It does not unformat existing content and does not stop Ctrl+B; the cleanFormat button and the schema are unchanged. It is off by default.

Translations

English, Ukrainian, German, Spanish, French, Italian, Dutch, Polish, Brazilian Portuguese and Turkish. The namespace is rich-editor-toolkit::.

AI agents

Laravel Boost guidelines ship in resources/boost/guidelines/core.blade.php.

Testing

composer test          # PHP (Testbench)
composer analyse       # PHPStan level 8
vendor/bin/pint --test
npm install
npm run test:js        # node --test + jsdom
npm run build          # recompile resources/dist (commit the result)

Changelog and license

CHANGELOG · MIT license