import React from 'react' const aspectRatios = { square: '1 / 1', portrait: '4 / 5', story: '9 / 16', landscape: '16 / 9', } const placementStyles = { 'top-left': { top: '12%', left: '12%' }, 'top-right': { top: '12%', right: '12%' }, 'bottom-left': { bottom: '12%', left: '12%' }, 'bottom-right': { bottom: '12%', right: '12%' }, } // --------------------------------------------------------------------------- // DraggableElement — wraps any canvas element with pointer-drag positioning. // Position is tracked locally during drag (avoids parent re-renders), then // committed to the parent via onMove(elementId, x%, y%) on pointer-up. // --------------------------------------------------------------------------- function DraggableElement({ elementId, canvasRef, freePos, savedWidth, onMove, style, className, children }) { const dragState = React.useRef(null) const [localPos, setLocalPos] = React.useState(null) const [localWidth, setLocalWidth] = React.useState(null) function handlePointerDown(event) { event.preventDefault() event.stopPropagation() const canvas = canvasRef.current if (!canvas) return const canvasRect = canvas.getBoundingClientRect() const elemRect = event.currentTarget.getBoundingClientRect() const offsetX = event.clientX - elemRect.left const offsetY = event.clientY - elemRect.top const startX = ((elemRect.left - canvasRect.left) / canvasRect.width) * 100 const startY = ((elemRect.top - canvasRect.top) / canvasRect.height) * 100 // Capture element width as % of canvas so it stays the same after going absolute. const widthPct = Math.round((elemRect.width / canvasRect.width) * 1000) / 10 dragState.current = { canvasRect, offsetX, offsetY, widthPct } event.currentTarget.setPointerCapture(event.pointerId) setLocalPos({ x: startX, y: startY }) setLocalWidth(widthPct) } function calcPos(event) { const { canvasRect, offsetX, offsetY } = dragState.current return { x: Math.max(0, Math.min(94, ((event.clientX - canvasRect.left - offsetX) / canvasRect.width) * 100)), y: Math.max(0, Math.min(92, ((event.clientY - canvasRect.top - offsetY) / canvasRect.height) * 100)), } } function handlePointerMove(event) { if (!dragState.current) return setLocalPos(calcPos(event)) } function handlePointerUp(event) { if (!dragState.current) return const pos = calcPos(event) const widthPct = dragState.current.widthPct dragState.current = null setLocalPos(null) setLocalWidth(null) onMove?.(elementId, Math.round(pos.x * 10) / 10, Math.round(pos.y * 10) / 10, widthPct) } const isDragging = localPos !== null const absPos = localPos ?? freePos // Width to use when the element is absolutely positioned. const resolvedWidth = localWidth ?? savedWidth const posStyle = absPos ? { position: 'absolute', left: `${absPos.x}%`, top: `${absPos.y}%`, zIndex: isDragging ? 50 : 10, width: resolvedWidth != null ? `${resolvedWidth}%` : undefined } : {} return (
{children}
) } function overlayStyle(style) { if (style === 'dark-strong') return 'linear-gradient(180deg, rgba(2,6,23,0.38), rgba(2,6,23,0.68))' if (style === 'light-soft') return 'linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.22))' if (style === 'dark-soft') return 'linear-gradient(180deg, rgba(2,6,23,0.18), rgba(2,6,23,0.48))' return 'none' } function positionStyle(position) { if (position === 'top') return { alignItems: 'flex-start', paddingTop: '10%' } if (position === 'upper-middle') return { alignItems: 'flex-start', paddingTop: '22%' } if (position === 'lower-middle') return { alignItems: 'flex-end', paddingBottom: '18%' } if (position === 'bottom') return { alignItems: 'flex-end', paddingBottom: '10%' } return { alignItems: 'center' } } function alignmentClass(alignment) { if (alignment === 'left') return 'justify-start items-start text-left' if (alignment === 'right') return 'justify-end items-end text-right' return 'justify-center items-center text-center' } function focalPositionStyle(position) { if (position === 'top') return 'center top' if (position === 'bottom') return 'center bottom' if (position === 'left') return 'left center' if (position === 'right') return 'right center' if (position === 'top-left') return 'left top' if (position === 'top-right') return 'right top' if (position === 'bottom-left') return 'left bottom' if (position === 'bottom-right') return 'right bottom' return 'center center' } function shadowValue(preset) { if (preset === 'none') return 'none' if (preset === 'strong') return '0 12px 36px rgba(2, 6, 23, 0.72)' return '0 8px 24px rgba(2, 6, 23, 0.5)' } function resolveTextBlocks(card, project) { const blocks = Array.isArray(project.text_blocks) ? project.text_blocks.filter((block) => block?.enabled !== false && String(block?.text || '').trim() !== '') : [] if (blocks.length) return blocks const content = project.content || {} return [ { key: 'title', type: 'title', text: card?.title || content.title || 'Untitled card', enabled: true }, { key: 'quote', type: 'quote', text: card?.quote_text || content.quote_text || 'Your next quote starts here.', enabled: true }, { key: 'author', type: 'author', text: card?.quote_author || content.quote_author || '', enabled: Boolean(card?.quote_author || content.quote_author) }, { key: 'source', type: 'source', text: card?.quote_source || content.quote_source || '', enabled: Boolean(card?.quote_source || content.quote_source) }, ].filter((block) => String(block.text || '').trim() !== '') } function blockClass(type) { if (type === 'title') return 'text-[11px] font-semibold uppercase tracking-[0.28em] text-white/55' if (type === 'author') return 'font-medium uppercase tracking-[0.18em] text-white/80 sm:text-sm lg:text-base' if (type === 'source') return 'text-[11px] uppercase tracking-[0.18em] text-white/50 sm:text-xs' if (type === 'caption') return 'text-[10px] uppercase tracking-[0.2em] text-white/45' if (type === 'body') return 'text-sm leading-6 text-white/90 sm:text-base' return 'font-semibold tracking-[-0.03em] sm:text-[1.65rem] lg:text-[2.1rem]' } function blockStyle(type, typography, textColor, accentColor, fontFamily) { const quoteSize = Math.max(10, Math.min(typography.quote_size || 72, 120)) const authorSize = Math.max(14, Math.min(typography.author_size || 28, 42)) const letterSpacing = Math.max(-1, Math.min(typography.letter_spacing || 0, 10)) const lineHeight = Math.max(0.9, Math.min(typography.line_height || 1.2, 1.8)) const shadowPreset = typography.shadow_preset || 'soft' // text_opacity: 10–100 (stored as integer percent), default 100 const opacity = typography.text_opacity != null ? Math.max(10, Math.min(100, Number(typography.text_opacity))) / 100 : 1 // Convert canvas pixels (1080-normalised) to container-query width units so // the preview proportions always match the rendered image regardless of how // wide the preview panel is. const toCqw = (px) => `${(px / 1080 * 100).toFixed(2)}cqw` const sizeMap = { title: toCqw(Math.max(16, quoteSize * 0.48)), quote: toCqw(quoteSize), author: toCqw(authorSize), source: toCqw(Math.max(12, authorSize * 0.82)), body: toCqw(Math.max(16, quoteSize * 0.54)), caption: toCqw(Math.max(12, authorSize * 0.74)), } const fontSize = sizeMap[type] ?? sizeMap.quote const font = fontFamily ? { fontFamily } : {} if (type === 'title') { return { ...font, opacity, color: accentColor, fontSize, letterSpacing: `${Math.max(letterSpacing, 0) / 10}em`, textShadow: shadowValue(shadowPreset) } } if (type === 'author' || type === 'source') { return { ...font, opacity, color: accentColor, fontSize, textShadow: shadowValue(shadowPreset) } } if (type === 'body' || type === 'caption') { return { ...font, opacity, color: textColor, fontSize, lineHeight, textShadow: shadowValue(shadowPreset) } } return { ...font, opacity, color: textColor, fontSize, lineHeight, letterSpacing: `${letterSpacing / 12}px`, textShadow: shadowValue(shadowPreset) } } export default function NovaCardCanvasPreview({ card, fonts = [], className = '', editable = false, onElementMove = null, renderMode = false }) { const canvasRef = React.useRef(null) const project = card?.project_json || {} const layout = project.layout || {} const typography = project.typography || {} const resolvedFont = fonts.find((f) => f.key === (typography.font_preset || 'modern-sans')) const fontFamily = resolvedFont?.family || null const background = project.background || {} const backgroundImage = card?.background_image?.processed_url const colors = Array.isArray(background.gradient_colors) && background.gradient_colors.length >= 2 ? background.gradient_colors : ['#0f172a', '#1d4ed8'] const backgroundStyle = background.type === 'upload' && backgroundImage ? `url(${backgroundImage}) ${focalPositionStyle(background.focal_position)}/cover no-repeat` : background.type === 'solid' ? background.solid_color || '#111827' : `linear-gradient(180deg, ${colors[0]}, ${colors[1]})` const allTextBlocks = resolveTextBlocks(card, project) // Blocks with a saved free position are rendered absolutely; others stay in normal flow. const flowTextBlocks = allTextBlocks.filter((b) => b.pos_x == null || b.pos_y == null) const freeTextBlocks = allTextBlocks.filter((b) => b.pos_x != null && b.pos_y != null) const decorations = Array.isArray(project.decorations) ? project.decorations : [] const assetItems = Array.isArray(project.assets?.items) ? project.assets.items : [] const textColor = typography.text_color || '#ffffff' const accentColor = typography.accent_color || textColor const layoutMaxWidth = layout.max_width === 'compact' ? '62%' : layout.max_width === 'wide' ? '88%' : '76%' const maxWidth = typography.quote_width != null ? `${typography.quote_width}%` : layoutMaxWidth const padding = layout.padding === 'tight' ? '8%' : layout.padding === 'airy' ? '14%' : '11%' return (
0 ? `blur(${Math.max(Number(background.blur_level) / 8, 0)}px)` : undefined }} />
{!renderMode && (
{(card?.format || 'square').replace('-', ' ')}
)} {/* Asset items (non-draggable) */} {assetItems.slice(0, 6).map((item, index) => { if (item?.type === 'frame') { const top = index % 2 === 0 ? '10%' : '88%' return
} return (
{item.glyph || item.label || '✦'}
) })} {/* Flow-positioned text blocks */}
{flowTextBlocks.map((block, index) => { const type = block?.type || 'body' const text = type === 'author' ? `— ${block.text}` : block.text const defStyle = blockStyle(type, typography, textColor, accentColor, fontFamily) const blockCls = blockClass(type) const blockKey = `${block.key || type}-${index}` if (editable) { return ( {text} ) } return (
{text}
) })}
{/* Absolutely-positioned text blocks (dragged out of flow) */} {freeTextBlocks.map((block, index) => { const type = block?.type || 'body' const text = type === 'author' ? `— ${block.text}` : block.text const defStyle = blockStyle(type, typography, textColor, accentColor, fontFamily) const blockCls = blockClass(type) const blockKey = `free-${block.key || type}-${index}` if (editable) { return ( {text} ) } return (
{text}
) })} {/* Decorations — rendered last so they sit on top of all text content */} {decorations.slice(0, 6).map((decoration, index) => { const hasFreePos = decoration.pos_x != null && decoration.pos_y != null const placementPos = placementStyles[decoration.placement] || placementStyles['top-right'] const decOpacity = decoration.opacity != null ? Math.max(10, Math.min(100, Number(decoration.opacity))) / 100 : 0.85 const decStyle = { color: accentColor, fontSize: `${(Math.max(18, Math.min(decoration.size || 28, 64)) / 1080 * 100).toFixed(2)}cqw`, opacity: decOpacity, zIndex: 20, } const decKey = `${decoration.key || decoration.glyph || 'dec'}-${index}` const decContent = decoration.glyph || '✦' if (editable) { const freePos = hasFreePos ? { x: decoration.pos_x, y: decoration.pos_y } : null const absStyle = hasFreePos ? { position: 'absolute', left: `${decoration.pos_x}%`, top: `${decoration.pos_y}%` } : { position: 'absolute', ...placementPos } return ( {decContent} ) } return (
{decContent}
) })}
) }