Files
SkinbaseNova/resources/js/components/nova-cards/NovaCardCanvasPreview.jsx

380 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div
style={{ ...style, ...posStyle, cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none' }}
className={`${className} rounded ${isDragging ? 'ring-2 ring-sky-400/70 opacity-90' : 'ring-1 ring-transparent hover:ring-white/30'} transition-[box-shadow]`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{children}
</div>
)
}
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: 10100 (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 (
<div ref={canvasRef} data-card-canvas className={`relative overflow-hidden bg-slate-950 [container-type:inline-size] ${renderMode ? '' : 'rounded-[28px] border border-white/10 shadow-[0_30px_80px_rgba(2,6,23,0.35)]'} ${className}`} style={{ aspectRatio: aspectRatios[card?.format || 'square'] || aspectRatios.square }}>
<div className="absolute inset-0" style={{ background: backgroundStyle, filter: background.type === 'upload' && Number(background.blur_level || 0) > 0 ? `blur(${Math.max(Number(background.blur_level) / 8, 0)}px)` : undefined }} />
<div className="absolute inset-0" style={{ background: overlayStyle(background.overlay_style), opacity: Math.max(0, Math.min(Number(background.opacity || 50), 100)) / 100 }} />
{!renderMode && (
<div className="absolute left-4 top-4 rounded-full border border-white/10 bg-black/25 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/80 backdrop-blur">
{(card?.format || 'square').replace('-', ' ')}
</div>
)}
{/* Asset items (non-draggable) */}
{assetItems.slice(0, 6).map((item, index) => {
if (item?.type === 'frame') {
const top = index % 2 === 0 ? '10%' : '88%'
return <div key={`${item.asset_key || item.label || 'frame'}-${index}`} className="absolute left-[12%] right-[12%] h-px bg-white/35" style={{ top }} />
}
return (
<div
key={`${item.asset_key || item.label || 'asset'}-${index}`}
className="absolute text-white/85 drop-shadow-[0_6px_14px_rgba(2,6,23,0.3)]"
style={{
top: `${12 + ((index % 3) * 18)}%`,
left: `${10 + (Math.floor(index / 3) * 72)}%`,
color: accentColor,
fontSize: `${(Math.max(18, Math.min(item.size || 26, 56)) / 1080 * 100).toFixed(2)}cqw`,
}}
>
{item.glyph || item.label || '✦'}
</div>
)
})}
{/* Flow-positioned text blocks */}
<div className={`relative flex h-full w-full ${alignmentClass(layout.alignment)}`} style={{ padding, ...positionStyle(layout.position) }}>
<div className="flex w-full flex-col gap-4" style={{ maxWidth }}>
{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 (
<DraggableElement
key={blockKey}
elementId={`block:${block.key || type}`}
canvasRef={canvasRef}
freePos={null}
onMove={onElementMove}
style={defStyle}
className={blockCls}
>
{text}
</DraggableElement>
)
}
return (
<div key={blockKey} style={defStyle} className={blockCls}>
{text}
</div>
)
})}
</div>
</div>
{/* 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 (
<DraggableElement
key={blockKey}
elementId={`block:${block.key || type}`}
canvasRef={canvasRef}
freePos={{ x: block.pos_x, y: block.pos_y }}
savedWidth={block.pos_width ?? null}
onMove={onElementMove}
style={defStyle}
className={blockCls}
>
{text}
</DraggableElement>
)
}
return (
<div
key={blockKey}
style={{ ...defStyle, position: 'absolute', left: `${block.pos_x}%`, top: `${block.pos_y}%`, ...(block.pos_width != null ? { width: `${block.pos_width}%` } : {}) }}
className={blockCls}
>
{text}
</div>
)
})}
{/* 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 (
<DraggableElement
key={decKey}
elementId={`decoration:${index}`}
canvasRef={canvasRef}
freePos={freePos}
savedWidth={null}
onMove={onElementMove}
style={{ ...absStyle, ...decStyle }}
className="drop-shadow-[0_6px_14px_rgba(2,6,23,0.3)]"
>
{decContent}
</DraggableElement>
)
}
return (
<div
key={decKey}
className="absolute drop-shadow-[0_6px_14px_rgba(2,6,23,0.3)]"
style={{ ...placementPos, ...decStyle }}
>
{decContent}
</div>
)
})}
</div>
)
}