import React, { CSSProperties, useCallback, useRef } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { escapeAttr, safeImageUrl } from '../../utils/escape'; export 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; animation?: string; animationDelay?: string; hideOnDesktop?: boolean; hideOnTablet?: boolean; hideOnMobile?: boolean; } /** Extracts the numeric portion of a plain "px" CSS length string, for * emitting real `width`/`height` HTML attributes on the exported `` * (helps the browser reserve layout space before the image loads -- * avoiding CLS -- something a CSS-only width/height can't do on its own). * Returns undefined for any other unit ('%', 'auto', '', etc.) so the * attribute is simply omitted when the pixel size isn't known. */ export function pxAttr(v: unknown): string | undefined { if (typeof v !== 'string') return undefined; const m = v.trim().match(/^(\d+(?:\.\d+)?)px$/); return m ? m[1] : undefined; } // 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 || PLACEHOLDER_SRC} alt={alt || 'Image'} onDrop={handleDrop} onDragOver={handleDragOver} style={{ display: 'block', maxWidth: '100%', outline: 'none', cursor: selected ? 'move' : 'pointer', ...style, }} /> ); }; ImageBlock.craft = { displayName: 'Image', props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto', aspectRatio: '', objectFit: '' as CSSProperties['objectFit'], objectPosition: '', marginTop: '', marginRight: '', marginBottom: '', marginLeft: '', paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '', border: 'none', boxShadow: 'none', opacity: '1', }, animation: '', animationDelay: '0', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false, }, rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true }, }; (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="${escapeAttr(props.alt)}"` : ' alt=""'; const widthAttr = pxAttr((props.style as any)?.width); const heightAttr = pxAttr((props.style as any)?.height); const dims = `${widthAttr ? ` width="${widthAttr}"` : ''}${heightAttr ? ` height="${heightAttr}"` : ''}`; return { html: `` }; };