pagepilot-visual-editor
v1.0.6
Published
Standalone visual editor — drop <PagePilotEditor> into any React app and get Style/Layout/Inspect/Layers panels, drag-and-drop blocks, undo/redo, dirty-tracked save, multi-section pages, and light/dark themes
Maintainers
Readme
pagepilot-visual-editor
A standalone, prop-driven React page-type visual editor. Drop
<PagePilotEditor /> into your host application, pass the host record through
props, and listen for onSaveChanges / onPublish callbacks — the editor
handles the UI, block creation, multi-section management, and HTML rendering.
Two integration modes:
- Callback-only (default). The package is purely the editor: no backend calls, no host coupling. The host owns persistence, navigation, plan gating, theming overrides, and everything else.
- Service-account API (opt-in via the
apiConfigprop). Point the editor at a pagepilot API instance and it takes over save / publish / template listing / section library / tenant-plan gating for you. Same auth pattern ahd-fe uses internally (Authorization: Bearer <token>), configured once at mount time. See Pagepilot API integration.
Install
npm install pagepilot-visual-editor
# or
pnpm add pagepilot-visual-editor
# or
yarn add pagepilot-visual-editorPeer dependencies (your host should provide these):
| Peer | Range | Notes |
|---|---|---|
| react | ^18.0.0 \|\| ^19.0.0 | React 18 and 19 both supported |
| react-dom | ^18.0.0 \|\| ^19.0.0 | Match your react version |
| @mui/material | >=5 | v6 tested; v5 works |
| @mui/icons-material | >=5 | |
| @emotion/react | >=11 | Required by MUI |
| @emotion/styled | >=11 | Required by MUI |
Also required at runtime (not currently listed as a peer, but the built bundle keeps them external — install these alongside the peers above):
npm install styled-components zustandIf you want to render exported types like TReaderBlockProps or use the
ReaderBlockSchema, add zod too:
npm install zodQuick start
import { PagePilotEditor } from "pagepilot-visual-editor";
export default function PageEditorScreen() {
const [record, setRecord] = useState(initialRecord);
// The container MUST have an explicit height (100vh, a fixed pixel value,
// or a flex parent that gives it space). The editor fills its parent 100%;
// if the parent has 0 height, so does the editor. `width: 100%` is
// inherited from the ScopedCssBaseline root inside the component.
return (
<div style={{ width: "100%", height: "100vh" }}>
<PagePilotEditor
record={record}
recordTitle={record.title}
recordStatus={record.status}
onUpdateRecord={setRecord}
onSaveChanges={async ({ record, html }) => {
await api.savePage(record.id, { record, html });
}}
onPublish={async ({ record, html }) => {
await api.publishPage(record.id, { record, html });
}}
onBack={() => navigate(-1)}
/>
</div>
);
}The editor renders its own MUI ThemeProvider + ScopedCssBaseline, so it
works inside hosts that already use MUI — you don't need to import our CSS
separately. If your host sets a non-default html { font-size }, our
ScopedCssBaseline re-anchors REM sizing at 14 px inside the editor so the
built-in layout doesn't scale unexpectedly.
Anatomy
┌──────────────────────────────────────────────────────────────────────────┐
│ ← Back My Page ● Draft [Section ▾] ↶ ↷ ⊟ Save ⋮ ⤢ ▶ Publish ▾ │ Topbar
├──────┬───────────────────┬─────────────────────────────────────────────┤
│Style │ Appearance │ │
│Layout│ Color ▢ ... │ (canvas — block-based editor) │
│Inspct│ Font ▾ │ │
│Layers│ Radius PX │ │
│Vars* │ Layout │ │
│SEO │ Width 100 % │ │
│PgPlt │ │ │
└──────┴───────────────────┴─────────────────────────────────────────────┘
* only when template has vars- Left rail — up to 7 panels: Style, Layout, Inspect, Layers, Variables, SEO, Pagepilot (each can be toggled off via the
panelsprop). Variables auto-hides unless a template with fields is applied or a repeatable container is selected. Pagepilot currently renders a "Coming soon" placeholder — the full AI page generator lands in a future release. - Topbar — every control is individually toggleable via the
topbarprop. Includes undo / redo / deselect block (auto-hidden when nothing is selected) / save / more menu / fullscreen / preview / publish. - Canvas — the block-based page editor. Click the empty-state "Add first section" or the ⋮ → Add Section.
- Block TuneMenu — appears above any selected block: move up / move down / delete / open in Layers / more menu (Save as template / Duplicate / Copy / Paste / Replace / Hide).
- 3-dot menu — currently exposes a single item, Add Section. Hidden by default; set
topbar.moreMenutotrue. - Layout panel — the Blocks palette is grouped into six categories (
Text/Content,Media,Layout/Structure,Actions/Controls,Embeds/Integrations,Support/Utility). WhenapiConfigis passed, two additional tabs appear: Templates (global page templates) and Saved (per-tenant templates), plus a Section Library tile that opens a full gallery of section templates.
<PagePilotEditor> props — full reference
1. Host data
| Prop | Type | Default | Behavior |
|---|---|---|---|
| record | PageRecord | undefined | The full page record. Mutations are echoed back through onUpdateRecord. |
| contentMetadata | PageRecord["contentMetadata"] | undefined | Top-level override for record.contentMetadata. When supplied, wins over record.contentMetadata. |
| sections | PageRecord["sections"] | undefined | Top-level override for record.sections. Section array for multi-section pages. |
| activeStepIndex | number | 0 | Index of the currently-active section in the canvas. The editor also tracks this internally via the section dropdown. |
| behaviour | Record<string, any> | undefined | Passthrough only — the editor does not consume this. Preserved in the save/publish payload if non-empty so host-owned config survives a round-trip. |
| steps | PageRecord["steps"] | undefined | Passthrough only — legacy shape from the original tour/tooltip editor. The page editor does not read or render steps; it's preserved in the save payload when non-empty. |
| apiConfig | PagepilotApiConfig | undefined | Opt-in pagepilot API integration. When passed, save/publish PUT the record through the API, Layout gets Templates / Saved tabs, Add-Block menu gets the Section Library tile, Publish opens a rich dialog, Variables panel gates on tenant plan. See Pagepilot API integration. |
The editor reads record.contentMetadata.document (or record.sections[activeStepIndex].contentMetadata.document for multi-section pages) and seeds the canvas on first mount. After that the editor owns the document state; the host receives mutations via onUpdateRecord.
PageRecord shape
interface PageRecord {
id?: string;
type?: string; // keep "page" for the page editor
title?: string; // displayed when `recordTitle` is not set
name?: string; // fallback for title
status?: string; // displayed as status pill ("draft" | "live" | ...)
contentMetadata?: {
document?: any; // the editor's source-of-truth document tree
html?: string; // set by the editor on save
[key: string]: any;
};
behaviour?: Record<string, any>;
steps?: Array<{ // for tour/tooltip flows
contentMetadata?: { document?: any; [key: string]: any };
behaviour?: Record<string, any>;
[key: string]: any;
}>;
sections?: PageSection[]; // for multi-section pages
[key: string]: any; // free-form passthrough
}
interface PageSection {
_id?: string;
title?: string;
identifier?: string;
sectionType?: "internal" | "external";
content?: string; // per-section rendered HTML (set by editor on save)
contentMetadata?: {
document?: any; // per-section editor document
[key: string]: any;
};
[key: string]: any;
}2. UI configuration
| Prop | Type | Default | Behavior |
|---|---|---|---|
| panels | EditorPanelToggles | all 4 visible | Toggle the four left-rail panels independently. |
| title | string | "Back" | Label rendered on the back button. |
| recordTitle | string | record.title ?? record.name | Text shown after the back button. Hidden when empty. |
| recordStatus | string | record.status | Pill shown after the title. Built-in color coding for draft (orange), live / published (green), scheduled (indigo). Any other string gets a neutral gray pill. |
| mode | "light" | "dark" | "dark" | Selects the built-in theme. Ignored when theme is set. |
| theme | Theme (MUI) | undefined | Full MUI theme override. Wins over mode. |
| translations | Record<string, string> | {} | Merged on top of the built-in English dictionary. Missing keys fall back to defaults; missing default keys fall back to humanizing the last segment of the key. |
| topbar | TopbarToggles | see below | Per-control visibility for the topbar. |
| blocks | BlockToggles | all true | Per-tile visibility for the Add-Block menu. |
| headerExtra | ReactNode | undefined | Rendered immediately below the topbar — use for custom banners/announcement bars. |
| children | ReactNode | undefined | Rendered after the editor body. |
EditorPanelToggles
interface EditorPanelToggles {
style?: boolean; // default true
layout?: boolean; // default true
inspect?: boolean; // default true
layers?: boolean; // default true
variables?: boolean; // default true — auto-hides when no template forms
// and no repeatable container selected
seo?: boolean; // default true
pagepilot?: boolean; // default true — Pagepilot AI tab (currently
// renders a "Coming soon" placeholder)
}TopbarToggles
interface TopbarToggles {
back?: boolean; // default true
title?: boolean; // default true — record title
status?: boolean; // default true — status pill
sectionsDropdown?: boolean; // default true — still requires sections.length > 1
undo?: boolean; // default true
redo?: boolean; // default true
unselect?: boolean; // default true — Deselect Block button
// (auto-hides when nothing selected)
save?: boolean; // default true
themeToggle?: boolean; // default true — sun/moon button flipping light/dark
fullscreen?: boolean; // default true
preview?: boolean; // default true
moreMenu?: boolean; // default FALSE
publish?: boolean; // default FALSE
}Defaults note: moreMenu and publish are off by default — they only appear when explicitly enabled. Everything else is on by default.
BlockToggles
Per-tile visibility for the Add-Block menu. Each key defaults to true.
type BlockKey =
| "heading" | "text" | "button" | "image" | "logo" | "video"
| "avatar" | "divider" | "spacer" | "html"
| "columns" | "flexColumns" | "grid"
| "buttonGroup" | "container"
| "stepper" | "accordion" | "pricingAccordion"
| "tabs" | "carousel" | "rating" | "marquee"
| "iframe" | "skipToMain" | "accessibilityTools"
| "timer" | "pagination" | "table"
| "demoPicker" | "menuPicker" | "formsPicker"
| "appBannerPicker" | "faqPicker";
type BlockToggles = Partial<Record<BlockKey, boolean>>;<PagePilotEditor
blocks={{
iframe: false, // hide the iFrame tile
accessibilityTools: false,
marquee: false,
}}
/>3. Callbacks
| Prop | Signature | When it fires |
|---|---|---|
| onUpdateRecord | (record: PageRecord) => void | Whenever the editor mutates the document, adds/removes/reorders sections, etc. The host owns the record state and should call setState/equivalent here. |
| onChange | (document: any) => void | Lower-level: fires on every document tree mutation. Use when you only need the canvas document, not the full record. |
| onSaveChanges | (payload: SaveChangesPayload) => Promise<boolean \| void> \| boolean \| void | Fires when Save Changes is clicked. Receives the full payload (record + document + rendered HTML). Return true (or void) for success, false to indicate the save failed. |
| onPublish | (payload: SaveChangesPayload) => Promise<void> \| void | Fires when the Publish button is clicked. Same payload as Save. |
| onPublishMenu | (anchorEl: HTMLElement, payload: SaveChangesPayload) => void | Fires when the small ▾ arrow next to Publish is clicked. The host can open any custom popover/menu/dialog anchored to anchorEl. Omit to disable the arrow. |
| renderPublish | ({ payload, onPublish }) => ReactNode | Fully replaces the Publish split-button group with the host's component. When provided, onPublish / onPublishMenu are ignored — host owns the slot. |
| onBack | () => void | Fires when the ← Back button is clicked. |
| onTogglePreview | (html: string, doc: any) => void | Optional preview hook. If provided, the host owns the preview UI (typically opening its own dialog with html). If omitted, the editor opens its built-in in-page preview dialog with desktop/tablet/mobile size toggles. |
| onBackgroundChange | (image: string) => void | Fires when the page-level background image changes. |
| onNavigateBranding | () => void | Fires from the Brand panel (currently stripped from the standalone build — kept for forward-compat). |
| onAddSection | () => void | Custom Add-Section handler. If provided, the editor delegates entirely to the host — no built-in dialog. If omitted, clicking ⋮ → Add Section opens the built-in dialog. |
| onApiError | (error: Error, action: "save" \| "publish") => void | Fires when a save/publish through apiConfig fails (network error, non-2xx response, invalid credentials, missing page id, etc.). Without this callback, failures only surface via console.error — the Save button stays "dirty" with no user-visible feedback. Use it to show a toast, refresh the auth token, redirect to a login screen, etc. Only relevant when apiConfig is set. |
| onUpgrade | () => void | Fires when the user clicks the Upgrade to unlock CTA on the Variables-panel paywall. Route it to your billing / upgrade UI. Without this callback the button is a no-op. Only relevant when apiConfig is set. |
SaveChangesPayload
Every save/publish callback receives this shape:
interface SaveChangesPayload {
record: PageRecord; // updated record with sections + html flushed in
document?: any; // present in single-doc mode; OMITTED in multi-
// section mode (there's no single page-level doc —
// read record.sections[i].contentMetadata.document
// per section instead)
html: string; // rendered HTML — for multi-section pages, every
// section's fragment is concatenated inside one
// <html> shell with viewport meta + base font
}Multi-section deduplication. When record.sections[] is present, the
payload's record is stripped down — the record-level contentMetadata
and any empty behaviour / steps are removed because sections are the
source of truth. Non-empty host-provided behaviour or steps are
preserved so real host-owned config data isn't dropped.
// multi-section save payload — nothing duplicated
{
"record": {
"id": "demo-1", "type": "page", "title": "My Page", "status": "draft",
"sections": [
{
"title": "Section 1",
"content": "<div ...>…</div>",
"contentMetadata": { "document": { /* per-section doc */ } }
}
]
// no contentMetadata, no empty behaviour, no empty steps
},
"html": "<!doctype html>…combined…"
// no outer document
}Single-doc mode (no sections) is unchanged — the payload still carries
document + record.contentMetadata.document + record.contentMetadata.html.
Pagepilot API integration (optional)
Passing apiConfig to <PagePilotEditor> turns the editor into a full page-management surface. Under the hood it uses the exact auth + endpoint shapes that ahd-fe uses internally (Authorization: Bearer <serviceAccountToken>), so pages saved from the standalone editor round-trip identically to pages saved from ahd-fe.
What you get
| Feature | Where it appears |
|---|---|
| Save Changes → POST /tenant/:tenantId/upsert-page-item | Topbar Save button + apiClient.updatePage fallback |
| Publish → same endpoint with status: "live" + schedule | Topbar Publish opens a rich PagePublishDialog |
| Templates tab (global page templates) | Layout panel > Templates |
| Saved tab (per-tenant my-templates) | Layout panel > Saved |
| Section Library gallery | Add-Block menu → Section Library tile |
| Template preview dialog (Use / Use Styles / Add Section) | Clicking any template card |
| Menu Links tab (add page to tenant menu) | Publish dialog |
| Variables panel with paywall gated on tenant plan | Left rail → Variables (auto-shown when template has fields) |
| getTenant() for plan detection | Runs once on mount when apiConfig is set |
Setup
import { PagePilotEditor } from "pagepilot-visual-editor";
<PagePilotEditor
record={page}
apiConfig={{
baseUrl: "https://pagepilot.fabbuilder.com/api", // no trailing slash, no "/tenant"
tenantId: "6655bc2b30a6760d8f897581", // your pagepilot tenant id
authToken: "eyJhbGciOi…", // pre-issued service-account bearer
}}
onApiError={(err, action) => toast.error(`${action}: ${err.message}`)}
onUpgrade={() => openBillingModal()}
// onSaveChanges / onPublish still fire AFTER a successful API write
// if you also want to react (e.g. clear a dirty flag, refresh a listing).
onSaveChanges={async ({ record }) => { revalidatePageList(); }}
/>Requirements for the API path to actually fire:
record.idmust be a real backend id (a MongoDB ObjectId in the tenant). The built-in seed id"doc-1"is intentionally skipped so demo records don't POST to a non-existent page.- The
authTokenmust be a valid service-account bearer for the giventenantId. Get one from your pagepilot admin console (same token ahd-fe stores inVITE_PAGEPILOT_SERVICE_AUTH_TOKEN).
PagepilotApiConfig shape
interface PagepilotApiConfig {
/** API root without a trailing slash, e.g. "https://pagepilot.fabbuilder.com/api". */
baseUrl: string;
/** Tenant id used in `/tenant/${tenantId}/...` paths. */
tenantId: string;
/** Pre-issued service-account bearer. Sent as `Authorization: Bearer <token>`. */
authToken: string;
}Save + Publish request shape
The editor batches the render + payload build for you. When the user clicks Save Changes or Publish, this exact request goes out:
POST https://pagepilot.fabbuilder.com/api/tenant/${tenantId}/upsert-page-item
Content-Type: application/json
Authorization: Bearer ${authToken}
{
"data": {
...record, // full record — id, name, slug, tags, groups, tenant, …
"pageId": "6a43bc20…", // === record.id
"status": "live" | "draft", // "draft" for Save Changes, "live" for Publish
"sections": [ // rendered sections (source of truth for multi-section pages)
{
"title": "Section 1",
"sectionType": "internal",
"identifier": "section-1784032010220",
"content": "<div …>…rendered HTML…</div>",
"contentMetadata": {
"document": { "root": { "type": "EmailLayout", … }, "block-…": {…} }
}
}
],
"startDate": "…", // optional — only when Publish schedule is set
"endDate": "…" // optional — only when Publish schedule is set
}
}Same shape ahd-fe writes. The rendered sections[].content HTML is what the pagepilot public page-serving layer reads.
Failure handling. Any non-2xx response throws inside the editor. Without onApiError, the error is logged to console.error and the Save button stays visually "dirty" (no state change). Passing onApiError lets you surface it to the user:
<PagePilotEditor
apiConfig={apiConfig}
onApiError={(err, action) => {
console.error(`[pagepilot] ${action} failed`, err);
toast.error(err.message);
}}
/>The Publish dialog
When apiConfig is set, clicking Publish opens a full dialog instead of firing onPublish immediately. The dialog mirrors ahd-fe's PagePublishDialog:
- Basic Info tab: page name, slug, publish date, "All Time Live" toggle, end date (when off).
- Menu Links tab: pick a tenant menu (fetched via
apiClient.listMenus) and append this page as a new item to that menu'sconfiguration. - Save Draft and Publish buttons at the bottom. Both use the same
upsert-page-itemendpoint above; the CTA differs by thestatusfield.
If you'd rather keep the direct one-click publish (fires onPublish without a dialog), set apiConfig to undefined — the direct callback flow still works.
Templates + Saved + Section Library
- Templates tab (
Layout > Templates): fetchesGET /global-template?filter[type]=page&limit=10&offset=0. Click a card → preview dialog with three actions:- Use this Template — replaces the canvas with the template's document.
- Use this Styles — overlays only the template's root visual styles (colors, fonts, radius, padding) onto your current document; blocks stay put.
- Add this section — appends the template's blocks to the current document (id-remapped so a second apply doesn't collide with the first).
- Saved tab (
Layout > Saved): same UI, fetchesGET /tenant/${tenantId}/mytemplate?filter[type]=page&limit=10&offset=0. Includes a delete affordance on hover. - Section Library modal: opened from the "Section Library" tile in the Add-Block menu. Fetches
GET /global-template?filter[type]=block&limit=100&offset=0, groups by category (hero,features,testimonial,pricing,cta,faq,about,team,contact,footer, …), and inserts the picked block as a new section.
Every apply route also populates the Variables panel from the template's declared formFields, so template tokens ({{name}}, {{price}}, {{FAQs}}, …) are ready to fill in immediately.
Paywall. Templates with a supportedPlans: ["proCombo"] (or higher) show an "Upgrade to unlock" prompt in the preview dialog when the tenant plan is below that tier. The onUpgrade callback routes the CTA to your billing UI.
Variables panel + tenant plan gating
The Variables tab surfaces template tokens for filling at edit time. Visibility is controlled by:
- The template must declare fields. The tab auto-hides on documents with no template forms and no repeatable container selected.
- The tenant plan must be Pro or above (or the template must be unrestricted). The gate is checked once via
apiClient.getTenant()on mount:plan === "proCombo" | "enterprise"→ real field editors render.- Anything else (including
"free","starterCombo", missing plan, failed fetch) → paywall renders.
- The paywall CTA ("Upgrade to unlock") fires
onUpgradeif the host provided it; otherwise it's a no-op with a console message.
The whole gating logic mirrors ahd-fe's useTenantPlan hook and can be safely disabled by passing panels={{ variables: false }} — the whole tab disappears from the rail.
Direct API client access
The PagepilotApiClient class the editor uses internally is also exported so hosts can hit the same endpoints outside the editor tree — e.g. to create a page before mounting:
import { PagepilotApiClient } from "pagepilot-visual-editor";
const client = new PagepilotApiClient({
baseUrl: "https://pagepilot.fabbuilder.com/api",
tenantId: "6655bc2b30a6760d8f897581",
authToken: process.env.PAGEPILOT_SERVICE_TOKEN!,
});
const created = await client.createPage({
name: "New Onboarding Page",
slug: "onboarding",
groups: [],
});
// Then mount the editor pointing at the new page.
<PagePilotEditor record={{ id: created.id, name: created.name }} apiConfig={...} />Available methods:
| Method | Endpoint |
|---|---|
| createPage(data) | POST /tenant/:tenantId/page |
| updatePage(id, data) | PUT /tenant/:tenantId/page/:id |
| quickSavePage(id, data) | PATCH /tenant/:tenantId/page/:id |
| upsertPage(data) | POST /tenant/:tenantId/upsert-page-item (used by save + publish) |
| findPage(id) | GET /tenant/:tenantId/page/:id |
| getTenant() | GET /tenant/:tenantId (used by plan gate) |
| listGlobalTemplates(params) | GET /global-template |
| findGlobalTemplate(id) | GET /global-template/:id |
| destroyGlobalTemplates(ids) | DELETE /global-template |
| listTenantTemplates(params) | GET /tenant/:tenantId/template |
| findTenantTemplate(id) | GET /tenant/:tenantId/template/:id |
| listMyTemplates(params) | GET /tenant/:tenantId/mytemplate |
| createTemplate(data) | POST /tenant/:tenantId/template |
| destroyTenantTemplates(ids) | DELETE /tenant/:tenantId/template |
| listMenus(params) | GET /tenant/:tenantId/menu |
| updateMenu(id, data) | PUT /tenant/:tenantId/menu/:id |
All methods send Content-Type: application/json + the bearer token automatically.
Behavior reference
Topbar buttons
| Button | Behavior |
|---|---|
| ← Back | Calls onBack. The host typically navigates away. |
| Page title + status pill | Display-only. Sourced from recordTitle / recordStatus (or record.title / record.name / record.status). |
| Section dropdown | Only appears when record.sections.length > 1. Lets the user pick / edit / delete sections. Switching a section flushes the current canvas edits into the active section, then loads the target section's document onto the canvas. |
| Undo / Redo | Hooked to the editor's internal history stack. Buttons disable when there's nothing to undo/redo. A settle-window guard on mount prevents block-level useEffects (auto-seed, positioning re-measure, etc.) from preloading spurious entries — Undo starts disabled on every fresh load until the user makes a real edit. |
| Deselect Block ⊟ | Only appears when a block is selected on the canvas. Clears selectedBlockId so the block's TuneMenu / handles collapse and the Inspect panel unfocuses. Same behavior as ahd-fe's TabUnselectedOutlined button. Hidden entirely via topbar={{ unselect: false }}. |
| Save Changes | Dirty-tracked. Disabled with tooltip "No unsaved changes" until the user makes their first edit; enables on every subsequent edit; disables again after onSaveChanges resolves (unless the handler returns false). Builds the payload (renders document → HTML, flushes sections) and calls onSaveChanges. When apiConfig is set, the editor first persists via POST /upsert-page-item, then fires onSaveChanges — errors surface via onApiError. |
| Theme toggle ☀/🌙 | Sun/moon icon flips between the built-in light and dark themes. The choice is persisted in localStorage under pagepilot.editor.mode.v1, so a page reload keeps the user's preference — even if props.mode changes. Hidden automatically when the host passes a custom theme prop (no mode to flip). |
| 3-dot menu ⋮ | Single item: + Add Section. Off by default — enable via topbar.moreMenu. |
| Fullscreen ⤢ | Toggles the editor's container via the browser Fullscreen API. Icon swaps to "exit fullscreen" while active; pressing Esc exits. |
| Preview ▶ | Opens the in-page Preview dialog with desktop/tablet/mobile size toggles. The iframe renders the same HTML that's sent to onSaveChanges / onPublish. If onTogglePreview is supplied, that callback fires instead. |
| Publish | Calls onPublish. |
| Publish ▾ | Calls onPublishMenu(anchorEl, payload) so the host can open a custom dialog/popover anchored to the button. |
Block TuneMenu
Selecting any block on the canvas surfaces a floating action popover just above it. The strip contains, in order:
| Action | Behavior |
|---|---|
| ↑ Move up | Swaps this block with its previous sibling under the same parent. |
| ↓ Move down | Swaps with the next sibling. |
| 🗑 Delete | Removes the block and its subtree. Also prunes any formFields whose sourceBlockId pointed at this block. |
| ⧉ Open in Layers | Selects this block and flips the sidebar to the Layers tab so you can see (and drag/reorder) the block inside the tree. |
| ⋯ More menu | Opens the secondary popover below. |
The ⋯ More popover contains:
| Item | Behavior |
|---|---|
| Save as template | Serialises the selected block into a template configuration. Only wired when the host provides its own persistence path — the standalone build has no built-in "save template" dialog. |
| Duplicate | Deep-clones the block (all descendants get fresh ids) and inserts the copy right after the original. |
| Copy | Writes the block's configuration to the editor's clipboard slot. Persists across selection changes so you can paste onto a different block. |
| Paste | Enabled only when there's a copied block. Appends the copied block as a child of THIS block (id-remapped so nothing collides). |
| Replace | Enabled only when there's a copied block. Swaps THIS block in place with a fresh id-remapped copy of the clipboard's block — same position, new content. |
| Hide | Toggles the block's canvas visibility (collapses to display: none). Unhide from the Layers panel. |
Add Section flow
- User clicks ⋮ → + Add Section (or the empty-state button).
- A modal asks for a Title.
- On submit:
- The active section's in-progress canvas document is flushed into
record.sections[activeIndex].contentMetadata.document(no edits lost). - A new section is appended, seeded with a default empty
EmailLayoutdocument. - The host receives the updated record via
onUpdateRecord. - The canvas swaps to the new section's document so the user can immediately edit it.
- The active step index is bumped.
- The active section's in-progress canvas document is flushed into
- Once there are 2+ sections, the section dropdown appears in the topbar.
To take over completely, pass onAddSection={() => /* your custom flow */}.
Inline images in a Text block
Double-click a Text block to open the floating formatting toolbar and click the image icon. A dialog prompts for two fields:
- Image URL — required. Any absolute URL your host serves images from.
- Alt text — optional. Written to the
<img alt="…">attribute for accessibility.
The image is inserted at the caret position and can then be resized
(100% / 50% / 25%), floated left/right, or deleted via the popover that
appears when you click it. The standalone editor deliberately doesn't ship
a media-library backend — the URL field is the integration point.
Pagepilot AI panel
Currently a Coming soon placeholder. The tab has the purple AiPanelIcon in the rail and opens a centered card that says AI-driven page generation is on the way. When the full generator is ported (prompt input + model picker + voice input, per the ahd-fe reference), the panel body swaps in without any other API changes. Hide the tab entirely via panels={{ pagepilot: false }}.
Layout panel
The Layout panel always shows the Blocks picker — a category-grouped tile grid (Text/Content, Media, Layout/Structure, Actions/Controls, Embeds/Integrations, Support/Utility). Add-Block tile visibility is controlled per-block via the blocks prop.
When apiConfig is set, two additional tabs appear at the top of the Layout panel:
- Templates — global page templates from
/global-template?filter[type]=page. Click any card → template preview dialog with three CTAs (Use Template / Use Styles / Add Section). - Saved — per-tenant
/mytemplatelist. Same card UI with an on-hover delete button.
The Add-Block menu also gains a highlighted Section Library tile (opens a full-screen gallery of block-type templates grouped by category). Without apiConfig, all three affordances are hidden — the Layout panel stays a lean block palette.
Image block sidebar
The Image block's Inspect panel exposes a Media text input for the
image URL. There is no Upload Media button — the standalone editor
doesn't ship a media backend. Consumers who want an upload flow can
either upload through their own host storage before setting record.…url,
or wrap <PagePilotEditor> and inject a custom uploader via children
if the standalone integration model doesn't fit.
Preview
- Default behavior (no
onTogglePreview): the editor renders every section throughrenderToStaticMarkup, concatenates the fragments inside a minimal HTML shell (with viewport meta + base font + canvas color fromEmailLayout.canvasColor), and shows it in an in-page MUI dialog with desktop / tablet / mobile size toggles. The iframe usessrcDoc(inline HTML) so no network round-trip and no blob-URL flake. The "Open in new tab" header icon is hidden in this mode (nothing to link to — the HTML is inline). A host that supplies a realpreviewUrl(via internal preview server) gets the icon. - Custom UI: pass
onTogglePreview={(html, doc) => myDialog.open(html)}to skip the built-in dialog and render your own.
Save / Publish payload generation
When the user clicks Save Changes or Publish, the editor does the following before invoking the callback:
- Reads the current canvas document from the internal store.
- If the record has
sections, flushes the canvas document intosections[activeIndex].contentMetadata.documentso the active section is up-to-date. - Renders every section's document via
renderToStaticMarkup({ rootBlockId: "root", injectElementIds: true, minifyCss: true, includePageCSS: true, lang, skipLink, accessibilityTools }). - Per-section HTML is written to
section.contentand per-section documents tosection.contentMetadata.document. - All section fragments are concatenated inside a single HTML document with viewport meta, base font, and canvas color from
EmailLayout. - Multi-section payload:
{ record, html }with the record's redundantcontentMetadata/ emptybehaviour/ emptystepsstripped, and the outerdocumentkey omitted.
Single-doc payload:{ record, document, html }whererecord.contentMetadatacarriesdocument+html.
This guarantees Preview ≡ Save Changes ≡ Publish — all three see the exact same rendered output.
Themes
import {
PagePilotEditor,
pagePilotEditorDarkTheme,
pagePilotEditorLightTheme,
} from "pagepilot-visual-editor";
// Built-in themes — these are full MUI Theme objects.
<PagePilotEditor mode="dark" />
<PagePilotEditor mode="light" />
// Or pass your own:
<PagePilotEditor theme={myCustomTheme} />Both built-in themes carry the editor's custom palette keys (performanceHeader, moduleIcon, moduleListComponentName, grayCloseIcon, codeBlock, brand.blue) tuned per mode so icons and dividers stay legible. If you pass your own theme, you'll likely want to copy those keys.
Runtime toggle + persistence. The topbar's sun/moon button lets the
user flip between the built-in light/dark themes at any time; the choice
is written to localStorage["pagepilot.editor.mode.v1"] and outranks
subsequent props.mode changes so the preference survives page reloads
and host re-renders. To hand control back to the host, clear that key.
Hide the toggle entirely with topbar={{ themeToggle: false }}.
Isolation from host CSS. The editor wraps its subtree in a MUI
ScopedCssBaseline (not global CssBaseline), so it plays nicely with
hosts that already run their own MUI theme. ScopedCssBaseline anchors
REM sizing at 14 px inside the editor's subtree, so a host with
html { font-size: 12px } (or any other non-default) doesn't shrink the
editor's layout. The editor root is flex-pinned to fill its parent
(width/height 100%, minHeight: 480 floor) — give it a parent with a
real height and it fills the rest.
Translations
Two ways:
Per-mount via prop:
<PagePilotEditor
translations={{
"pagePilotEditor.tabs.style": "Stile",
"pagePilotEditor.tabs.layout": "Layout",
"pagePilotEditor.templatePanel.mainPanel.page.addFirstSection": "Aggiungi sezione",
// missing keys fall back to built-in English
}}
/>Imperatively (from anywhere outside the component):
import {
setPagePilotEditorTranslations,
resetPagePilotEditorTranslations,
} from "pagepilot-visual-editor";
setPagePilotEditorTranslations(myDict); // merges on top of defaults
resetPagePilotEditorTranslations(); // back to English defaultsThe full set of keys the editor reads is documented inline at src/host-stubs/i18n.ts in the package.
Block types
Out of the box the Add-Block menu exposes 33 block types, grouped into six categories:
| Text/Content | Media | Layout/Structure | Actions/Controls | Embeds/Integrations | Support/Utility | |---|---|---|---|---|---| | Heading | Image | Container | Button | Timer | Rating | | Text | Logo | Columns | Button Group | YouTube Url | Skip Link | | Table | Video | Flex Columns | Accordion | Demo Picker¹ | Accessibility | | | Avatar | Grid | Tabs | Header¹ (MenuPicker) | | | | | Spacer | Stepper | Forms¹ | | | | | Divider | | App Banner¹ | | | | | Html | | FAQs¹ | | | | | Carousel | | IFrame | | | | | Pagination | | Calendly | | | | | Marquee | | | |
¹ Picker blocks (Demo / Header / Forms / App Banner / FAQs) surface tenant-managed records. Without host-provided data (via record.contentMetadata.document or a bespoke integration) they render their empty state.
Hide any of them via the blocks prop. Tile labels can be re-translated via translations.
Imperative API (advanced)
The original Pagepilot EditorContext Zustand store is re-exported under the EditorContext namespace so advanced consumers can drive document state imperatively from outside the component:
import { EditorContext } from "pagepilot-visual-editor";
EditorContext.setDocument({ root: ... });
EditorContext.resetDocument(myDoc);
EditorContext.useDocument(); // hook
EditorContext.editorStateStore; // raw Zustand storeThe built-in Reader is also re-exported so you can render saved documents outside the editor:
import { Reader, renderToStaticMarkup } from "pagepilot-visual-editor";
const html = renderToStaticMarkup(myDoc, { rootBlockId: "root" });Exports
// Component + imperative document setters
export { PagePilotEditor, setDocument, resetDocument } from "pagepilot-visual-editor";
// Themes
export { pagePilotEditorDarkTheme, pagePilotEditorLightTheme } from "pagepilot-visual-editor";
// Translation helpers
export {
setPagePilotEditorTranslations,
resetPagePilotEditorTranslations,
} from "pagepilot-visual-editor";
// Reader / renderer
export {
Reader, ReaderBlock,
renderToStaticMarkup,
ReaderBlockSchema, ReaderDocumentSchema,
} from "pagepilot-visual-editor";
// Imperative store access
export * as EditorContext from "pagepilot-visual-editor";
// Pagepilot API client (for hosts using `apiConfig`)
export { PagepilotApiClient } from "pagepilot-visual-editor";
// Types
export type {
PagePilotEditorProps, PageRecord, SaveChangesPayload,
EditorPanelToggles, TopbarToggles,
BlockKey, BlockToggles,
PagepilotApiConfig, ListParams, PageListRow, PageListResponse,
TReaderBlock, TReaderDocument, TReaderBlockProps, TReaderProps,
} from "pagepilot-visual-editor";Development
npm install
npm run dev # demo at http://localhost:5175
npm run build # produces dist/index.js + index.cjs + index.d.ts
npm run typecheckThe demo entry is at example/src/main.tsx — it shows every prop in action including theme switching, translations, custom Publish-menu popover, and a seeded multi-section record.
License
MIT
