import React, { CSSProperties, useCallback, useRef, useState } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E"; interface ImageBlockProps { src?: string; alt?: string; style?: CSSProperties; } // Helper: upload a file to the WHP API and return the proxy URL async function uploadToWhp(file: File): Promise { const cfg = (window as any).WHP_CONFIG; if (!cfg) return URL.createObjectURL(file); // Standalone fallback const formData = new FormData(); formData.append('file', file); try { const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, { method: 'POST', headers: { 'X-CSRF-Token': cfg.csrfToken }, body: formData, }); const data = await resp.json(); if (data.success && data.url) return data.url; return null; } catch { return null; } } export const ImageBlock: UserComponent = ({ src = PLACEHOLDER_SRC, alt = '', style = {}, }) => { const { connectors: { connect, drag }, selected, actions: { setProp }, } = useNode((node) => ({ selected: node.events.selected })); const imgRef = useRef(null); const isPlaceholder = !src || src === PLACEHOLDER_SRC || src.startsWith('data:image/svg'); // Handle drag-and-drop of files directly onto the image const handleDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); const file = e.dataTransfer.files?.[0]; if (file && file.type.startsWith('image/')) { const url = await uploadToWhp(file); if (url) setProp((p: ImageBlockProps) => { p.src = url; }); } }, [setProp]); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }, []); return ( { imgRef.current = ref; if (ref) connect(drag(ref)); }} src={src} alt={alt || 'Image'} onDrop={handleDrop} onDragOver={handleDragOver} style={{ display: 'block', maxWidth: '100%', outline: 'none', cursor: selected ? 'move' : 'pointer', ...style, }} /> ); }; /* ---------- Helpers for parsing CSS unit values ---------- */ type SizeUnit = 'px' | '%' | 'auto'; function parseSizeValue(value: string | number | undefined): { num: string; unit: SizeUnit } { if (!value || value === 'auto') return { num: '', unit: 'auto' }; const str = String(value); if (str === 'auto') return { num: '', unit: 'auto' }; const match = str.match(/^(\d+(?:\.\d+)?)\s*(px|%)$/); if (match) return { num: match[1], unit: match[2] as SizeUnit }; // Pure number = px if (/^\d+(?:\.\d+)?$/.test(str)) return { num: str, unit: 'px' }; return { num: '', unit: 'px' }; } function buildSizeString(num: string, unit: SizeUnit): string | undefined { if (unit === 'auto') return 'auto'; if (!num) return undefined; return `${num}${unit}`; } type Alignment = 'left' | 'center' | 'right'; function detectAlignment(style: CSSProperties | undefined): Alignment { if (!style) return 'left'; const ml = style.marginLeft; const mr = style.marginRight; if (ml === 'auto' && mr === 'auto') return 'center'; if (ml === 'auto' && mr !== 'auto') return 'right'; return 'left'; } /* ---------- Settings panel ---------- */ const ImageBlockSettings: React.FC = () => { const { actions: { setProp }, props } = useNode((node) => ({ props: node.data.props as ImageBlockProps, })); const isPlaceholder = !props.src || props.src === PLACEHOLDER_SRC || props.src?.startsWith('data:image/svg'); const fileInputRef = useRef(null); const [showBrowser, setShowBrowser] = useState(false); const [browserAssets, setBrowserAssets] = useState([]); const [browserLoading, setBrowserLoading] = useState(false); // Sizing unit state const widthParsed = parseSizeValue(props.style?.width); const [widthUnit, setWidthUnit] = useState(widthParsed.unit === 'auto' ? 'px' : widthParsed.unit); const heightParsed = parseSizeValue(props.style?.height); const [heightUnit, setHeightUnit] = useState(heightParsed.unit === 'auto' ? 'px' : heightParsed.unit); const maxWidthParsed = parseSizeValue(props.style?.maxWidth); const [maxWidthUnit, setMaxWidthUnit] = useState(maxWidthParsed.unit === 'auto' ? '%' : maxWidthParsed.unit); const alignment = detectAlignment(props.style); const handleUpload = useCallback(async (file: File) => { const url = await uploadToWhp(file); if (url) setProp((p: ImageBlockProps) => { p.src = url; }); }, [setProp]); const handleBrowse = useCallback(async () => { if (showBrowser) { setShowBrowser(false); return; } const cfg = (window as any).WHP_CONFIG; if (!cfg) return; setBrowserLoading(true); try { const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`); const data = await resp.json(); if (data.success && Array.isArray(data.assets)) { const images = data.assets.filter((a: any) => (a.type || '').startsWith('image')); setBrowserAssets(images); setShowBrowser(true); } } catch (e) { console.error('Browse failed:', e); } finally { setBrowserLoading(false); } }, [showBrowser]); const radiusPresets = ['0', '8px', '16px', '32px', '50%']; const setPropStyle = useCallback((key: string, value: string | undefined) => { setProp((p: ImageBlockProps) => { p.style = { ...p.style, [key]: value }; }); }, [setProp]); const setAlignment = useCallback((align: Alignment) => { setProp((p: ImageBlockProps) => { const s = { ...p.style }; if (align === 'center') { s.marginLeft = 'auto'; s.marginRight = 'auto'; s.display = 'block'; } else if (align === 'right') { s.marginLeft = 'auto'; s.marginRight = undefined; s.display = 'block'; } else { s.marginLeft = undefined; s.marginRight = undefined; s.display = 'block'; } p.style = s; }); }, [setProp]); // Extract friendly filename from URL const getFriendlyName = (src: string) => { const match = src.match(/filename=([^&]+)/); if (match) return decodeURIComponent(match[1]).replace(/^\d+_[a-f0-9]+_/, ''); return src.split('/').pop() || 'image'; }; const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 }; const inputStyle: CSSProperties = { flex: 1, minWidth: 0, padding: '4px 6px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }; const selectStyle: CSSProperties = { padding: '4px 2px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, cursor: 'pointer' }; const btnStyle = (active: boolean): CSSProperties => ({ flex: 1, padding: '4px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: active ? '#3b82f6' : '#27272a', color: active ? '#fff' : '#a1a1aa', }); return (
{/* Image preview */}
{!isPlaceholder ? ( <> {/* Current image thumbnail + filename + remove */}
{getFriendlyName(props.src || '')}
) : ( /* Drop zone when no image set */
{ e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }} onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }} onDrop={async (e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3f3f46'; const file = e.dataTransfer.files?.[0]; if (file && file.type.startsWith('image/')) await handleUpload(file); }} onClick={() => fileInputRef.current?.click()} > Drop image here or click to upload
)} {/* Action buttons: Upload + Browse */}
{/* Inline asset browser grid */} {showBrowser && (
{browserAssets.map(asset => (
{ setProp((p: ImageBlockProps) => { p.src = asset.url; }); setShowBrowser(false); }} style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1', transition: 'border-color 0.15s' }} onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }} onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }} > {asset.name}
))} {browserAssets.length === 0 && (

No images uploaded yet. Use Upload above.

)}
)} { const file = e.target.files?.[0]; if (file) handleUpload(file); e.target.value = ''; }} /> {/* URL input (collapsed, for advanced users) */}
setProp((p: ImageBlockProps) => { p.src = e.target.value || PLACEHOLDER_SRC; })} placeholder="Or paste image URL..." style={{ width: '100%', padding: '4px 8px', background: '#1e1e2a', color: '#71717a', border: '1px solid #27272a', borderRadius: 4, fontSize: 10 }} />
{/* Alt Text */}
setProp((p: ImageBlockProps) => { p.alt = e.target.value; })} placeholder="Describe the image..." style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }} />
{/* Width */}
{ const val = buildSizeString(e.target.value, widthUnit); setPropStyle('width', val || 'auto'); }} placeholder="auto" style={inputStyle} />
{/* Max Width */}
{ const val = buildSizeString(e.target.value, maxWidthUnit); setPropStyle('maxWidth', val || '100%'); }} placeholder="100%" style={inputStyle} />
{/* Height */}
{ const val = buildSizeString(e.target.value, heightUnit); setPropStyle('height', val || 'auto'); }} placeholder="auto" style={inputStyle} />
{/* Object Fit (visible when both width and height are explicit values) */} {props.style?.width && props.style.width !== 'auto' && props.style?.height && props.style.height !== 'auto' && (
{(['cover', 'contain', 'fill', 'none'] as const).map((fit) => ( ))}
)} {/* Alignment */}
{/* Border Radius */}
{radiusPresets.map((r) => ( ))}
); }; ImageBlock.craft = { displayName: 'Image', props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } }, rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true }, related: { settings: ImageBlockSettings }, }; (ImageBlock as any).toHtml = (props: ImageBlockProps, _c: string) => { // Skip placeholder/empty images in export const src = props.src || ''; if (!src || src.startsWith('data:image/svg') || src === PLACEHOLDER_SRC) { return { html: '' }; } const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style }); const alt = props.alt ? ` alt="${props.alt.replace(/"/g, '"')}"` : ' alt=""'; return { html: `` }; };