npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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:

  1. 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.
  2. Service-account API (opt-in via the apiConfig prop). 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-editor

Peer 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 zustand

If you want to render exported types like TReaderBlockProps or use the ReaderBlockSchema, add zod too:

npm install zod

Quick 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 panels prop). 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 topbar prop. 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.moreMenu to true.
  • Layout panel — the Blocks palette is grouped into six categories (Text/Content, Media, Layout/Structure, Actions/Controls, Embeds/Integrations, Support/Utility). When apiConfig is 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.id must 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 authToken must be a valid service-account bearer for the given tenantId. Get one from your pagepilot admin console (same token ahd-fe stores in VITE_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's configuration.
  • Save Draft and Publish buttons at the bottom. Both use the same upsert-page-item endpoint above; the CTA differs by the status field.

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): fetches GET /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, fetches GET /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:

  1. The template must declare fields. The tab auto-hides on documents with no template forms and no repeatable container selected.
  2. 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.
  3. The paywall CTA ("Upgrade to unlock") fires onUpgrade if 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

  1. User clicks ⋮ → + Add Section (or the empty-state button).
  2. A modal asks for a Title.
  3. 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 EmailLayout document.
    • 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.
  4. 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 /mytemplate list. 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 through renderToStaticMarkup, concatenates the fragments inside a minimal HTML shell (with viewport meta + base font + canvas color from EmailLayout.canvasColor), and shows it in an in-page MUI dialog with desktop / tablet / mobile size toggles. The iframe uses srcDoc (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 real previewUrl (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:

  1. Reads the current canvas document from the internal store.
  2. If the record has sections, flushes the canvas document into sections[activeIndex].contentMetadata.document so the active section is up-to-date.
  3. Renders every section's document via renderToStaticMarkup({ rootBlockId: "root", injectElementIds: true, minifyCss: true, includePageCSS: true, lang, skipLink, accessibilityTools }).
  4. Per-section HTML is written to section.content and per-section documents to section.contentMetadata.document.
  5. All section fragments are concatenated inside a single HTML document with viewport meta, base font, and canvas color from EmailLayout.
  6. Multi-section payload: { record, html } with the record's redundant contentMetadata / empty behaviour / empty steps stripped, and the outer document key omitted.
    Single-doc payload: { record, document, html } where record.contentMetadata carries document + 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 defaults

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

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

The 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