Page Builder — Developer Reference
The Page Builder is a full-screen React + Zustand layout editor that mounts inside a WordPress meta box. It lets editors compose page content using a hierarchy of sections, rows, columns, and widgets. Everything is serialized to JSON and saved in the sanilwb_data post meta key. On the frontend, PHP renders the stored data as Bootstrap layout with [sanilwb] shortcodes.
Architecture Overview
PageBuilder.jsx ← root component, keyboard shortcuts, close handler
├── BuilderTopbar ← shared topbar shell (left/right slots)
│ └── SaveButton ← save state machine, AJAX POST, disabled when clean
├── LayersPanel ← all editing actions (add, move, delete, settings)
├── HistoryPanel ← undo/redo timeline viewer
├── PreviewPanel
│ └── IframeCanvas ← canvas React root mounted inside an iframe
│ └── Canvas ← reads store, renders live layout
└── PageBuilderSettingsSidebar ← right-side settings panel (section / column / widget)
All layout state lives in usePageBuilderStore (Zustand). Both the main document (LayersPanel) and the iframe (Canvas) import the same store module from the same JS bundle, so they share a single store instance — no props or context needed between the two React roots.
Entry Point and Data Bootstrap
| File | Purpose |
|---|---|
| admin/assets/js/src/page-builder/index.jsx | React app entry — mounts <PageBuilder /> |
| admin/templates/meta-box-contents.php | PHP template that renders the meta box and injects window.PageBuilderData |
| admin/class-sanil-website-builder-admin.php | meta_box_html() — loads post meta, builds PageBuilderData, enqueues dist/page-builder.js |
window.PageBuilderData shape
PHP injects this object before the JS bundle runs:
{
builderData: string, // saved sanilwb_data post meta (JSON string or '[]')
postId: number,
postTitle: string,
postEditUrl: string,
pbSaveNonce: string, // nonce for sanilwb_pb_save AJAX action
pluginUrl: string,
categories: [{ id, name }],
availableTemplates: [{ id, name, builder_type, category, preview, supports, buttons }],
}
On mount, initFromWindowData() reads builderData, parses it from PHP format, and seeds the store's sections array. Everything else in PageBuilderData is read lazily (e.g. categories and availableTemplates are read when a widget dialog opens).
Data Structure
The Page Builder stores layout as a flat array of sections. Each section holds rows, each row holds columns, and each top-level column holds an ordered items[] array that can contain any mix of widgets and nested rows.
sections[]
└── section
├── id
├── options { sanilwb_admin_label, sanilwb_bck_color, ... }
└── rows[]
└── row
├── id
├── layoutId (e.g. 'six_six-column-template')
├── options { sanilwb_admin_label }
└── columns[]
└── column
├── id
├── options
└── items[] ← ordered mix of widgets and nested rows
├── { kind: 'widget', id, uid, type, values }
└── { kind: 'nestedRow', id, layoutId, columns[] }
└── nestedColumn
├── id
└── widgets[] ← nested columns use widgets[], not items[]
Key rules:
- A top-level column's items[] can hold any combination of widgets and nested rows in any order. There is no either/or constraint.
- A column can contain multiple nested rows.
- Nested columns (inside nested rows) still use a flat widgets[] array — the items[] model applies only to top-level columns.
- Nested rows are limited to one level deep — you cannot add a nested row inside a nested column.
- Widget values is a flat object of sanilwb_* key-value pairs.
- Responsive fields are stored with device suffixes: fieldName, fieldName__tablet, fieldName__mobile.
PHP storage format
serializeToJson() converts the internal tree to a PHP-compatible JSON array saved in sanilwb_data. The shape matches what the PHP shortcode renderer expects:
[{
"section_options": { "sanilwb_admin_label": "Section 1", "sanilwb_bck_color": "" },
"rows": [{
"row_template": "six_six-column-template",
"row_options": { "sanilwb_admin_label": "Row 1" },
"columns": [
{
"column_options": {},
"items": [
{
"kind": "widget",
"widget_uid": "abc123",
"widget_type": "posts",
"widget_values": "{\"sanilwb_style\":\"db:3\",\"sanilwb_columns\":\"3\"}",
"widget_title": "Posts 1",
"show_desktop": "1",
"show_tablet": "1",
"show_mobile": "1"
},
{
"kind": "nested_row",
"id": "nr-abc",
"nested_row_template": "six_six-column-template",
"columns": [
{ "widgets": [] },
{ "widgets": [] }
]
}
]
}
]
}]
}]
widget_values is a JSON string — PHP decodes it independently with json_decode(). The widget_uid is stable across saves (not regenerated). The internal id is ephemeral (regenerated on every fromPhpFormat() call). Each item carries a kind discriminator ("widget" or "nested_row") so the PHP renderer can process them in order without separate fields.
Store — usePageBuilderStore
File: admin/assets/js/src/page-builder/stores/usePageBuilderStore.js
This is the single source of truth for all layout state. Every action that mutates state calls snapshot(label) first to push the current state onto the undo stack.
State
| Key | Type | Purpose |
|---|---|---|
sections |
Section[] |
The live layout tree |
past |
{ label, sections }[] |
Undo stack (capped at 50) |
future |
{ label, sections }[] |
Redo stack |
activeSettingsTarget |
{ type, id } \| null |
The item currently open in the settings sidebar. type is 'section', 'column', or 'widget'. |
settingsPanelIsNew |
boolean |
True when the panel was opened for a brand-new uncommitted widget — on close without save, the widget is removed with no undo snapshot |
selectedWidgetId |
string\|null |
Widget highlighted by a canvas click — layers panel scrolls and highlights the matching bar |
selectedLayerId |
string\|null |
Layer item most recently clicked in the layers panel — IframeCanvas subscribes to scroll the matching canvas element into view |
collapsedLayerIds |
{ [id]: true } |
Opt-out collapse map for all collapsible layer bars (section, row, column, nested row, nested column). Absent key = expanded. |
Actions
History
| Action | Description |
|---|---|
snapshot(label) |
Saves current sections to past[] before any mutation. Clears future[]. Flushes any pending debounced edit first (see below) so it is not lost or merged into the wrong entry. |
undo() |
Flushes any pending debounced edit, then pops past[], pushes current state to future[], restores the popped state |
redo() |
Flushes any pending debounced edit, then pops future[], pushes current state to past[], restores the popped state |
jumpToHistory(targetIndex) |
Flushes any pending debounced edit, then computes the equivalent past/future arrays for an arbitrary jump in a single set() call — not a loop of undo()/redo() calls, which would re-render every subscriber once per step. Used by the History panel's click-to-jump feature. |
Debounced edit snapshots: field-level edits (updateSectionOptions, updateColumnOptions, updateRowOptions, updateNestedRowOptions, updateWidget) call scheduleDebouncedSnapshot(get, set, itemType, itemId) instead of snapshot(label) directly. The pre-edit state is captured once when the burst starts, and the history entry is only pushed to past[] after a 500ms pause in edits — so typing in a settings field collapses into a single undo entry instead of one per keystroke. A single module-scope session object ({ timer, type, id, state, hadUnsavedChanges }) is shared across all of these actions (only one settings panel can be open at a time), keyed by type + id rather than a label — so switching to a different item mid-edit (including two items of the same type, e.g. Section A → Section B) flushes the in-progress burst as its own entry instead of merging it with the new one.
The label itself is no longer a fixed string — it is computed when the burst commits, by diffing the item's field values from before the burst against after:
| Diff result | History label |
|---|---|
| No fields changed (e.g. typed then reverted before the pause) | No entry is created; hasUnsavedChanges is restored to whatever it was before the burst started |
Only sanilwb_admin_label changed |
Renamed to {new name} |
| Exactly one other field changed | Edit {item name} {field label} (e.g. "Edit Post 3 Background Color") |
| More than one field changed | {item name} updated (naming just one of several changed fields would be misleading) |
Field labels are resolved via getFieldLabel() against the same dialog tab configs (getWidgetDialogTabs(), SECTION_DIALOG_TABS, COLUMN_DIALOG_TABS, ROW_DIALOG_TABS, NESTED_ROW_DIALOG_TABS) that PageBuilderSettingsSidebar renders fields from. A responsive field's __tablet/__mobile suffix is stripped before lookup and the breakpoint name is appended to the result (e.g. "Horizontal Gap (Tablet)"). Fields not present in the static config (e.g. a per-template title bar button URL, generated dynamically per template) fall back to a humanized version of the raw field key.
Sections
| Action | Description |
|---|---|
addSection(layoutId?) |
Appends a new section with one initial row |
addSectionAfter(sectionId, layoutId?) |
Inserts a section immediately after the given section |
removeSection(sectionId) |
Removes a section and all its content |
moveSection(fromIndex, toIndex) |
Reorders sections by index |
updateSectionOptions(sectionId, options) |
Replaces the section's options object (saves background color, admin label, etc.) |
Rows
| Action | Description |
|---|---|
addRow(sectionId, layoutId?) |
Appends a row to the end of a section |
addRowAfter(sectionId, afterRowId, layoutId?) |
Inserts a row immediately after the given row |
removeRow(sectionId, rowId) |
Removes a row and all its content |
moveRow(sectionId, fromIndex, toIndex) |
Reorders rows within the same section |
moveRowToSection(rowId, fromSectionId, toSectionId, toIndex) |
Moves a row from one section to another |
changeRowLayout(sectionId, rowId, newLayoutId) |
Changes column split — preserves existing column data for columns that survive the change |
updateRowOptions(rowId, options) |
Replaces a row's options object (saves admin label, etc.). Searches all sections to find the row. |
Columns
| Action | Description |
|---|---|
moveColumn(sectionId, rowId, fromIndex, toIndex) |
Reorders columns within the same row |
updateColumnOptions(columnId, options) |
Replaces a column's options object. Works for both top-level and nested columns via mapAllColumns. |
Nested Rows
| Action | Description |
|---|---|
addNestedRow(sectionId, rowId, columnId, layoutId?) |
Appends a nested row item to the column's items[]. Multiple nested rows per column are allowed. |
removeNestedRow(sectionId, rowId, columnId, nestedRowId) |
Removes the specific nested row identified by nestedRowId and all its content |
changeNestedRowLayout(sectionId, rowId, columnId, nestedRowId, newLayoutId) |
Changes the column split of the specific nested row identified by nestedRowId |
updateNestedRowOptions(nestedRowId, options) |
Replaces a nested row's options object. Traverses the full sections → rows → columns → items tree to find it. |
Widgets
| Action | Description |
|---|---|
addWidget(columnId, type) |
Creates a new widget of the given type. For top-level columns, appends { kind: 'widget', ...widget } to items[]. For nested columns, appends the bare widget to widgets[]. Returns the widget object. |
removeWidget(widgetId) |
Removes a widget (creates undo snapshot) |
removeNewWidget(widgetId) |
Removes a brand-new widget the user cancelled — no undo snapshot |
moveItem(columnId, fromIndex, toIndex) |
Reorders any item (widget or nested row) within a top-level column's items[] |
moveWidget(columnId, fromIndex, toIndex) |
Reorders widgets within a nested column's widgets[] |
moveWidgetAcrossColumns(fromColumnId, fromIndex, toColumnId, toIndex) |
Moves a widget between any two columns (works for top-level and nested columns) |
updateWidget(widgetId, values) |
Replaces a widget's values object after the settings panel saves |
duplicateWidget(widgetId) |
Inserts a deep copy of the widget immediately after the original |
Layer panel expand/collapse
| Action | Description |
|---|---|
collapseLayer(id) |
Marks a layer bar as collapsed by adding its id to collapsedLayerIds |
expandLayer(id) |
Removes a layer bar from collapsedLayerIds, expanding it |
expandAncestorsOf(widgetId) |
Walks the full sections tree to find the widget, then calls expandLayer on every ancestor (section → row → column → nestedRow → nestedColumn as applicable). Called by CanvasWidgets on iframe click so collapsed parents are automatically opened. |
Settings panel
| Action | Description |
|---|---|
openSettingsPanel(type, id, isNew?) |
Opens the settings sidebar for the given item. If isDirty is true and a different item is already open, shows a window.confirm before switching. isNew defaults to false; pass true for a freshly created widget so closing without saving removes it. |
closeSettingsPanel() |
Clears activeSettingsTarget and settingsPanelIsNew. |
Serialization / init
| Action | Description |
|---|---|
initFromWindowData() |
Parses window.PageBuilderData.builderData and seeds sections[] |
serializeToJson() |
Returns the PHP-compatible JSON string for the sanilwb_data hidden input |
Internal helpers (not exported)
mapAllColumns(sections, topFn, nestedFn)— applies separate mapping functions to every column in the tree.topFnreceives top-level columns (which haveitems[]);nestedFnreceives nested columns (which havewidgets[]). Used by widget add/remove/update/duplicate actions.countWidgetsByType(sections, type)— counts widgets of a given type across the full tree (bothitems[]and nestedwidgets[]). Used to generate sequential admin labels like "Posts 1", "Posts 2".fromPhpFormat(phpData)— deserializes the stored JSON array back into the internal tree shape, readingcol.items[]and mapping each item by itskinddiscriminator.toPhpFormat(sections)— serializes the internal tree to the PHP-compatible JSON array, writingcol.items[]withkind: 'widget'orkind: 'nested_row'on each item.extractItemFromSections(sections, columnId, itemIndex)— mutates a cloned tree to remove a widget from a given column position. Used bymoveWidgetAcrossColumns.insertItemIntoSections(sections, columnId, insertIndex, widgetItem)— mutates a cloned tree to insert a widget at a given column position. Handles both top-level (items[]) and nested (widgets[]) columns, adding or stripping thekinddiscriminator as needed.
UI Components
PageBuilder.jsx
File: admin/assets/js/src/page-builder/PageBuilder.jsx
The root component. Renders:
- BuilderTopbar — shared topbar shell from
shared/components/BuilderTopbar.jsx. Left slot: layers toggle + post title. Right slot: device toggles,SaveButton, Close button. - Body — LayersPanel and HistoryPanel on the left, PreviewPanel fills the centre,
PageBuilderSettingsSidebarsits on the right. - PageBuilderSettingsSidebar — reads
activeSettingsTargetfrom the store and renders the appropriate form fields (section, column, or widget) inside the sharedSettingsSidebarshell. Opening any layer bar row callsopenSettingsPanel(type, id)in the store, which this component subscribes to.
PageBuilder only reads hasUnsavedChanges from the store (for the close-button confirm dialog). All save logic lives in SaveButton.
SaveButton.jsx
File: admin/assets/js/src/page-builder/components/SaveButton.jsx
Owns the entire save flow:
- Reads
serializeToJson,markSaved, andhasUnsavedChangesfromusePageBuilderStoredirectly — no props needed. - Disabled when
hasUnsavedChangesis false or a save is already in flight (saveState !== 'idle'). This prevents multiple AJAX requests from firing when there is nothing to save or a request is already pending. - State machine:
idle→saving→saved(1.5s) →idle. A synchronousisSavingRefmutex provides a secondary guard against programmatic double-calls. - POSTs to
window.ajaxurlwith actionsanilwb_pb_save.
Keyboard shortcuts:
- Ctrl+Z — undo
- Ctrl+Y / Ctrl+Shift+Z — redo
- Shortcuts are ignored when focus is inside an INPUT, TEXTAREA, SELECT, or a contentEditable element.
- Attached twice: once in PageBuilder.jsx on the parent window (covers the layers panel, topbar, sidebar), and once in IframeCanvas.jsx on iframe.contentWindow (covers the canvas). See PreviewPanel.jsx / IframeCanvas.jsx below for why the second listener is necessary.
Device state: useDeviceStore is the single source of truth for the active device (desktop | tablet | mobile). The top bar buttons call setDevice(). PreviewPanel reads the device and passes it to IframeCanvas, which resizes the iframe width. The widget settings dialog's device toggle also reads useDeviceStore, so switching the top-bar device and the dialog device are in sync.
LayersPanel.jsx
File: admin/assets/js/src/page-builder/components/LayersPanel.jsx
The layers panel is the primary editing interface. The canvas is purely visual — all create, delete, reorder, and settings actions happen here.
Visibility: controlled by activePanel state in PageBuilder. Only one of LayersPanel or HistoryPanel is visible at a time. The layers header has a "show history" button and a close button.
Empty state: when sections.length === 0, shows a placeholder with guidance text.
Layer bar color coding:
| Layer type | CSS class | Color |
|---|---|---|
| Section | sanilwb-layer-item--section |
Blue |
| Row | sanilwb-layer-item--row |
Green |
| Column | sanilwb-layer-item--column |
Violet |
| Nested Row | sanilwb-layer-item--nested-row |
Light green |
| Nested Column | sanilwb-layer-item--nested-column |
Light violet |
| Widget | sanilwb-layer-item--widget |
Amber + colored dot matching widget type |
Widget selection sync: clicking a widget bar sets selectedWidgetId in the store. The canvas also calls setSelectedWidgetId when the user clicks a widget in the preview. The bar uses a useEffect + scrollIntoView to scroll itself into view when it becomes selected.
Auto-expand on iframe click: when a widget is clicked in the preview iframe, CanvasWidgets also calls expandAncestorsOf(widgetId) in the store. This walks the sections tree and removes every ancestor ID (section, row, column, nested row, nested column) from collapsedLayerIds, so collapsed parents are opened before the widget bar scrolls into view.
Collapse state: each collapsible layer bar (SectionLayerBar, RowLayerBar, ColumnLayerBar, NestedRowLayerBar, NestedColumnLayerBar) derives its open/closed state from collapsedLayerIds in the store via a per-ID boolean selector (!s.collapsedLayerIds[id]). The toggle buttons call collapseLayer(id) or expandLayer(id). Using a derived boolean means only the bar whose state changed re-renders — not every bar in the panel.
Drag and drop: uses @dnd-kit/core with a single DndContext wrapping the entire panel. All item types (section, row, column, widget, nested row) are useSortable nodes. The handleDragEnd function routes based on active.data.current.type:
section— reorders by index insections[]row— reorders within the same section or moves to another section, resolving the destination from theovertarget's type (section,row, orcolumn)column— reorders within the same row onlywidget— reorders within the same column (callsmoveItemfor top-level columns,moveWidgetfor nested) or moves to another column (callsmoveWidgetAcrossColumns)nestedRow— reorders within the same column'sitems[]only; cross-column moves are rejected with a feedback message
A DragOverlay renders a lightweight clone of the dragged item that follows the cursor. The original item is hidden during drag (opacity: 0). Drag activation requires 6px of movement to prevent accidental drags on click.
Actions per layer type:
SectionLayerBar — clicking the row opens the section settings sidebar; add row, duplicate section, delete section
RowLayerBar — change layout (opens LayoutPickerPopover), add row below, delete row
ColumnLayerBar — clicking the row opens the column settings sidebar; add widget (opens WidgetPickerDialog), add nested row. Both buttons are always visible — columns can freely mix widgets and nested rows in any order.
NestedRowLayerBar — change layout, delete nested row. Each nested row bar is independently sortable within its parent column's items[] alongside widgets.
NestedColumnLayerBar — add widget
WidgetLayerBar — clicking the row opens the widget settings sidebar; duplicate, copy shortcode, delete
Clicking any layer row calls openSettingsPanel(type, id) in the store. The expand/collapse toggle and action buttons call stopPropagation so they do not also trigger the settings panel.
Action button tooltips: All icon-only action buttons in the layers panel are wrapped with HoverTooltip (from shared/components/HoverTooltip.jsx) using direction="below". The below direction is required because the layers panel has overflow-y: auto — the scroll container clips anything that would appear above the button. Tooltips appear below the button with an upward-pointing arrow, right-anchored so they stay within the panel's right edge.
HistoryPanel.jsx
File: admin/assets/js/src/page-builder/components/HistoryPanel.jsx
Thin wrapper that connects the shared admin/assets/js/src/shared/components/HistoryPanel.jsx presentational component to usePageBuilderStore's past, future, and jumpToHistory. Template Builder's template-builder/components/HistoryPanel.jsx is the same kind of thin wrapper around the same shared component — the timeline rendering logic (flat list, current-entry highlight, jump-index math, empty state) exists in exactly one place instead of being duplicated per builder.
The shared component displays a flat, stable timeline of all actions. The list never reorders — undo/redo only moves the blue highlight within the fixed list, like a cursor through static text. The user can click any entry to jump directly to that point using jumpToHistory().
The timeline is built as [ ...future, ...[ ...past ].reverse() ] — future entries (undone actions) appear at the top as redoable, past entries follow in reverse so the most recent past action is closest to the cursor.
Styling: the shared component uses its own sanilwb-history-panel* class names, defined once in admin/assets/css/shared/_history-panel.scss and imported by both admin-styles.scss (Page Builder) and template-builder.scss (Template Builder) — not the sanilwb-pb-layers* classes that LayersPanel.jsx uses for its own panel shell.
PreviewPanel.jsx / IframeCanvas.jsx
Files: admin/assets/js/src/page-builder/components/PreviewPanel.jsx, admin/assets/js/src/page-builder/components/IframeCanvas.jsx
PreviewPanel is a thin wrapper that passes device through to IframeCanvas.
IframeCanvas loads a real WordPress page (/?sanilwb_pb_canvas=1) into an iframe. This gives the canvas full frontend CSS — theme styles, Bootstrap, plugin styles, compiled template CSS — without any hardcoded stylesheet list. The canvas URL strips all <script> tags from wp_head() and omits wp_footer() to prevent theme/plugin JavaScript from navigating away or conflicting with the React root.
Dedicated React root: the canvas mounts its own createRoot inside the iframe's document body. This is necessary because react-frame-component uses ReactDOM.createPortal, which keeps event delegation on the parent document — click handlers inside the iframe silently fail. With a dedicated createRoot, React sets up event delegation on the iframe's own body, so all onClick handlers work normally.
Shared store: both the main document's React root and the iframe's React root import the same usePageBuilderStore module from the same JS bundle. Zustand stores are module-level singletons, so both roots read and write the same state instance automatically.
Undo/redo keyboard shortcut inside the iframe: keydown events that originate inside the iframe fire on the iframe's own window, not the parent's — they never bubble to PageBuilder.jsx's listener. IframeCanvas therefore attaches its own Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z listener to iframe.contentWindow inside mountCanvas(), calling usePageBuilderStore.getState().undo() / .redo() directly. It is removed on unmount via the removeKeyDownListener ref. This mirrors the parent listener's input-field guard so typing inside canvas-rendered fields is not intercepted.
Carousel scripts: the canvas page strips <script> tags, so Embla Carousel and the carousel initialiser (public/js/sanilwb-carousel.js) are injected manually after the page loads. They are injected sequentially (Embla → carousel init). After injection, a scriptsReady ref is set to true and a sanilwb:content-loaded CustomEvent is dispatched on the iframe document. A store subscription on sections fires this event again whenever the layout changes, so carousels re-initialise after any edit.
Device widths:
| Device | iframe width |
|---|---|
desktop |
100% (min-width: 992px to keep Bootstrap col-lg-* columns side-by-side) |
tablet |
BREAKPOINTS.CANVAS_TABLET |
mobile |
BREAKPOINTS.CANVAS_MOBILE |
Width transitions are animated (transition: width 0.25s ease). On tablet and mobile the iframe is horizontally centred with margin: auto.
Widget System
Widget type registry
File: admin/assets/js/src/page-builder/config/widgetTypes.js
WIDGET_TYPES is a plain object keyed by type string. Each entry defines:
| Property | Type | Purpose |
|---|---|---|
type |
string |
Key (e.g. 'posts') |
label |
string |
Human-readable name shown in the widget picker and layer bars |
icon |
string |
pb-icon-* icon name for the widget picker |
barBg |
string |
CSS color for the colored dot on the widget layer bar |
barText |
string |
CSS text color (paired with barBg) |
defaultValues |
object |
Initial sanilwb_* values applied when a new widget is created |
Dialog tabs are not stored in WIDGET_TYPES — they are built by getWidgetDialogTabs(type, availableTemplates, categories) which is called at dialog open time. This keeps template picker options (which come from window.PageBuilderData) out of the module-level constant.
Built-in widget types
| Type | Label | Description |
|---|---|---|
posts |
Posts | Posts listing grid with query controls, display toggles, and optional carousel |
read_more_btn |
Read More Button | CTA button with URL, style, color, size, width, and alignment controls |
title_bar |
Title Bar | Title bar using a titlebar template, with custom title text and per-button URL fields |
heading |
Heading | Static heading with a chosen tag (h1–h6), editor-entered text, optional link, and the Typography fields (plus Link Hover Color, since it can have a link) |
paragraph |
Paragraph | Static block of plain text (no rich text/multi-paragraph support — see note below) with the Typography fields |
image |
Image | Media-library image, stored as an attachment ID so wp_get_attachment_image() can output a responsive srcset at the chosen registered size |
post_title |
Post Title | Dynamic — renders get_the_title() against the ambient WP post context, with a tag select, a link-to-post toggle, and the Typography fields (plus Link Hover Color) |
archive_title |
Archive Title | Dynamic — renders get_the_archive_title() (category/tag/date/author/post-type labeling handled by WP core), with an optional prefix toggle and the Typography fields |
featured_image |
Featured Image | Dynamic — delegates to the existing sanilwb_get_featured_image() helper (same helper the Posts widget uses), with a size select and link toggle |
post_content |
Post Content | Dynamic — renders apply_filters('the_content', get_the_content()) (the same filter chain WP core's the_content() runs), no manual content field, with its own per-selector Style tab (Body Text, H1–H6, Images) — not the shared Typography fields |
excerpt |
Excerpt | Dynamic — renders get_the_excerpt() against the ambient post context, with an optional word-count override that scopes the excerpt_length filter to this render only, and the Typography fields |
"Dynamic" widgets read whatever post WordPress's current loop/query has in scope (get_the_title(), get_the_content(), etc.) rather than storing their own content — they have no widget-owned WP_Query, consistent with the plugin's rule that Page Builder widgets do presentation, not data-fetching. On a regular Page there is exactly one implicit post in scope. Inside a real WP loop (have_posts()/the_post()) they resolve per-iteration — this is the mechanism the planned Site Layout CPT will rely on for archive-type templates.
Note on Paragraph: the shortcode attribute pipeline runs every attribute through sanitize_text_field() in process_shortcodes() before any handler sees it, which strips tags and collapses line breaks to single spaces. Multi-paragraph text is not possible via this widget — plain single-paragraph text only.
getWidgetDialogTabs()
Builds the full tab array for the given widget type. After the widget-specific tabs are built:
- Finds the
styletab and appendsSHARED_STYLE_FIELDSinto it (renamed first section from 'Container' to 'Spacing' for readability). - Appends
SHARED_ADVANCED_TABat the very end.
This means every widget dialog ends with a consistent Style and Advanced tab structure.
Posts widget — supports filtering
When a user selects a template in the Posts widget's Template tab, the Content tab's display toggle fields (Show Image, Show Topic, Show Excerpt, etc.) show or hide based on whether that template actually contains those node types. This is implemented via supports_* flags (supports_image, supports_topic, etc.) derived from the selected template's supports property (a comma-separated string). These flags are passed as extraValues to the Dialog component — they are used in showIf evaluation but never saved to the widget's stored values.
Settings Sidebar
PageBuilderSettingsSidebar.jsx
File: admin/assets/js/src/page-builder/components/PageBuilderSettingsSidebar.jsx
The settings panel for sections, columns, and widgets. Sits to the right of the canvas in the body flex row and pushes it left when open (300 px fixed width).
- Reads
activeSettingsTargetfromusePageBuilderStore. When it is non-null, looks up the target item withfindWidgetById/findSectionById/findColumnById. - Computes a breadcrumb trail (
findBreadcrumbPath) showing the path to the current item (e.g.Page › Section A › Row 1when a column is open). - The
DeviceProvideris keyed on the target id — switching items unmounts and remounts it, which re-seeds the dialog buffer with the new item's saved values and resets theisDirtyflag. handleClose— ifsettingsPanelIsNewis true and the target is a widget, callsremoveWidget(id)before closing so a cancelled new widget is not left in the tree.handleSave— callsupdateWidget/updateSectionOptions/updateColumnOptionsbased on type, then closes the panel.
The widget case uses a nested WidgetSidebarContent component that subscribes to the live buffer's sanilwb_style field to re-derive supportsValues whenever the user picks a different template — without re-rendering the whole sidebar.
SettingsSidebar.jsx (shared shell)
File: admin/assets/js/src/shared/components/SettingsSidebar.jsx
Shared shell used by both builders. Props:
| Prop | Type | Purpose |
|---|---|---|
isOpen |
boolean |
Mounts / unmounts the panel |
title |
string |
Panel header title |
breadcrumbs |
string[] |
Optional ancestor path shown below the title (Page Builder only) |
onClose |
function |
Called by the X button |
children |
ReactNode |
Form content (typically DeviceProvider + DialogBody) |
Width is fixed at 300 px via CSS. The header renders the title + X button row first, breadcrumbs below.
findBreadcrumbPath.js
File: admin/assets/js/src/page-builder/utils/findBreadcrumbPath.js
Returns an array of ancestor label strings for a given type + id pair:
| Target | Example result |
|---|---|
| section | ['Page'] |
| column (top-level) | ['Page', 'Section A', 'Row 1'] |
| column (nested) | ['Page', 'Section A', 'Row 1', 'Column'] |
| widget (top-level column) | ['Page', 'Section A', 'Row 1', 'Column'] |
| widget (nested column) | ['Page', 'Section A', 'Row 1', 'Column', 'Nested'] |
Section and row labels come from options.sanilwb_admin_label; fall back to 'Section' / 'Row' if empty.
Shared Dialog Components
HoverTooltip
File: admin/assets/js/src/shared/components/HoverTooltip.jsx
Wraps any trigger element in a positioned <span> and shows a dark tooltip bubble on mouse enter. All styles are inline — no external CSS needed, so it works inside the canvas iframe or any admin page.
Props:
| Prop | Type | Default | Purpose |
|---|---|---|---|
text |
string |
— | Label shown in the tooltip bubble |
children |
ReactNode |
— | The trigger element (button, icon, etc.) to wrap |
direction |
'above' or 'below' |
'above' |
Which side of the trigger the bubble appears on. Use 'below' when an overflow: hidden or overflow-y: auto container would clip an upward bubble (e.g. the layers panel sidebar). |
The 'below' direction right-anchors the bubble and points its arrow upward toward the trigger. This matches the layers panel requirement where the scroll container clips anything above the buttons.
Shared Dialog component
File: admin/assets/js/src/shared/components/Dialog.jsx
All settings dialogs (widget settings, section settings) use this single shared Dialog component. It renders an overlay + modal shell and wraps its body in a DeviceProvider. This gives all child form fields access to the device-aware value cascade (desktop → tablet → mobile fallback).
Props:
| Prop | Type | Purpose |
|---|---|---|
isOpen |
boolean |
Whether the dialog is visible |
title |
string |
Dialog header text |
tabs |
{ id, label, fields[], requiresField? }[] |
Tab definitions — drives the tab bar and field rendering |
savedValues |
object |
Current values from the store — seeds the dialog buffer |
extraValues |
object |
Synthetic read-only values injected into showIf evaluation only (not saved) |
itemId |
string\|null |
Optional — when provided, tracks the active item in useDialogBufferStore for canvas live preview |
onSave |
function |
Called with serialized values when the user saves |
onClose |
function |
Called when the user closes without saving |
dialogStyle |
object |
Optional inline styles on the dialog shell |
overlayStyle |
object |
Optional inline styles on the overlay |
A requiresField on a tab definition disables that tab until the named field has a truthy value in the buffer. The Posts widget uses this to keep the Content tab disabled until a template is selected.
DialogBody (named export)
Dialog.jsx exports a named DialogBody function in addition to the default Dialog. DialogBody renders the tab bar, form fields, and Save / Cancel footer without any outer chrome (no overlay, no title bar). It must be rendered inside a DeviceProvider.
PageBuilderSettingsSidebar uses DialogBody directly — the sidebar shell (SettingsSidebar) provides the chrome, and DeviceProvider is handled by the sidebar component itself.
Shared Dialog Fields
File: admin/assets/js/src/shared/config/sharedDialogFields.js
These field definitions are shared across all builders (Page Builder and Template Builder).
SHARED_STYLE_FIELDS
Injected into the Style tab of every widget and section dialog. Contains:
- Container section — Margin (top/bottom, side-by-side), Padding (vertical/horizontal, side-by-side). All responsive.
- Borders section — Border Radius (top/bottom), Border Width (all four sides in a 2×2 grid), Border Style (select), Border Color. All responsive.
Field names (sanilwb_margin_top, sanilwb_padding_vertical, border-radius-top, border-width-top, border-style, border-color) must not be renamed — they are read by both the PHP template compiler and the JS canvas style builder.
SHARED_ADVANCED_TAB
The Advanced tab appended to every widget and section dialog. Contains:
- Visibility section — Show on Desktop, Show on Tablet, Show on Mobile (toggles, not responsive)
- Developer section — Admin Label (
sanilwb_admin_label), CSS Class (sanilwb_css_class)
The sanilwb_admin_label value is what appears as the layer name in the Layers panel.
Typography Fields
File: admin/assets/js/src/page-builder/config/widgetTypes.js — TYPOGRAPHY_STYLE_FIELDS
Shared field set spread into the Style tab of every text-bearing widget (Heading, Paragraph, Post Title, Archive Title, Excerpt): Text Color, Font Size, Font (family), Font Weight, Font Style, Line Height, Text Align. It mirrors the field set Template Builder's buildFontFields() gives its own text nodes, reusing the same font-family / font-weight / font-style field components — but with flat sanilwb_* names, since each Page Builder widget owns its own isolated field namespace (Template Builder needs a per-node-role prefix like heading-font-family because multiple roles coexist in one compiled template).
Post Content does not use this field set. It has no single element to style — see Post Content Widget Styling below for its own, structurally different per-selector system.
Line Height accepts decimal steps (step: 0.1). NumberField.jsx didn't read a step prop at all until this was added, so every number field — including Line Height — defaulted to the native <input type="number"> whole-number step. Any other number field that needs finer-grained values can opt in the same way by adding step to its field config.
All seven fields are responsive (desktop/tablet/mobile). PHP resolves them in build_typography_declarations() and assembles them into a <style> block scoped to the widget's own [data-uid="{widget_uid}"] selector via build_typography_responsive_style(), both in class-sanil-website-builder-public.php — the same shape collect_element_css() already uses for spacing/border fields, just for a different field set. This <style> block is prepended to the widget's own shortcode return value (like posts_shortcode()'s $bp_style), so it works identically on the real frontend, a lone hand-typed [sanilwb type="heading"] shortcode, and the AJAX canvas-preview path.
Desktop values live in the same stylesheet block as tablet/mobile, not as an inline style="" attribute. This is deliberate: an inline style on a tag always beats an external stylesheet rule targeting that same tag, no matter how specific the rule or which @media query it's wrapped in (unless that rule uses !important) — so if desktop stayed inline while tablet/mobile lived in a separate stylesheet block, the breakpoint overrides would be computed correctly but silently never actually apply. build_typography_style_attr() (inline, desktop-only) still exists, but only as a fallback for the rare case where a shortcode instance has no shortcode_uid (e.g. a hand-typed [sanilwb ...] shortcode outside the widget loop) — with no unique ID, there's no [data-uid] selector to scope a stylesheet rule to.
Link Hover Color — only Heading and Post Title get a second color field, sanilwb_hover_color, since they're the only two typography widgets that can wrap their text in a link (sanilwb_link_url / sanilwb_link). It's added via TYPOGRAPHY_STYLE_FIELDS_WITH_LINK_HOVER in widgetTypes.js, which tags the base Text Color field hoverState: 'normal' and the new field hoverState: 'hover' — this reuses the shared Normal/Hover section toggle already built for Template Builder (FormFields.jsx), no new UI needed. build_typography_responsive_style() emits it as [data-uid="..."] a:hover{color:...!important} at all three breakpoints, resolved from hover_color/hover_color__tablet/hover_color__mobile.
Link resting-state color also needs its own rule. The Text Color declaration lands on the parent tag (<h2>, etc.); a linked <a> inside it only inherits that color, and the browser's own default link color beats a merely-inherited value — so without an explicit rule, linked text would show as the browser's default blue outside of hover. build_typography_responsive_style() also emits [data-uid="..."] a{color:...!important} (again at all three breakpoints) whenever the widget has a link. This mirrors Template Builder's own heading-color/heading-hover-color handling in class-sanilwb-template-compiler.php, which has the identical fix for the identical reason.
Font Size carries a selectable CSS unit (units: SANILWB_LENGTH_UNITS on the field definition, rendered via the shared UnitPicker — see admin/assets/js/src/shared/utils/unitValue.js). The stored value is a combined number+unit string (e.g. "16px", "1.5em"), not a bare number — NumberField.jsx always calls formatUnitValue() on change, even when the unit is left at the default px. Any code that reads sanilwb_font_size must split it back apart with parseUnitValue() (or the field's own bare-number value will still parse fine, since parseUnitValue() falls back to px for legacy values with no unit letters) — a plain isNaN()/parseInt() check on the raw value will incorrectly treat every unit-suffixed value as non-numeric and silently drop the font-size declaration. renderWidgetPreview.js's buildTypographyStyleAttr() (the canvas preview's JS-rendered path) makes this call correctly, resolving each field's device-specific value via getResponsiveValue() (the same cascade helper buildAppearanceStyle() uses for spacing/border), and emits its own [data-uid]-scoped <style> tags for the link resting-color and hover-color rules so the editor canvas matches the frontend, including real interactive :hover on the actual rendered DOM inside the canvas iframe.
Font Family requires a save to actually load the font. Selecting a Google Font in the dropdown only emits font-family:"X",sans-serif; in the widget's typography styling — it does not by itself make the browser render that font. On save (SANILWB_Admin_PageBuilder::save_metabox() and ajax_pb_save()), sync_page_builder_fonts() recursively scans the saved sanilwb_data for any sanilwb_font_family values and registers them with SANILWB_Font_Sync::update_entity_fonts('post_' . $post_id, $fonts) — the same 'post_{ID}' entity-id convention SANILWB_Template_Compiler::extract_and_sync_fonts() already uses for Template Builder schemas. That call fires SANILWB_Font_Downloader::process_fonts(), which downloads and enqueues the font site-wide. Until the widget is saved at least once, a newly picked font won't actually render.
Post Content Widget Styling
Files: admin/assets/js/src/page-builder/config/widgetTypes.js (buildPostContentTabs(), buildTypographyFieldsWithPrefix(), buildSpacingFieldsWithPrefix()) · admin/assets/js/src/page-builder/utils/patchWidgetLive.js (buildPostContentStyleCss()) · public/class-sanil-website-builder-public.php (build_post_content_style())
Post Content renders whatever the current post's own body contains — arbitrary paragraphs, headings, images, embeds, shortcodes — via apply_filters('the_content', get_the_content()). Unlike every other Typography widget (one tag, fully controlled by the widget's own fields), Post Content has no single element to style: a shared [data-uid]{...} rule only reaches the wrapper <div class="sanilwb-post-content"> itself, and CSS inheritance can't carry a font-size into headings (browsers' own h1-h6 UA-stylesheet rules always beat an inherited value) or into images (no text properties to inherit at all).
Style tab layout. buildPostContentTabs() returns a single tab (id: 'style', so getWidgetDialogTabs() still merges the shared wrapper-level Spacing/Borders/Other/Position sections in at the bottom) containing, in order: Body Text, H1 through H6, Image. Each text section is generated by buildTypographyFieldsWithPrefix(prefix, label) (Text Color, Font Size, Font, Font Weight, Font Style, Line Height, Text Align) plus buildSpacingFieldsWithPrefix(prefix) (Margin Top/Bottom, Padding all 4 sides) — every section carries its own typography and its own margin/padding, scoped to just that section's tag (p, h1...h6, img), independent of the wrapper-level Spacing section that still targets the widget's outer container as a whole. The Image section swaps typography for Border Radius + Box Shadow (reusing BOX_SHADOW_OPTIONS) plus the same Margin/Padding fields.
Field naming: sanilwb_{prefix}_{field} — e.g. sanilwb_body_color, sanilwb_h3_font_size, sanilwb_content_img_border_radius. The Images prefix is content_img (not img), to avoid colliding with the Image/Featured Image widgets' own unrelated sanilwb_img_* frame-sizing fields.
PHP generation — build_post_content_style(). POST_CONTENT_STYLE_GROUPS maps each prefix to its selector (body_ => p, h1_ => h1, ..., content_img_ => img). For each group the method builds desktop/tablet/mobile declarations — typography via build_typography_declarations($args, $suffix, $field_prefix, $important) (now accepting an optional field-prefix so it can read body_color instead of just color, plus an $important flag — see below) for text groups, build_post_content_image_declarations() for the Images group, and build_post_content_spacing_declarations() for every group's Margin/Padding — and emits one [data-uid="{uid}"] {tag}{...} rule per group, wrapped in the same tablet/mobile @media pattern build_typography_responsive_style() uses elsewhere. join_css_declarations() safely concatenates the typography/image and spacing fragments with ; — plain string concatenation of two already-;-joined fragments would silently drop the separator between them.
Every declaration is !important. Real post content saved via the classic/Gutenberg editor commonly carries its own inline style="" on individual tags — e.g. clicking the toolbar's Justify button bakes style="text-align:justify" directly onto that <p>. An inline style always beats an external stylesheet rule regardless of selector specificity unless that rule itself uses !important, so without it a Body Text/Heading/Image style change would silently have no visible effect on any element the post's own author already inline-styled. This is why Post Content's declarations are unconditionally !important while the shared TYPOGRAPHY_STYLE_FIELDS widgets (which style their own fully-controlled element, never someone else's authored markup) are not.
JS live-patch mirror — buildPostContentStyleCss() in patchWidgetLive.js reproduces the same field-for-field logic (including the !important flags, hardcoded since these builder functions are only ever used for Post Content) for the editor's live canvas preview. It's dispatched from CanvasWidgets.jsx's 'style' classification branch via buildStyleCssForWidget(widget.type, widget.uid, widget.values) — a small dispatcher that routes post_content to buildPostContentStyleCss() and every other type to the existing buildPostsStyleCss().
Why structuralFields: [] (empty, not omitted). Every field on this widget is style-only — none of them change which content is displayed, since that always mirrors the current post's own body. classifyValuesChange() treats a widget type with no structuralFields key at all as 'unsupported', which falls through unconditionally to a full AJAX re-render — including on a pure device-toggle with no actual value change (this was the original bug: switching the desktop/tablet/mobile toggle alone re-fetched the widget over AJAX every time). Giving Post Content an empty structuralFields array instead makes classifyValuesChange() correctly return 'none' when nothing changed (skip entirely) and 'style' for any real field edit (live CSS patch, no AJAX) on every subsequent render. The one AJAX round trip that's unavoidable — fetching the post's own filtered content — is still forced via CanvasWidgets.jsx's separate postIdChanged check (first mount, or the Site Layout Preview Post changing), independent of this classification.
Media Field
File: admin/assets/js/src/shared/components/fields/MediaField.jsx, registered as field type image in FormFields.jsx.
Used by the Image widget's sanilwb_attachment_id field. Unlike the older ImageUrlField (field type not registered in FormFields.jsx, used directly by Site Layout instead), MediaField stores a WordPress attachment ID, not a URL — this lets the PHP handler call wp_get_attachment_image() for a responsive srcset at the widget's chosen size and fall back to the attachment's own alt text. It resolves a preview thumbnail via wp.media.attachment(id).fetch() on mount (existing widget) or from the picker's own selection data (new pick).
Requires wp_enqueue_media(). Both Page Builder enqueue paths (enqueue_scripts() for the classic metabox, enqueue_pb_editor_assets() for the full-screen editor) call wp_enqueue_media() — without it, wp.media is undefined and the picker button silently does nothing.
Column Layout Definitions
File: admin/assets/js/src/page-builder/config/columnLayouts.js
COLUMN_LAYOUTS defines all available column splits. Each layout has:
| Property | Purpose |
|---|---|
id |
The string stored in row_template / nested_row_template in the JSON. Must match PHP's Bootstrap class lookup. |
label |
Long description shown in the layout picker |
shortLabel |
Short badge shown on row layer bars (e.g. 1 : 1) |
columns |
Array of Bootstrap grid widths (sum to 12) |
group |
Visual grouping in the layout picker popover |
Available layouts:
| ID | Split | Short label |
|---|---|---|
single-column-template |
12 | Full |
six_six-column-template |
6 / 6 | 1 : 1 |
eight_four-column-template |
8 / 4 | 2 : 1 |
nine_three-column-template |
9 / 3 | 3 : 1 |
three_nine-column-template |
3 / 9 | 1 : 3 |
four_eight-column-template |
4 / 8 | 1 : 2 |
five_seven-column-template |
5 / 7 | 5 : 7 |
seven_five-column-template |
7 / 5 | 7 : 5 |
The default layout for new sections and rows is single-column-template. When a row layout changes, existing column data is preserved for columns that survive the change — new columns are created only if the new layout has more columns.
Canvas Preview Rendering
Files: admin/assets/js/src/page-builder/components/canvas/CanvasWidgets.jsx admin/assets/js/src/page-builder/utils/renderWidgetPreview.js admin/assets/js/src/page-builder/utils/patchWidgetLive.js
CanvasWidget resolves each widget's preview HTML through one of two paths, chosen per widget type:
- JS-rendered —
heading,paragraph,image,read_more_btn,post_title,archive_title,featured_image,excerpt.renderWidgetPreview()builds the HTML directly in JS fromwidget.values(and, for the ambient-context widgets, a per-session cached fetch of the post being edited viasanilwb_get_post_preview). Nosanilwb_pb_render_widgetAJAX call happens for these types on a settings change. - AJAX-rendered —
posts,title_bar,post_content, each for its own reason:posts/title_bardepend on a compileddb:{id}template or a liveWP_Query;post_contentdepends on the realthe_contentfilter chain (shortcodes, blocks, embeds), which can't be reimplemented in JS.renderWidgetPreview()returnsnullfor all three andCanvasWidgets.jsxfalls back to the realsanilwb_pb_render_widget/do_shortcode()round trip (ajax_render_widget()inpublic/class-sanil-website-builder-public.php).
isJsRenderedWidgetType(type) (exported from renderWidgetPreview.js) is the source of truth for which list a type is in — CanvasWidgets.jsx checks it before deciding whether to show the loading spinner.
Loading spinner only applies to the AJAX path. Earlier, every widget type went through the same setLoading(true) → 500ms debounce → fetch pipeline on any settings change, including JS-rendered types that never touch the network — this made the canvas flash a spinner on every keystroke in, e.g., the Heading widget's text field, even though the render was already complete synchronously. CanvasWidgetImpl's effect now checks isJsRenderedWidgetType(widget.type) right after the domPatch branch: if true, it calls renderWidgetPreview() immediately (no debounce, no setLoading(true)) and just updates html when it resolves. Only posts/title_bar/post_content still go through the debounce + spinner, since those are the types that actually wait on a server response.
classifyValuesChange() (in patchWidgetLive.js) is a separate, narrower optimization scoped to posts/title_bar/post_content — it distinguishes style-only edits (patched live via CSS, no AJAX), title_bar content edits (patched live via DOM text/attribute updates), and Post Content edits (also patched live via CSS — see Post Content Widget Styling) from edits that require a full AJAX re-render. It returns 'unsupported' for every widget type that doesn't define structuralFields in WIDGET_TYPES at all (i.e. every JS-rendered type) — that return value is what used to fall through to the generic AJAX spinner path before this fix. Post Content is the one type whose structuralFields is an empty array rather than a populated one or missing entirely — every one of its fields is style-only, so it can only ever classify as 'none' or 'style', never 'structural', once past the unavoidable first-mount fetch.
Re-render scope: updateWidget() in the store rebuilds only the path from the root down to the edited widget (mapAllColumns() returns the same section/row/column object references for anything that doesn't contain the widget). Combined with React.memo on CanvasSection, CanvasRow, CanvasColumn, CanvasNestedRow, CanvasNestedColumn, and CanvasWidget, only the edited widget actually re-renders — sibling widgets, columns, rows, and sections bail out of memo without re-executing.
Save / AJAX
AJAX action: sanilwb_pb_save
Nonce: window.PageBuilderData.pbSaveNonce
Handler: admin/class-sanil-website-builder-admin.php
Save logic lives entirely in SaveButton.jsx. The button is disabled (greyed out, not-allowed cursor) when there are no unsaved changes or a save is already in flight — no AJAX request fires in those states. When clicked while enabled, it serializes the store via serializeToJson() and POSTs to window.ajaxurl. The button shows three states: idle → saving… → Saved (1.5s) → idle.
The Close button (handleClose in PageBuilder.jsx) shows a window.confirm dialog before navigating away when hasUnsavedChanges is true.
Frontend Rendering
Files:
public/class-sanil-website-builder-public.php
public/helpers.php
PHP reads the stored sanilwb_data post meta, iterates the sections array, renders Bootstrap rows and columns, and calls do_shortcode('[sanilwb type="posts" ...]') for each widget. The widget_values JSON string is decoded and each key-value pair becomes a shortcode attribute.
The buildShortcode function in widgetTypes.js strips the sanilwb_ prefix from field names when building the shortcode string — the PHP shortcode handler receives attribute names without that prefix.
Ambient post context in the AJAX canvas preview
ajax_render_widget() (action sanilwb_pb_render_widget) sets up post context for dynamic widgets (Post Title, Excerpt, Post Content, etc.) so they preview correctly against the post being edited:
if ( $post_id ) {
global $post;
$post = get_post( $post_id );
if ( $post ) {
setup_postdata( $post );
}
}
The global $post; declaration is required. In modern WordPress, setup_postdata() alone does not set $GLOBALS['post'] — it only sets the auxiliary globals ($authordata, $page, $more, etc.) and fires the the_post action; $GLOBALS['post'] is set by WP_Query::the_post()'s own explicit global $post; assignment. Without global $post; here, $post = get_post($post_id) creates a method-local variable, setup_postdata() has no visible effect on the global, and every ambient-context template tag (get_the_title(), get_the_excerpt(), get_the_content(), get_permalink()) silently returns empty — this was a real bug found while building the dynamic widgets above, not a hypothetical.
Unified style block
All spacing, border, and layout styles (margin, padding, border-radius, border-width, border-style, border-color, flex layout) are emitted as a single <style id="sanilwb-page-styles"> block that appears immediately before the page builder HTML. This block is generated fresh on every page load by render_page_builder_content().
How it works:
ob_start()buffers the HTML while the section/row/column/widget loop runs.- Every element gets a unique scoped CSS class (
sanilwb-el-N) assigned by a static page-request counter. Widgets get a persistent class (sanilwb-widget-{uid}) derived from the stablewidget_uidfield. - Three private helpers collect the CSS rules:
build_appearance_declarations($opts, $suffix)— background-color, margin, padding, border-radius, border-width, border-style, border-colorbuild_layout_declarations($opts, $suffix)— display, flex-direction, align-items, justify-content, flex-wrap, align-content, gapcollect_element_css($selector, $opts, $include_layout)— calls both helpers for the desktop pass (no suffix), tablet pass (__tablet), and mobile pass (__mobile), wrapping breakpoint rules in media queries and appending everything to$this->pending_page_css- After the loop,
ob_get_clean()captures the HTML. The style block is echoed first, then the HTML.
Breakpoints:
| Constant | Value | Used for |
|---|---|---|
SANILWB_BP_TABLET_MIN |
768px | min-width of tablet media query |
SANILWB_BP_TABLET_MAX |
1199px | max-width of tablet media query |
SANILWB_BP_MOBILE_MAX |
767px | max-width of mobile media query |
Responsive field values (e.g. sanilwb_margin_top__tablet) are read with the __tablet / __mobile suffix automatically by the helpers. If a responsive value is not set, no rule is emitted for that breakpoint.
No inline styles: structural elements (sections, rows, columns, nested rows, nested columns, widgets) carry only their scoped class — no style="" attribute. All visual rules live exclusively in the unified <style> block.
Why <style> in body: wp_add_inline_style() silently drops styles added after wp_head() has fired (which it has by the time the_content runs). The body-level <style> tag is the intentional workaround.
Adding a New Widget Type — Checklist
-
widgetTypes.js — Add an entry to
WIDGET_TYPESwithtype,label,icon,barBg,barText, anddefaultValues. Add acaseingetWidgetDialogTabs()that calls a newbuildYourTypeTabs()function. -
usePageBuilderStore.js — No changes required. The store is widget-type-agnostic;
addWidget(columnId, type)works for any type inWIDGET_TYPES. -
public/class-sanil-website-builder-public.php — Add a shortcode handler (or a case in the existing switch) to render the widget's frontend output from the
sanilwb_*attribute values. -
Test: open the Widget Picker in the Layers panel, add the new widget, open its settings dialog, verify all fields save and render correctly in the canvas preview and on the frontend.