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:

  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

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 for schema

Ref Handle

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

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 onSave prop
  • 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 onSave callback 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 onManualSave callback

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:

  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

// 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)

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:

  • 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

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:

  1. generateBlockHtml() missing case for block type
  2. Category name mismatch
  3. Block registration timing issue

Solutions:

  1. Add case to generateBlockHtml():

    case 'my-custom-block':
      return `<section>My custom block HTML</section>`;
    
  2. Check category:

    // Block category: "Campaign"
    // GrapesJS shows blocks in collapsible "Campaign" section
    // Case-sensitive match
    
  3. Verify registration timing:

    // Registration happens in useEffect after init
    console.log('Registering blocks:', customBlocks.length);
    
  4. 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
  • 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:

    // 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:

    // In parent component
    console.log('Editor ref:', editorRef.current); // Should be { triggerSave: fn }
    
  3. Test command directly:

    // In browser console (after editor loads)
    window.editor.runCommand('save-page');
    // Should trigger onSave callback
    
  4. 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:

  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:

    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:

    console.log('Loading data:', JSON.stringify(initialData, null, 2));
    // Should have keys: assets, styles, pages
    
  3. 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:

  1. Inline styles not supported
  2. External stylesheet missing
  3. Canvas iframe CSP issue

Solutions:

  1. Use inline styles in generateBlockHtml():

    // Good
    return `<section style="padding: 40px; background: #f00;">...</section>`;
    
    // Bad (requires CSS injection)
    return `<section class="hero">...</section>`;
    
  2. Inject fonts into canvas:

    canvas: {
      styles: [
        'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',
      ],
    }
    
  3. 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


Components

Features

External