# 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; onSave: (data: { projectData: Record; 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(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 (
); } ``` --- ## 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: 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; 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

Welcome to Our Campaign

Join us in making a difference.

Get Involved
``` ### Built-In Block Templates **1. Hero Section** ```typescript case 'hero': return `

${defaults.title || 'Hero Title'}

${defaults.subtitle || 'Subtitle text here'}

${defaults.ctaText || 'Get Started'}
`; ``` **2. Text Block** ```typescript case 'text': return `

${defaults.heading || 'Heading'}

${defaults.body || 'Body text goes here.'}

`; ``` **3. Features Grid** ```typescript case 'features': { const features = (defaults.features as Array<{ title: string; description: string }>) || []; const featureHtml = features.map(f => `

${f.title}

${f.description}

`).join(''); return `
${featureHtml}
`; } ``` **4. Call to Action** ```typescript case 'cta': return `

${defaults.heading || 'Call to Action'}

${defaults.description || 'Description here'}

${defaults.buttonText || 'Click Here'}
`; ``` **5. Video Block** ```typescript case 'video': { const videoId = defaults.videoId || 'PLACEHOLDER'; const playerType = defaults.playerType || 'standard'; return `

Video Player

ID: ${videoId}

${playerType === 'advanced' ? 'Advanced Player (with reactions)' : 'Standard HTML5 Player'}

Video will render on published page

`; } ``` --- ## 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; 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( function GrapesJSEditor({ initialData, onSave, customBlocks }, ref) { const editorRef = useRef(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(null); const handleManualSave = () => { editorRef.current?.triggerSave(); // Programmatic save }; return (
); ``` **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(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 (
{error}
); } ``` **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'; Editor failed to load. Please try CODE mode.} onReset={() => navigate('/app/pages')} > ``` **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 ( navigate('/app/pages')}>Back to Pages} /> ); } ``` **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: 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(null); const [page, setPage] = useState(null); const [blocks, setBlocks] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { const fetchData = async () => { try { const [pageRes, blocksRes] = await Promise.all([ api.get(`/pages/${pageId}`), api.get('/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 ; if (!page) return null; return (
} onSave={handleSave} customBlocks={blocks} />
); } ``` ### 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() ``` ### 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 `

Our Impact

${volunteers.toLocaleString()}
Volunteers
${emails.toLocaleString()}
Emails Sent
${events}
Events
`; } ``` --- ## 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 `
My custom block HTML
`; ``` 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 `
...
`; // Bad (requires CSS injection) return `
...
`; ``` 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