29 KiB
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
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:
- Mount: LandingPageEditor creates ref, renders GrapesJSEditor
- Init: GrapesJSEditor calls
grapesjs.init()→ Loads plugins - Blocks: Registers custom blocks from PageBlock library
- Data: Loads
initialData(GrapesJS projectData JSON) - Expose:
useImperativeHandleexposestriggerSave()method - Save: Parent calls
editorRef.current.triggerSave()→ Runssave-pagecommand - Callback: GrapesJS extracts HTML/CSS → Calls
onSave()→ Parent saves to API
Component API
Props
interface GrapesJSEditorProps {
initialData?: Record<string, unknown>;
onSave: (data: { projectData: Record<string, unknown>; html: string; css: string }) => void;
customBlocks?: PageBlock[];
}
Fields:
initialData(optional): GrapesJSprojectDataJSON 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
- Receives
customBlocks(optional): Array of PageBlock records from database- Registered as draggable blocks in left panel
- See Block Library for schema
Ref Handle
interface GrapesJSEditorHandle {
triggerSave: () => void;
}
Method:
triggerSave(): Programmatically trigger save command- Extracts current editor state
- Calls
onSavecallback - Used by parent's "Save" button or keyboard shortcut
Usage Example
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
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:
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
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
// 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:
// 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:
<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
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
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
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
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
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
// 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
onSaveprop - Parent can update callback without re-initializing editor
- Pattern:
const onSaveRef = useRef(onSave); onSaveRef.current = onSave;
Keyboard Shortcut
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
onSavecallback with current state
forwardRef Pattern
Implementation
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
// 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
onManualSavecallback
Error Handling
Error Boundary State
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:
- Missing plugin: GrapesJS throws error during
init() - Browser incompatibility: Old browser doesn't support ES6 modules
- Memory exhaustion: Very large
initialDatacrashes 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
// 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:
- GrapesJS init error → Internal error state
- React render error → ErrorBoundary catches
- User sees fallback → Can switch to CODE mode
Mobile Detection
Desktop-Only Warning
Location: LandingPageEditor.tsx (parent component)
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
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:
blocksfield contains full GrapesJSprojectData(components tree, styles, assets)- Empty object
{}for new pages (GrapesJS shows blank canvas) - Large JSON (50KB+) loads in ~200ms
Save Flow
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
// 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
// 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
// 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:
generateBlockHtml()missing case for block type- Category name mismatch
- Block registration timing issue
Solutions:
-
Add case to generateBlockHtml():
case 'my-custom-block': return `<section>My custom block HTML</section>`; -
Check category:
// Block category: "Campaign" // GrapesJS shows blocks in collapsible "Campaign" section // Case-sensitive match -
Verify registration timing:
// Registration happens in useEffect after init console.log('Registering blocks:', customBlocks.length); -
Inspect BlockManager:
// 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
onSavecallback never called
Causes:
- Keyboard event listener not registered
- forwardRef not working
save-pagecommand not registered
Solutions:
-
Check keyboard listener:
// 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'); } }; -
Verify ref handle:
// In parent component console.log('Editor ref:', editorRef.current); // Should be { triggerSave: fn } -
Test command directly:
// In browser console (after editor loads) window.editor.runCommand('save-page'); // Should trigger onSave callback -
Check onSaveRef pattern:
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:
-
Split into multiple pages:
- Separate hero, features, testimonials into 3 pages
- Link pages via navigation
-
Use CODE mode for complex layouts:
- Write HTML directly → Faster than GrapesJS rendering
- Import via "Sync Overrides"
-
Optimize images:
- Use external CDN (not base64-encoded)
- Compress before upload
- Lazy load below fold
-
Increase browser memory:
- Chrome →
--max-old-space-size=4096 - Edge → Similar flag
- Chrome →
Problem: Initial Data Not Loading
Symptoms:
- Editor opens with blank canvas
initialDataprop has data- Console shows no errors
Causes:
loadProjectData()called before editor ready- Invalid JSON structure
- Async timing issue
Solutions:
-
Check editor ready state:
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); } }); }, []); -
Validate JSON:
console.log('Loading data:', JSON.stringify(initialData, null, 2)); // Should have keys: assets, styles, pages -
Handle empty data:
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:
- Inline styles not supported
- External stylesheet missing
- Canvas iframe CSP issue
Solutions:
-
Use inline styles in generateBlockHtml():
// Good return `<section style="padding: 40px; background: #f00;">...</section>`; // Bad (requires CSS injection) return `<section class="hero">...</section>`; -
Inject fonts into canvas:
canvas: { styles: [ 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap', ], } -
Check iframe sandbox:
// GrapesJS canvas uses <iframe> — ensure no sandbox restrictions // Default config works, but custom CSP may block
Performance Optimization
Lazy Loading
// 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
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 — Full-screen editor wrapper
- LandingPagesPage — Table view with edit links
Features
- Page Builder — Complete page builder system
- Block Library — Custom blocks database
- MkDocs Export — Export to documentation site
External
- GrapesJS Docs — Official documentation
- GrapesJS API — JavaScript API reference
- GrapesJS Plugins — Plugin ecosystem