The visual editor

A merchant changes the words on their storefront by clicking them. This page is for the people who build the sections and themes those words live in.

The short version: you do not have to do anything. If you are adding a theme section, skip to Theme sections to understand what you get for free and the two habits that keep it working.

The two mechanisms

There are two, they cover different code, and they fail independently.

Covers Binds by
InlineText The 22 generic section types in DynamicSections.vue An explicit setting-key
ThemeSectionEditable The 61 theme sections in themes/*/sections/ Matching rendered text against the section’s settings

Both send the same message up to the dashboard:

{ type: 'INLINE_EDIT', sectionId, blockId?, key, value }

blockId is what makes an edit land on a repeatable block — a pillar’s title, a team member’s bio, a pricing tier’s name — rather than on the section. The dashboard drops a blockId it cannot find rather than falling back to the section: writing a block’s title onto the section would silently rename the whole section, which is worse than the edit not landing.

Generic sections

Render copy through InlineText, never as a mustache:

<InlineText
  v-if="editorActive || getStr(section, 'heading')"
  tag="h2"
  class="ds-section-title"
  :section-id="section.id"
  setting-key="heading"
  :model-value="getStr(section, 'heading')"
  placeholder="Heading"
/>

The editorActive || guard is what makes an empty setting editable: without it a merchant cannot click a heading they have not written yet, because there is no element to click.

For block copy, add :block-id:

<InlineText
  tag="h3"
  :section-id="section.id"
  :block-id="b.id"
  setting-key="title"
  :model-value="String(b.settings?.title ?? '')"
  placeholder="Title"
/>

A guard test (utils/inline-editing-guard.test.ts) sweeps the template for copy still rendered as a raw mustache and fails on it, including in section types that do not exist yet. If you add a section and that test goes red, it is telling you a heading is not editable.

Two things that are not text

Links and buttons are rendered twice, deliberately:

<NuxtLink v-if="!editorActive && getStr(section, 'ctaUrl')" :to="...">
  {{ getStr(section, 'ctaText') }}
</NuxtLink>
<InlineText v-else-if="editorActive" tag="span" setting-key="ctaText" … />

A caret inside an <a> fights the navigation. The label is editable in the editor; the URL behind it stays in the settings panel, where a text cursor would be a worse tool than the control built for it.

Components that hide their copy behind behaviour — a rotating announcement bar, a <details> accordion, a countdown that paints its headline inside the timer — lift that copy into a static block while the editor is open. A <details> that has to be clicked to reveal its answer swallows the click that would place a caret in it.

Theme sections

Theme sections take flat props (headline, season, body) and are mounted through defineAsyncComponent. They do not know which section instance they are, and they do not need to.

ThemeSectionEditable wraps them and binds from the outside: after the theme paints, it walks the DOM and asks, for each leaf element, whether its whole text is exactly the value of one of the section’s settings. If it is, that element is that setting.

This is why a theme section written next month is editable the day it ships.

What it will not bind, and why

Guessing wrong is worse than not guessing — a wrong bind silently rewrites a setting the merchant never touched, and they find out when the live site changes somewhere else. So it declines whenever the match is not certain:

  • A value two settings share. Nothing distinguishes them in the DOM.
  • Values under four characters. New, £29, 1/4 turn up in three unrelated places in a theme.
  • Values that are not prose — urls, hex colours, CSS lengths, uuids, kebab tokens — and any key that names a machine value (ctaUrl, backgroundImage, layout).
  • Elements with element children, form controls, links, buttons, media.
  • Anything InlineText already claimed, so the two never both bind one node.

The rules are in utils/inlineBinding.ts and unit-tested in inlineBinding.test.ts.

The two habits that keep it working

  1. Render a setting’s value verbatim. {{ headline }} binds. {{ headline }} → does not, and should not — writing the edit back would swallow the arrow. Put decoration in CSS or a sibling element.
  2. Transform in CSS, not in the template. text-transform: uppercase still binds, because CSS does not change textContent. Uppercasing in the template does not, and must not: the edit would persist the shouted version.

Why a MutationObserver

Because the sections are async. At onMounted + nextTick the slot is still empty, so a single bind pass would find nothing and never run again. The observer also covers a carousel advancing or an image reflowing.

It watches childList only, never attributes — bindElement sets attributes, and observing them would make every bind trigger another. It also never re-binds while focus is inside the section, which would strip the contenteditable off the element being typed in and end the edit mid-word.

The publish review

diffThemeContent in @mercentia/shared/utils/themeDiff compares the published content to the draft and returns per-section changes; a modified section carries a fields list with before and after for each setting.

applyFieldRevert is the inverse and lives beside it, in shared rather than in the gateway, because the dashboard runs it. Two copies of “can this field be put back, and how” is how the two ends up disagreeing about, say, whether a summarised array value is revertable — and that failure is silent: the Undo button appears and writes the string 3 items over an array.

Three cases refuse a revert, and the UI hides the control for them:

  • A summarised value (3 items, updated) — a description of the data, not the data.
  • A whole added or removed block — the row does not carry the block’s other settings.
  • An unknown section or block id.

The field list is capped at 25 per section, with the remainder counted out loud in the UI. A truncated list that looks complete is how someone publishes a change they never saw.

Security

The bridge refuses to enter edit mode without an explicit editorNonce and editorOrigin on the query string, and discards any message whose origin or nonce does not match. Nothing is editable on a live storefront: without the handshake, no listener is attached and no contenteditable is set.