Skip to content

PDF Template Editor - Implementation Guide

Complete guide for building the minimal block-based visual editor.


Overview

The PDF template editor is a custom-built, minimal block-based interface for designing PDF layouts without writing HTML/CSS. It's inspired by Notion/WordPress block editors but simplified for healthcare document templates.

Goals:

  • ✅ Non-technical users can create professional PDFs
  • ✅ Drag-drop blocks, inline editing
  • ✅ Live preview with real-time updates
  • ✅ Advanced users can edit HTML/CSS directly
  • ✅ No external dependencies (GrapeJS, Unlayer, etc.)

Architecture

┌─────────────────────────────────────────────────────────┐
│  PDF Template Editor (React)                             │
├─────────────────────────────────────────────────────────┤
│                                                           │
│  ┌─────────────┐  ┌─────────────────┐  ┌──────────────┐│
│  │   Block     │  │     Canvas      │  │   Preview    ││
│  │  Palette    │  │  (Editable)     │  │   (Live)     ││
│  │             │  │                 │  │              ││
│  │  📝 Text    │  │  [Letterhead]   │  │  [PDF]       ││
│  │  📋 Heading │  │  [Patient Info] │  │              ││
│  │  📊 Table   │  │  [Form Fields]  │  │  chromedp    ││
│  │  🏢 Letter- │  │  [Signature]    │  │  rendering   ││
│  │     head    │  │                 │  │              ││
│  └─────────────┘  └─────────────────┘  └──────────────┘│
│                                                           │
│  ┌─────────────────────────────────────────────────────┐│
│  │  Properties Panel (Selected Block Config)           ││
│  │  • Font Size • Color • Alignment • Margins           ││
│  └─────────────────────────────────────────────────────┘│
│                                                           │
│  [< Back]  [Save Draft]  [Preview]  [Publish Template]  │
└─────────────────────────────────────────────────────────┘

Data Model

Block Structure

typescript
interface Block {
  id: string;              // Unique block ID (UUID)
  type: BlockType;         // Block type enum
  config: BlockConfig;     // Type-specific configuration
  children?: Block[];      // Nested blocks (for containers)
}

enum BlockType {
  // Layout blocks
  SECTION = 'section',
  COLUMNS = 'columns',

  // Content blocks
  TEXT = 'text',
  HEADING = 'heading',
  PARAGRAPH = 'paragraph',
  IMAGE = 'image',
  TABLE = 'table',

  // Dynamic blocks
  FIELD = 'field',              // Single form field
  FIELD_LIST = 'field_list',    // All form fields as list
  FIELD_TABLE = 'field_table',  // All form fields as table

  // Components
  LETTERHEAD = 'letterhead',
  SIGNATURE = 'signature',
  FOOTER = 'footer',
}

// Base config (all blocks)
interface BaseBlockConfig {
  marginTop?: number;     // mm
  marginBottom?: number;
  marginLeft?: number;
  marginRight?: number;
  paddingTop?: number;
  paddingBottom?: number;
  paddingLeft?: number;
  paddingRight?: number;
  backgroundColor?: string;
  borderColor?: string;
  borderWidth?: number;
}

// Text block
interface TextBlockConfig extends BaseBlockConfig {
  content: string;
  fontSize: number;
  fontWeight: 'normal' | 'bold';
  fontStyle: 'normal' | 'italic';
  textAlign: 'left' | 'center' | 'right' | 'justify';
  color: string;
  lineHeight: number;
}

// Heading block
interface HeadingBlockConfig extends BaseBlockConfig {
  content: string;
  level: 1 | 2 | 3 | 4;
  fontSize?: number;       // Defaults based on level
  fontWeight?: 'normal' | 'bold';
  textAlign?: 'left' | 'center' | 'right';
  color?: string;
}

// Image block
interface ImageBlockConfig extends BaseBlockConfig {
  src: string;             // URL or template variable: {{.Organization.LogoURL}}
  alt: string;
  width?: number;          // px or %
  height?: number;
  alignment: 'left' | 'center' | 'right';
}

// Table block
interface TableBlockConfig extends BaseBlockConfig {
  headers: string[];
  rows: string[][];        // Can contain template variables
  borderColor?: string;
  headerBackgroundColor?: string;
}

// Field block (single form field value)
interface FieldBlockConfig extends BaseBlockConfig {
  fieldId?: number;        // custom_field_id (if pre-selected)
  label: string;
  showLabel: boolean;
  format?: 'text' | 'date' | 'currency' | 'percentage';
}

// Field list block (all form fields)
interface FieldListBlockConfig extends BaseBlockConfig {
  showPrivateFields: boolean;
  showEmptyFields: boolean;
  labelWidth?: number;     // px
  labelPosition: 'left' | 'top';
}

// Section block (container)
interface SectionBlockConfig extends BaseBlockConfig {
  title?: string;
  showBorder: boolean;
  columns?: number;        // 1, 2, or 3
}

// Letterhead component
interface LetterheadBlockConfig extends BaseBlockConfig {
  componentId?: number;    // References pdf_template_components
  showLogo: boolean;
  showAddress: boolean;
  showPhone: boolean;
  alignment: 'left' | 'center' | 'right';
}

// Signature component
interface SignatureBlockConfig extends BaseBlockConfig {
  componentId?: number;
  showSignatureImage: boolean;
  showName: boolean;
  showTitle: boolean;
  showLicense: boolean;
  alignment: 'left' | 'right';
}

// Footer component
interface FooterBlockConfig extends BaseBlockConfig {
  componentId?: number;
  showPageNumbers: boolean;
  showDate: boolean;
  text?: string;
  alignment: 'left' | 'center' | 'right';
}

Editor State

typescript
interface EditorState {
  blocks: Block[];
  selectedBlockId: string | null;
  isDirty: boolean;          // Unsaved changes
  previewMode: 'edit' | 'preview' | 'split';
}

// Redux/Zustand store
interface TemplateEditorStore {
  template: PDFTemplate;
  editorState: EditorState;

  // Actions
  addBlock: (blockType: BlockType, afterBlockId?: string) => void;
  removeBlock: (blockId: string) => void;
  updateBlock: (blockId: string, config: Partial<BlockConfig>) => void;
  moveBlock: (blockId: string, direction: 'up' | 'down') => void;
  selectBlock: (blockId: string | null) => void;

  // Template actions
  saveTemplate: () => Promise<void>;
  publishTemplate: (notes: string) => Promise<void>;

  // Preview
  generatePreview: () => Promise<void>;
}

Component Hierarchy

<TemplateEditor>
  ├─ <EditorToolbar>
  │   ├─ [< Back]
  │   ├─ [Save Draft]
  │   ├─ [Preview Toggle]
  │   └─ [Publish]

  ├─ <EditorLayout>
  │   │
  │   ├─ <BlockPalette>
  │   │   ├─ <BlockPaletteGroup title="Layout">
  │   │   │   ├─ <BlockButton type="section" />
  │   │   │   └─ <BlockButton type="columns" />
  │   │   ├─ <BlockPaletteGroup title="Content">
  │   │   │   ├─ <BlockButton type="text" />
  │   │   │   ├─ <BlockButton type="heading" />
  │   │   │   ├─ <BlockButton type="image" />
  │   │   │   └─ <BlockButton type="table" />
  │   │   ├─ <BlockPaletteGroup title="Dynamic">
  │   │   │   ├─ <BlockButton type="field" />
  │   │   │   ├─ <BlockButton type="field_list" />
  │   │   │   └─ <BlockButton type="field_table" />
  │   │   └─ <BlockPaletteGroup title="Components">
  │   │       ├─ <BlockButton type="letterhead" />
  │   │       ├─ <BlockButton type="signature" />
  │   │       └─ <BlockButton type="footer" />
  │   │
  │   ├─ <Canvas>
  │   │   └─ <BlockList blocks={editorState.blocks}>
  │   │       {blocks.map(block => (
  │   │         <BlockRenderer
  │   │           key={block.id}
  │   │           block={block}
  │   │           selected={block.id === selectedBlockId}
  │   │           onSelect={() => selectBlock(block.id)}
  │   │           onChange={(config) => updateBlock(block.id, config)}
  │   │           onDelete={() => removeBlock(block.id)}
  │   │           onMove={(dir) => moveBlock(block.id, dir)}
  │   │         />
  │   │       ))}
  │   │
  │   ├─ <PropertiesPanel>
  │   │   {selectedBlock && (
  │   │     <BlockProperties
  │   │       block={selectedBlock}
  │   │       onChange={(config) => updateBlock(selectedBlock.id, config)}
  │   │     />
  │   │   )}
  │   │
  │   └─ <PreviewPane>
  │       <iframe src="/api/v1/pdf-templates/{id}/preview?format=html" />

  └─ <EditorFooter>
      └─ Status bar, word count, etc.

Block Renderer Implementation

typescript
// BlockRenderer.tsx
export const BlockRenderer: React.FC<BlockRendererProps> = ({
  block,
  selected,
  onSelect,
  onChange,
  onDelete,
  onMove,
}) => {
  const [isEditing, setIsEditing] = useState(false);

  const handleClick = (e: React.MouseEvent) => {
    e.stopPropagation();
    onSelect();
  };

  const handleDoubleClick = (e: React.MouseEvent) => {
    e.stopPropagation();
    setIsEditing(true);
  };

  return (
    <div
      className={cn('block-wrapper', {
        'block-selected': selected,
        'block-editing': isEditing,
      })}
      onClick={handleClick}
      onDoubleClick={handleDoubleClick}
    >
      {/* Block toolbar (appears on hover/select) */}
      {selected && (
        <BlockToolbar>
          <IconButton onClick={() => onMove('up')} icon="ArrowUp" />
          <IconButton onClick={() => onMove('down')} icon="ArrowDown" />
          <IconButton onClick={onDelete} icon="Trash" />
        </BlockToolbar>
      )}

      {/* Render block based on type */}
      {renderBlockContent(block, isEditing, onChange)}
    </div>
  );
};

function renderBlockContent(
  block: Block,
  isEditing: boolean,
  onChange: (config: Partial<BlockConfig>) => void
) {
  switch (block.type) {
    case BlockType.TEXT:
      return (
        <TextBlock
          config={block.config as TextBlockConfig}
          isEditing={isEditing}
          onChange={onChange}
        />
      );

    case BlockType.HEADING:
      return (
        <HeadingBlock
          config={block.config as HeadingBlockConfig}
          isEditing={isEditing}
          onChange={onChange}
        />
      );

    case BlockType.IMAGE:
      return (
        <ImageBlock
          config={block.config as ImageBlockConfig}
          onChange={onChange}
        />
      );

    case BlockType.FIELD:
      return (
        <FieldBlock
          config={block.config as FieldBlockConfig}
          onChange={onChange}
        />
      );

    case BlockType.FIELD_LIST:
      return <FieldListBlock config={block.config as FieldListBlockConfig} />;

    case BlockType.LETTERHEAD:
      return <LetterheadBlock config={block.config as LetterheadBlockConfig} />;

    case BlockType.SIGNATURE:
      return <SignatureBlock config={block.config as SignatureBlockConfig} />;

    default:
      return <div>Unknown block type: {block.type}</div>;
  }
}

Block-Specific Implementations

TextBlock Component

typescript
const TextBlock: React.FC<TextBlockProps> = ({ config, isEditing, onChange }) => {
  const [localContent, setLocalContent] = useState(config.content);

  const handleBlur = () => {
    onChange({ content: localContent });
  };

  if (isEditing) {
    return (
      <textarea
        value={localContent}
        onChange={(e) => setLocalContent(e.target.value)}
        onBlur={handleBlur}
        style={{
          fontSize: `${config.fontSize}px`,
          fontWeight: config.fontWeight,
          textAlign: config.textAlign,
          color: config.color,
          lineHeight: config.lineHeight,
        }}
        autoFocus
      />
    );
  }

  return (
    <div
      style={{
        fontSize: `${config.fontSize}px`,
        fontWeight: config.fontWeight,
        textAlign: config.textAlign,
        color: config.color,
        lineHeight: config.lineHeight,
        margin: `${config.marginTop}mm ${config.marginRight}mm ${config.marginBottom}mm ${config.marginLeft}mm`,
      }}
    >
      {config.content}
    </div>
  );
};

FieldBlock Component (Form Field Picker)

typescript
const FieldBlock: React.FC<FieldBlockProps> = ({ config, onChange }) => {
  const [customFields, setCustomFields] = useState<CustomField[]>([]);

  useEffect(() => {
    // Fetch custom fields for field picker
    fetch('/api/v1/custom-fields?entity_type=patient')
      .then(res => res.json())
      .then(data => setCustomFields(data.fields));
  }, []);

  return (
    <div className="field-block">
      {config.showLabel && <strong>{config.label}:</strong>}

      {config.fieldId ? (
        <span className="field-value-placeholder">
          {`{{index .FormValues "field_${config.fieldId}"}}`}
        </span>
      ) : (
        <Select
          placeholder="Select form field..."
          options={customFields.map(f => ({
            value: f.id,
            label: f.label,
          }))}
          onChange={(fieldId) => {
            const field = customFields.find(f => f.id === fieldId);
            onChange({
              fieldId,
              label: field?.label || '',
            });
          }}
        />
      )}
    </div>
  );
};

LetterheadBlock Component

typescript
const LetterheadBlock: React.FC<LetterheadBlockProps> = ({ config }) => {
  const [components, setComponents] = useState<PDFTemplateComponent[]>([]);

  useEffect(() => {
    fetch('/api/v1/pdf-template-components?category=letterhead')
      .then(res => res.json())
      .then(data => setComponents(data.data));
  }, []);

  const selectedComponent = components.find(c => c.id === config.componentId);

  return (
    <div className="letterhead-block">
      {selectedComponent ? (
        <div dangerouslySetInnerHTML={{ __html: selectedComponent.component_html }} />
      ) : (
        <div className="letterhead-placeholder">
          <img src="{{.Organization.LogoURL}}" alt="Logo" className="logo" />
          <h1>{{.Organization.Name}}</h1>
          {config.showAddress && <p>{{.Organization.Address}}</p>}
          {config.showPhone && <p>{{.Organization.Phone}}</p>}
        </div>
      )}
    </div>
  );
};

Properties Panel

typescript
const BlockProperties: React.FC<BlockPropertiesProps> = ({ block, onChange }) => {
  return (
    <div className="properties-panel">
      <h3>Block Properties</h3>

      {/* Common properties (all blocks) */}
      <PropertyGroup title="Spacing">
        <PropertyField label="Margin Top (mm)">
          <NumberInput
            value={block.config.marginTop || 0}
            onChange={(v) => onChange({ marginTop: v })}
          />
        </PropertyField>
        <PropertyField label="Margin Bottom (mm)">
          <NumberInput
            value={block.config.marginBottom || 0}
            onChange={(v) => onChange({ marginBottom: v })}
          />
        </PropertyField>
      </PropertyGroup>

      {/* Type-specific properties */}
      {block.type === BlockType.TEXT && (
        <TextBlockProperties
          config={block.config as TextBlockConfig}
          onChange={onChange}
        />
      )}

      {block.type === BlockType.HEADING && (
        <HeadingBlockProperties
          config={block.config as HeadingBlockConfig}
          onChange={onChange}
        />
      )}

      {block.type === BlockType.FIELD_LIST && (
        <FieldListBlockProperties
          config={block.config as FieldListBlockConfig}
          onChange={onChange}
        />
      )}
    </div>
  );
};

const TextBlockProperties: React.FC = ({ config, onChange }) => (
  <>
    <PropertyGroup title="Typography">
      <PropertyField label="Font Size (pt)">
        <NumberInput
          value={config.fontSize}
          onChange={(v) => onChange({ fontSize: v })}
        />
      </PropertyField>

      <PropertyField label="Font Weight">
        <Select
          value={config.fontWeight}
          options={[
            { value: 'normal', label: 'Normal' },
            { value: 'bold', label: 'Bold' },
          ]}
          onChange={(v) => onChange({ fontWeight: v })}
        />
      </PropertyField>

      <PropertyField label="Text Align">
        <ButtonGroup
          value={config.textAlign}
          options={[
            { value: 'left', icon: 'AlignLeft' },
            { value: 'center', icon: 'AlignCenter' },
            { value: 'right', icon: 'AlignRight' },
            { value: 'justify', icon: 'AlignJustify' },
          ]}
          onChange={(v) => onChange({ textAlign: v })}
        />
      </PropertyField>

      <PropertyField label="Color">
        <ColorPicker
          value={config.color}
          onChange={(v) => onChange({ color: v })}
        />
      </PropertyField>
    </PropertyGroup>
  </>
);

Live Preview Implementation

typescript
const PreviewPane: React.FC<PreviewPaneProps> = ({ templateId }) => {
  const [previewUrl, setPreviewUrl] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  const refreshPreview = useCallback(async () => {
    setLoading(true);
    try {
      const response = await fetch(
        `/api/v1/pdf-templates/${templateId}/preview?format=html&sample_data=true`
      );
      const data = await response.json();

      // Create blob URL for iframe
      const blob = new Blob([data.data.html], { type: 'text/html' });
      const url = URL.createObjectURL(blob);
      setPreviewUrl(url);
    } catch (error) {
      console.error('Preview failed:', error);
    } finally {
      setLoading(false);
    }
  }, [templateId]);

  // Auto-refresh on template changes (debounced)
  const debouncedRefresh = useMemo(
    () => debounce(refreshPreview, 500),
    [refreshPreview]
  );

  useEffect(() => {
    debouncedRefresh();
  }, [debouncedRefresh]);

  return (
    <div className="preview-pane">
      <div className="preview-toolbar">
        <button onClick={refreshPreview} disabled={loading}>
          {loading ? 'Refreshing...' : 'Refresh Preview'}
        </button>
      </div>

      {previewUrl ? (
        <iframe
          src={previewUrl}
          className="preview-iframe"
          title="PDF Preview"
          sandbox="allow-same-origin"
        />
      ) : (
        <div className="preview-loading">Loading preview...</div>
      )}
    </div>
  );
};

Drag & Drop Implementation

typescript
import { DndContext, DragEndEvent, closestCenter } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

const Canvas: React.FC<CanvasProps> = ({ blocks, onReorder }) => {
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;

    if (over && active.id !== over.id) {
      const oldIndex = blocks.findIndex(b => b.id === active.id);
      const newIndex = blocks.findIndex(b => b.id === over.id);

      onReorder(oldIndex, newIndex);
    }
  };

  return (
    <DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
      <SortableContext items={blocks.map(b => b.id)} strategy={verticalListSortingStrategy}>
        {blocks.map(block => (
          <SortableBlock key={block.id} block={block} />
        ))}
      </SortableContext>
    </DndContext>
  );
};

const SortableBlock: React.FC<{ block: Block }> = ({ block }) => {
  const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
    id: block.id,
  });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
  };

  return (
    <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
      <BlockRenderer block={block} {...otherProps} />
    </div>
  );
};

HTML/CSS Code Editor (Advanced Mode)

typescript
import { Editor } from '@monaco-editor/react';

const CodeEditorView: React.FC<CodeEditorViewProps> = ({ template, onChange }) => {
  const [activeTab, setActiveTab] = useState<'html' | 'css'>('html');

  return (
    <div className="code-editor-view">
      <div className="code-editor-tabs">
        <button
          className={activeTab === 'html' ? 'active' : ''}
          onClick={() => setActiveTab('html')}
        >
          HTML
        </button>
        <button
          className={activeTab === 'css' ? 'active' : ''}
          onClick={() => setActiveTab('css')}
        >
          CSS
        </button>
      </div>

      <Editor
        height="600px"
        language={activeTab === 'html' ? 'html' : 'css'}
        value={activeTab === 'html' ? template.template_html : template.template_css}
        onChange={(value) => {
          if (activeTab === 'html') {
            onChange({ template_html: value });
          } else {
            onChange({ template_css: value });
          }
        }}
        options={{
          minimap: { enabled: false },
          fontSize: 14,
          lineNumbers: 'on',
          formatOnPaste: true,
        }}
      />
    </div>
  );
};

Save & Publish Workflow

typescript
const EditorToolbar: React.FC<EditorToolbarProps> = ({ template, editorState, onSave, onPublish }) => {
  const handleSaveDraft = async () => {
    try {
      const html = generateHTMLFromBlocks(editorState.blocks);
      const css = generateCSSFromBlocks(editorState.blocks);

      await onSave({
        template_html: html,
        template_css: css,
        editor_state: { blocks: editorState.blocks },
      });

      toast.success('Template saved as draft');
    } catch (error) {
      toast.error('Failed to save template');
    }
  };

  const handlePublish = async () => {
    const notes = prompt('Enter change notes (optional):');

    try {
      await onPublish(notes || '');
      toast.success(`Template published as version ${template.version + 1}`);
    } catch (error) {
      toast.error('Failed to publish template');
    }
  };

  return (
    <div className="editor-toolbar">
      <button onClick={() => router.back()}>← Back</button>

      <div className="toolbar-actions">
        <button
          onClick={handleSaveDraft}
          disabled={!editorState.isDirty}
        >
          Save Draft
        </button>

        <button
          onClick={handlePublish}
          disabled={template.published}
          className="btn-primary"
        >
          Publish Template
        </button>
      </div>
    </div>
  );
};

Generating HTML from Blocks

typescript
function generateHTMLFromBlocks(blocks: Block[]): string {
  const blockHTMLParts = blocks.map(block => {
    switch (block.type) {
      case BlockType.TEXT:
        const textConfig = block.config as TextBlockConfig;
        return `<p style="font-size: ${textConfig.fontSize}pt; text-align: ${textConfig.textAlign};">${textConfig.content}</p>`;

      case BlockType.HEADING:
        const headingConfig = block.config as HeadingBlockConfig;
        return `<h${headingConfig.level}>${headingConfig.content}</h${headingConfig.level}>`;

      case BlockType.FIELD:
        const fieldConfig = block.config as FieldBlockConfig;
        return `
          <div class="field">
            ${fieldConfig.showLabel ? `<strong>${fieldConfig.label}:</strong>` : ''}
            {{index .FormValues "field_${fieldConfig.fieldId}"}}
          </div>
        `;

      case BlockType.FIELD_LIST:
        return `
          <div class="form-fields">
            {{range .FormFields}}
              <div class="field">
                <strong>{{.Label}}:</strong> {{.Value}}
              </div>
            {{end}}
          </div>
        `;

      case BlockType.LETTERHEAD:
        return `<div class="letterhead">
          <img src="{{.Organization.LogoURL}}" alt="Logo" />
          <h1>{{.Organization.Name}}</h1>
        </div>`;

      default:
        return '';
    }
  });

  return `
    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="UTF-8">
      <style>
        ${generateCSSFromBlocks(blocks)}
      </style>
    </head>
    <body>
      ${blockHTMLParts.join('\n')}
    </body>
    </html>
  `;
}

function generateCSSFromBlocks(blocks: Block[]): string {
  // Extract CSS from block configs
  const cssRules: string[] = [];

  blocks.forEach(block => {
    const config = block.config;
    if (config.marginTop || config.marginBottom) {
      cssRules.push(`
        .block-${block.id} {
          margin-top: ${config.marginTop || 0}mm;
          margin-bottom: ${config.marginBottom || 0}mm;
        }
      `);
    }
  });

  return cssRules.join('\n');
}

Testing Strategy

Unit Tests

typescript
describe('BlockRenderer', () => {
  it('renders text block with correct styling', () => {
    const block: Block = {
      id: '1',
      type: BlockType.TEXT,
      config: {
        content: 'Hello World',
        fontSize: 12,
        textAlign: 'center',
        color: '#000',
      },
    };

    const { getByText } = render(<BlockRenderer block={block} />);
    const element = getByText('Hello World');

    expect(element).toHaveStyle({
      fontSize: '12px',
      textAlign: 'center',
      color: '#000',
    });
  });
});

Integration Tests

typescript
describe('Template Editor', () => {
  it('adds block to canvas on palette click', async () => {
    const { getByText, getAllByRole } = render(<TemplateEditor templateId={5} />);

    // Click "Text" block in palette
    const textButton = getByText('Text');
    fireEvent.click(textButton);

    // Verify block added to canvas
    const blocks = getAllByRole('block');
    expect(blocks).toHaveLength(1);
  });

  it('saves template as draft', async () => {
    const { getByText } = render(<TemplateEditor templateId={5} />);

    const saveDraftButton = getByText('Save Draft');
    fireEvent.click(saveDraftButton);

    await waitFor(() => {
      expect(toast.success).toHaveBeenCalledWith('Template saved as draft');
    });
  });
});

Performance Considerations

  1. Debounce live preview - Avoid re-rendering on every keystroke
  2. Virtualize block list - Use react-window for large templates
  3. Lazy load components - Only fetch components when palette is opened
  4. Memoize block renders - Use React.memo for blocks
  5. Web workers for HTML generation - Offload HTML/CSS generation to worker thread

Summary

Custom-built minimal editor - No external dependencies ✅ Block-based interface - Drag-drop, inline editing ✅ Live preview - Real-time HTML preview with chromedp ✅ Properties panel - Visual config for non-technical users ✅ Code editor - HTML/CSS editing for advanced users ✅ Component library - Reusable letterhead, signature, footer ✅ Version control - Draft → Publish workflow

This editor strikes the perfect balance: simple enough for non-technical users, powerful enough for advanced customization.