Skip to content

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:

  1. Sidebar click — the SectionList Livewire component dispatches section-selected (with the section id). The editor parent listens via Livewire.on('section-selected', …).
  2. Settings panel open — same dispatch from SettingsPanel.
  3. New section insertedAddSectionModal::insert() and PresetPicker::pick() both dispatch section-selected for the id they just created, so a freshly added section self-selects and scrolls into view.
  4. Iframe click — clicking a section inside the iframe round-trips through the bus (SectionClick) and fires the same section-selected Livewire event.
  5. 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, not morph.updatedmorphed fires once per component; morph.updated fires 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.

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 gutter

bindBody() 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 gutter

The 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.

Storefront links the renderer emits carry data-fc-nav="<kind>"product, shop, category, cart, checkout — plus data-fc-nav-slug on the product/category ones. The iframe click handler catches a click inside such a link ahead of the section-select branch, preventDefaults it, and posts the Navigate message (navigate) with the kind and slug. Parent-side, editor.ts turns that into a Livewire editor-navigate dispatch, handled by the topbar's InteractsWithPreviewNavigation concern: PageKindResolver maps the kind to one of the current site's templates by the sections it holds (a product-detail section marks the product page, cart the cart page, product-listing the shop/category page), and the editor redirects to that template with the clicked slug 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 the product-detail / product-listing sections resolve their record from. The attributes are inert on the published (JS-free) site.

Where the code lives

FileWhat it does
resources/js/editor.tsParent-side bus setup, Livewire listeners, scroll retry/ack, live patcher + commit flush, layout boot orchestration
resources/js/iframe-injected.tsIframe-side bus handlers — SectionScrollTo with the visibility guard, the hover-toolbar overlays
resources/js/layout-resize.tssplit-grid + the gutter-rebind logic
resources/js/postmessage-bus.tsThe transport between editor and iframes; framework-agnostic
resources/js/protocol.tsTypeScript mirror of the server-side MessageType enum, shared by both sides of the bus
resources/js/keybindings.tshotkeys-js bindings + the ? shortcuts overlay
resources/js/tooltips.tsDelegated tippy.js tooltips for [data-tippy-content]
resources/js/site.tsStorefront-side JS — carousels, tabs, countdowns, banner dismissal — ships to the live site, not the editor
src/Editor/Protocol/MessageType.phpEnum of every message type sent over the bus
src/Editor/Support/PageKindResolver.phpMaps a preview-link kind to the site template that renders it, by the sections each template holds

If you change any .ts file, run npm run build to refresh resources/dist/ — the CI gate enforces parity between source and dist.