Appearance
Editor Internals
You don't need any of this to use the editor — only to debug it if something looks off, or to extend it. For the day-to-day tour of the workspace, see The Editor.
Auto-scroll preview on section selection
When you click a section in the sidebar (or open the editor with ?section=<id> in the URL), the preview iframe smooth-scrolls to that section. Trigger paths:
- Sidebar click — the
SectionListLivewire component dispatchessection-selected(with the section id). The editor parent listens viaLivewire.on('section-selected', …). - Settings panel open — same dispatch from
SettingsPanel. - New section inserted —
AddSectionModal::insert()andPresetPicker::pick()both dispatchsection-selectedfor the id they just created, so a freshly added section self-selects and scrolls into view. - Iframe click — clicking a section inside the iframe round-trips through the bus (
SectionClick) and fires the samesection-selectedLivewire event. - Initial URL load —
?section=<id>bypasses the Livewire handler entirely; it is read once when the iframe's postMessage bus reports ready:
ts
void bus.ready.then(() => {
const sectionId = new URL(window.location.href).searchParams.get('section');
if (!sectionId) return;
bus.send(MessageType.SectionScrollTo, { sectionId, behavior: 'smooth' });
});The Livewire paths funnel into queueSectionScroll() in editor.ts, which runs a retry/ack protocol against the iframe.
The retry/ack protocol
A SectionScrollTo fired right after a structural change (add, duplicate, preset apply) can arrive before the iframe has re-rendered the new section — the target doesn't exist yet. So the parent doesn't fire-and-forget: it retries every 150 ms, up to 40 attempts, until the iframe acknowledges the section was found:
ts
const SECTION_SCROLL_RETRY_MS = 150;
const SECTION_SCROLL_MAX_ATTEMPTS = 40;
const SECTION_SCROLL_DUPLICATE_WINDOW_MS = 200;ts
function queueSectionScroll(sectionId: string, behavior: ScrollBehavior = 'smooth'): void {
const now = Date.now();
if (
lastSectionScrollRequest?.sectionId === sectionId &&
now - lastSectionScrollRequest.requestedAt < SECTION_SCROLL_DUPLICATE_WINDOW_MS
) {
return;
}
lastSectionScrollRequest = { sectionId, requestedAt: now };
clearPendingSectionScroll();
pendingSectionScroll = { sectionId, behavior, attempts: 0, timer: null };
flushPendingSectionScroll();
}The 200 ms duplicate window absorbs the burst when several components dispatch section-selected for the same id in the same interaction (sidebar row + settings panel), without suppressing a later legitimate re-click of the same section.
The iframe answers every SectionScrollTo with a SectionScrollResult ({ sectionId, found }). On found: true the parent stops retrying:
ts
bus.on(MessageType.SectionScrollResult, (data) => {
const payload = data as { sectionId?: unknown; found?: unknown };
if (typeof payload.sectionId !== 'string') return;
if (payload.found === true) completePendingSectionScroll(payload.sectionId);
});found: false (section not rendered yet) leaves the pending scroll in place, and the next 150 ms retry catches the section once the iframe morph lands.
Iframe-side visibility guard
The iframe's SectionScrollTo handler reads getBoundingClientRect() and suppresses the scroll when the target is already in the upper half of the viewport — but still acks, so the parent stops retrying:
ts
const rect = el.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const inUpperHalf = rect.top >= 0 && rect.top <= viewportHeight * 0.5;
if (inUpperHalf) {
send(MessageType.SectionScrollResult, { sectionId: data.sectionId, found: true });
return;
}Without this, clicking a section inside the iframe (case 4 above) would snap the iframe back to the same spot the user just clicked — visually jarring.
Rebinding after Livewire morphs
The editor's parent-side bindings (SortableJS, the bus, canvas scale, the layout gutter) target elements a Livewire morph can replace. editor.ts re-runs them from two triggers, coalesced into a single rebind per animation frame:
ts
let rebindScheduled = false;
function scheduleRebind(): void {
if (rebindScheduled) return;
rebindScheduled = true;
requestAnimationFrame(() => {
rebindScheduled = false;
bootSortable();
bootBus();
bindLivewireBusListener();
bindCanvasScale();
bindLayoutResize(refreshCanvasScales);
});
}
document.addEventListener('livewire:init', () => {
bindLivewireBusListener();
const livewire = window.Livewire as
| (NonNullable<Window['Livewire']> & {
hook?: (name: string, handler: (payload?: unknown) => void) => void;
})
| undefined;
// `morphed` fires once per component after its subtree is fully morphed
// (Livewire v3 + v4); `morph.updated` fired once per mutated element.
if (livewire && typeof livewire.hook === 'function') {
livewire.hook('morphed', scheduleRebind);
}
});
// Safety net for environments where the hook is unavailable; coalesced, so when
// both this and `morphed` fire for one update only one rebind runs.
document.addEventListener('livewire:update', scheduleRebind);Two deliberate choices here:
morphed, notmorph.updated—morphedfires once per component;morph.updatedfires once per mutated element, and a single edit mutates dozens.Livewire.hook()is version-agnostic, so the same registration works on Livewire 3 and 4.- requestAnimationFrame coalescing — one edit morphs several components, so both triggers can fire multiple times per update. All boot functions are idempotent, and rAF runs after morphdom's synchronous writes, so the single rebind still sees fresh nodes.
Commit flush on pointerdown
Live setting edits commit to the server behind a 400 ms debounce (LIVE_SERVER_COMMIT_DEBOUNCE_MS in editor.ts). A click that navigates — locale switch, page switch, site switch — inside that window would discard the in-flight edit. So flushPendingServerCommits() runs on capture-phase pointerdown and puts every pending commit on the wire before the click's own handler can navigate:
ts
document.addEventListener('input', handleLiveInput, true);
document.addEventListener('pointerdown', flushPendingServerCommits, true);beforeunload dirty guard
The topbar's dirty dot is server-rendered, so it lags a live edit by one Livewire round-trip plus the autosave debounce. A clientDirty flag tracks edits browser-side (set on fc:live-edit, cleared when draft-synced reports cleared: true), and the beforeunload handler warns when either the flag or the dot says there are unsaved changes.
Hover toolbar internals
The canvas outline + floating toolbar is drawn entirely inside the iframe (iframe-injected.ts). Three overlay kinds share one pipeline:
ts
type OverlayKind = 'select' | 'highlight' | 'hover';select is the persistent outline + toolbar on the selected section (painted when the parent sends SectionSelect), highlight is an outline-only style with no toolbar (driven by the SectionHighlight message), and hover is the transient outline + toolbar under the pointer. The toolbar's hide button sends the SectionToggleHidden message (section:toggle-hidden in src/Editor/Protocol/MessageType.php); sections wrapped with data-fc-section-locked render only the name chip and edit button — no move/duplicate/hide/delete.
Sidebar resize
The editor's three-column layout (nav rail · settings rail · preview canvas) is managed by split-grid via resources/js/layout-resize.ts. The user drags the vertical handle between the settings rail and the preview to resize.
The morph problem
split-grid binds a mousedown listener to the gutter element ([data-fc-layout-gutter]). When Livewire or Filament navigation morphs the editor page tree, the body element keeps its identity but the gutter child can be replaced — leaving the listener bound to a detached element. The handle goes silently dead.
The fix
layout-resize.ts tracks two WeakMaps:
ts
const instances = new WeakMap<HTMLElement, SplitInstance>(); // body → split-grid
const gutterByBody = new WeakMap<HTMLElement, HTMLElement>(); // body → live gutterbindBody() checks the parallel map and rebinds if the live gutter differs from the bound one:
ts
if (instances.has(body)) {
const previousGutter = gutterByBody.get(body);
if (previousGutter && previousGutter === gutter && previousGutter.isConnected) {
return; // still good, skip
}
destroyBody(body, false); // tear down the stale instance
}
// ... bind a fresh split-grid to the live gutterThe end-to-end effect: any code path that re-runs bindLayoutResize() heals a resize handle that was broken by a morph. Those paths are scheduleBoot on DOMContentLoaded, livewire:navigated, the mount MutationObserver (bindMountObserver watches the editor root for re-inserted editor markup), and scheduleRebind — triggered by both the morphed Livewire hook and the livewire:update event, coalesced as above.
Why not bind on the gutter directly?
split-grid's API takes the gutter as the configured target but reads this.element.parentNode (the body) at drag-start. The grid layout lives on the parent. So the WeakMap key has to be the body (because it's the grid container), and we just track the gutter identity alongside.
Preview link navigation
The canvas never leaves the preview route (filamentcraft/preview/{template}), because only that document receives edits — yet it has to behave like the live site. iframe-injected.ts gets both by listening in two phases:
- A capture listener on
documentonly postsSectionClickto select the section. It never cancels the click, so page scripts, Alpine and Livewire handlers all run. It skips clicks that submit a form: the select request would race the form post and save a stale session over it (a lost cart add). - A bubble listener on
windowruns after every page handler and bails if one already calledpreventDefault(). Otherwise it decides where a link goes:[data-fc-locale]postsLocaleSwitch.[data-fc-nav="<kind>"](product,shop,category,cart,checkout, plusdata-fc-nav-slug) postsNavigatewith the kind, slug and resolved href.- A link whose path is the current preview path (sort, filter, pagination) or a
#link is left alone and loads in place. So aretarget="_blank",downloadand non-HTTP links. - Any other link posts
NavigateUrl(navigate:url) with its absolute href. AGETform aimed at another path does the same with its fields serialised into the query.
Parent-side, editor.ts turns Navigate into a Livewire editor-navigate dispatch and NavigateUrl into editor-navigate-url. Both are handled by the topbar's InteractsWithPreviewNavigation concern:
LinkedPageResolvermaps an href to a template. It first looks for a template whosepublicUrl()has that path. Failing that, it strips the host's mount prefix (/{tenant}), then triesTemplateResolver(for dynamic templates) and finally a slug match (for unpublished pages). It keepsproduct/categoryfrom the query or route parameters.PageKindResolveris the fallback forNavigate. It picks a template by the sections it holds:product-detailmarks the product page,cartthe cart page,product-listingthe shop and category page.- If neither resolves, the editor shows a notice with an Open in new tab action.
The editor then redirects to the template with the record as ?product= / ?category=. Canvas reads the query back and threads it through every preview URL. The BuildsPreviewContext trait on the preview controllers turns it into the RenderContext that the product-detail / product-listing sections resolve their record from.
Navigation that slips through anyway, such as a cart POST that redirects to the storefront cart, is caught on the iframe's load event. returnToPreview() sends the landed URL through editor-navigate-url and reloads the preview.
Live refreshes keep the canvas state. templateRefresh and sectionRefresh merge the canvas's current query into the URL they fetch. SectionRefreshController renders the fragment against a request for the preview page itself, so request()->path()-built links and cart return values match the page the fragment is patched into. The data-fc-* attributes do nothing on the published (JS-free) site.
Where the code lives
| File | What it does |
|---|---|
resources/js/editor.ts | Parent-side bus setup, Livewire listeners, scroll retry/ack, live patcher + commit flush, layout boot orchestration |
resources/js/iframe-injected.ts | Iframe-side bus handlers — SectionScrollTo with the visibility guard, the hover-toolbar overlays |
resources/js/layout-resize.ts | split-grid + the gutter-rebind logic |
resources/js/postmessage-bus.ts | The transport between editor and iframes; framework-agnostic |
resources/js/protocol.ts | TypeScript mirror of the server-side MessageType enum, shared by both sides of the bus |
resources/js/keybindings.ts | hotkeys-js bindings + the ? shortcuts overlay |
resources/js/tooltips.ts | Delegated tippy.js tooltips for [data-tippy-content] |
resources/js/site.ts | Storefront-side JS — carousels, tabs, countdowns, banner dismissal — ships to the live site, not the editor |
src/Editor/Protocol/MessageType.php | Enum of every message type sent over the bus |
src/Editor/Support/PageKindResolver.php | Maps a preview-link kind to the site template that renders it, by the sections each template holds |
src/Editor/Support/LinkedPageResolver.php | Maps a URL clicked in the canvas to the site template whose public URL (or route) it points at |
If you change any .ts file, run npm run build to refresh resources/dist/ — the CI gate enforces parity between source and dist.