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
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
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
// 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
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)
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
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
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
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
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)
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
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
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
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
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
- Debounce live preview - Avoid re-rendering on every keystroke
- Virtualize block list - Use
react-windowfor large templates - Lazy load components - Only fetch components when palette is opened
- Memoize block renders - Use
React.memofor blocks - 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.