Skip to content

The block editor

Where a lesson and a page are written. Blocks is what a block is; this is the panel around it.

Every builder BlocksBuilder makes is a Hilms\Blocks\Filament\Canvas, and the chrome is the blocks module’s own resources/css/panel.css and resources/js/panel.js. Both are registered through FilamentAsset and published verbatim by filament:assets, so both are plain CSS and plain JavaScript with no build step behind them. Run bin/artisan filament:assets after changing either.

Every panel asset of ours is the application’s own. The host’s resources/{css,js}/panel.*, the blocks module’s two, the library module’s two and the navigation module’s stylesheet are all registered under the app package, published into public/css/app and public/js/app, and versioned by App\Filament\PanelAssets, which AdminPanelProvider::boot() hands to FilamentAsset::appVersion(). Filament otherwise versions an asset by the Composer version of the package it was registered under, and a module in app-modules/ answers 1.0.0.0 for ever — an edited stylesheet kept its address, and a browser holding the old one had no reason to ask for it again. The version is the release in production and a short hash of the published files’ timestamps everywhere else, so nobody has to empty a cache to see the stylesheet they just wrote. tests/Feature/PanelAssetsTest.php guards it.

A leaf block is drawn as a preview of itself, through the theme’s own view (Blocks).

A container is drawn as its children. Its settings stay in the state the canvas holds — hidden, dehydrated and never required there — and the inner builder is what an editor sees:

$builderBlock
->preview(null)
->schema([
Block::uuidField(),
...array_map(
fn (Component $component): Component => $component instanceof Field
? $component->hidden()->dehydratedWhenHidden()->required(false)
: $component->hidden()->dehydratedWhenHidden(),
$block->settings(),
),
self::hints($block),
self::inner($block, $context, $depth),
]);

Nothing there is required on purpose: a value missing from stored data would otherwise fail a save with a message about a field nobody can see, and every block reads its own settings back with a default for what it is not given.

The inner builder carries what the container is doing — data-hilms-layout, -columns, -gap, -stack, from canvasLayout() over its live state — and each child carries a marker saying how much room it takes (canvasHints()). The stylesheet turns the two into a grid of tracks or a row that wraps.

Two things about that are deliberate:

  • The thresholds are container queries on the canvas itself, not on the window. The editor is narrower than a page, and the canvas is the same question the block’s own view asks when a reader meets it.
  • The canvas draws no more tracks than it has room for. An editor’s cell holds a toolbar as well as its blocks, so a track needs about sixteen rem: two from 32rem, three from 48, four from 64 and six from 80, however many the page itself will get. A toolbar that still runs out of room wraps.

Filament owns the element around a block and lets nothing put an attribute on it, which is why a child’s marker is rendered inside the item, as a hidden <span class="hilms-canvas-hints">, and the stylesheet reads it with a spelled-out :has() chain. BlockCanvasTest guards that chain, because a Filament class name changing underneath it is silent otherwise.

Hilms\Blocks\Filament\BlockSettingsAction is the edit action of every builder we make. Filament’s own would draw the whole item — a container’s children included — and write back whatever the window held; this one draws the block’s fields() — its settings(), then its look, if it takes one — and nothing else.

  • A slide-over from the side, under the block’s own label and icon, with a sticky header and footer and a control that widens it. The choice is $persisted in the browser, so it outlives the block, the page and the session.
  • The schema is a grid that asks its own width through a container query — what a person widens is the panel, not the window — so the fields read in two columns from @3xl, while anything a person writes into (a builder, a checkbox list, an upload, a repeater, a rich editor, a textarea) keeps the whole line. No field moves, because a layout component has no state path.
  • What the panel hands back is merged into the data the canvas is holding, so editing a container’s settings never touches its children. The merge is shallow, which is why a look is one key, look: the panel hands it back whole, and turning Follow the style on again keeps none of its choices (chapter 26).
  • It opens on the block’s defaults under what the block actually holds, so content written by a seeder, an assistant or a pattern is editable rather than a window of empty required fields. A stored null is nothing stored: the canvas fills every settings key it knows of.

Adding a leaf opens the same panel. Adding a container opens nothing: modalHidden() answers true for it, the schema is empty — a modal-less action still validates the schema it was given, and a container’s would be the whole canvas — and the container arrives with its template, a grid with two cells.

A block’s fields are validated when the page is saved but are drawn only on that panel, so Filament had nowhere to hang the message and Save did nothing anybody could explain. Two things close that, and the first is not about blocks at all.

App\Filament\FailedSaveNotification sets Filament’s BasePage::$reportValidationErrorUsing for the whole panel — every page, every form — and sends one persistent danger toast, “Not saved”, with how many fields need attention (in Polish plurals) and the first three messages. It carries a fixed id, so a second attempt replaces it rather than stacking another copy, and it is persistent because nothing was written and the page still holds everything the person typed.

Hilms\Blocks\Support\BlockErrors reads the error bag the way the editor holds its state — <canvas>.<item>.data.<field>, and the same again for every builder inside — and answers for one item at a time:

Asks Answers
for($item) Everything refused under this item, a block nested inside it included: a grid answers for the heading in one of its cells, and so does the cell
own($item, $settingKeys) Only what belongs to this block’s own fields, which are the ones its settings panel draws
deepest($item) The innermost block the refused save named, which is where a person is sent when nothing of the item’s own is at fault

It knows nothing of Filament or Livewire: it is handed a message bag and a state path.

That is what lets the canvas mark the block at fault. Canvas::getAttentionAction() is an extra item action on every canvas: an exclamation-triangle icon button in the item’s own header, danger-coloured, whose accessible name is the first message and whose tooltip is all of them — the words carry it, never the colour. The panel’s stylesheet draws a danger outline around the item from that button being there, through another :has() chain BlockCanvasTest guards.

Pressing it opens that block’s settings the way Filament’s own click-to-edit overlay does ($wire.mountAction('edit', …), with the Livewire click handler off so no action is left half-mounted underneath). Where nothing of the block’s own is at fault — a grid whose cell holds the heading that is — the button says so in its name and takes the person to the block that really holds it, which wears a mark of its own.

A panel opened over a block a save refused says what it refused, under the heading and before the fields. Filament empties the error bag as it opens a modal, reasonably, so the messages are read while the panel is mounting and repeated as the modal’s description. Giving the panel what it asked for calls resetValidation() on that item, so the mark goes without waiting for another Save.

The module’s own view inside Filament’s dropdown shell, so it opens, floats and closes like every other dropdown in the panel.

  • A search field that filters on the label, the sentence and the category, with and without accents — a person typing on a keyboard that has none still finds the block.
  • The blocks grouped under their category, in the order BlockCategory lists, with one line under each name saying what it is for.
  • The patterns under a heading of their own, after all of them.
  • Arrow keys between the entries, Enter on the first match, Escape back to the trigger, and a word when nothing matches.

It offers nothing Save would refuse. Filament’s own maxItems counts one level of one builder, while the rules read the whole tree, so Canvas::fitting() walks the root state — an inner canvas climbs to the canvas that was told its context — and drops a once() block the tree already holds and a block that draws the page heading when something already does. A child-only block is offered only inside its parents, because the registry never handed it to the root.

A builder that offers a single type has no picker at all: its button adds that block, with Filament’s own click handler put back, because Filament turns it off when its dropdown does the mounting.

The panel’s width is a min-inline-size, because Filament caps every dropdown’s width with a layered !important and a minimum is what sidesteps it.

The insert action lives on the same canvas and is offered in the same picker. Canvas::getPatterns() asks PatternBlocks::fits() every time the picker draws, because whether a pattern may be inserted depends on what the canvas is holding at that moment.

Pressing an entry validates the pattern again (PatternBlocks::validate()), mints a uuid for every block at every level through BlockCopy::items(), and inserts the lot after the item the picker was opened under — or at the end. Each inserted item’s child schema is then fill()ed, which is the same step the stock add action takes: what the pattern left out is the block’s own default, and a container’s children become items of its builder.

A block that shows a file carries Hilms\Library\Filament\AssetPicker in its settings, so choosing one happens in the settings panel like any other field. The field shows its choice — a 16:9 preview, the title, and the kind and visibility badges — and beside its label “Upload new” and “Clear”.

  • “Choose from the library” opens a slide-over holding the library’s own cards (AssetPickerTable over AssetCard), searchable by title and file name, newest first, twelve to forty-eight a page. It offers what MediaAsset::visibleTo() lets the signed-in person see, narrowed to the kinds the block shows and to the visibilities the record may show — a lesson both, a page and a course cover public only. Those arrive as the table’s arguments, which Filament’s table component keeps #[Locked], and who is asking is read from the session, never from an argument. A card is chosen by pressing it anywhere: the library’s panel.js hands the press to the card’s own checkbox, which stays the one control a keyboard or a screen reader uses.
  • “Upload new” (authorised by create on MediaAsset) opens a window over a new asset, not the record being edited, so the upload lands on the disk of the visibility the record asks for: course material from a lesson, public from a page or a cover. It takes the allowed kinds’ types with AssetFile, the largest allowed kind’s ceiling and “Add from a web address”, a title, and, where pictures are allowed, the alternative text, kept under the record’s language. The new asset is then chosen, and a file the media library refuses leaves no asset behind.
  • The server asks the same three questions of whatever id the request carries: not in the person’s library, not a kind this place shows, course material where the record cannot show it. Filament’s own “one of the options” rule is switched off so the message is the reason, and the field dehydrates to an integer.

A picture’s words. What a picture shows is written once, on the file, per language. The image, gallery and hero blocks carry a live decorative toggle and Hilms\Blocks\Filament\AltText, “Alternative text for this place only”. While the toggle is off, the field is optional until the file says nothing in the language of the record showing it nor in the default language: then its hint changes to say so, Save requires it, and the message points at the library, where the words would serve every place. While the toggle is on the field is hidden and the view writes alt="".

A video’s captions are its library file’s, so the video block’s picker says so under the field and, once a file is chosen, links to that file in the library, where the tracks are edited.

Filament’s own clone appends the item at the end and hands the copy the original’s uuids, which would leave two blocks as one place. Ours:

  • mints every uuid in the block’s tree again and keeps the asset ids, so the copy shows the same library files by reference and no byte is copied (BlockCopy);
  • lands the copy right below its original;
  • announces where the block is (hilms-focus-block), because Livewire morphs the canvas without moving anybody.

A block a page holds once is offered no copy, nor is one a full builder has no room for. The copy’s uses are recorded by the next save of the record, like every other change on the canvas.

Hilms\Blocks\Filament\DocumentOutline is a sidebar section on the page and lesson editors, reading the builder’s live state: every block in the order a reader meets it, headings indented by their level, a cell’s contents folded into the level of the container above it, and each entry a button that takes a person to its block on the canvas.

It marks the three things that would refuse a save — a heading that skips a level, a second page heading, a second copy of a block a page holds once — with an icon and the very sentence the rule fails with, because both read HeadingOutline. The editor can never be warned about one thing while Save refuses another.

A fourth, OutlineProblem::NeedsAttention, is the other way round: it is what a save already refused, laid over the entries by the sidebar rather than seen by HeadingOutline, which knows nothing of an error bag. It lands on the block at fault with its own message, and on every container it sits in with a quieter sentence saying something inside needs attention.

Every builder therefore carries partiallyRenderAfterActionsCalled(false): the outline reads the same state from the other side of the form, and a partial render of the builder alone would leave it describing the page as it was a moment ago.

On a create page there is nothing to outline, and inside a relation manager there is no block field at all; in both it draws nothing, the way the sidebar’s other sections do.

panel.js holds one idea and two ways in: the outline’s entries carry data-hilms-focus-block, and the server dispatches hilms-focus-block after a duplicate or a pattern insert. Both end at the same function — the block scrolls into view (auto under prefers-reduced-motion) and its header takes focus, because a page that moves without moving focus leaves the keyboard behind. The header is given tabindex="-1" at that moment and never sits in the tab order.

Filament gives the item element no id of its own, so a block is found by [x-sortable-item="<key>"] — the key the editor holds it in.

  • The canvas sits in the left column beside the sidebar, on the page editor as on the lesson editor, so Save and the outline stay beside the blocks rather than above them.
  • Save never scrolls away. From lg up, the sidebar’s own content is sticky inside its full-height column and scrolls by itself. A section cannot stick by itself — the wrappers Filament draws around one are exactly as tall as it is, so it has nowhere to travel — so what sticks is the column’s content, named for the stylesheet by SidebarActions (Conventions).
  • The panel’s navigation collapses on a desktop (sidebarCollapsibleOnDesktop()), because the widest thing in the panel is a canvas, and the content column is full width.
  • Leaving a form that holds unsaved work asks first (unsavedChangesAlerts()): a lesson of forty blocks is not something to lose to a stray click.
  • A block preview is inert, because a preview is a picture of a block and its links, buttons and fields would otherwise sit in the editor’s tab order. Filament’s click-to-edit overlay is a sibling of the wrapper, so editing is untouched.
  • A preview follows the panel’s colour scheme. Filament says dark with a dark class on <html> and the theme’s tokens read data-theme, so the preview wrapper bridges the two with a small Alpine effect and the frame paints --color-surface, --color-ink and the matching color-scheme. Without it a theme with dark ink drew dark on dark the moment the panel went dark, because Filament’s builder item is a translucent white over the panel’s own background.
  • Every builder is reorderable with buttons as well as by dragging: a keyboard reaches a button and never a drag handle.

Hilms\Blocks\Support\BlockValidator::problems($record) answers the editor’s own question without a panel: whether this page or lesson could be saved as it stands, and if not, which block, which field, why, and where the block sits (Grid › Cell 2 › Image).

It is faithful by construction rather than by a second set of rules. The schema is the panel’s own BlocksBuilder::make(), filled from the record the way the editor fills it, handed a Livewire component of its own (Hilms\Blocks\Support\BlockForm, never rendered, never registered), and the answer is what Schema::validate() says — so a conditional required, a hidden field, an in list and an asset picker asking whether the id it holds may stand there all behave exactly as they do in front of a person. Nobody is signed in on the console, so there the picker asks the kind and the visibility and not who may see the file. One editor is built per BlockContext and filled again for each record, which is what makes a sweep of a thousand lessons seconds rather than minutes. It reads and never writes.

BlockValidatorTest feeds the same broken records to the validator and to the page editor and compares the messages word for word.

Two things read it: hilms:blocks:check and the BlockContentCheck health check (Operations).

app-modules/blocks/tests/Feature: BlockCanvasTest (the markers and the stylesheet’s chains), BlockPickerTest (the search, the categories, what is not offered), BlockErrorsTest and the attention action, DocumentOutlineTest, DuplicateBlockTest, InsertPatternTest, BlocksBuilderTest, BlockValidatorTest, the picker’s offer and its refusals in app-modules/library/tests/Feature/AssetPickerTest.php, plus tests/Feature/EditorComfortsTest.php and PanelAssetsTest.php in the host.

HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.