figma-code-agent 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -0
- package/bin/install.js +328 -0
- package/knowledge/README.md +62 -0
- package/knowledge/css-strategy.md +973 -0
- package/knowledge/design-to-code-assets.md +855 -0
- package/knowledge/design-to-code-layout.md +929 -0
- package/knowledge/design-to-code-semantic.md +1085 -0
- package/knowledge/design-to-code-typography.md +1003 -0
- package/knowledge/design-to-code-visual.md +1145 -0
- package/knowledge/design-tokens-variables.md +1261 -0
- package/knowledge/design-tokens.md +960 -0
- package/knowledge/figma-api-devmode.md +894 -0
- package/knowledge/figma-api-plugin.md +920 -0
- package/knowledge/figma-api-rest.md +742 -0
- package/knowledge/figma-api-variables.md +848 -0
- package/knowledge/figma-api-webhooks.md +876 -0
- package/knowledge/payload-blocks.md +1184 -0
- package/knowledge/payload-figma-mapping.md +1210 -0
- package/knowledge/payload-visual-builder.md +1004 -0
- package/knowledge/plugin-architecture.md +1176 -0
- package/knowledge/plugin-best-practices.md +1206 -0
- package/knowledge/plugin-codegen.md +1313 -0
- package/package.json +31 -0
- package/skills/README.md +103 -0
- package/skills/audit-plugin/SKILL.md +244 -0
- package/skills/build-codegen-plugin/SKILL.md +279 -0
- package/skills/build-importer/SKILL.md +320 -0
- package/skills/build-plugin/SKILL.md +199 -0
- package/skills/build-token-pipeline/SKILL.md +363 -0
- package/skills/ref-html/SKILL.md +290 -0
- package/skills/ref-layout/SKILL.md +150 -0
- package/skills/ref-payload-block/SKILL.md +415 -0
- package/skills/ref-react/SKILL.md +222 -0
- package/skills/ref-tokens/SKILL.md +347 -0
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
# PayloadCMS Visual Builder Plugin Architecture
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Authoritative reference for the `@eab/payload-visual-builder` plugin architecture. Documents the complete plugin system including the two-entry-point pattern (server + client), configuration API, core components (VisualBuilder, Canvas, BlockWrapper, Inspector, BlockLayers, TabbedSidebar), the edit block registry, inline editing primitives (EditableText, EditableMedia, Lexical editors), container adapter pattern for block nesting, drag-and-drop via dnd-kit, undo/redo history context, debounced save queue, keyboard shortcuts, responsive viewport preview, CSS token architecture, and the thin wrapper pattern for consuming applications. Encodes production patterns from a PayloadCMS 3.x visual page builder implementation.
|
|
6
|
+
|
|
7
|
+
## When to Use
|
|
8
|
+
|
|
9
|
+
Reference this module when you need to:
|
|
10
|
+
|
|
11
|
+
- Understand the visual builder plugin architecture and component hierarchy
|
|
12
|
+
- Build or extend visual editing capabilities for PayloadCMS blocks
|
|
13
|
+
- Add new edit block components to the registry
|
|
14
|
+
- Implement inline editing for new block types using EditableText, EditableMedia, or Lexical
|
|
15
|
+
- Work with the container adapter pattern for nested block structures
|
|
16
|
+
- Understand the drag-and-drop implementation across containers
|
|
17
|
+
- Debug the undo/redo history system or save queue behavior
|
|
18
|
+
- Add keyboard shortcuts or viewport preview presets
|
|
19
|
+
- Build a Figma-to-CMS importer that needs to understand the visual editing layer
|
|
20
|
+
- Customize the plugin CSS token system for a consuming application
|
|
21
|
+
- Mount the visual builder in a new PayloadCMS project
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Content
|
|
26
|
+
|
|
27
|
+
### 1. Purpose and When to Use
|
|
28
|
+
|
|
29
|
+
The `@eab/payload-visual-builder` plugin adds a canvas-based visual editing experience to PayloadCMS collections that use the blocks field type. Instead of editing blocks through Payload's default form interface, editors get a WYSIWYG-like canvas where they can:
|
|
30
|
+
|
|
31
|
+
- See blocks rendered with frontend-like styling
|
|
32
|
+
- Edit text and media inline (without navigating to field forms)
|
|
33
|
+
- Drag and drop blocks to reorder them within and across containers
|
|
34
|
+
- Use an inspector panel for block properties and layout controls
|
|
35
|
+
- Navigate the block hierarchy through a layers panel
|
|
36
|
+
- Preview layouts at different viewport sizes
|
|
37
|
+
- Undo and redo changes with full history tracking
|
|
38
|
+
|
|
39
|
+
The plugin is designed as a portable package that can be installed into any PayloadCMS 3.x project with minimal configuration. It does NOT replace Payload's built-in editing -- it provides an additional "Visual Builder" tab on document views for collections that opt in.
|
|
40
|
+
|
|
41
|
+
**Key architectural decision:** The plugin uses `useDocumentInfo()` to read initial block data and direct API calls (`PATCH /api/{collection}/{id}`) to persist changes. It deliberately avoids Payload's form state hooks to prevent re-render cascades during visual editing. This is the most important architectural decision in the entire plugin.
|
|
42
|
+
|
|
43
|
+
### 2. Plugin Architecture Overview
|
|
44
|
+
|
|
45
|
+
The plugin follows a **two-entry-point pattern** that separates server-safe code from browser-only React components.
|
|
46
|
+
|
|
47
|
+
#### Entry Points
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
@eab/payload-visual-builder
|
|
51
|
+
├── index.ts → Re-exports from plugin-entry.ts
|
|
52
|
+
├── src/
|
|
53
|
+
│ ├── plugin-entry.ts → Server entry (Node-safe, no CSS)
|
|
54
|
+
│ ├── client-entry.ts → Client entry (React, CSS, browser-only)
|
|
55
|
+
│ ├── plugin.ts → Plugin factory function
|
|
56
|
+
│ ├── types/ → Shared type definitions
|
|
57
|
+
│ ├── components/ → React components (client-only)
|
|
58
|
+
│ ├── hooks/ → React hooks (client-only)
|
|
59
|
+
│ ├── context/ → React contexts (client-only)
|
|
60
|
+
│ ├── registry/ → Block registry (client-only)
|
|
61
|
+
│ ├── utils/ → Utility functions (shared)
|
|
62
|
+
│ └── styles/ → CSS Modules + tokens (client-only)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
#### Server Entry (`index.ts` / `plugin-entry.ts`)
|
|
66
|
+
|
|
67
|
+
The server entry is imported in `payload.config.ts`. It exports:
|
|
68
|
+
|
|
69
|
+
- `visualBuilderPlugin()` -- Plugin factory function
|
|
70
|
+
- `VisualBuilderPluginConfig`, `VisualBuilderFeatures`, `ResolvedVisualBuilderConfig` -- Types
|
|
71
|
+
- `defaultFeatures`, `resolveConfig` -- Configuration utilities
|
|
72
|
+
- `validatePluginConfig`, `validateCollectionsExist` -- Validation utilities
|
|
73
|
+
|
|
74
|
+
**Critical rule:** This entry must NEVER import React components or CSS files. It runs in Node.js during Payload startup.
|
|
75
|
+
|
|
76
|
+
#### Client Entry (`client-entry.ts`)
|
|
77
|
+
|
|
78
|
+
The client entry is imported in React components (browser context). It exports:
|
|
79
|
+
|
|
80
|
+
- All React components (VisualBuilder, Canvas, BlockWrapper, etc.)
|
|
81
|
+
- All hooks (useSaveQueue, useBlockKeyboardShortcuts, etc.)
|
|
82
|
+
- Registry functions (registerEditBlock, getEditBlock, etc.)
|
|
83
|
+
- Utility functions (createBlock, isContainer, findBlockById, etc.)
|
|
84
|
+
- Style utilities (cn, getCSSVar, setCSSVar, tokens, etc.)
|
|
85
|
+
- Type re-exports for consumer convenience
|
|
86
|
+
|
|
87
|
+
**Usage pattern:**
|
|
88
|
+
```typescript
|
|
89
|
+
// In payload.config.ts (server)
|
|
90
|
+
import { visualBuilderPlugin } from '@eab/payload-visual-builder'
|
|
91
|
+
|
|
92
|
+
// In React components (client)
|
|
93
|
+
import { VisualBuilder } from '@eab/payload-visual-builder/client'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
#### Plugin Factory Pattern
|
|
97
|
+
|
|
98
|
+
The `visualBuilderPlugin()` function follows Payload's plugin convention -- it returns a config modifier function:
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
export const visualBuilderPlugin = (
|
|
102
|
+
pluginConfig: VisualBuilderPluginConfig
|
|
103
|
+
): ((config: Config) => Config) => {
|
|
104
|
+
validatePluginConfig(pluginConfig)
|
|
105
|
+
const resolvedConfig = resolveConfig(pluginConfig)
|
|
106
|
+
|
|
107
|
+
return (config: Config): Config => {
|
|
108
|
+
validateCollectionsExist(pluginConfig, config)
|
|
109
|
+
|
|
110
|
+
const modifiedCollections = (config.collections ?? []).map((collection) => {
|
|
111
|
+
if (resolvedConfig.collections.includes(collection.slug)) {
|
|
112
|
+
return addVisualBuilderToCollection(collection, resolvedConfig)
|
|
113
|
+
}
|
|
114
|
+
return collection
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
return { ...config, collections: modifiedCollections }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The factory validates configuration, resolves defaults, and modifies the target collections by injecting `admin.custom.visualBuilder` metadata with the plugin's settings.
|
|
123
|
+
|
|
124
|
+
### 3. Configuration API
|
|
125
|
+
|
|
126
|
+
#### VisualBuilderPluginConfig
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
interface VisualBuilderPluginConfig {
|
|
130
|
+
/** Collections to enable Visual Builder on */
|
|
131
|
+
collections: string[]
|
|
132
|
+
|
|
133
|
+
/** Path to the blocks field (dot notation). Default: 'layout.blocks' */
|
|
134
|
+
blocksFieldPath?: string
|
|
135
|
+
|
|
136
|
+
/** Feature flags for optional capabilities */
|
|
137
|
+
features?: VisualBuilderFeatures
|
|
138
|
+
|
|
139
|
+
/** Custom block registry mapping slugs to edit components */
|
|
140
|
+
blockRegistry?: Record<string, ComponentType<unknown>>
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
#### VisualBuilderFeatures
|
|
145
|
+
|
|
146
|
+
All features default to `true`:
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
interface VisualBuilderFeatures {
|
|
150
|
+
inlineEditing?: boolean // Inline text/media editing
|
|
151
|
+
dragAndDrop?: boolean // Block reordering via drag
|
|
152
|
+
inspector?: boolean // Side panel for properties
|
|
153
|
+
keyboardShortcuts?: boolean // Keyboard shortcuts
|
|
154
|
+
undoRedo?: boolean // Undo/redo history
|
|
155
|
+
responsivePreview?: boolean // Viewport size switching
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
#### Configuration Flow
|
|
160
|
+
|
|
161
|
+
1. Consumer calls `visualBuilderPlugin({ collections: ['pages'], blocksFieldPath: 'layout.blocks' })`
|
|
162
|
+
2. `resolveConfig()` merges user config with `defaultFeatures`
|
|
163
|
+
3. Plugin modifies each target collection's `admin.custom.visualBuilder` with resolved config
|
|
164
|
+
4. At runtime, the VisualBuilder component reads this config to determine behavior
|
|
165
|
+
|
|
166
|
+
#### Example Usage
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
// payload.config.ts
|
|
170
|
+
import { buildConfig } from 'payload'
|
|
171
|
+
import { visualBuilderPlugin } from '@eab/payload-visual-builder'
|
|
172
|
+
|
|
173
|
+
export default buildConfig({
|
|
174
|
+
plugins: [
|
|
175
|
+
visualBuilderPlugin({
|
|
176
|
+
collections: ['pages'],
|
|
177
|
+
blocksFieldPath: 'layout.blocks',
|
|
178
|
+
features: {
|
|
179
|
+
responsivePreview: true,
|
|
180
|
+
undoRedo: true,
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
],
|
|
184
|
+
})
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### 4. Core Components
|
|
188
|
+
|
|
189
|
+
#### VisualBuilder (`VisualBuilder.tsx`)
|
|
190
|
+
|
|
191
|
+
The top-level component. It wraps `VisualBuilderInner` with a `HistoryProvider` for undo/redo support.
|
|
192
|
+
|
|
193
|
+
**Data flow:**
|
|
194
|
+
|
|
195
|
+
1. `useDocumentInfo()` provides `initialData` from Payload's document context
|
|
196
|
+
2. Blocks are extracted from `initialData.layout.blocks` (configurable path)
|
|
197
|
+
3. Local `useState` manages the blocks array -- this is the source of truth during editing
|
|
198
|
+
4. Changes flow through `handleBlocksChange()` which: validates blocks, updates local state, records a history snapshot, queues a debounced API save, and marks the document as dirty
|
|
199
|
+
|
|
200
|
+
**Key state:**
|
|
201
|
+
- `blocks: Block[]` -- Current block tree
|
|
202
|
+
- `selectedBlockId: string | null` -- Currently selected block
|
|
203
|
+
- `hoveredBlockId: string | null` -- Block hovered in layers panel
|
|
204
|
+
- `activeTab: string` -- Current sidebar tab ('fields' | 'outline' | 'blocks')
|
|
205
|
+
- `showPreview: boolean` -- Preview modal visibility
|
|
206
|
+
|
|
207
|
+
**Critical behavior:**
|
|
208
|
+
- Initial data loads ONCE per document (tracked by `initialLoadDoneRef`)
|
|
209
|
+
- Collapses Payload's main nav on mount to maximize horizontal space
|
|
210
|
+
- Text edits are batched via `useTextEditHistory` (1s debounce) to avoid flooding history
|
|
211
|
+
- Undo/redo do NOT trigger saves -- they are exploratory. The user must explicitly continue editing to trigger a save.
|
|
212
|
+
|
|
213
|
+
**Component tree:**
|
|
214
|
+
```
|
|
215
|
+
VisualBuilder
|
|
216
|
+
└── HistoryProvider
|
|
217
|
+
└── VisualBuilderInner
|
|
218
|
+
├── DndContext (dnd-kit)
|
|
219
|
+
│ ├── TabbedSidebar
|
|
220
|
+
│ │ ├── Inspector (fields tab)
|
|
221
|
+
│ │ ├── BlockLayers (outline tab)
|
|
222
|
+
│ │ └── BlockLibrary (blocks tab)
|
|
223
|
+
│ ├── Canvas
|
|
224
|
+
│ │ └── VisualCanvas
|
|
225
|
+
│ │ └── SortableBlockCard[]
|
|
226
|
+
│ │ └── EditBlockRenderer
|
|
227
|
+
│ │ └── [EditBlock component]
|
|
228
|
+
│ └── DragOverlay
|
|
229
|
+
└── PreviewModal
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
#### Canvas (`Canvas.tsx`)
|
|
233
|
+
|
|
234
|
+
Renders the block list with drag-and-drop support. Provides the `Block` type definition used throughout the plugin:
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
export type Block = {
|
|
238
|
+
id: string
|
|
239
|
+
blockType: string
|
|
240
|
+
[key: string]: unknown
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**Features:**
|
|
245
|
+
- `SortableContext` from dnd-kit for reordering
|
|
246
|
+
- `useDroppable` for canvas-level drop zone
|
|
247
|
+
- Viewport-constrained container with dashed edge indicators
|
|
248
|
+
- Empty state when no blocks exist
|
|
249
|
+
- Block inserter at footer and inline after each block
|
|
250
|
+
- Recursive rendering of nested container children via `SortableBlockCard`
|
|
251
|
+
|
|
252
|
+
**Block operations:** Remove, move up, move down, duplicate, insert after -- all implemented as immutable array operations using path-based tree updates.
|
|
253
|
+
|
|
254
|
+
#### BlockWrapper (`BlockWrapper.tsx`)
|
|
255
|
+
|
|
256
|
+
Provides the visual editing chrome around each block:
|
|
257
|
+
|
|
258
|
+
- **Selection outline:** 2px solid blue when selected, 1px dashed blue on hover, transparent otherwise
|
|
259
|
+
- **Top bar:** Positioned above the block (-32px), appears on hover/selection/menu-open
|
|
260
|
+
- Left: Block type label pill with drag handle (GripVertical icon)
|
|
261
|
+
- Right: BlockActionsMenu (move up/down, add below, duplicate, remove)
|
|
262
|
+
- **Click behavior:** Clicks on the wrapper select the block. Clicks on editable content (contenteditable, input, textarea) pass through without selecting.
|
|
263
|
+
- **Hover debounce:** 100ms delay on mouse leave to allow cursor travel from content to toolbar
|
|
264
|
+
|
|
265
|
+
#### Inspector (`Inspector.tsx`)
|
|
266
|
+
|
|
267
|
+
Side panel for editing selected block properties:
|
|
268
|
+
|
|
269
|
+
- **No selection state:** Shows empty state with "No Block Selected" message
|
|
270
|
+
- **Selected block:** Shows block type header, truncated block ID, "Edit Content" button (navigates to Payload's Edit tab), ContainerControls (for container blocks), and generic BlockControls (layoutMeta fields)
|
|
271
|
+
- Uses `onBlockUpdate(blockId, updates)` callback for all property changes
|
|
272
|
+
|
|
273
|
+
#### BlockLayers (`BlockLayers.tsx`)
|
|
274
|
+
|
|
275
|
+
Hierarchical tree view of the page block structure:
|
|
276
|
+
|
|
277
|
+
- Recursive `LayerNode` components with depth-based indentation (16px per level)
|
|
278
|
+
- Expand/collapse chevrons for containers
|
|
279
|
+
- Click-to-select (syncs with canvas selection)
|
|
280
|
+
- Mouse hover triggers `onHoverBlock` (syncs with canvas highlight)
|
|
281
|
+
- Exposed via `forwardRef` with `BlockLayersRef` interface for external expand/collapse all
|
|
282
|
+
- ARIA tree roles for accessibility (`role="tree"`, `role="treeitem"`, `aria-expanded`)
|
|
283
|
+
|
|
284
|
+
#### TabbedSidebar (`TabbedSidebar.tsx`)
|
|
285
|
+
|
|
286
|
+
Combines `IconRail` (vertical icon tab strip) and `TabPanel` (content area) into the sidebar:
|
|
287
|
+
|
|
288
|
+
- Default tabs: Fields (Sliders icon), Outline (Layers icon), Blocks (LayoutGrid icon)
|
|
289
|
+
- Position switching (left/right) persisted via `useSidebarPosition` hook to localStorage
|
|
290
|
+
- Configurable panel width (default 220px)
|
|
291
|
+
- Optional header actions (e.g., expand/collapse all for Outline tab)
|
|
292
|
+
|
|
293
|
+
### 5. Edit Block System
|
|
294
|
+
|
|
295
|
+
Edit blocks are React components that render block content with inline editing capabilities. They are the visual representations of Payload blocks in the canvas.
|
|
296
|
+
|
|
297
|
+
#### EditBlockProps Interface
|
|
298
|
+
|
|
299
|
+
Every edit block receives these props:
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
interface EditBlockProps<T extends Block = Block> {
|
|
303
|
+
block: T // Block data from Payload
|
|
304
|
+
onChange: (updates: Partial<T>) => void // Partial update callback
|
|
305
|
+
isSelected: boolean // Whether this block is selected
|
|
306
|
+
onSelect: () => void // Selection callback
|
|
307
|
+
showControls?: boolean // Show editing controls
|
|
308
|
+
dragHandleProps?: HTMLAttributes // dnd-kit drag handle
|
|
309
|
+
onEditInPayload?: () => void // Navigate to Payload edit tab
|
|
310
|
+
onRemove?: () => void // Remove this block
|
|
311
|
+
onMoveUp?: () => void // Move block up
|
|
312
|
+
onMoveDown?: () => void // Move block down
|
|
313
|
+
onDuplicate?: () => void // Duplicate this block
|
|
314
|
+
onAddBelow?: () => void // Insert block below
|
|
315
|
+
selectedBlockId?: string | null // Currently selected block (for nested)
|
|
316
|
+
onBlockSelect?: (id: string | null) => void // Select any block (for nested)
|
|
317
|
+
hoveredBlockId?: string | null // Hovered block for highlight sync
|
|
318
|
+
parentContainerId?: string | null // Parent container for hierarchy
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
#### Registry Pattern
|
|
323
|
+
|
|
324
|
+
Edit blocks are registered in a global `Map<string, EditBlockRegistryEntry>`:
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
// Registration
|
|
328
|
+
registerEditBlock('hero', HeroEdit, { label: 'Hero', icon: Star })
|
|
329
|
+
|
|
330
|
+
// Lookup
|
|
331
|
+
const EditComponent = getEditBlock('hero') // Returns HeroEdit or null
|
|
332
|
+
|
|
333
|
+
// Check
|
|
334
|
+
hasEditBlock('hero') // true
|
|
335
|
+
|
|
336
|
+
// List all
|
|
337
|
+
getRegisteredBlockTypes() // ['hero', 'richText', 'container', ...]
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
The registry is initialized once via `initializeEditBlocks()`, called at module load time in `VisualBuilder.tsx`. This function is idempotent (safe to call multiple times).
|
|
341
|
+
|
|
342
|
+
#### Registered Block Types
|
|
343
|
+
|
|
344
|
+
Phase B (core):
|
|
345
|
+
- `richText` (RichTextEdit) -- Lexical rich text editor
|
|
346
|
+
- `hero` (HeroEdit) -- Hero section with image, rich text, buttons
|
|
347
|
+
- `media` (MediaEdit) -- Media/image block
|
|
348
|
+
- `button` (ButtonEdit) -- Button with editable label
|
|
349
|
+
- `container` (ContainerEdit) -- Recursive container with nested blocks
|
|
350
|
+
|
|
351
|
+
Phase C (content):
|
|
352
|
+
- `card` (CardEdit) -- Card with image, title, description
|
|
353
|
+
- `accordion` (AccordionEdit) -- Expandable accordion items
|
|
354
|
+
- `video` (VideoEdit) -- Video embed
|
|
355
|
+
- `testimonial` (TestimonialEdit) -- Quote with attribution
|
|
356
|
+
- `stats` (StatsEdit) -- Statistics display
|
|
357
|
+
- `callToAction` (CallToActionEdit) -- CTA section
|
|
358
|
+
- `stickyCTA` (StickyCTAEdit) -- Sticky CTA bar
|
|
359
|
+
- `subnavigation` (SubNavigationEdit) -- Sub-navigation links
|
|
360
|
+
- `formEmbed` (FormEmbedEdit) -- Form embed
|
|
361
|
+
|
|
362
|
+
#### Fallback for Unregistered Blocks
|
|
363
|
+
|
|
364
|
+
When `getEditBlock()` returns null for a block type, the `EditBlockRenderer` falls back to a generic card view showing the block type and basic metadata.
|
|
365
|
+
|
|
366
|
+
#### How Edit Blocks Use Inline Primitives
|
|
367
|
+
|
|
368
|
+
Each edit block composes inline editing primitives to make its content editable. For example, `HeroEdit`:
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
// HeroEdit composition
|
|
372
|
+
<BlockWrapper block={block} blockType="Hero" ...>
|
|
373
|
+
<div className={containerClass}>
|
|
374
|
+
<EditableMedia // Background image
|
|
375
|
+
value={imageData}
|
|
376
|
+
onChange={handleImageChange}
|
|
377
|
+
size="full"
|
|
378
|
+
/>
|
|
379
|
+
<PayloadLexicalEditor // Rich text content
|
|
380
|
+
value={richTextData}
|
|
381
|
+
onChange={handleRichTextChange}
|
|
382
|
+
showToolbar={isSelected}
|
|
383
|
+
/>
|
|
384
|
+
{buttons.map((btn, idx) => (
|
|
385
|
+
<EditableText // Button labels
|
|
386
|
+
value={btn.link?.label || ''}
|
|
387
|
+
onChange={(label) => handleButtonLabelChange(idx, label)}
|
|
388
|
+
tag="span"
|
|
389
|
+
/>
|
|
390
|
+
))}
|
|
391
|
+
</div>
|
|
392
|
+
</BlockWrapper>
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
**Pattern:** Every edit block wraps its content in `BlockWrapper` for selection chrome, then uses `EditableText` for short text fields, `EditableMedia` for images, and `PayloadLexicalEditor` or `InlineLexicalEditor` for rich text areas. The `onChange` prop on each primitive calls the parent's `onChange` with a partial block update.
|
|
396
|
+
|
|
397
|
+
#### ContainerEdit: Recursive Rendering
|
|
398
|
+
|
|
399
|
+
`ContainerEdit` is unique because it recursively renders its children using `EditBlockRenderer`, creating nested sortable contexts:
|
|
400
|
+
|
|
401
|
+
```
|
|
402
|
+
ContainerEdit
|
|
403
|
+
└── BlockWrapper
|
|
404
|
+
└── <Tag> (dynamic: div, section, article, etc.)
|
|
405
|
+
└── SortableContext (per container)
|
|
406
|
+
├── SortableChildBlock → EditBlockRenderer → [EditBlock]
|
|
407
|
+
├── SortableChildBlock → EditBlockRenderer → ContainerEdit (recursive)
|
|
408
|
+
│ └── SortableContext (nested)
|
|
409
|
+
│ └── ...
|
|
410
|
+
└── ContainerEndDropZone
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Container settings (layout direction, alignment, gap, grid columns, width) are rendered as CSS flexbox/grid properties directly on the container element.
|
|
414
|
+
|
|
415
|
+
### 6. Inline Editing Components
|
|
416
|
+
|
|
417
|
+
These primitives enable in-place content editing within edit blocks.
|
|
418
|
+
|
|
419
|
+
#### EditableText (`EditableText.tsx`)
|
|
420
|
+
|
|
421
|
+
A `contenteditable` div component for inline text editing.
|
|
422
|
+
|
|
423
|
+
**Props:**
|
|
424
|
+
```typescript
|
|
425
|
+
interface EditableTextProps {
|
|
426
|
+
value: string // Current text value
|
|
427
|
+
onChange: (newValue: string) => void // Fires on blur or Enter (single-line)
|
|
428
|
+
placeholder?: string // Placeholder when empty
|
|
429
|
+
tag?: 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'label'
|
|
430
|
+
className?: string
|
|
431
|
+
style?: React.CSSProperties
|
|
432
|
+
multiline?: boolean // Enter adds newline vs. submits
|
|
433
|
+
disabled?: boolean
|
|
434
|
+
enableFormatting?: boolean // Rich text mode (bold, italic, link)
|
|
435
|
+
htmlValue?: string // HTML value when formatting enabled
|
|
436
|
+
onHtmlChange?: (html: string) => void // HTML change callback
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
**Behavior:**
|
|
441
|
+
- Renders the specified HTML tag with `contenteditable`
|
|
442
|
+
- Changes propagate on blur (always) or Enter (single-line mode)
|
|
443
|
+
- Escape reverts to last committed value
|
|
444
|
+
- Paste strips formatting in plain text mode, preserves in rich text mode
|
|
445
|
+
- When `enableFormatting` is true, shows a floating `FormatToolbar` on text selection
|
|
446
|
+
- Format shortcuts: Cmd+B (bold), Cmd+I (italic), Cmd+K (link)
|
|
447
|
+
|
|
448
|
+
**Use cases:** Hero headlines, card titles, button labels, any short text field.
|
|
449
|
+
|
|
450
|
+
#### EditableMedia (`EditableMedia.tsx`)
|
|
451
|
+
|
|
452
|
+
Clickable image component that opens Payload's media drawer.
|
|
453
|
+
|
|
454
|
+
**Props:**
|
|
455
|
+
```typescript
|
|
456
|
+
interface EditableMediaProps {
|
|
457
|
+
value: Media | string | null // Full object, ID string, or null
|
|
458
|
+
onChange: (newMedia: Media | null) => void
|
|
459
|
+
alt?: string
|
|
460
|
+
placeholder?: string
|
|
461
|
+
aspectRatio?: string // CSS aspect-ratio (e.g., '16/9')
|
|
462
|
+
style?: React.CSSProperties
|
|
463
|
+
disabled?: boolean
|
|
464
|
+
size?: 'small' | 'medium' | 'large' | 'full'
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
**Behavior:**
|
|
469
|
+
- Displays current image with hover overlay ("Click to change")
|
|
470
|
+
- Empty state shows dashed border with "Add image" placeholder
|
|
471
|
+
- Click opens `useListDrawer` from `@payloadcms/ui` for media selection
|
|
472
|
+
- Remove button (X) clears the media
|
|
473
|
+
- Resolves media by ID via `/api/media/{id}` with in-memory cache
|
|
474
|
+
- Keyboard accessible: Enter/Space to open drawer
|
|
475
|
+
|
|
476
|
+
**Use cases:** Hero background images, card images, media blocks, any image field.
|
|
477
|
+
|
|
478
|
+
#### PayloadLexicalEditor (`PayloadLexicalEditor.tsx`)
|
|
479
|
+
|
|
480
|
+
Full Lexical rich text editor with a Payload-styled toolbar.
|
|
481
|
+
|
|
482
|
+
**Features:**
|
|
483
|
+
- Full formatting: bold, italic, underline, strikethrough, code
|
|
484
|
+
- Headings: H1-H4 via dropdown selector
|
|
485
|
+
- Lists: bullet and numbered
|
|
486
|
+
- Block quotes
|
|
487
|
+
- Text alignment: left, center, right, justify (via dropdown)
|
|
488
|
+
- Links: insert and remove
|
|
489
|
+
- Undo/redo (Lexical-internal history)
|
|
490
|
+
- Toolbar appears on focus or when externally controlled
|
|
491
|
+
|
|
492
|
+
**Architecture:** Uses `LexicalComposer` with `HeadingNode`, `QuoteNode`, `ListNode`, `ListItemNode`, and `LinkNode`. The `InitialStatePlugin` hydrates the editor from serialized Lexical JSON on mount only (never resets during typing).
|
|
493
|
+
|
|
494
|
+
#### InlineLexicalEditor (`InlineLexicalEditor.tsx`)
|
|
495
|
+
|
|
496
|
+
Full-featured rich text editor with a dark-themed toolbar. Similar to PayloadLexicalEditor but with a different visual treatment.
|
|
497
|
+
|
|
498
|
+
**Key difference from PayloadLexicalEditor:** Uses a dark toolbar style (#1f2937 background) with icon-only buttons. Shows toolbar only when focused or externally controlled via `showToolbar` prop.
|
|
499
|
+
|
|
500
|
+
#### FormatToolbar (`FormatToolbar.tsx`)
|
|
501
|
+
|
|
502
|
+
Floating toolbar for inline text formatting, rendered via React portal.
|
|
503
|
+
|
|
504
|
+
**Features:**
|
|
505
|
+
- Appears above selected text
|
|
506
|
+
- Three buttons: Bold (Cmd+B), Italic (Cmd+I), Link (Cmd+K)
|
|
507
|
+
- Viewport-aware positioning (prevents going off-screen)
|
|
508
|
+
- Dark theme (#1f2937 background)
|
|
509
|
+
- Active state highlighting for current formats
|
|
510
|
+
- `onMouseDown={preventDefault}` to preserve text selection during clicks
|
|
511
|
+
|
|
512
|
+
### 7. Container Adapter Pattern
|
|
513
|
+
|
|
514
|
+
All container nesting logic is abstracted through `containerAdapter.ts`. This is the single source of truth for how blocks contain children.
|
|
515
|
+
|
|
516
|
+
#### Two Container Schemas
|
|
517
|
+
|
|
518
|
+
The system supports two container data structures:
|
|
519
|
+
|
|
520
|
+
```
|
|
521
|
+
Main Container (layout schema): block.layout.content.blocks
|
|
522
|
+
Nested Container (content schema): block.content.content.blocks
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
Both share the same `blockType: 'container'` slug. The adapter determines which schema to use by checking for the presence of a `layout` property.
|
|
526
|
+
|
|
527
|
+
#### Adapter Functions
|
|
528
|
+
|
|
529
|
+
```typescript
|
|
530
|
+
// Check if block is a container
|
|
531
|
+
isContainer(block: Block): boolean
|
|
532
|
+
// Returns true if blockType === 'container'
|
|
533
|
+
|
|
534
|
+
// Determine which schema is used
|
|
535
|
+
getContainerSchema(block: Block): 'layout' | 'content' | null
|
|
536
|
+
// Returns 'layout' if block has layout property, else 'content'
|
|
537
|
+
|
|
538
|
+
// Check for children
|
|
539
|
+
hasChildren(block: Block): boolean
|
|
540
|
+
|
|
541
|
+
// Get children array
|
|
542
|
+
getChildren(block: Block): Block[]
|
|
543
|
+
// Returns [] for non-containers or empty containers
|
|
544
|
+
|
|
545
|
+
// Set children (immutably)
|
|
546
|
+
setChildren(block: Block, children: Block[]): Block
|
|
547
|
+
// Returns new block with updated children at correct schema path
|
|
548
|
+
|
|
549
|
+
// Get path array for children (used by Canvas for deep updates)
|
|
550
|
+
getChildrenPath(block: Block): string[]
|
|
551
|
+
// Returns ['layout', 'content', 'blocks'] or ['content', 'content', 'blocks']
|
|
552
|
+
|
|
553
|
+
// Recursive block search
|
|
554
|
+
findBlockById(blocks: Block[], id: string): Block | null
|
|
555
|
+
|
|
556
|
+
// Find parent container of a block
|
|
557
|
+
findParentContainer(blocks: Block[], childId: string): Block | null
|
|
558
|
+
|
|
559
|
+
// Recursive immutable block update
|
|
560
|
+
updateBlockInTree(blocks: Block[], blockId: string, updates: Partial<Block>): Block[]
|
|
561
|
+
|
|
562
|
+
// Cycle detection for drag-and-drop
|
|
563
|
+
wouldCreateCycle(blocks: Block[], draggedId: string, targetId: string): boolean
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
#### Why This Abstraction Matters
|
|
567
|
+
|
|
568
|
+
Without the adapter, every component that touches container children would need to know about both schema variants. The adapter centralizes this knowledge so that Canvas, ContainerEdit, BlockLayers, dnd handlers, and the save pipeline all work through a single interface.
|
|
569
|
+
|
|
570
|
+
**Rule:** Never access `block.layout.content.blocks` or `block.content.content.blocks` directly. Always use `getChildren()` and `setChildren()`.
|
|
571
|
+
|
|
572
|
+
#### Container Adapter in Practice
|
|
573
|
+
|
|
574
|
+
The adapter is used across the entire plugin:
|
|
575
|
+
|
|
576
|
+
| Consumer | Functions Used | Purpose |
|
|
577
|
+
|----------|---------------|---------|
|
|
578
|
+
| `Canvas.tsx` | `isContainer`, `getChildren`, `getChildrenPath` | Recursive block rendering, path-based updates |
|
|
579
|
+
| `ContainerEdit.tsx` | `getContainerSchema`, `getChildren`, `setChildren` | Child CRUD operations, layout rendering |
|
|
580
|
+
| `BlockLayers.tsx` | `isContainer`, `getChildren` | Tree view hierarchy |
|
|
581
|
+
| `VisualBuilder.tsx` | `findBlockById`, `updateBlockInTree`, `findParentContainer`, `isContainer`, `getChildren`, `setChildren` | Selection, updates, insert logic |
|
|
582
|
+
| `useDndHandlers.ts` | `isContainer`, `getChildren`, `setChildren`, `getChildrenPath`, `wouldCreateCycle` | Cross-container moves, cycle prevention |
|
|
583
|
+
| `useBlockKeyboardShortcuts.ts` | (via blockOperations) | Delete, duplicate, move operations |
|
|
584
|
+
|
|
585
|
+
#### Path-Based Deep Updates
|
|
586
|
+
|
|
587
|
+
The Canvas uses a path-based approach for deeply nested updates. A path like `['0', 'layout', 'content', 'blocks', '2', 'content', 'content', 'blocks']` describes how to reach a specific nested block array. The `updateBlocksAtPath()` function walks this path and applies an updater function at the target level, returning a new immutable tree.
|
|
588
|
+
|
|
589
|
+
### 8. Drag and Drop (dnd-kit)
|
|
590
|
+
|
|
591
|
+
The plugin uses `@dnd-kit/core` and `@dnd-kit/sortable` for drag-and-drop.
|
|
592
|
+
|
|
593
|
+
#### Architecture
|
|
594
|
+
|
|
595
|
+
```
|
|
596
|
+
DndContext (in VisualBuilder)
|
|
597
|
+
├── sensors: [PointerSensor { distance: 8 }]
|
|
598
|
+
├── collisionDetection: pointerWithin
|
|
599
|
+
├── onDragStart → setActiveId
|
|
600
|
+
├── onDragOver → cycle detection + visual feedback
|
|
601
|
+
└── onDragEnd → block reorder/move
|
|
602
|
+
├── SortableContext (root blocks in Canvas)
|
|
603
|
+
│ └── SortableBlockCard[] (useSortable per block)
|
|
604
|
+
└── SortableContext (per container in ContainerEdit)
|
|
605
|
+
└── SortableChildBlock[] (useSortable per child)
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
#### Key Implementation Details
|
|
609
|
+
|
|
610
|
+
**PointerSensor activation:** 8px movement threshold before drag activates. This allows regular clicks to work on blocks without accidentally triggering drags.
|
|
611
|
+
|
|
612
|
+
**Collision detection:** `pointerWithin` is used instead of `closestCenter` because it provides more accurate hit detection for nested containers.
|
|
613
|
+
|
|
614
|
+
**Cycle prevention:** Before any drop, `wouldCreateCycle()` checks whether dropping block A into block B would create a cycle (e.g., dropping a container into itself or its descendants). The check uses `isDescendant()` recursive traversal. Invalid drop targets get visual feedback (red highlight) via the `invalidDropTarget` state.
|
|
615
|
+
|
|
616
|
+
**Cross-container moves:** When moving a block between containers:
|
|
617
|
+
1. Remove the block from its source container
|
|
618
|
+
2. Recalculate the target container's children path (indices may have shifted)
|
|
619
|
+
3. Insert the block at the target position
|
|
620
|
+
|
|
621
|
+
**New block drops from library:** The BlockLibrary supports dragging new blocks onto the canvas. New blocks have IDs prefixed with `library-{blockType}`. On drop, `createBlock(blockType)` generates a fresh block with a new UUID.
|
|
622
|
+
|
|
623
|
+
**DragOverlay:** Shows a `BlockCardPreview` of the dragged block floating under the cursor.
|
|
624
|
+
|
|
625
|
+
#### Container-Specific Drop Zones
|
|
626
|
+
|
|
627
|
+
Each container provides:
|
|
628
|
+
- `ContainerEmptyDropZone` -- Shown when container has no children (dashed border, "Add blocks" or "Drop block here")
|
|
629
|
+
- `ContainerEndDropZone` -- Shown at the end of children during drag (thin bar indicator)
|
|
630
|
+
- Per-child drop indicators -- Horizontal or vertical lines based on container layout direction
|
|
631
|
+
|
|
632
|
+
#### Layout-Aware Sorting
|
|
633
|
+
|
|
634
|
+
ContainerEdit uses layout-aware sorting strategies:
|
|
635
|
+
- `verticalListSortingStrategy` for column and grid layouts (default)
|
|
636
|
+
- `horizontalListSortingStrategy` for row layouts
|
|
637
|
+
|
|
638
|
+
Drop indicators match the layout direction:
|
|
639
|
+
- Column/grid layouts: horizontal line above the drop position
|
|
640
|
+
- Row layouts: vertical line to the left of the drop position
|
|
641
|
+
|
|
642
|
+
Both indicator types use a 4px thick blue (#3b82f6) bar with a glow effect (`box-shadow: 0 0 8px rgba(59, 130, 246, 0.6)`).
|
|
643
|
+
|
|
644
|
+
#### Block Library Drag Source
|
|
645
|
+
|
|
646
|
+
The `BlockLibrary` component allows dragging new block types from the sidebar onto the canvas. Library items are configured as drag sources with:
|
|
647
|
+
- `id: 'library-{blockType}'` -- Unique ID format for distinguishing from existing blocks
|
|
648
|
+
- `data: { type: 'new-block', blockType }` -- Data payload for the drop handler
|
|
649
|
+
|
|
650
|
+
On drop, the `handleDragEnd` in `useDndHandlers` detects the `new-block` type, creates a fresh block via `createBlock()`, and inserts it at the target position.
|
|
651
|
+
|
|
652
|
+
### 9. History and Undo/Redo
|
|
653
|
+
|
|
654
|
+
#### HistoryContext (`HistoryContext.tsx`)
|
|
655
|
+
|
|
656
|
+
A React context providing undo/redo functionality with these safety measures:
|
|
657
|
+
|
|
658
|
+
**Deep cloning:** Uses `JSON.parse(JSON.stringify(blocks))` for deep cloning. Not `structuredClone`, because the blocks may contain non-cloneable references. The `safeCloneBlocks()` utility handles this with validation.
|
|
659
|
+
|
|
660
|
+
**Validation:** Every snapshot is validated before recording AND before restoring. If validation fails, the operation is rejected with a console error.
|
|
661
|
+
|
|
662
|
+
**Maximum entries:** 50 entries maximum. When exceeded, oldest entries are pruned.
|
|
663
|
+
|
|
664
|
+
**Cursor-based navigation:** The history maintains an array of entries and a cursor. Undo decrements the cursor, redo increments it. New edits truncate everything after the current cursor (destroying redo history).
|
|
665
|
+
|
|
666
|
+
#### HistoryEntry Structure
|
|
667
|
+
|
|
668
|
+
```typescript
|
|
669
|
+
interface HistoryEntry {
|
|
670
|
+
id: string // crypto.randomUUID()
|
|
671
|
+
timestamp: number // Date.now()
|
|
672
|
+
blocks: Block[] // Deep-cloned block state
|
|
673
|
+
operation: OperationType // 'initial' | 'insert' | 'remove' | 'move' | 'duplicate' | 'update' | 'batch'
|
|
674
|
+
label: string // Human-readable label
|
|
675
|
+
}
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
#### Text Edit Batching (`useTextEditHistory`)
|
|
679
|
+
|
|
680
|
+
Rapid keystrokes during text editing would create one history entry per keystroke. The `useTextEditHistory` hook batches them:
|
|
681
|
+
|
|
682
|
+
1. First keystroke calls `startBatch()` -- clones current blocks as "before" state
|
|
683
|
+
2. Subsequent keystrokes within 1000ms reset the debounce timer
|
|
684
|
+
3. After 1000ms of inactivity OR explicit `flushPending()` -- records a single "Edit text" entry
|
|
685
|
+
4. Non-text changes (structural operations) auto-flush any pending text batch first
|
|
686
|
+
|
|
687
|
+
**Flush triggers:**
|
|
688
|
+
- Debounce timeout (1000ms)
|
|
689
|
+
- Focus leaves the VisualBuilder container (onBlur)
|
|
690
|
+
- Non-text structural change (move, delete, insert)
|
|
691
|
+
|
|
692
|
+
#### Undo/Redo Behavior
|
|
693
|
+
|
|
694
|
+
**Undo** restores the previous state WITHOUT triggering a save. The rationale: undo is exploratory. The user may undo several steps just to look, then redo back. Only when the user makes a new edit from an undone state does a save get queued.
|
|
695
|
+
|
|
696
|
+
**Redo** similarly restores without saving.
|
|
697
|
+
|
|
698
|
+
**History-aware save status:** The `SaveStatusIndicator` compares the current history cursor with the last saved cursor to determine if the user is exploring history (viewing a state older than what was saved).
|
|
699
|
+
|
|
700
|
+
### 10. Save Strategy
|
|
701
|
+
|
|
702
|
+
#### Direct API Approach
|
|
703
|
+
|
|
704
|
+
The visual builder does NOT use Payload's form state for writes. Instead:
|
|
705
|
+
|
|
706
|
+
```typescript
|
|
707
|
+
// In VisualBuilder.tsx
|
|
708
|
+
const saveBlocks = async (blocksToSave: Block[]) => {
|
|
709
|
+
const response = await fetch(
|
|
710
|
+
`/api/${collectionSlug}/${documentId}?depth=0`,
|
|
711
|
+
{
|
|
712
|
+
method: 'PATCH',
|
|
713
|
+
headers: { 'Content-Type': 'application/json' },
|
|
714
|
+
body: JSON.stringify({ layout: { blocks: blocksToSave } }),
|
|
715
|
+
}
|
|
716
|
+
)
|
|
717
|
+
// ... error handling
|
|
718
|
+
}
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
#### Why NOT Form State
|
|
722
|
+
|
|
723
|
+
Payload's form state hooks (`useField`, `useForm`) trigger React re-renders on every field change. In a visual editor with many blocks, each containing multiple fields, this creates render cascades that degrade performance. By managing local state and saving via direct API calls, the visual builder achieves:
|
|
724
|
+
|
|
725
|
+
- **Instant UI updates:** `setBlocks()` updates local state immediately
|
|
726
|
+
- **Debounced saves:** Only one API call per 500ms burst of changes
|
|
727
|
+
- **No form re-renders:** Block changes don't cascade through Payload's form system
|
|
728
|
+
- **Optimistic UI:** Changes appear instantly; save happens in the background
|
|
729
|
+
|
|
730
|
+
#### useSaveQueue Hook
|
|
731
|
+
|
|
732
|
+
```typescript
|
|
733
|
+
const { status, queueSave, retry } = useSaveQueue(saveFn, {
|
|
734
|
+
debounceMs: 500, // Wait 500ms after last change
|
|
735
|
+
savedDisplayMs: 2000, // Show "Saved" status for 2s
|
|
736
|
+
onSuccess: () => { ... }, // Called after successful save
|
|
737
|
+
onError: (err) => { ... }, // Called on save failure
|
|
738
|
+
})
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
**Save states:** `idle` -> `dirty` -> `saving` -> `saved` -> `idle` (or `error`)
|
|
742
|
+
|
|
743
|
+
**Queue behavior:**
|
|
744
|
+
1. `queueSave(data)` stores the latest data and starts a debounce timer
|
|
745
|
+
2. After 500ms of no new calls, `executeSave()` fires
|
|
746
|
+
3. If a new `queueSave()` arrives during save, it stores as pending
|
|
747
|
+
4. After save completes, if pending data exists, it saves again
|
|
748
|
+
5. Only one request is in-flight at a time
|
|
749
|
+
|
|
750
|
+
**Error handling:** On save failure, the pending data is preserved for retry. The `retry()` function re-attempts the last failed save.
|
|
751
|
+
|
|
752
|
+
#### Unsaved Changes Warning
|
|
753
|
+
|
|
754
|
+
The `useUnsavedChanges` hook sets up a `beforeunload` event listener when the document has unsaved changes, warning users before navigating away.
|
|
755
|
+
|
|
756
|
+
### 11. Keyboard Shortcuts
|
|
757
|
+
|
|
758
|
+
#### Block Operations (`useBlockKeyboardShortcuts`)
|
|
759
|
+
|
|
760
|
+
| Shortcut | Action | Condition |
|
|
761
|
+
|----------|--------|-----------|
|
|
762
|
+
| Delete / Backspace | Delete selected block | Block selected, not in editable |
|
|
763
|
+
| Cmd+D | Duplicate selected block | Block selected, not in editable |
|
|
764
|
+
| Cmd+ArrowUp | Move block up | Block selected, not in editable |
|
|
765
|
+
| Cmd+ArrowDown | Move block down | Block selected, not in editable |
|
|
766
|
+
| Escape | Clear selection | Any time |
|
|
767
|
+
|
|
768
|
+
**Skip conditions:** All shortcuts (except Escape) are disabled when focus is in an input, textarea, or contenteditable element to avoid interfering with text editing.
|
|
769
|
+
|
|
770
|
+
#### Global Shortcuts (in VisualBuilder)
|
|
771
|
+
|
|
772
|
+
| Shortcut | Action |
|
|
773
|
+
|----------|--------|
|
|
774
|
+
| Cmd+Z | Undo |
|
|
775
|
+
| Cmd+Shift+Z | Redo |
|
|
776
|
+
| Cmd+P | Open preview modal |
|
|
777
|
+
|
|
778
|
+
These global shortcuts also skip when focus is in input/textarea/contenteditable.
|
|
779
|
+
|
|
780
|
+
#### Inline Text Shortcuts (in EditableText)
|
|
781
|
+
|
|
782
|
+
| Shortcut | Action | Condition |
|
|
783
|
+
|----------|--------|-----------|
|
|
784
|
+
| Cmd+B | Bold | `enableFormatting` is true |
|
|
785
|
+
| Cmd+I | Italic | `enableFormatting` is true |
|
|
786
|
+
| Cmd+K | Insert/remove link | `enableFormatting` is true |
|
|
787
|
+
| Enter | Submit (blur) | Single-line mode |
|
|
788
|
+
| Cmd+Enter | Submit (blur) | Multiline mode |
|
|
789
|
+
| Escape | Revert and blur | Always |
|
|
790
|
+
|
|
791
|
+
### 12. Responsive Preview
|
|
792
|
+
|
|
793
|
+
#### useViewportPreset Hook
|
|
794
|
+
|
|
795
|
+
```typescript
|
|
796
|
+
const VIEWPORT_PRESETS = {
|
|
797
|
+
desktop: { width: 1280, label: 'Desktop', icon: 'Monitor' },
|
|
798
|
+
tablet: { width: 768, label: 'Tablet', icon: 'Tablet' },
|
|
799
|
+
mobile: { width: 375, label: 'Mobile', icon: 'Smartphone' },
|
|
800
|
+
full: { width: null, label: 'Full Width', icon: 'Maximize2' },
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const { preset, setPreset, width } = useViewportPreset()
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
- `width: null` means no constraint (full canvas width)
|
|
807
|
+
- Default preset is `desktop` (1280px)
|
|
808
|
+
- No persistence -- resets on unmount
|
|
809
|
+
|
|
810
|
+
#### Canvas Viewport Containment
|
|
811
|
+
|
|
812
|
+
When a viewport width is set, the Canvas applies:
|
|
813
|
+
```css
|
|
814
|
+
max-width: ${viewportWidth}px;
|
|
815
|
+
margin: 0 auto;
|
|
816
|
+
transition: max-width 0.2s ease;
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
Dashed blue edge indicators (opacity 0.6) show the viewport boundaries.
|
|
820
|
+
|
|
821
|
+
#### ViewportSwitcher Component
|
|
822
|
+
|
|
823
|
+
A segmented control in the toolbar that shows the available presets and allows switching between them. Each button shows the preset's icon (Monitor, Tablet, Smartphone, Maximize2).
|
|
824
|
+
|
|
825
|
+
#### Preview Modal
|
|
826
|
+
|
|
827
|
+
`PreviewModal` shows a full-screen overlay with the block tree rendered at selected viewport sizes. It provides separate viewport switching independent of the editor viewport.
|
|
828
|
+
|
|
829
|
+
**Preview viewport widths:**
|
|
830
|
+
```typescript
|
|
831
|
+
const PREVIEW_VIEWPORT_WIDTHS = {
|
|
832
|
+
desktop: 1280,
|
|
833
|
+
tablet: 768,
|
|
834
|
+
mobile: 375,
|
|
835
|
+
}
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
The preview modal renders blocks via `PreviewBlockRenderer`, which renders blocks without editing chrome (no BlockWrapper, no inline editing). This provides a clean preview of the final layout.
|
|
839
|
+
|
|
840
|
+
### 13. CSS Architecture
|
|
841
|
+
|
|
842
|
+
#### Plugin CSS Variables (`--vb-*`)
|
|
843
|
+
|
|
844
|
+
The plugin defines its own CSS custom properties in `tokens.css`:
|
|
845
|
+
|
|
846
|
+
```css
|
|
847
|
+
:root {
|
|
848
|
+
--vb-font-family: system-ui, ...;
|
|
849
|
+
--vb-font-size-base: 18px;
|
|
850
|
+
--vb-spacing-md: 16px;
|
|
851
|
+
--vb-color-primary: #3b82f6;
|
|
852
|
+
--vb-radius-md: 8px;
|
|
853
|
+
--vb-shadow-md: 0 4px 6px ...;
|
|
854
|
+
--vb-transition-fast: 150ms ease;
|
|
855
|
+
/* ... */
|
|
856
|
+
}
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
**DEPRECATED:** The `tokens.css` file is deprecated in favor of the frontend's `--token-*` variables. The `VisualCanvas` component now aliases:
|
|
860
|
+
```css
|
|
861
|
+
--vb-color-primary: var(--token-color-primary, #3b82f6);
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
This means consuming projects that define `--token-*` variables get automatic parity between their frontend and the visual builder.
|
|
865
|
+
|
|
866
|
+
#### CSS Modules
|
|
867
|
+
|
|
868
|
+
Components use SCSS or CSS Modules for scoped styles:
|
|
869
|
+
- `visualBuilder.module.scss` -- Layout container, header, toolbar
|
|
870
|
+
- `tabbedSidebar.module.scss` -- Sidebar layout, icon rail, tab panel
|
|
871
|
+
- `blockLayers.module.scss` -- Tree view nodes, expand/collapse
|
|
872
|
+
- `blockLibrary.module.scss` -- Block library grid
|
|
873
|
+
- `visualCanvas.module.css` -- Canvas containment and token aliasing
|
|
874
|
+
- `viewportSwitcher.module.css` -- Preset buttons
|
|
875
|
+
- `previewModal.module.scss` -- Modal overlay
|
|
876
|
+
|
|
877
|
+
#### Block-Specific Styles
|
|
878
|
+
|
|
879
|
+
Each edit block has its own CSS Module exposed via `styles/blocks.ts`:
|
|
880
|
+
```typescript
|
|
881
|
+
export {
|
|
882
|
+
heroStyles,
|
|
883
|
+
containerStyles,
|
|
884
|
+
cardStyles,
|
|
885
|
+
richTextStyles,
|
|
886
|
+
mediaStyles,
|
|
887
|
+
buttonStyles,
|
|
888
|
+
// ...
|
|
889
|
+
} from './blocks'
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
These are CSS Module objects that edit blocks import for their visual treatment.
|
|
893
|
+
|
|
894
|
+
#### CSS Containment
|
|
895
|
+
|
|
896
|
+
The `VisualCanvas` component uses CSS containment to isolate the rendering context:
|
|
897
|
+
```css
|
|
898
|
+
.visualCanvas {
|
|
899
|
+
/* Style isolation from Payload admin */
|
|
900
|
+
isolation: isolate;
|
|
901
|
+
contain: content;
|
|
902
|
+
}
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
This prevents Payload's admin styles from bleeding into block rendering and vice versa.
|
|
906
|
+
|
|
907
|
+
#### Style Utilities
|
|
908
|
+
|
|
909
|
+
```typescript
|
|
910
|
+
// Class name merging (falsy-safe)
|
|
911
|
+
cn('base', isActive && 'active', undefined) // => 'base active'
|
|
912
|
+
|
|
913
|
+
// Read CSS variable
|
|
914
|
+
getCSSVar('vb-color-primary', '#3b82f6')
|
|
915
|
+
|
|
916
|
+
// Set CSS variable on element
|
|
917
|
+
setCSSVar(element, 'vb-color-primary', '#2563eb')
|
|
918
|
+
|
|
919
|
+
// TypeScript token access
|
|
920
|
+
tokens.colors.primary // => '#3b82f6'
|
|
921
|
+
getToken('colors.primary') // => '#3b82f6'
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
### 14. Thin Wrapper Pattern
|
|
925
|
+
|
|
926
|
+
Consuming applications mount the Visual Builder through a thin wrapper pattern.
|
|
927
|
+
|
|
928
|
+
#### How It Works
|
|
929
|
+
|
|
930
|
+
1. The plugin registers itself on target collections via `visualBuilderPlugin()`
|
|
931
|
+
2. The consuming app creates a thin wrapper component that re-exports the Visual Builder:
|
|
932
|
+
|
|
933
|
+
```typescript
|
|
934
|
+
// In consuming app (e.g., app/(payload)/admin/components/VisualBuilder.tsx)
|
|
935
|
+
export { VisualBuilder as default } from '@eab/payload-visual-builder/client'
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
3. Payload's import map resolution picks up this component for the custom document view tab
|
|
939
|
+
|
|
940
|
+
#### Plugin Import Map
|
|
941
|
+
|
|
942
|
+
The plugin uses Payload 3.x's import map system to inject the Visual Builder as a custom document view tab. When a user navigates to a document in an enabled collection, they see an additional "Visual Builder" tab alongside the default "Edit" tab.
|
|
943
|
+
|
|
944
|
+
#### Customization Points
|
|
945
|
+
|
|
946
|
+
- **Block registry:** Consumers can register additional edit blocks or override existing ones
|
|
947
|
+
- **CSS variables:** Setting `--token-*` variables in the admin context customizes the visual builder's appearance
|
|
948
|
+
- **Feature flags:** Individual features can be disabled via the config
|
|
949
|
+
- **Tab configuration:** The sidebar tabs can be customized (though defaults are provided)
|
|
950
|
+
- **Block categories:** The `BLOCK_CATEGORIES` and `getBlocksByCategory` utilities organize blocks in the library
|
|
951
|
+
|
|
952
|
+
### 15. Figma Parallels
|
|
953
|
+
|
|
954
|
+
The Visual Builder's architecture mirrors Figma's design patterns, which is intentional -- it informs the design of a future Figma-to-CMS importer.
|
|
955
|
+
|
|
956
|
+
| Visual Builder | Figma | Notes |
|
|
957
|
+
|----------------|-------|-------|
|
|
958
|
+
| Canvas | Figma canvas | Root rendering surface, click-to-deselect |
|
|
959
|
+
| Block selection (blue outline) | Node selection (blue outline) | Both use outline + box-shadow |
|
|
960
|
+
| Inspector panel | Right panel (Design/Prototype/Inspect) | Properties for selected element |
|
|
961
|
+
| BlockLayers panel | Layers panel | Hierarchical tree with expand/collapse |
|
|
962
|
+
| TabbedSidebar (Fields/Outline/Blocks) | Left sidebar (Layers/Assets/Pages) | Icon rail + panel content |
|
|
963
|
+
| Container nesting | Auto Layout frames | Both support recursive nesting |
|
|
964
|
+
| ContainerEdit (flex/grid layout) | Auto Layout settings | Direction, gap, alignment, padding |
|
|
965
|
+
| BlockWrapper hover/select chrome | Node hover/select chrome | Label pill, action buttons |
|
|
966
|
+
| Drag-and-drop reorder | Layer drag reorder | Within and across containers |
|
|
967
|
+
| Inline text editing (EditableText) | Text layer double-click editing | Direct content manipulation |
|
|
968
|
+
| Undo/redo (HistoryContext) | Undo/redo (Cmd+Z / Cmd+Shift+Z) | Snapshot-based history |
|
|
969
|
+
| Viewport presets | Device frames | Desktop/tablet/mobile preview |
|
|
970
|
+
| DragOverlay (ghost preview) | Drag ghost | Visual feedback during drag |
|
|
971
|
+
| BlockLibrary | Assets panel | Catalog of available components |
|
|
972
|
+
| Block categories | Component sections | Organizational grouping |
|
|
973
|
+
|
|
974
|
+
#### Implications for Figma Importer
|
|
975
|
+
|
|
976
|
+
Understanding these parallels means a Figma importer can map:
|
|
977
|
+
- Figma page structure -> Visual Builder block tree
|
|
978
|
+
- Figma Auto Layout frames -> Container blocks with layout settings
|
|
979
|
+
- Figma component instances -> Registered edit blocks
|
|
980
|
+
- Figma text layers -> EditableText or Lexical content
|
|
981
|
+
- Figma image fills -> EditableMedia values
|
|
982
|
+
- Figma component properties -> Block field values
|
|
983
|
+
|
|
984
|
+
The mapping rules in `payload-figma-mapping.md` formalize these correspondences.
|
|
985
|
+
|
|
986
|
+
### 16. Cross-References
|
|
987
|
+
|
|
988
|
+
This module connects to several other knowledge modules:
|
|
989
|
+
|
|
990
|
+
- **`payload-blocks.md`** -- Block configurations (slug, fields, tabs) that the edit block system renders. Every `EditBlockComponent` corresponds to a block config defined in this module. Container schemas (layout vs. content) documented here are the same schemas the container adapter handles.
|
|
991
|
+
|
|
992
|
+
- **`payload-figma-mapping.md`** -- Mapping rules that determine which Figma components become which block types. The visual builder's edit block registry mirrors the block catalog from this mapping. Container nesting rules translate between Figma Auto Layout and the Container block's flex/grid settings.
|
|
993
|
+
|
|
994
|
+
- **`design-to-code-layout.md`** -- Auto Layout to CSS Flexbox mapping. ContainerEdit renders the same CSS flexbox/grid properties documented in this module: flex-direction, align-items, justify-content, gap, grid-template-columns. The visual builder makes these properties editable via ContainerControls.
|
|
995
|
+
|
|
996
|
+
- **`css-strategy.md`** -- The three-layer CSS architecture (Tailwind + Custom Properties + CSS Modules). The visual builder's CSS architecture follows this strategy: `--vb-*` custom properties for tokens, CSS Modules for scoped component styles, and the `--token-*` variable bridge for frontend parity.
|
|
997
|
+
|
|
998
|
+
- **`design-tokens.md`** -- Token extraction pipeline and CSS rendering. The visual builder's `tokens.css` and `styles/utils.ts` implement the same token categories (colors, typography, spacing, radii, shadows) documented in this module. The deprecated `--vb-*` to `--token-*` migration reflects the tokens module's single-source-of-truth principle.
|
|
999
|
+
|
|
1000
|
+
- **`figma-api-plugin.md`** -- Figma plugin sandbox model and development patterns. The visual builder's plugin architecture (two-entry pattern, configuration factory) parallels Figma's plugin model. Both use message-passing (Figma: main/UI threads; Visual Builder: server/client entries) and registration patterns.
|
|
1001
|
+
|
|
1002
|
+
- **`design-to-code-semantic.md`** -- Semantic HTML generation. ContainerEdit supports dynamic HTML tags (section, article, div, nav, aside) matching the semantic element mapping documented in this module. The visual builder preserves semantic structure during visual editing.
|
|
1003
|
+
|
|
1004
|
+
- **`design-to-code-visual.md`** -- Visual property mapping (fills, strokes, effects). Edit blocks like HeroEdit apply visual properties (background images, overlays, shadows) that correspond to the Figma visual properties documented in this module.
|