import React from 'react'; import * as fabric from 'fabric'; import { CANVAS_HEIGHT, CANVAS_WIDTH, LayoutElement, clamp, LayoutElementType, } from './schema'; type DesignerCanvasProps = { elements: LayoutElement[]; selectedId: string | null; onSelect: (id: string | null) => void; onChange: (id: string, patch: Partial) => void; background: string; gradient: { angle?: number; stops?: string[] } | null; accent: string; text: string; secondary: string; badge: string; qrCodeDataUrl: string | null; logoDataUrl: string | null; scale?: number; layoutKey?: string; readOnly?: boolean; }; type FabricObjectWithId = fabric.Object & { elementId?: string }; export function DesignerCanvas({ elements, selectedId, onSelect, onChange, background, gradient, accent, text, secondary, badge, qrCodeDataUrl, logoDataUrl, scale = 1, layoutKey, readOnly = false, }: DesignerCanvasProps): React.JSX.Element { const canvasElementRef = React.useRef(null); const fabricCanvasRef = React.useRef(null); const containerRef = React.useRef(null); const disposeTokenRef = React.useRef(0); const pendingDisposeRef = React.useRef(null); const pendingTimeoutRef = React.useRef(null); const lastRenderSignatureRef = React.useRef(null); const destroyCanvas = React.useCallback((canvas: fabric.Canvas | null) => { if (!canvas) { return; } if (fabricCanvasRef.current === canvas) { fabricCanvasRef.current = null; } const upperEl = canvas.upperCanvasEl as (HTMLElement & Record) | undefined; if (upperEl) { if (upperEl.__canvas === canvas) { delete upperEl.__canvas; } if (upperEl.__fabricCanvas === canvas) { delete upperEl.__fabricCanvas; } } const lowerEl = canvas.lowerCanvasEl as (HTMLElement & Record) | undefined; if (lowerEl) { if (lowerEl.__canvas === canvas) { delete lowerEl.__canvas; } if (lowerEl.__fabricCanvas === canvas) { delete lowerEl.__fabricCanvas; } } const targetEl = canvas.getElement() as (HTMLCanvasElement & Record) | undefined; if (targetEl) { if (targetEl.__canvas === canvas) { delete targetEl.__canvas; } if (targetEl.__fabricCanvas === canvas) { delete targetEl.__fabricCanvas; } } const wrapper = (canvas as unknown as { wrapperEl?: (HTMLElement & Record) }).wrapperEl; if (wrapper) { if (wrapper.__fabricCanvas === canvas) { delete wrapper.__fabricCanvas; } if (Object.getOwnPropertyDescriptor(wrapper, '__canvas')) { try { delete wrapper.__canvas; } catch (error) { console.warn('[Invites][Fabric] failed to delete wrapper __canvas', error); } } delete wrapper.dataset?.fabric; } if ((window as unknown as Record).__inviteCanvas === canvas) { delete (window as unknown as Record).__inviteCanvas; } try { canvas.dispose(); } catch (error) { console.warn('[Invites][Fabric] dispose failed', error); } }, []); React.useLayoutEffect(() => { const element = canvasElementRef.current; if (!element) { console.warn('[Invites][Fabric] canvas element missing'); return undefined; } if (pendingTimeoutRef.current !== null) { window.clearTimeout(pendingTimeoutRef.current); pendingTimeoutRef.current = null; pendingDisposeRef.current = null; } destroyCanvas(fabricCanvasRef.current); console.warn('[Invites][Fabric] initializing canvas element'); const canvas = new fabric.Canvas(element, { selection: !readOnly, preserveObjectStacking: true, perPixelTargetFind: true, }); fabricCanvasRef.current = canvas; lastRenderSignatureRef.current = null; const disposeToken = ++disposeTokenRef.current; (window as unknown as Record).__inviteCanvas = canvas; (element as unknown as { __fabricCanvas?: fabric.Canvas }).__fabricCanvas = canvas; if (containerRef.current) { const wrapper = containerRef.current as (HTMLElement & Record); wrapper.__fabricCanvas = canvas; Object.defineProperty(wrapper, '__canvas', { configurable: true, enumerable: false, writable: true, value: canvas, }); wrapper.dataset.fabric = 'ready'; } return () => { const timeoutId = window.setTimeout(() => { if (disposeTokenRef.current !== disposeToken) { return; } destroyCanvas(canvas); pendingTimeoutRef.current = null; pendingDisposeRef.current = null; }, 0); pendingTimeoutRef.current = timeoutId; pendingDisposeRef.current = disposeToken; }; }, [destroyCanvas, readOnly]); React.useEffect(() => { const canvas = fabricCanvasRef.current; if (!canvas) { return; } canvas.selection = !readOnly; canvas.forEachObject((object) => { object.set({ selectable: !readOnly, hoverCursor: readOnly ? 'default' : 'move', }); }); }, [readOnly]); React.useEffect(() => { const canvas = fabricCanvasRef.current; if (!canvas) { return; } const handleSelection = () => { if (readOnly) { return; } const active = canvas.getActiveObject() as FabricObjectWithId | null; if (!active || typeof active.elementId !== 'string') { onSelect(null); return; } onSelect(active.elementId); }; const handleSelectionCleared = () => { if (readOnly) { return; } onSelect(null); }; const handleObjectModified = (event: fabric.IEvent) => { if (readOnly) { return; } const target = event.target as FabricObjectWithId | undefined; if (!target || typeof target.elementId !== 'string') { return; } const elementId = target.elementId; const bounds = target.getBoundingRect(true, true); const nextPatch: Partial = { x: clamp(Math.round(bounds.left ?? 0), 0, CANVAS_WIDTH), y: clamp(Math.round(bounds.top ?? 0), 0, CANVAS_HEIGHT), width: clamp(Math.round(bounds.width ?? target.width ?? 0), 40, CANVAS_WIDTH), height: clamp(Math.round(bounds.height ?? target.height ?? 0), 40, CANVAS_HEIGHT), }; target.set({ scaleX: 1, scaleY: 1, left: nextPatch.x, top: nextPatch.y, width: nextPatch.width, height: nextPatch.height, }); onChange(elementId, nextPatch); canvas.requestRenderAll(); }; canvas.on('selection:created', handleSelection); canvas.on('selection:updated', handleSelection); canvas.on('selection:cleared', handleSelectionCleared); canvas.on('object:modified', handleObjectModified); return () => { canvas.off('selection:created', handleSelection); canvas.off('selection:updated', handleSelection); canvas.off('selection:cleared', handleSelectionCleared); canvas.off('object:modified', handleObjectModified); }; }, [onChange, onSelect, readOnly]); React.useEffect(() => { const canvas = fabricCanvasRef.current; if (!canvas) { return; } const signature = JSON.stringify({ elements, accent, text, secondary, badge, qrCodeDataUrl, logoDataUrl, background, gradient, readOnly, }); if (lastRenderSignatureRef.current === signature) { return; } lastRenderSignatureRef.current = signature; renderFabricLayout(canvas, { elements, accentColor: accent, textColor: text, secondaryColor: secondary, badgeColor: badge, qrCodeDataUrl, logoDataUrl, backgroundColor: background, backgroundGradient: gradient, readOnly, }).catch((error) => { console.error('[Fabric] Failed to render layout', error); }); }, [ elements, accent, text, secondary, badge, qrCodeDataUrl, logoDataUrl, background, gradient, readOnly, ]); React.useEffect(() => { const canvas = fabricCanvasRef.current; if (!canvas) { return; } if (readOnly) { canvas.discardActiveObject(); canvas.requestRenderAll(); return; } if (!selectedId) { canvas.discardActiveObject(); canvas.requestRenderAll(); return; } const match = canvas .getObjects() .find((object) => (object as FabricObjectWithId).elementId === selectedId); if (match) { canvas.setActiveObject(match); } else { canvas.discardActiveObject(); } canvas.requestRenderAll(); }, [selectedId, readOnly]); React.useEffect(() => { const canvas = fabricCanvasRef.current; if (!canvas) { return; } const normalizedScale = Number.isFinite(scale) && scale > 0 ? scale : 1; canvas.setZoom(normalizedScale); const cssWidth = CANVAS_WIDTH * normalizedScale; const cssHeight = CANVAS_HEIGHT * normalizedScale; const element = canvas.getElement(); if (element) { element.style.width = `${cssWidth}px`; element.style.height = `${cssHeight}px`; } if (canvas.upperCanvasEl) { canvas.upperCanvasEl.style.width = `${cssWidth}px`; canvas.upperCanvasEl.style.height = `${cssHeight}px`; } if (canvas.lowerCanvasEl) { canvas.lowerCanvasEl.style.width = `${cssWidth}px`; canvas.lowerCanvasEl.style.height = `${cssHeight}px`; } if (canvas.wrapperEl) { canvas.wrapperEl.style.width = `${cssWidth}px`; canvas.wrapperEl.style.height = `${cssHeight}px`; } if (containerRef.current) { containerRef.current.style.width = `${cssWidth}px`; containerRef.current.style.height = `${cssHeight}px`; } canvas.calcOffset(); canvas.requestRenderAll(); }, [scale]); return (
); } export type FabricRenderOptions = { elements: LayoutElement[]; accentColor: string; textColor: string; secondaryColor: string; badgeColor: string; qrCodeDataUrl: string | null; logoDataUrl: string | null; backgroundColor: string; backgroundGradient: { angle?: number; stops?: string[] } | null; readOnly: boolean; }; export async function renderFabricLayout( canvas: fabric.Canvas, options: FabricRenderOptions, ): Promise { const { elements, accentColor, textColor, secondaryColor, badgeColor, qrCodeDataUrl, logoDataUrl, backgroundColor, backgroundGradient, readOnly, } = options; canvas.discardActiveObject(); canvas.getObjects().forEach((object) => canvas.remove(object)); applyBackground(canvas, backgroundColor, backgroundGradient); console.debug('[Invites][Fabric] render', { elementCount: elements.length, backgroundColor, hasGradient: Boolean(backgroundGradient), readOnly, }); const objectPromises = elements.map((element) => createFabricObject({ element, accentColor, textColor, secondaryColor, badgeColor, qrCodeDataUrl, logoDataUrl, readOnly, }), ); const fabricObjects = await Promise.all(objectPromises); console.debug('[Invites][Fabric] resolved objects', { count: fabricObjects.length, nulls: fabricObjects.filter((obj) => !obj).length, }); fabricObjects.forEach((object) => { if (!object) { console.debug('[Invites][Fabric] Skip null fabric object'); return; } if (readOnly) { object.set({ selectable: false, hoverCursor: 'default', }); } try { canvas.add(object); if (typeof object.setCoords === 'function') { object.setCoords(); } const bounds = object.getBoundingRect(true, true); console.warn('[Invites][Fabric] added object', { elementId: (object as FabricObjectWithId).elementId, left: bounds.left, top: bounds.top, width: bounds.width, height: bounds.height, }); } catch (error) { console.error('[Invites][Fabric] failed to add object', error); } }); console.warn('[Invites][Fabric] object count', canvas.getObjects().length); canvas.renderAll(); } export function applyBackground( canvas: fabric.Canvas, color: string, gradient: { angle?: number; stops?: string[] } | null, ): void { let background: string | fabric.Gradient = color; if (gradient?.stops?.length) { const angle = ((gradient.angle ?? 180) * Math.PI) / 180; const halfWidth = CANVAS_WIDTH / 2; const halfHeight = CANVAS_HEIGHT / 2; const x = Math.cos(angle); const y = Math.sin(angle); background = new fabric.Gradient({ type: 'linear', coords: { x1: halfWidth - x * halfWidth, y1: halfHeight - y * halfHeight, x2: halfWidth + x * halfWidth, y2: halfHeight + y * halfHeight, }, colorStops: gradient.stops.map((stop, index) => ({ offset: gradient.stops.length === 1 ? 0 : index / (gradient.stops.length - 1), color: stop, })), }); } const canvasWithBackgroundFn = canvas as fabric.Canvas & { setBackgroundColor?: (value: string | fabric.Gradient, callback?: () => void) => void; }; if (typeof canvasWithBackgroundFn.setBackgroundColor === 'function') { canvasWithBackgroundFn.setBackgroundColor(background, () => canvas.requestRenderAll()); } else { canvas.backgroundColor = background; canvas.requestRenderAll(); } } export type FabricObjectFactoryContext = { element: LayoutElement; accentColor: string; textColor: string; secondaryColor: string; badgeColor: string; qrCodeDataUrl: string | null; logoDataUrl: string | null; readOnly: boolean; }; export async function createFabricObject({ element, accentColor, textColor, secondaryColor, badgeColor, qrCodeDataUrl, logoDataUrl, readOnly, }: FabricObjectFactoryContext): Promise { console.debug('[Invites][Fabric] create element', { id: element.id, type: element.type, width: element.width, height: element.height, content: element.content, }); const baseConfig = { left: element.x, top: element.y, elementId: element.id, selectable: !readOnly, hasBorders: !readOnly, hasControls: !readOnly, } as FabricObjectWithId; switch (element.type) { case 'headline': case 'subtitle': case 'description': case 'text': return new fabric.Textbox(element.content ?? '', { ...baseConfig, width: element.width, height: element.height, fontSize: element.fontSize ?? 26, fill: textColor, textAlign: mapTextAlign(element.align), }); case 'link': return new fabric.Textbox(element.content ?? '', { ...baseConfig, width: element.width, height: element.height, fontSize: element.fontSize ?? 24, fill: accentColor, underline: true, textAlign: mapTextAlign(element.align), }); case 'badge': return createTextBadge({ baseConfig, text: element.content ?? '', width: element.width, height: element.height, backgroundColor: badgeColor, textColor: '#ffffff', fontSize: element.fontSize ?? 22, }); case 'cta': return createTextBadge({ baseConfig, text: element.content ?? '', width: element.width, height: element.height, backgroundColor: accentColor, textColor: '#ffffff', fontSize: element.fontSize ?? 24, cornerRadius: 18, }); case 'logo': if (logoDataUrl) { return loadImageObject(logoDataUrl, element, baseConfig, { objectFit: 'contain', }); } return null; case 'qr': if (qrCodeDataUrl) { console.debug( '[Invites][Fabric] qr image source', qrCodeDataUrl.length, qrCodeDataUrl.slice(0, 48), ); return loadImageObject(qrCodeDataUrl, element, baseConfig, { shadow: 'rgba(15,23,42,0.25)', }); } return new fabric.Rect({ ...baseConfig, width: element.width, height: element.height, fill: secondaryColor, rx: 20, ry: 20, }); default: return new fabric.Textbox(element.content ?? '', { ...baseConfig, width: element.width, height: element.height, fontSize: element.fontSize ?? 24, fill: secondaryColor, textAlign: mapTextAlign(element.align), }); } } export function createTextBadge({ baseConfig, text, width, height, backgroundColor, textColor, fontSize, cornerRadius = 12, }: { baseConfig: FabricObjectWithId; text: string; width: number; height: number; backgroundColor: string; textColor: string; fontSize: number; cornerRadius?: number; }): fabric.Group { const rect = new fabric.Rect({ width, height, rx: cornerRadius, ry: cornerRadius, fill: backgroundColor, left: 0, top: 0, selectable: false, evented: false, }); const label = new fabric.Textbox(text, { width: width - 32, left: 16, top: height / 2, fontSize, fill: textColor, originY: 'center', textAlign: 'center', selectable: false, evented: false, }); return new fabric.Group([rect, label], { ...baseConfig, width, height, originX: 'left', originY: 'top', }) as fabric.Group & FabricObjectWithId; } export async function loadImageObject( source: string, element: LayoutElement, baseConfig: FabricObjectWithId, options?: { objectFit?: 'contain' | 'cover'; shadow?: string }, ): Promise { return new Promise((resolve) => { let resolved = false; const resolveSafely = (value: fabric.Object | null) => { if (resolved) { return; } resolved = true; resolve(value); }; const isDataUrl = source.startsWith('data:'); const onImageLoaded = (img?: HTMLImageElement | HTMLCanvasElement | null) => { if (!img) { console.warn('[Invites][Fabric] image load returned empty', { source }); resolveSafely(null); return; } const image = new fabric.Image(img, { ...baseConfig }); const intrinsicWidth = image.width ?? element.width; const intrinsicHeight = image.height ?? element.height; const scaleX = element.width / intrinsicWidth; const scaleY = element.height / intrinsicHeight; image.set({ ...baseConfig, width: element.width, height: element.height, scaleX, scaleY, }); if (options?.shadow) { image.set('shadow', options.shadow); } if (options?.objectFit === 'contain') { const ratio = Math.min(scaleX, scaleY); image.set({ scaleX: ratio, scaleY: ratio, left: element.x + (element.width - intrinsicWidth * ratio) / 2, top: element.y + (element.height - intrinsicHeight * ratio) / 2, }); } resolveSafely(image); }; const onError = (error?: unknown) => { console.warn('[Invites][Fabric] failed to load image', source, error); resolveSafely(null); }; try { if (isDataUrl) { const imageElement = new Image(); imageElement.onload = () => { console.debug('[Invites][Fabric] image loaded (data-url)', { source: source.slice(0, 48), width: imageElement.naturalWidth, height: imageElement.naturalHeight, }); onImageLoaded(imageElement); }; imageElement.onerror = onError; imageElement.src = source; } else { fabric.util.loadImage( source, (img) => { if (!img) { onError(); return; } console.debug('[Invites][Fabric] image loaded', { source: source.slice(0, 48), width: (img as HTMLImageElement).width, height: (img as HTMLImageElement).height, }); onImageLoaded(img); }, undefined, 'anonymous', ); } } catch (error) { onError(error); } window.setTimeout(() => resolveSafely(null), 3000); }); } export function mapTextAlign(align?: LayoutElement['align']): 'left' | 'center' | 'right' { switch (align) { case 'center': return 'center'; case 'right': return 'right'; default: return 'left'; } }