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 }; const DEFAULT_MIN_SCALE = 0.15; const DEFAULT_MAX_SCALE = 0.85; export function DesignerCanvas({ elements, selectedId, onSelect, onChange, background, gradient, accent, text, secondary, badge, qrCodeDataUrl, logoDataUrl, scale, 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 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; 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; } renderFabricLayout(canvas, { elements, accentColor: accent, textColor: text, secondaryColor: secondary, badgeColor: badge, qrCodeDataUrl, logoDataUrl, backgroundColor: background, backgroundGradient: gradient, readOnly, selectedId, }).catch((error) => { console.error('[Fabric] Failed to render layout', error); }); }, [ elements, accent, text, secondary, badge, qrCodeDataUrl, logoDataUrl, background, gradient, selectedId, readOnly, ]); React.useEffect(() => { const canvas = fabricCanvasRef.current; if (!canvas) { return; } canvas.setZoom(scale); canvas.setDimensions( { width: CANVAS_WIDTH * scale, height: CANVAS_HEIGHT * scale, }, { cssOnly: true }, ); 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; selectedId?: string | null; }; export async function renderFabricLayout( canvas: fabric.Canvas, options: FabricRenderOptions, ): Promise { const { elements, accentColor, textColor, secondaryColor, badgeColor, qrCodeDataUrl, logoDataUrl, backgroundColor, backgroundGradient, readOnly, selectedId, } = 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); 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); if (!readOnly && selectedId) { const match = canvas .getObjects() .find((object) => (object as FabricObjectWithId).elementId === selectedId); if (match) { canvas.setActiveObject(match); } } 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) { 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) => { fabric.Image.fromURL( source, (image) => { if (!image) { resolve(null); return; } const scaleX = element.width / (image.width ?? element.width); const scaleY = element.height / (image.height ?? element.height); 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 - (image.width ?? 0) * ratio) / 2, top: element.y + (element.height - (image.height ?? 0) * ratio) / 2, }); } resolve(image); }, { crossOrigin: 'anonymous' }, ); }); } export function mapTextAlign(align?: LayoutElement['align']): 'left' | 'center' | 'right' { switch (align) { case 'center': return 'center'; case 'right': return 'right'; default: return 'left'; } } export function CanvasScaleControl({ scale, min = DEFAULT_MIN_SCALE, max = DEFAULT_MAX_SCALE, onChange, }: { scale: number; min?: number; max?: number; onChange: (value: number) => void; }): React.JSX.Element { return (
Zoom onChange(Number(event.target.value))} className="h-1 w-32 overflow-hidden rounded-full" /> {Math.round(scale * 100)}%
); }