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 —
SectionListLivewire component dispatchessection-selected(with the section id). Editor parent listens viaLivewire.on('section-selected', …). - Settings panel open — same dispatch from
SettingsPanel. - Initial URL load —
?section=<id>in the URL is read once when the iframe's postMessage bus reports ready. - Iframe click — clicking a section inside the iframe round-trips through the bus and fires the same
section-selectedLivewire event.
All four paths funnel into the same parent-side handler. Two guards keep it efficient.
Parent-side dedupe
let lastScrolledSectionId: string | null = null;
window.Livewire.on('section-selected', (payload) => {
if (data.id === lastScrolledSectionId) return;
lastScrolledSectionId = data.id;
// ... fan-out per iframe
});Without this, sidebar-click + settings-open + initial-URL-load all fire section-selected in rapid succession for the same id, and the parent would fan out three identical postMessages per canvas iframe.
Iframe-side visibility guard
The iframe's SectionScrollTo handler reads getBoundingClientRect() and skips the scroll when the target is already in the upper half of the viewport:
const rect = el.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const inUpperHalf = rect.top >= 0 && rect.top <= viewportHeight * 0.5;
if (inUpperHalf) 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.
Cross-version Livewire hook
In Livewire 3, livewire:morph.updated was a DOM event you could subscribe to with document.addEventListener. Livewire 4 removed the DOM dispatch; the same name is now an internal trigger() hook only.
The editor subscribes through both APIs so the post-morph boot reliably runs on both versions:
// Legacy v3 DOM event — silently dead on v4, harmless to subscribe.
document.addEventListener('livewire:morph.updated', bootAll);
// Universal hook — exists in both v3 and v4.
document.addEventListener('livewire:init', () => {
window.Livewire?.hook?.('morph.updated', bootAll);
});Livewire.hook is the API that matters going forward.
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:
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:
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() (livewire:init, livewire:navigated, livewire:update, the morph.updated hook) heals a resize handle that was broken by a morph.
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.
Where the code lives
| File | What it does |
|---|---|
resources/js/editor.ts | Parent-side bus setup, Livewire listeners, scroll dispatch, layout boot orchestration |
resources/js/iframe-injected.ts | Iframe-side bus handlers including SectionScrollTo with the visibility guard |
resources/js/layout-resize.ts | split-grid + the gutter-rebind logic |
resources/js/postmessage-bus.ts | The transport between editor and iframes; framework-agnostic |
src/Editor/Protocol/MessageType.php | Enum of every message type sent over the bus |
If you change any .ts file, run npm run build to refresh resources/dist/ — the CI gate enforces parity between source and dist.
