@medalsocial/meda
Version:
Shared Meda UI shell and runtime package.
95 lines (94 loc) • 7.95 kB
JavaScript
'use client';
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useCallback, useMemo, useState } from 'react';
import { cn } from '../lib/utils.js';
import { BlockPalette } from './block-palette.js';
import { createBlock } from './block-registry.js';
import { BuilderCanvas } from './builder-canvas.js';
import { BuilderHeader } from './builder-header.js';
import { BuilderLeftTabs } from './builder-left-tabs.js';
import { EnvelopeCard } from './envelope-card.js';
import { FloatingBar } from './floating-bar.js';
import { addBlock, duplicateBlock, moveBlock, removeBlock, updateBlockProps, } from './internal/builder-state-utils.js';
import { BuilderDndWrapper } from './internal/dnd-wrapper.js';
import { MobileDrawer } from './mobile-drawers.js';
import { MobileTabBar } from './mobile-tab-bar.js';
import { PropertyInspector } from './property-inspector.js';
import { renderToEmailHtml } from './render-to-email-html.js';
import { defaultEmailBuilderLabels, } from './types.js';
export function EmailBuilder({ document, onDocumentChange, brand, renderMediaPicker, renderSavedBlocks, renderTextEditor, onExportHtml, labels: labelsProp, className, initialDevice = 'desktop', }) {
const labels = useMemo(() => ({ ...defaultEmailBuilderLabels, ...(labelsProp ?? {}) }), [labelsProp]);
const [selectedBlockId, setSelectedBlockId] = useState(null);
const [device, setDevice] = useState(initialDevice);
const [mobilePane, setMobilePane] = useState(null);
const selectedBlock = useMemo(() => document.blocks.find((b) => b.id === selectedBlockId) ?? null, [document.blocks, selectedBlockId]);
const handleAddBlock = useCallback((kind) => {
const block = createBlock(kind);
onDocumentChange(addBlock(document, block));
setSelectedBlockId(block.id);
setMobilePane(null);
}, [document, onDocumentChange]);
const handleSavedBlockPick = useCallback((block) => {
onDocumentChange(addBlock(document, block));
setSelectedBlockId(block.id);
setMobilePane(null);
}, [document, onDocumentChange]);
const handlePropChange = useCallback((blockId, patch) => {
onDocumentChange(updateBlockProps(document, blockId, patch));
}, [document, onDocumentChange]);
const handleDelete = useCallback((blockId) => {
onDocumentChange(removeBlock(document, blockId));
setSelectedBlockId(null);
}, [document, onDocumentChange]);
const handleDuplicate = useCallback((blockId) => {
onDocumentChange(duplicateBlock(document, blockId));
}, [document, onDocumentChange]);
const handleMove = useCallback((blockId, delta) => {
const idx = document.blocks.findIndex((b) => b.id === blockId);
/* v8 ignore next -- defensive guard; floating bar only renders for blocks that exist in document */
if (idx === -1)
return;
onDocumentChange(moveBlock(document, idx, idx + delta));
}, [document, onDocumentChange]);
const handleDragEnd = useCallback((event) => {
const { active, over } = event;
if (!over || active.id === over.id)
return;
const fromIndex = document.blocks.findIndex((b) => b.id === active.id);
const toIndex = document.blocks.findIndex((b) => b.id === over.id);
if (fromIndex === -1 || toIndex === -1)
return;
onDocumentChange(moveBlock(document, fromIndex, toIndex));
}, [document, onDocumentChange]);
const handleExport = useCallback(() => {
const html = renderToEmailHtml(document, brand ? { brand } : undefined);
if (onExportHtml) {
onExportHtml(html);
return;
}
/* v8 ignore next -- typeof window guard is always true in jsdom; SSR-only branch */
if (typeof window !== 'undefined') {
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = window.document.createElement('a');
a.href = url;
a.download = `${document.envelope?.subject ?? 'email'}.html`;
a.click();
URL.revokeObjectURL(url);
}
}, [brand, document, onExportHtml]);
const blockIds = useMemo(() => document.blocks.map((b) => b.id), [document.blocks]);
const blocksTabContent = (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(BlockPalette, { onPick: handleAddBlock }), renderSavedBlocks ? (_jsx("div", { "data-slot": "email-builder-saved-blocks-slot", className: "border-border border-t p-2", children: renderSavedBlocks({ onPick: handleSavedBlockPick }) })) : null] }));
const envelopeTabContent = (_jsx(EnvelopeCard, { envelope: document.envelope, onChange: (envelope) => onDocumentChange({ ...document, envelope }) }));
return (_jsxs("div", { "data-slot": "email-builder", className: cn('relative flex h-full min-h-[600px] w-full flex-col bg-background', className), children: [_jsx(BuilderHeader, { device: device, onDeviceChange: setDevice, onExport: handleExport, labels: labels }), _jsx(BuilderDndWrapper, { blockIds: blockIds, onDragEnd: handleDragEnd, children: _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsx("aside", { "data-slot": "email-builder-left", "aria-label": labels.blocksTab, className: "hidden w-72 shrink-0 border-border border-r md:block", children: _jsx(BuilderLeftTabs, { labels: labels, blocksContent: blocksTabContent, envelopeContent: envelopeTabContent }) }), _jsx("main", { className: "min-w-0 flex-1", children: _jsx(BuilderCanvas, { blocks: document.blocks, selectedBlockId: selectedBlockId, onSelectBlock: setSelectedBlockId, device: device, labels: labels, renderFloatingBar: (block) => (_jsx(FloatingBar, { labels: labels, canMoveUp: document.blocks[0]?.id !== block.id, canMoveDown: document.blocks[document.blocks.length - 1]?.id !== block.id, onMoveUp: () => handleMove(block.id, -1), onMoveDown: () => handleMove(block.id, 1), onDuplicate: () => handleDuplicate(block.id), onDelete: () => handleDelete(block.id) })) }) }), _jsx("aside", { "data-slot": "email-builder-right", "aria-label": labels.openInspector, className: "hidden w-80 shrink-0 overflow-y-auto border-border border-l md:block", children: _jsx(PropertyInspector, { block: selectedBlock, onChange: (id, patch) => handlePropChange(id, patch), emptyContent: labels.inspectorEmpty, renderMediaPicker: renderMediaPicker, renderTextEditor: renderTextEditor }) })] }) }), _jsx(MobileTabBar, { labels: labels, onOpenBlocks: () => setMobilePane('blocks'), onOpenInspector: () => setMobilePane('inspector'), onOpenSettings: () => setMobilePane('settings') }), _jsx(MobileDrawer, { open: mobilePane === 'blocks',
/* v8 ignore next -- vaul close events (swipe/overlay/escape) don't fire in jsdom */
onOpenChange: (o) => !o && setMobilePane(null), title: labels.blocksTab, children: blocksTabContent }), _jsx(MobileDrawer, { open: mobilePane === 'inspector',
/* v8 ignore next -- vaul close events (swipe/overlay/escape) don't fire in jsdom */
onOpenChange: (o) => !o && setMobilePane(null), title: labels.openInspector, children: _jsx(PropertyInspector, { block: selectedBlock,
/* v8 ignore next -- onChange is identical to the right-rail's; mobile drawer portal content is not exercised in jsdom */
onChange: (id, patch) => handlePropChange(id, patch), emptyContent: labels.inspectorEmpty, renderMediaPicker: renderMediaPicker, renderTextEditor: renderTextEditor }) }), _jsx(MobileDrawer, { open: mobilePane === 'settings',
/* v8 ignore next -- vaul close events (swipe/overlay/escape) don't fire in jsdom */
onOpenChange: (o) => !o && setMobilePane(null), title: labels.envelopeTab, children: envelopeTabContent })] }));
}
// Re-exported for convenience.
export { renderToEmailHtml } from './render-to-email-html.js';