Skip to content
MCG Filament CMS Demo

Editing on the page

This document explains the inline editing feature, in which an editor who is signed in to the admin panel hovers over a block on the public site and either edits that block in its own form or drags it to a new position among its siblings, without returning to the panel. It covers who sees the feature, how the edit form is built, the duplicate, reorder and add actions, what a layout has to provide, how the affordances are styled, how the edit form gets its stylesheet, and the known limits. It is written for developers.

The author-facing description of the same feature is in the package's docs/authoring.md. Layouts and regions are described in Layouts. Draft view, which inline editing depends on, is described in Draft view and live reload.

The affordances

Six affordances appear when a signed-in editor hovers over a block. A visitor who is not signed in sees none of them.

  • Wash: a translucent fill over the whole block, 20% of --color-cms-inline-add. It marks the block under the pointer.
  • Region tag: on the wash's top edge. It names the region the block sits in, for example Region Β· Above the categories or Shared region Β· Catalog note.
  • Grip: at the block's top left. It drags the block to a new position among its siblings.
  • + button: at the top right, outermost of the three buttons. It adds a new block after this one.
  • Pencil button: at the top right, second from the corner. It opens this block's form in a slide-over.
  • Duplicate button: at the top right, innermost of the three buttons. It copies this block and places the copy directly beneath it.

Saving the slide-over writes a draft version through ContentWriter::update(), the same method the panel and the MCP server use. An inline edit is therefore validated against the block's own rules, stored as an immutable version, and attributed to the editor like any other write. Saving does not publish. The editor sees the change immediately because draft view is on for them. A visitor continues to see the published version until someone publishes the draft.

Inline editing has no separate edit mode. ConcreteCMS, the system the idea is taken from, requires the editor to switch into an edit mode that reloads the page and adds toolbars, so the page being edited differs from the page a visitor sees. In this package the wash, the pencil, the duplicate button and the + button are position: fixed elements outside the block's markup, placed against the block's measured box. The grip is the exception: it sits inside the block and is absolutely positioned, as described in Known limits. Neither technique changes the layout, so a page with editing available lays out identically to the same page without it.

Who sees the affordances

InlineEditing::active() decides whether the current request shows the affordances. It returns true only when all four of the following conditions hold:

  1. cms.inline_editing is true in config/cms.php. The default is true. Setting it to false turns the feature off for the whole installation.
  2. The "Edit blocks from the front end" switch is on. It is in the admin panel's Site settings screen, under the Editing heading. It is on by default, including on every site created before the switch existed. The switch is intended for the person who runs the site and takes effect without a deploy, for example while a page is demonstrated to a client and the pencil should not appear over the copy. The config flag in condition 1 is intended for the developer who built the site.
  3. DraftView::mayView() returns true. This is the same check that decides whether the request sees drafts at all, so turning draft view off also turns inline editing off.
  4. Abilities::allows(Abilities::CONTENT_UPDATE) returns true for the signed-in user.

The site setting in condition 2 controls the block affordances and nothing else. Draft view, the draft banner, the banner's poller and the broadcast reload decide whether an editor sees unpublished work, and the setting does not affect them. With the setting off, an editor still sees their drafts, still sees the banner, and still has the page reloaded when a colleague publishes. The editor has no pencil, grip, +, region tag or empty-region box, and the panel is the only place to change a block. DraftBanner contains no reference to InlineEditing, so the banner's behavior cannot depend on the setting.

The setting is read once per request and memoized, because active() is called for every block on the page. SiteSetting::instance() is held for the request as a scoped container binding, which a save clears, and InlineEditing also keeps its own memo of the resolved boolean. The panel's settings page and the cms_update_site_settings MCP tool both call InlineEditing::forgetSiteSetting() after writing, because each one saves the setting and then reads it again within the same request.

Abilities for each action

  • Edit, reorder, duplicate and add a block: CONTENT_UPDATE.
  • Delete a block: CONTENT_UPDATE and CONTENT_DELETE.

Deleting a block requires CONTENT_DELETE in addition, because a user trusted to correct a paragraph is not automatically trusted to remove the block that holds it. An editor without CONTENT_DELETE sees the form without the Delete button. Reordering requires only CONTENT_UPDATE, because it removes nothing from the page and the previous version still holds the previous order.

A visitor's page carries none of the inline editing markup: no data attributes, no stylesheet and no Livewire component.

The edit form

The slide-over renders Block::form() from the block class. The panel's block builder renders the same method. Changing a field's columnSpan, its order, its visible() closure or its helper text therefore changes the field in both places, because both read one definition.

form() divides the fields into three tabs, Content, Layout and Configuration, so the slide-over and the panel group them identically. schema() fills the Content tab. commonFields() is split across the other two tabs according to Block::LAYOUT_FIELDS. See Authoring blocks.

Both containers are one column wide, so a columnSpan behaves the same in each. The panel gets its single column from Builder\Block::make(...)->columns(1). The inline editor gets it from Filament's default for an action's schema. A field shares a row with another field only when the block's own schema() places it in a ->columns(2) container, and that container is part of the schema in both places.

The fields from commonFields() are included: Hide, Width, Alignment, CSS classes, Anchor, Accessible name and Landmark role. InlineBlockEditor::fieldsFor(), ContentForm and ChildBlocks all call form() instead of composing their own field lists, so a field added to schema() or commonFields() appears in every form without a second list to update.

An earlier version of the slide-over omitted the commonFields() fields, and this document described the omission as intentional: the slide-over held the block's content, and block-level settings stayed in the panel. In use, that design required the editor to go to the panel to change the width or alignment of a block they were looking at on the page. Width and alignment describe how a block sits on the page, and the page is the only place that placement is visible, so the fields were added to the slide-over.

Including those fields has two consequences:

  1. Hiding a block from the front end reloads the page. A hidden block renders nothing, so there is no markup to swap in and no element to find afterwards. saveBlock() checks Block::isHidden() on the merged data and dispatches the removal event instead of the save event.
  2. The re-render receives the merged data, not only the submitted fields. $cmsAttrs is built from width, alignment, anchor and the custom classes. A render given only the fields the form posted cast the others back to their defaults. Editing the text of a block set to Wide swapped in a block rendered at Normal, and the block visibly changed size until the next page load. The stored version was correct in that case; only the rendered page was wrong.

Excluding a block from inline editing

A block class excludes itself from inline editing by overriding inlineEditable():

PHP

public function inlineEditable(): bool
{
    return false;
}

The default is true. Most Filament fields work outside a panel, including the media picker, which mounts Filament's own TableSelectLivewireComponent. A field that depends on panel state does not work outside a panel, and Filament provides no way to ask a component whether it depends on panel state. A block that cannot be edited in place therefore declares it with this override, and the override should state in a comment which field prevents it.

Returning false removes the pencil and leaves the block otherwise unchanged. The block still renders, still carries its path, and can still be dragged. It must stay in the sibling list because its siblings are reordered around it, and a sibling list missing an entry would place a drop in the wrong position. The block shows no pencil at all, rather than a pencil that does nothing.

Duplicating a block

The duplicate button copies the block and inserts the copy directly beneath it in the same list. No form opens, because the copy is identical to the existing block and has nothing to configure.

  • Containers. A container is duplicated with everything inside it. Duplicating a three-column section copies the columns and the blocks in them.
  • Block id. The copy does not keep the original's id. Stored blocks carry only type and data, but the MCP tools address a block by id where one exists and resolve it by walking the tree and keeping the last match. Two blocks with one id would silently redirect every such reference to the copy.
  • Ability. CONTENT_UPDATE, with no ability of its own. Duplicating adds a block, and add() is governed by the same permission.
  • maxPerVersion cap. A block already at its cap is refused, and a notification states the reason. This is the only duplicate outcome that does not reload the page, so the button is also told to stop its spinner. page_heading is capped at one per version and is the quickest way to see the refusal.

While the write is in flight, the button shows a spinner and disables its own pointer events, so a second press cannot create a second copy. The wait is one round trip followed by a full page reload. Draft view and live reload explains why every inline write broadcasts synchronously, and why a websocket server that is not answering adds its full timeout to this wait.

Reordering blocks

Dragging is limited to one sibling group. A block cannot be dragged out of its container, because moving it to another container requires choosing that container, and a drag position does not identify one. To move a block to a different container, call cms_remove_block and then cms_append_block with into; each of the two calls states its own purpose.

The move sends the path of the sibling the block was dropped in front of, not a numeric position. A hidden block renders no element, so a browser that counts the elements it can see computes a destination that is short by the number of hidden siblings before it, and places the block in a position where nothing looks wrong afterwards. BlockTree::moveBefore() computes the destination on the server, where the whole stored list is available.

A drag that changes nothing writes nothing

Sortable reports every completed drag, including a block picked up and put back in place and a plain click on the grip. BlockTree::moveBefore() returns the block list it was given when the order is unchanged, and move() writes a version only when it receives a different list back. The client also performs the check, so an unchanged drag usually makes no request at all.

An earlier version had this check and it failed without an error. The siblings a move reads are passed through cast(), so writing them back upgraded any container stored in the pre-container shape. On a drag that changed nothing, that upgrade was the only difference, and it was enough for the "nothing changed" comparison to fail. On this application's home page, 11 of its 16 blocks wrote a new version for a click on the grip, and each of the 11 was inside a legacy container. On a published page, the new version brought back the draft banner. The defect was found that way: after a publish, a click on a grip made the banner reappear with no change behind it.

Paths after a move

A move renumbers every sibling between the source and the destination, so every path the page holds below the higher of the two now addresses the neighboring block. Earlier versions resolved this by reloading the page. The current version re-stamps the paths instead: the move returns the paths of every visible block in the container where it happened, and the editor writes them back, in order, onto the data-cms-path attributes of the elements already in the DOM. Nothing is re-rendered, and the dragged block is the only element that moves.

A delete still reloads the page, because it renumbers the siblings in the same way and because the deleted block's markup has to be removed.

Writes and the draft banner

Every inline write (edit, delete, add and reorder) announces the version it produced, so the draft banner's poll recognizes the version as this window's own work and does not reload the page two seconds later.

The exception is the first write to a page that had no unpublished changes. That page has no draft banner, and the banner is rendered on the server, so the page reloads once to show it. Without the reload, the editor would be looking at unpublished changes with nothing on screen indicating them and no way to publish them from the page. See Draft view and live reload.

Adding a block

The + button is the outermost of the three buttons at the block's top right corner. Its title, "Add a block", states that the new block is added after the current one.

Adding a block from the front end

  1. On the public page, hover over the block the new block should follow, and click +.
  2. In the "Add a block" slide-over, choose a type in the Block type field, which is a searchable list. The fields of the chosen block type appear below it.
  3. Fill in the fields and click Save. The block is created after the block whose + was clicked. Clicking Cancel creates nothing.

Why the type and the fields are one form

The panel's builder can hold an unsaved, empty item in form state until the page is saved. The front end cannot insert an empty block to be filled in later. Every inline write goes through ContentWriter and is validated, and most blocks require a field that their defaults() leaves empty: rich_text defaults body to an empty string and requires it. The block type and its fields are therefore one form with one save.

Which block types are offered

The Block type list offers the same types as the panel's picker. It takes the block's contexts(), intersects them with the content type's allowedBlocks(), filters the result through authorize(), and, when the destination is inside a container, filters it through nestable(). Measured on this installation, the list holds 46 types at the top level and 37 inside a container. The 9 types dropped inside a container are the 4 containers, which are never nestable, and 5 page-level blocks whose nestable() returns false.

A block already at its maxPerVersion cap is still offered, and it is refused on save with a message that names the cap. The cap counts blocks across the whole tree, so a picker that enforced it locally would refuse the second copy in the wrong place and for the wrong reason.

Empty regions and containers

An empty region or container has no block whose + can be clicked, so it renders a dashed gray box holding a single + instead. Hovering over the box shows the same region tag a filled region shows, so the editor can tell which of the layout's regions the block will be added to. The box is editor-only markup. A visitor's view of an empty region renders nothing.

The box is gray whether the region belongs to this page or is a shared region. An empty shared region holds no block whose editing could affect another page, so the amber color that marks a shared-region edit does not apply yet. The amber color appears on hover once the shared region holds a block.

Position of the + button

The + button previously sat centered on the bottom edge of the hover outline, which is the boundary where the new block appears. That position failed for nested blocks. A block that is the last child of a section shares the section's bottom edge, so the two + positions were within 24px of each other at the same horizontal position. Measured on /about, they were 0px apart horizontally and 24px apart vertically. The page has one + element, which is moved to the hovered block, rather than one per block. Moving the pointer toward the button therefore crossed from the child into the section and back, and the click applied to whichever block the hover had settled on last.

A child's top-right corner is inset from its parent's top-right corner by the parent's padding, so the two corners do not coincide. Measured on the same two blocks, the corner positions are 921px and 1804px apart. Because the position no longer indicates "after this block", the button's title states it instead.

Layout requirements

A layout needs no changes to support inline editing. The feature is delivered through two tags that every layout already uses:

  • <x-cms::head /> pushes <x-cms::inline-editor /> onto the cms-scripts stack. It emits nothing unless the request comes from a signed-in editor.
  • <x-cms::region /> wraps its blocks in <div class="cms-region" data-cms-region="…">. A drag is limited to this element.

The layout must render @stack('cms-scripts') before </body>, which it already needs for block scripts.

Shared regions are excluded from inline editing. Their blocks are stored against the layout rather than the page and carry no path, so they are edited on the panel's layout areas screen. Shared regions still receive the .cms-region wrapper, so CSS reaches their blocks in the same way, but the wrapper has no data-cms-region attribute.

.cms-region must not be a flex or grid container

The wrapper adds one CSS rule for skin authors: do not make .cms-region a flex or grid container.

.cms-region display Gap between blocks First block offset
block (shipped) 32px 74px
contents 32px 74px
flow-root 32px 94px
flex or grid 64px 94px

The measurements were taken on /docs/data-model, with skin market and 15 top-level blocks. A flex or grid container prevents adjacent vertical margins from collapsing, so every gap between blocks doubles. Four shipped skins set --cms-rhythm to the finished gap on the assumption that margins collapse: skins/marquee.css:107, skins/press.css:62, skins/saas.css:70 and skins/signal.css:58. To increase the space between blocks, set --cms-rhythm.

cms.css sets .cms-region { display: block }, with the measurements above in its comment.

Selectors that target a block from the column

The wrapper sits between .cms-main and the blocks, so a selector written as .cms-main > .cms-block no longer matches. Seven selectors in this package were changed to .cms-main > .cms-region > …, and two of the seven also needed :first-child on the wrapper:

CSS

/* Matches the block that is first in the main column. Without :first-child
   on .cms-region, it also matches on the docs and article layouts, whose own
   <h1> sits above the region, and the gap under the title drops from 32px to
   20px. */
.cms-main > .cms-region:first-child > .cms-block:first-child {
    margin-block-start: 0;
}

A custom skin with selectors written against .cms-main > … needs the same change.

Styling the affordances

The affordances are styled by these classes in the front-end stylesheet, cms.css:

Class Element
.cms-inline-hint The translucent wash over the hovered block
.cms-inline-pencil The edit button
.cms-inline-duplicate The duplicate button; .cms-inline-duplicate--busy while its write is in flight
.cms-inline-grip The drag handle
.cms-inline-add The add button
.cms-inline-drop Sortable's placeholder during a drag
.cms-inline-placeholder The box an empty region or container shows
.cms-inline-tag The region tag, on a hovered block's wash and on the empty box

Skins cannot restyle the affordances

Every class above except .cms-inline-drop takes its colors and its font from tokens declared once in cms.css and marked skinnable: false in the design token catalog (Support\DesignTokens), so the editing affordances look identical on every site. .cms-inline-drop, the placeholder shown during a drag, still draws its dashed outline in --color-cms-accent, which a skin can reassign.

Token Used by
--color-cms-inline-add and --color-cms-inline-add-ink Wash, pencil, duplicate button, +, region tag, and the empty box on hover
--color-cms-inline-neutral and --color-cms-inline-neutral-ink The empty box at rest, its +, its tag, and the grip
--color-cms-shared-edit and --color-cms-shared-edit-ink All of the above when the block belongs to a shared region
--font-cms-inline The font for all of the affordances

This changed in v0.54.0. Before that version, the wash and pencil used --color-cms-accent, the grip used --color-cms-raised and --color-cms-muted, and the empty box used --color-cms-line-strong and --color-cms-faint. Every skin reassigns all of those tokens, so the same affordance looked like a different control on each site, and on skins whose accent color was close to the page's text color, the wash was hard to distinguish from the content it covered.

Skin files need no change. The tokens a skin sets are unchanged and still apply everywhere else; they no longer reach the editing affordances.

All of the affordances sit at z-index 30 or 31, below the site's own chrome and below every Filament overlay. The scale is documented in cms.css under the comment heading "The layer the site's own chrome lives on". The upper limit comes from Filament, whose modal overlay is at z-index 40 and whose stacked modal is at z-index 50.

The panel stylesheet

The edit slide-over uses the panel's own stylesheet, and the host application installs nothing for it.

Filament's JavaScript is loaded by @filamentScripts. Filament's component CSS is not loaded by @filamentStyles, which the directive's name does not suggest. AssetManager::renderStyles() emits registered Css assets and fonts, and a Theme is neither. @filamentStyles therefore outputs the font and a few inline rules, every fi-* component rule is missing, and the form renders correct Filament markup with no styling. A rich editor rendered that way has its toolbar in the DOM and invisible.

The inline editor loads the stylesheet through Panel::getTheme(), the same call the panel's own layout makes. Theme extends Css, so getHtml() returns the same <link> tag the panel emits, and it resolves according to the panel's configuration:

Panel configuration Stylesheet the slide-over loads
No custom theme Filament's precompiled dist/theme.css, published to public/css/filament/filament/ by filament:assets, which filament:upgrade runs on every composer install
A viteTheme() The same compiled theme the panel uses, which is already built because the panel itself needs it

In both cases the host application builds nothing extra, and the slide-over matches a customized panel instead of always using Filament's default theme.

The stylesheet is emitted only for a signed-in editor, so a visitor never downloads the panel's stylesheet to read a page.

Using a different stylesheet

cms.inline_editing_vite_entry in config/cms.php names a Vite entry to load instead of the panel theme. It is null by default, and an installation should leave it null unless the slide-over must use a stylesheet other than that of the panel it saves to.

An entry that is not in the built Vite manifest falls back to the panel's theme instead of throwing an exception. This setting replaced an older default that named an entry the host application had to author, list in vite.config.js and build. That default was guarded by a check that build/manifest.json existed. The file exists on any application that builds any asset, so the check passed, and @vite threw Unable to locate file in Vite manifest on the first page an editor opened.

Known limits

  • No dragging between regions or containers. A drag stays within one sibling group. See Reordering blocks.
  • Snippet children are not addressable. A snippet's blocks belong to the snippet's own version. A path composed on the embedding page would address the wrong version.
  • One level of nesting. BlockTree::MAX_DEPTH is 1, so a child's path is always {parentIndex}.{slot}.{childIndex}.
  • The grip is styled with !important, and a host block cannot restyle it. The grip is the only part of this package's interface placed inside another block's markup, because Sortable does not accept a handle that is not a descendant of the item. Every rule a block writes about its own children therefore applies to the grip. The grip's top, right, bottom, left and margin are protected with !important; its other properties are not.
  • The grip sits 8px inside the block's top left corner, over whatever content is there. On a left-aligned heading, the grip covers the first word. It previously sat beside the pencil, which put it in clear space on most blocks but made its position depend on whether the block had a pencil.
  • [data-cms-block-path] is position: relative. The grip is absolutely positioned against its block, so a transform on the block does not displace it. Blocks carry transforms from the entrance animation and from the marquee skin's scroll-driven animation. The rule moves no element: measured over 299 elements, adding it changed no element's box. It applies only to editors, because the attribute is written only for an editor.