1061 lines
29 KiB
Markdown
1061 lines
29 KiB
Markdown
# GrapesJS Editor Integration
|
|
|
|
React wrapper component for GrapesJS WYSIWYG editor with forwardRef pattern, custom block registration, and keyboard shortcuts.
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
The GrapesJS Editor component provides a production-ready integration of the GrapesJS page builder library into the Changemaker Lite admin interface. It handles initialization, plugin configuration, custom block registration, and save orchestration.
|
|
|
|
### Key Features
|
|
|
|
- **forwardRef Pattern**: Parent components trigger save via ref handle
|
|
- **Custom Block Library**: Register campaign-specific blocks from database
|
|
- **Plugin Ecosystem**: 10+ GrapesJS plugins pre-configured
|
|
- **Keyboard Shortcuts**: Ctrl+S (Cmd+S on Mac) to save
|
|
- **Error Boundary**: Graceful fallback on initialization failure
|
|
- **Mobile Detection**: Desktop-only warning for small screens
|
|
- **Video Block Support**: Placeholder generation for media library videos
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```mermaid
|
|
graph TD
|
|
A[LandingPageEditor] -->|ref| B[GrapesJSEditor]
|
|
B -->|useImperativeHandle| C[triggerSave handle]
|
|
B --> D[grapesjs.init]
|
|
D --> E[Load Plugins]
|
|
E --> F[Register Custom Blocks]
|
|
F --> G[Load Initial Data]
|
|
G --> H[Canvas Ready]
|
|
|
|
A -->|handleSave| I[editorRef.current.triggerSave]
|
|
I --> J[Commands.run save-page]
|
|
J --> K[getProjectData + getHtml + getCss]
|
|
K --> L[onSave callback]
|
|
L --> M[API PUT /pages/:id]
|
|
|
|
style B fill:#9d4edd
|
|
style D fill:#3498db
|
|
style M fill:#2ecc71
|
|
```
|
|
|
|
**Flow:**
|
|
|
|
1. **Mount**: LandingPageEditor creates ref, renders GrapesJSEditor
|
|
2. **Init**: GrapesJSEditor calls `grapesjs.init()` → Loads plugins
|
|
3. **Blocks**: Registers custom blocks from PageBlock library
|
|
4. **Data**: Loads `initialData` (GrapesJS projectData JSON)
|
|
5. **Expose**: `useImperativeHandle` exposes `triggerSave()` method
|
|
6. **Save**: Parent calls `editorRef.current.triggerSave()` → Runs `save-page` command
|
|
7. **Callback**: GrapesJS extracts HTML/CSS → Calls `onSave()` → Parent saves to API
|
|
|
|
---
|
|
|
|
## Component API
|
|
|
|
### Props
|
|
|
|
```typescript
|
|
interface GrapesJSEditorProps {
|
|
initialData?: Record<string, unknown>;
|
|
onSave: (data: { projectData: Record<string, unknown>; html: string; css: string }) => void;
|
|
customBlocks?: PageBlock[];
|
|
}
|
|
```
|
|
|
|
**Fields:**
|
|
|
|
- **`initialData`** (optional): GrapesJS `projectData` JSON from previous save
|
|
- Contains components tree, styles, assets
|
|
- Empty object `{}` for new pages
|
|
- **`onSave`** (required): Callback when save triggered
|
|
- Receives `{ projectData, html, css }`
|
|
- Parent responsibility: Send to API
|
|
- **`customBlocks`** (optional): Array of PageBlock records from database
|
|
- Registered as draggable blocks in left panel
|
|
- See [Block Library](block-library.md) for schema
|
|
|
|
### Ref Handle
|
|
|
|
```typescript
|
|
interface GrapesJSEditorHandle {
|
|
triggerSave: () => void;
|
|
}
|
|
```
|
|
|
|
**Method:**
|
|
|
|
- **`triggerSave()`**: Programmatically trigger save command
|
|
- Extracts current editor state
|
|
- Calls `onSave` callback
|
|
- Used by parent's "Save" button or keyboard shortcut
|
|
|
|
### Usage Example
|
|
|
|
```typescript
|
|
import { useRef } from 'react';
|
|
import GrapesJSEditor, { GrapesJSEditorHandle } from '@/components/GrapesJSEditor';
|
|
|
|
function MyEditor() {
|
|
const editorRef = useRef<GrapesJSEditorHandle>(null);
|
|
|
|
const handleSave = async (data) => {
|
|
await api.put('/pages/123', {
|
|
blocks: data.projectData,
|
|
htmlOutput: data.html,
|
|
cssOutput: data.css,
|
|
});
|
|
};
|
|
|
|
const handleManualSave = () => {
|
|
editorRef.current?.triggerSave();
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<button onClick={handleManualSave}>Save</button>
|
|
<GrapesJSEditor
|
|
ref={editorRef}
|
|
initialData={page.blocks}
|
|
onSave={handleSave}
|
|
customBlocks={blocks}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## GrapesJS Configuration
|
|
|
|
### Initialization Options
|
|
|
|
```typescript
|
|
const editor = grapesjs.init({
|
|
container: containerRef.current,
|
|
height: '100%',
|
|
width: 'auto',
|
|
storageManager: false, // No localStorage persistence (managed by API)
|
|
plugins: [
|
|
blocksBasicPlugin,
|
|
presetWebpagePlugin,
|
|
formsPlugin,
|
|
navbarPlugin,
|
|
countdownPlugin,
|
|
tabsPlugin,
|
|
typedPlugin,
|
|
customCodePlugin,
|
|
exportPlugin,
|
|
styleGradientPlugin,
|
|
touchPlugin,
|
|
],
|
|
pluginsOpts: {
|
|
[blocksBasicPlugin]: { flexGrid: true },
|
|
// ... other plugin options
|
|
},
|
|
canvas: {
|
|
styles: [
|
|
'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',
|
|
],
|
|
},
|
|
});
|
|
```
|
|
|
|
**Key Settings:**
|
|
|
|
- **`storageManager: false`**: Disables auto-save to localStorage (we use API persistence)
|
|
- **`height: '100%'`**: Fills parent container (full-screen editor)
|
|
- **`canvas.styles`**: Injects Google Fonts into preview iframe
|
|
|
|
### Plugins Ecosystem
|
|
|
|
| Plugin | Purpose | Features |
|
|
|--------|---------|----------|
|
|
| `grapesjs-blocks-basic` | Basic blocks | Section, text, image, video, map, link, flexGrid |
|
|
| `grapesjs-preset-webpage` | Full page presets | Header, footer, hero templates |
|
|
| `grapesjs-plugin-forms` | Form components | Input, textarea, select, button, checkbox, radio |
|
|
| `grapesjs-navbar` | Navigation bars | Responsive navbar with dropdowns |
|
|
| `grapesjs-component-countdown` | Countdown timers | Event countdown with custom styling |
|
|
| `grapesjs-tabs` | Tab panels | Horizontal/vertical tab containers |
|
|
| `grapesjs-typed` | Typing animation | Typewriter text effect |
|
|
| `grapesjs-custom-code` | Embed raw HTML/JS | Custom code blocks (advanced users) |
|
|
| `grapesjs-plugin-export` | Export templates | ZIP download of HTML/CSS/assets |
|
|
| `grapesjs-style-gradient` | Gradient editor | Visual gradient picker for backgrounds |
|
|
| `grapesjs-touch` | Touch support | Mobile/tablet drag-and-drop (experimental) |
|
|
|
|
**Installation:**
|
|
|
|
```bash
|
|
cd admin && npm install \
|
|
grapesjs \
|
|
grapesjs-blocks-basic \
|
|
grapesjs-preset-webpage \
|
|
grapesjs-plugin-forms \
|
|
grapesjs-navbar \
|
|
grapesjs-component-countdown \
|
|
grapesjs-tabs \
|
|
grapesjs-typed \
|
|
grapesjs-custom-code \
|
|
grapesjs-plugin-export \
|
|
grapesjs-style-gradient \
|
|
grapesjs-touch
|
|
```
|
|
|
|
---
|
|
|
|
## Custom Blocks Registration
|
|
|
|
### Block Registration Flow
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant API as API Database
|
|
participant Parent as LandingPageEditor
|
|
participant Editor as GrapesJSEditor
|
|
participant GJS as GrapesJS
|
|
|
|
Parent->>API: GET /api/page-blocks
|
|
API-->>Parent: PageBlock[]
|
|
Parent->>Editor: <GrapesJSEditor customBlocks={blocks} />
|
|
Editor->>Editor: useEffect(() => init)
|
|
Editor->>GJS: grapesjs.init()
|
|
GJS-->>Editor: editor instance
|
|
Editor->>Editor: Register custom blocks loop
|
|
loop For each block
|
|
Editor->>Editor: generateBlockHtml(type, defaults)
|
|
Editor->>GJS: BlockManager.add(id, config)
|
|
end
|
|
GJS-->>Editor: Blocks ready
|
|
```
|
|
|
|
### Block Generation Logic
|
|
|
|
```typescript
|
|
// From GrapesJSEditor.tsx
|
|
const blockManager = editor.Blocks;
|
|
for (const block of customBlocks) {
|
|
const defaults = block.defaults as Record<string, unknown>;
|
|
const html = generateBlockHtml(block.type, defaults);
|
|
|
|
blockManager.add(`custom-${block.type}`, {
|
|
label: block.label,
|
|
category: block.category || 'Campaign',
|
|
content: html,
|
|
});
|
|
}
|
|
```
|
|
|
|
**Example Block:**
|
|
|
|
```typescript
|
|
// From seed.ts
|
|
{
|
|
id: 'default-hero',
|
|
type: 'hero',
|
|
label: 'Hero Section',
|
|
category: 'Headers',
|
|
defaults: {
|
|
title: 'Welcome to Our Campaign',
|
|
subtitle: 'Join us in making a difference.',
|
|
ctaText: 'Get Involved',
|
|
ctaUrl: '#',
|
|
},
|
|
}
|
|
```
|
|
|
|
**Generated HTML:**
|
|
|
|
```html
|
|
<section style="padding: 80px 40px; text-align: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: #fff;">
|
|
<h1 style="font-size: 2.5rem; margin-bottom: 16px;">Welcome to Our Campaign</h1>
|
|
<p style="font-size: 1.25rem; opacity: 0.85; margin-bottom: 32px;">Join us in making a difference.</p>
|
|
<a href="#" style="display: inline-block; padding: 12px 32px; background: #9d4edd; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600;">Get Involved</a>
|
|
</section>
|
|
```
|
|
|
|
### Built-In Block Templates
|
|
|
|
**1. Hero Section**
|
|
|
|
```typescript
|
|
case 'hero':
|
|
return `
|
|
<section style="padding: 80px 40px; text-align: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: #fff;">
|
|
<h1 style="font-size: 2.5rem; margin-bottom: 16px;">${defaults.title || 'Hero Title'}</h1>
|
|
<p style="font-size: 1.25rem; opacity: 0.85; margin-bottom: 32px;">${defaults.subtitle || 'Subtitle text here'}</p>
|
|
<a href="${defaults.ctaUrl || '#'}" style="display: inline-block; padding: 12px 32px; background: #9d4edd; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600;">${defaults.ctaText || 'Get Started'}</a>
|
|
</section>`;
|
|
```
|
|
|
|
**2. Text Block**
|
|
|
|
```typescript
|
|
case 'text':
|
|
return `
|
|
<section style="padding: 60px 40px; max-width: 800px; margin: 0 auto;">
|
|
<h2 style="font-size: 1.75rem; margin-bottom: 16px;">${defaults.heading || 'Heading'}</h2>
|
|
<p style="font-size: 1rem; line-height: 1.7; opacity: 0.85;">${defaults.body || 'Body text goes here.'}</p>
|
|
</section>`;
|
|
```
|
|
|
|
**3. Features Grid**
|
|
|
|
```typescript
|
|
case 'features': {
|
|
const features = (defaults.features as Array<{ title: string; description: string }>) || [];
|
|
const featureHtml = features.map(f => `
|
|
<div style="flex: 1; min-width: 250px; padding: 24px; text-align: center;">
|
|
<h3 style="font-size: 1.25rem; margin-bottom: 8px;">${f.title}</h3>
|
|
<p style="opacity: 0.8;">${f.description}</p>
|
|
</div>`).join('');
|
|
|
|
return `
|
|
<section style="padding: 60px 40px;">
|
|
<div style="display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;">
|
|
${featureHtml}
|
|
</div>
|
|
</section>`;
|
|
}
|
|
```
|
|
|
|
**4. Call to Action**
|
|
|
|
```typescript
|
|
case 'cta':
|
|
return `
|
|
<section style="padding: 60px 40px; text-align: center; background: linear-gradient(135deg, #9d4edd 0%, #7b2cbf 100%); color: #fff;">
|
|
<h2 style="font-size: 2rem; margin-bottom: 12px;">${defaults.heading || 'Call to Action'}</h2>
|
|
<p style="font-size: 1.1rem; margin-bottom: 24px; opacity: 0.9;">${defaults.description || 'Description here'}</p>
|
|
<a href="${defaults.buttonUrl || '#'}" style="display: inline-block; padding: 12px 32px; background: #fff; color: #9d4edd; text-decoration: none; border-radius: 6px; font-weight: 600;">${defaults.buttonText || 'Click Here'}</a>
|
|
</section>`;
|
|
```
|
|
|
|
**5. Video Block**
|
|
|
|
```typescript
|
|
case 'video': {
|
|
const videoId = defaults.videoId || 'PLACEHOLDER';
|
|
const playerType = defaults.playerType || 'standard';
|
|
|
|
return `
|
|
<section style="padding: 60px 40px;">
|
|
<div class="video-block"
|
|
data-video-id="${videoId}"
|
|
data-player-type="${playerType}"
|
|
data-autoplay="${defaults.autoplay || false}"
|
|
data-controls="${defaults.controls !== false}"
|
|
data-show-reactions="${defaults.showReactions !== false}"
|
|
style="max-width: 100%; margin: 0 auto;">
|
|
<div class="video-placeholder" style="aspect-ratio: 16/9; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; display: flex; align-items: center; justify-content: center;">
|
|
<div style="text-align: center; color: #fff; padding: 24px;">
|
|
<svg style="width: 64px; height: 64px; margin-bottom: 16px; opacity: 0.9;" fill="currentColor" viewBox="0 0 20 20">
|
|
<path d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" />
|
|
</svg>
|
|
<p style="margin: 0; font-size: 1.1rem; font-weight: 600;">Video Player</p>
|
|
<p style="margin: 8px 0 0; font-size: 0.9rem; opacity: 0.8;">ID: ${videoId}</p>
|
|
<p style="margin: 4px 0 0; font-size: 0.85rem; opacity: 0.7;">${playerType === 'advanced' ? 'Advanced Player (with reactions)' : 'Standard HTML5 Player'}</p>
|
|
<p style="margin: 12px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Video will render on published page</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>`;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Save Command Integration
|
|
|
|
### Command Registration
|
|
|
|
```typescript
|
|
// In useEffect() after editor init
|
|
editor.Commands.add('save-page', {
|
|
run(ed: Editor) {
|
|
const projectData = ed.getProjectData() as Record<string, unknown>;
|
|
const html = ed.getHtml();
|
|
const css = ed.getCss() || '';
|
|
onSaveRef.current({ projectData, html, css });
|
|
},
|
|
});
|
|
```
|
|
|
|
**Why `onSaveRef`?**
|
|
|
|
- Avoids stale closure over `onSave` prop
|
|
- Parent can update callback without re-initializing editor
|
|
- Pattern: `const onSaveRef = useRef(onSave); onSaveRef.current = onSave;`
|
|
|
|
### Keyboard Shortcut
|
|
|
|
```typescript
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
e.preventDefault();
|
|
editor.runCommand('save-page');
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
|
|
// Cleanup
|
|
return () => {
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
editor.destroy();
|
|
};
|
|
```
|
|
|
|
**Shortcuts:**
|
|
|
|
- **Windows/Linux**: `Ctrl+S`
|
|
- **macOS**: `Cmd+S`
|
|
|
|
**Behavior:**
|
|
|
|
- Prevents browser's default "Save Page As..." dialog
|
|
- Triggers GrapesJS save command
|
|
- Calls `onSave` callback with current state
|
|
|
|
---
|
|
|
|
## forwardRef Pattern
|
|
|
|
### Implementation
|
|
|
|
```typescript
|
|
const GrapesJSEditor = forwardRef<GrapesJSEditorHandle, GrapesJSEditorProps>(
|
|
function GrapesJSEditor({ initialData, onSave, customBlocks }, ref) {
|
|
const editorRef = useRef<Editor | null>(null);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
triggerSave() {
|
|
editorRef.current?.runCommand('save-page');
|
|
},
|
|
}));
|
|
|
|
// ... rest of component
|
|
}
|
|
);
|
|
```
|
|
|
|
### Parent Usage
|
|
|
|
```typescript
|
|
// In LandingPageEditor.tsx
|
|
import { useRef } from 'react';
|
|
|
|
const editorRef = useRef<GrapesJSEditorHandle>(null);
|
|
|
|
const handleManualSave = () => {
|
|
editorRef.current?.triggerSave(); // Programmatic save
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<button onClick={handleManualSave}>Save</button>
|
|
<GrapesJSEditor ref={editorRef} onSave={handleSave} />
|
|
</div>
|
|
);
|
|
```
|
|
|
|
**Why forwardRef?**
|
|
|
|
- Decouples save trigger from GrapesJS internals
|
|
- Parent controls when to save (toolbar button, auto-save timer, etc.)
|
|
- Cleaner API than prop drilling `onManualSave` callback
|
|
|
|
---
|
|
|
|
## Error Handling
|
|
|
|
### Error Boundary State
|
|
|
|
```typescript
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
try {
|
|
editor = grapesjs.init({ /* ... */ });
|
|
} catch (err) {
|
|
console.error('GrapesJS init error:', err);
|
|
setError('Failed to initialize the page editor. Please refresh the page.');
|
|
return;
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ff4d4f' }}>
|
|
{error}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Failure Modes:**
|
|
|
|
1. **Missing plugin:** GrapesJS throws error during `init()`
|
|
2. **Browser incompatibility:** Old browser doesn't support ES6 modules
|
|
3. **Memory exhaustion:** Very large `initialData` crashes tab
|
|
|
|
**Recovery:**
|
|
|
|
- Error state shows user-friendly message
|
|
- No infinite re-render (error doesn't trigger re-init)
|
|
- User can refresh page or report issue
|
|
|
|
### Parent-Level Fallback
|
|
|
|
```typescript
|
|
// In LandingPageEditor.tsx
|
|
import { ErrorBoundary } from 'react-error-boundary';
|
|
|
|
<ErrorBoundary
|
|
fallback={<div>Editor failed to load. Please try CODE mode.</div>}
|
|
onReset={() => navigate('/app/pages')}
|
|
>
|
|
<GrapesJSEditor ref={editorRef} onSave={handleSave} />
|
|
</ErrorBoundary>
|
|
```
|
|
|
|
**Cascade:**
|
|
|
|
1. GrapesJS init error → Internal error state
|
|
2. React render error → ErrorBoundary catches
|
|
3. User sees fallback → Can switch to CODE mode
|
|
|
|
---
|
|
|
|
## Mobile Detection
|
|
|
|
### Desktop-Only Warning
|
|
|
|
**Location:** `LandingPageEditor.tsx` (parent component)
|
|
|
|
```typescript
|
|
import { Grid } from 'antd';
|
|
|
|
const screens = Grid.useBreakpoint();
|
|
const isMobile = !screens.md; // md = 768px
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<Result
|
|
status="warning"
|
|
title="Desktop Required"
|
|
subTitle="The page editor requires a desktop or tablet device (minimum 768px width)."
|
|
extra={<Button onClick={() => navigate('/app/pages')}>Back to Pages</Button>}
|
|
/>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Why desktop-only?**
|
|
|
|
- GrapesJS drag-and-drop requires precise mouse interactions
|
|
- Small screens can't fit 3-panel layout (blocks, canvas, properties)
|
|
- Touch support experimental (grapesjs-touch plugin unstable)
|
|
|
|
**Alternative for mobile admins:**
|
|
|
|
- Use CODE mode (Monaco editor works on mobile)
|
|
- Edit on desktop, preview on mobile
|
|
- Use responsive design testing tools
|
|
|
|
---
|
|
|
|
## Data Flow Patterns
|
|
|
|
### Initial Load
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant DB as Database
|
|
participant API as API Service
|
|
participant Parent as LandingPageEditor
|
|
participant Editor as GrapesJSEditor
|
|
participant GJS as GrapesJS
|
|
|
|
Parent->>API: GET /api/pages/:id
|
|
API->>DB: SELECT blocks FROM landing_pages
|
|
DB-->>API: { blocks: {...} }
|
|
API-->>Parent: LandingPage JSON
|
|
Parent->>Editor: <GrapesJSEditor initialData={page.blocks} />
|
|
Editor->>GJS: editor.loadProjectData(initialData)
|
|
GJS-->>Editor: Canvas rendered
|
|
```
|
|
|
|
**Key Points:**
|
|
|
|
- `blocks` field contains full GrapesJS `projectData` (components tree, styles, assets)
|
|
- Empty object `{}` for new pages (GrapesJS shows blank canvas)
|
|
- Large JSON (50KB+) loads in ~200ms
|
|
|
|
### Save Flow
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant User as User
|
|
participant Parent as LandingPageEditor
|
|
participant Editor as GrapesJSEditor
|
|
participant GJS as GrapesJS
|
|
participant API as API
|
|
|
|
User->>User: Press Ctrl+S
|
|
User->>Editor: KeyboardEvent
|
|
Editor->>GJS: runCommand('save-page')
|
|
GJS->>GJS: getProjectData()
|
|
GJS->>GJS: getHtml()
|
|
GJS->>GJS: getCss()
|
|
GJS-->>Editor: { projectData, html, css }
|
|
Editor->>Parent: onSave(data)
|
|
Parent->>API: PUT /api/pages/:id
|
|
API-->>Parent: 200 OK
|
|
Parent->>User: "Page saved" notification
|
|
```
|
|
|
|
**Critical Detail:**
|
|
|
|
- `getProjectData()` returns full editor state (for future edits)
|
|
- `getHtml()` returns rendered HTML (for public display)
|
|
- `getCss()` returns compiled CSS (for public display)
|
|
- All three saved to database (different use cases)
|
|
|
|
---
|
|
|
|
## Code Examples
|
|
|
|
### Complete Integration Example
|
|
|
|
```typescript
|
|
// admin/src/pages/LandingPageEditor.tsx
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Button, message, Spin } from 'antd';
|
|
import { api } from '@/lib/api';
|
|
import GrapesJSEditor, { GrapesJSEditorHandle } from '@/components/GrapesJSEditor';
|
|
import type { LandingPage, PageBlock } from '@/types/api';
|
|
|
|
interface LandingPageEditorProps {
|
|
pageId: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export default function LandingPageEditor({ pageId, onClose }: LandingPageEditorProps) {
|
|
const navigate = useNavigate();
|
|
const editorRef = useRef<GrapesJSEditorHandle>(null);
|
|
const [page, setPage] = useState<LandingPage | null>(null);
|
|
const [blocks, setBlocks] = useState<PageBlock[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
try {
|
|
const [pageRes, blocksRes] = await Promise.all([
|
|
api.get<LandingPage>(`/pages/${pageId}`),
|
|
api.get<PageBlock[]>('/page-blocks'),
|
|
]);
|
|
setPage(pageRes.data);
|
|
setBlocks(blocksRes.data);
|
|
} catch {
|
|
message.error('Failed to load page');
|
|
onClose();
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
fetchData();
|
|
}, [pageId, onClose]);
|
|
|
|
const handleSave = async (data: { projectData: any; html: string; css: string }) => {
|
|
try {
|
|
await api.put(`/pages/${pageId}`, {
|
|
blocks: data.projectData,
|
|
htmlOutput: data.html,
|
|
cssOutput: data.css,
|
|
});
|
|
message.success('Page saved');
|
|
} catch {
|
|
message.error('Failed to save page');
|
|
}
|
|
};
|
|
|
|
const handleManualSave = () => {
|
|
editorRef.current?.triggerSave();
|
|
};
|
|
|
|
if (loading) return <Spin size="large" />;
|
|
if (!page) return null;
|
|
|
|
return (
|
|
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
<div style={{ padding: '12px 16px', borderBottom: '1px solid #d9d9d9' }}>
|
|
<Button onClick={onClose}>Back</Button>
|
|
<Button type="primary" onClick={handleManualSave} style={{ marginLeft: 8 }}>
|
|
Save (Ctrl+S)
|
|
</Button>
|
|
</div>
|
|
<GrapesJSEditor
|
|
ref={editorRef}
|
|
initialData={page.blocks as Record<string, unknown>}
|
|
onSave={handleSave}
|
|
customBlocks={blocks}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Custom Block Registration
|
|
|
|
```typescript
|
|
// Add a custom "Campaign Stats" block
|
|
const campaignStatsBlock: PageBlock = {
|
|
id: 'custom-campaign-stats',
|
|
type: 'campaign-stats',
|
|
label: 'Campaign Stats',
|
|
category: 'Campaign',
|
|
sortOrder: 10,
|
|
schema: {
|
|
volunteers: { type: 'number', label: 'Volunteers' },
|
|
emails: { type: 'number', label: 'Emails Sent' },
|
|
events: { type: 'number', label: 'Events' },
|
|
},
|
|
defaults: {
|
|
volunteers: 1250,
|
|
emails: 5400,
|
|
events: 32,
|
|
},
|
|
};
|
|
|
|
// GrapesJSEditor will auto-register via generateBlockHtml()
|
|
<GrapesJSEditor customBlocks={[campaignStatsBlock, ...otherBlocks]} />
|
|
```
|
|
|
|
### Adding Custom HTML Generation
|
|
|
|
```typescript
|
|
// In GrapesJSEditor.tsx generateBlockHtml() function
|
|
case 'campaign-stats': {
|
|
const volunteers = defaults.volunteers || 0;
|
|
const emails = defaults.emails || 0;
|
|
const events = defaults.events || 0;
|
|
|
|
return `
|
|
<section style="padding: 60px 40px; background: #f8f9fa; text-align: center;">
|
|
<h2 style="margin-bottom: 32px; font-size: 2rem;">Our Impact</h2>
|
|
<div style="display: flex; gap: 48px; justify-content: center; flex-wrap: wrap;">
|
|
<div>
|
|
<div style="font-size: 3rem; font-weight: 700; color: #9d4edd;">${volunteers.toLocaleString()}</div>
|
|
<div style="font-size: 1rem; color: #666;">Volunteers</div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size: 3rem; font-weight: 700; color: #9d4edd;">${emails.toLocaleString()}</div>
|
|
<div style="font-size: 1rem; color: #666;">Emails Sent</div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size: 3rem; font-weight: 700; color: #9d4edd;">${events}</div>
|
|
<div style="font-size: 1rem; color: #666;">Events</div>
|
|
</div>
|
|
</div>
|
|
</section>`;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Problem: Blocks Not Appearing in Left Panel
|
|
|
|
**Symptoms:**
|
|
|
|
- Custom blocks array passed to GrapesJSEditor
|
|
- Left panel shows default blocks only
|
|
- No campaign-specific blocks
|
|
|
|
**Causes:**
|
|
|
|
1. `generateBlockHtml()` missing case for block type
|
|
2. Category name mismatch
|
|
3. Block registration timing issue
|
|
|
|
**Solutions:**
|
|
|
|
1. **Add case to generateBlockHtml():**
|
|
```typescript
|
|
case 'my-custom-block':
|
|
return `<section>My custom block HTML</section>`;
|
|
```
|
|
|
|
2. **Check category:**
|
|
```typescript
|
|
// Block category: "Campaign"
|
|
// GrapesJS shows blocks in collapsible "Campaign" section
|
|
// Case-sensitive match
|
|
```
|
|
|
|
3. **Verify registration timing:**
|
|
```typescript
|
|
// Registration happens in useEffect after init
|
|
console.log('Registering blocks:', customBlocks.length);
|
|
```
|
|
|
|
4. **Inspect BlockManager:**
|
|
```typescript
|
|
// In browser console (after editor loads)
|
|
window.editor.BlockManager.getAll().forEach(b => console.log(b.id));
|
|
// Should include 'custom-hero', 'custom-text', etc.
|
|
```
|
|
|
|
---
|
|
|
|
### Problem: Save Not Triggering
|
|
|
|
**Symptoms:**
|
|
|
|
- Press Ctrl+S → Nothing happens
|
|
- Manual save button doesn't work
|
|
- `onSave` callback never called
|
|
|
|
**Causes:**
|
|
|
|
1. Keyboard event listener not registered
|
|
2. forwardRef not working
|
|
3. `save-page` command not registered
|
|
|
|
**Solutions:**
|
|
|
|
1. **Check keyboard listener:**
|
|
```typescript
|
|
// In GrapesJSEditor useEffect
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
console.log('Key pressed:', e.key, 'Ctrl:', e.ctrlKey);
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
console.log('Save shortcut triggered');
|
|
e.preventDefault();
|
|
editor.runCommand('save-page');
|
|
}
|
|
};
|
|
```
|
|
|
|
2. **Verify ref handle:**
|
|
```typescript
|
|
// In parent component
|
|
console.log('Editor ref:', editorRef.current); // Should be { triggerSave: fn }
|
|
```
|
|
|
|
3. **Test command directly:**
|
|
```typescript
|
|
// In browser console (after editor loads)
|
|
window.editor.runCommand('save-page');
|
|
// Should trigger onSave callback
|
|
```
|
|
|
|
4. **Check onSaveRef pattern:**
|
|
```typescript
|
|
const onSaveRef = useRef(onSave);
|
|
onSaveRef.current = onSave; // Update on every render
|
|
```
|
|
|
|
---
|
|
|
|
### Problem: Editor Crashes on Large Pages
|
|
|
|
**Symptoms:**
|
|
|
|
- Loading page with 100+ components → Tab freezes
|
|
- GrapesJS UI unresponsive
|
|
- Save takes 10+ seconds
|
|
|
|
**Causes:**
|
|
|
|
- Too many components in single page
|
|
- Deep nesting (10+ levels)
|
|
- Heavy images without lazy loading
|
|
|
|
**Solutions:**
|
|
|
|
1. **Split into multiple pages:**
|
|
- Separate hero, features, testimonials into 3 pages
|
|
- Link pages via navigation
|
|
|
|
2. **Use CODE mode for complex layouts:**
|
|
- Write HTML directly → Faster than GrapesJS rendering
|
|
- Import via "Sync Overrides"
|
|
|
|
3. **Optimize images:**
|
|
- Use external CDN (not base64-encoded)
|
|
- Compress before upload
|
|
- Lazy load below fold
|
|
|
|
4. **Increase browser memory:**
|
|
- Chrome → `--max-old-space-size=4096`
|
|
- Edge → Similar flag
|
|
|
|
---
|
|
|
|
### Problem: Initial Data Not Loading
|
|
|
|
**Symptoms:**
|
|
|
|
- Editor opens with blank canvas
|
|
- `initialData` prop has data
|
|
- Console shows no errors
|
|
|
|
**Causes:**
|
|
|
|
1. `loadProjectData()` called before editor ready
|
|
2. Invalid JSON structure
|
|
3. Async timing issue
|
|
|
|
**Solutions:**
|
|
|
|
1. **Check editor ready state:**
|
|
```typescript
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
|
|
const editor = grapesjs.init({ /* ... */ });
|
|
|
|
// Wait for editor load event
|
|
editor.on('load', () => {
|
|
if (initialData && Object.keys(initialData).length > 0) {
|
|
editor.loadProjectData(initialData);
|
|
}
|
|
});
|
|
}, []);
|
|
```
|
|
|
|
2. **Validate JSON:**
|
|
```typescript
|
|
console.log('Loading data:', JSON.stringify(initialData, null, 2));
|
|
// Should have keys: assets, styles, pages
|
|
```
|
|
|
|
3. **Handle empty data:**
|
|
```typescript
|
|
if (initialData && Object.keys(initialData).length > 0) {
|
|
editor.loadProjectData(initialData);
|
|
} else {
|
|
console.log('Starting with blank canvas');
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Problem: Styles Not Applying in Canvas
|
|
|
|
**Symptoms:**
|
|
|
|
- Drag block to canvas → No background color
|
|
- Text has wrong font
|
|
- Layout broken
|
|
|
|
**Causes:**
|
|
|
|
1. Inline styles not supported
|
|
2. External stylesheet missing
|
|
3. Canvas iframe CSP issue
|
|
|
|
**Solutions:**
|
|
|
|
1. **Use inline styles in generateBlockHtml():**
|
|
```typescript
|
|
// Good
|
|
return `<section style="padding: 40px; background: #f00;">...</section>`;
|
|
|
|
// Bad (requires CSS injection)
|
|
return `<section class="hero">...</section>`;
|
|
```
|
|
|
|
2. **Inject fonts into canvas:**
|
|
```typescript
|
|
canvas: {
|
|
styles: [
|
|
'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',
|
|
],
|
|
}
|
|
```
|
|
|
|
3. **Check iframe sandbox:**
|
|
```typescript
|
|
// GrapesJS canvas uses <iframe> — ensure no sandbox restrictions
|
|
// Default config works, but custom CSP may block
|
|
```
|
|
|
|
---
|
|
|
|
## Performance Optimization
|
|
|
|
### Lazy Loading
|
|
|
|
```typescript
|
|
// In LandingPageEditor.tsx
|
|
import { lazy, Suspense } from 'react';
|
|
|
|
const GrapesJSEditor = lazy(() => import('@/components/GrapesJSEditor'));
|
|
|
|
return (
|
|
<Suspense fallback={<Spin size="large" />}>
|
|
<GrapesJSEditor ref={editorRef} onSave={handleSave} />
|
|
</Suspense>
|
|
);
|
|
```
|
|
|
|
**Benefit:** Reduces initial bundle size by ~800KB (GrapesJS + plugins)
|
|
|
|
### Debounced Auto-Save
|
|
|
|
```typescript
|
|
import { useRef, useEffect } from 'react';
|
|
|
|
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
|
|
|
const handleEditorChange = () => {
|
|
clearTimeout(autoSaveTimerRef.current);
|
|
autoSaveTimerRef.current = setTimeout(() => {
|
|
editorRef.current?.triggerSave();
|
|
}, 5000); // Auto-save after 5s of inactivity
|
|
};
|
|
|
|
useEffect(() => {
|
|
// Listen to editor change events
|
|
const editor = window.editor; // Access via global (not recommended for prod)
|
|
editor?.on('component:update', handleEditorChange);
|
|
editor?.on('style:update', handleEditorChange);
|
|
|
|
return () => {
|
|
clearTimeout(autoSaveTimerRef.current);
|
|
editor?.off('component:update', handleEditorChange);
|
|
editor?.off('style:update', handleEditorChange);
|
|
};
|
|
}, []);
|
|
```
|
|
|
|
**Trade-off:** More API calls vs. reduced data loss risk
|
|
|
|
---
|
|
|
|
## Related Documentation
|
|
|
|
### Components
|
|
|
|
- **[LandingPageEditor](/v2/frontend/pages/LandingPageEditor)** — Full-screen editor wrapper
|
|
- **[LandingPagesPage](/v2/frontend/pages/LandingPagesPage)** — Table view with edit links
|
|
|
|
### Features
|
|
|
|
- **[Page Builder](page-builder.md)** — Complete page builder system
|
|
- **[Block Library](block-library.md)** — Custom blocks database
|
|
- **[MkDocs Export](mkdocs-export.md)** — Export to documentation site
|
|
|
|
### External
|
|
|
|
- **[GrapesJS Docs](https://grapesjs.com/docs/)** — Official documentation
|
|
- **[GrapesJS API](https://grapesjs.com/docs/api/)** — JavaScript API reference
|
|
- **[GrapesJS Plugins](https://grapesjs.com/docs/plugins/)** — Plugin ecosystem
|