import React from 'react'; import * as fabric from 'fabric'; import { CANVAS_HEIGHT, CANVAS_WIDTH, LayoutElement, clamp, } 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; 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, 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 requestedSelectionRef = React.useRef(selectedId); React.useEffect(() => { requestedSelectionRef.current = selectedId; }, [selectedId]); const destroyCanvas = React.useCallback((canvas: fabric.Canvas | null) => { if (!canvas) { return; } if (fabricCanvasRef.current === canvas) { fabricCanvasRef.current = null; } const upperEl = canvas.upperCanvasEl as unknown 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 unknown 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, transparentCorners: true, cornerSize: 8, padding: readOnly ? 0 : 10, // Default padding for text/objects, 0 for readonly }); 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 unknown 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(() => { 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: fabric.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; } requestedSelectionRef.current = active.elementId ?? null; onSelect(active.elementId); }; const handleSelectionCleared = (event?: fabric.IEvent) => { const pointerEvent = event?.e; if (readOnly) { return; } const triggeredByPointer = Boolean(pointerEvent?.e); if (!triggeredByPointer && requestedSelectionRef.current) { return; } requestedSelectionRef.current = null; 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(); const nextPatch: Partial = { x: clamp(Math.round(bounds.left ?? 0), 20, CANVAS_WIDTH - 20), y: clamp(Math.round(bounds.top ?? 0), 20, CANVAS_HEIGHT - 20), }; // Manual collision check: Calculate overlap and push vertically const otherObjects = canvas .getObjects() .filter((obj): obj is FabricObjectWithId => obj !== target && Boolean((obj as FabricObjectWithId).elementId)); otherObjects.forEach((other) => { const otherBounds = other.getBoundingRect(); const overlapX = Math.max(0, Math.min(bounds.left + bounds.width, otherBounds.left + otherBounds.width) - Math.max(bounds.left, otherBounds.left)); const overlapY = Math.max(0, Math.min(bounds.top + bounds.height, otherBounds.top + otherBounds.height) - Math.max(bounds.top, otherBounds.top)); if (overlapX > 0 && overlapY > 0) { // Push down by 120px if overlap (massive spacing für größeren QR-Code) nextPatch.y = Math.max(nextPatch.y ?? 0, (Number(otherBounds.top || 0)) + (Number(otherBounds.height || 0)) + 120); } }); const isImage = target.type === 'image'; if (isImage) { const currentScaleX = target.scaleX ?? 1; const currentScaleY = target.scaleY ?? 1; const naturalWidth = target.width ?? 0; const naturalHeight = target.height ?? 0; if (elementId === 'qr') { // For QR: Enforce uniform scale, cap size, padding=0 const avgScale = (currentScaleX + currentScaleY) / 2; const cappedSize = Math.min(Math.round(naturalWidth * avgScale), 800); // Cap at 800px for massive QR nextPatch.width = cappedSize; nextPatch.height = cappedSize; nextPatch.scaleX = cappedSize / naturalWidth; nextPatch.scaleY = cappedSize / naturalHeight; target.set({ left: nextPatch.x, top: nextPatch.y, scaleX: nextPatch.scaleX, scaleY: nextPatch.scaleY, padding: 12, // Increased padding for better frame visibility uniformScaling: true, // Lock aspect ratio lockScalingFlip: true, }); } else { nextPatch.width = Math.round(naturalWidth * currentScaleX); nextPatch.height = Math.round(naturalHeight * currentScaleY); nextPatch.scaleX = currentScaleX; nextPatch.scaleY = currentScaleY; target.set({ left: nextPatch.x, top: nextPatch.y, padding: 10 }); } } else { nextPatch.width = clamp(Math.round(bounds.width ?? target.width ?? 0), 40, CANVAS_WIDTH - 40); nextPatch.height = clamp(Math.round(bounds.height ?? target.height ?? 0), 40, CANVAS_HEIGHT - 40); target.set({ scaleX: 1, scaleY: 1, left: nextPatch.x, top: nextPatch.y, width: nextPatch.width, height: nextPatch.height, padding: 10, // Default padding for text }); } 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); const handleEditingExited = (event: fabric.IEvent & { target?: FabricObjectWithId & { text?: string } }) => { if (readOnly) { return; } const target = event?.target; if (!target || typeof target.elementId !== 'string') { return; } const updatedText = typeof (target as fabric.Textbox).text === 'string' ? (target as fabric.Textbox).text : target.text ?? ''; handleObjectModified({ target }); onChange(target.elementId, { content: updatedText }); canvas.requestRenderAll(); }; canvas.on('editing:exited', handleEditingExited); return () => { canvas.off('selection:created', handleSelection); canvas.off('selection:updated', handleSelection); canvas.off('selection:cleared', handleSelectionCleared); canvas.off('object:modified', handleObjectModified); canvas.off('editing:exited', handleEditingExited); }; }, [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 is FabricObjectWithId => (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.viewportTransform = [normalizedScale, 0, 0, normalizedScale, 0, 0]; canvas.setDimensions({ width: CANVAS_WIDTH * normalizedScale, height: CANVAS_HEIGHT * normalizedScale, }); canvas.requestRenderAll(); canvas.calcViewportBoundaries(); console.log('Zoom applied:', normalizedScale, 'Transform:', canvas.viewportTransform); }, [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.clear(); applyBackground(canvas, backgroundColor, backgroundGradient); console.debug('[Invites][Fabric] render', { elementCount: elements.length, backgroundColor, hasGradient: Boolean(backgroundGradient), readOnly, }); const abortController = new AbortController(); const objectPromises = elements.map((element) => createFabricObject({ element, accentColor, textColor, secondaryColor, badgeColor, qrCodeDataUrl, logoDataUrl, readOnly, }), ); const fabricObjects = await Promise.all(objectPromises); abortController.abort(); // Abort any pending loads 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(); 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<'linear'> = 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<'linear'>, 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 ?? 36, fill: textColor, fontFamily: element.fontFamily ?? 'Lora', textAlign: mapTextAlign(element.align), lineHeight: element.lineHeight ?? 1.5, charSpacing: element.letterSpacing ?? 0.5, padding: 12, // Enhanced padding for better readability }); case 'link': return new fabric.Textbox(element.content ?? '', { ...baseConfig, width: element.width, height: element.height, fontSize: element.fontSize ?? 24, fill: accentColor, fontFamily: element.fontFamily ?? 'Montserrat', underline: true, textAlign: mapTextAlign(element.align), lineHeight: element.lineHeight ?? 1.5, charSpacing: element.letterSpacing ?? 0.5, padding: 10, }); case 'badge': return createTextBadge({ baseConfig, text: element.content ?? '', width: element.width, height: element.height, backgroundColor: badgeColor, textColor: '#ffffff', fontSize: element.fontSize ?? 22, lineHeight: element.lineHeight ?? 1.5, letterSpacing: element.letterSpacing ?? 0.5, }); case 'cta': return createTextBadge({ baseConfig, text: element.content ?? '', width: element.width, height: element.height, backgroundColor: accentColor, textColor: '#ffffff', fontSize: element.fontSize ?? 24, cornerRadius: 18, lineHeight: element.lineHeight ?? 1.5, letterSpacing: element.letterSpacing ?? 0.5, }); 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), ); const qrImage = await loadImageObject(qrCodeDataUrl, element, baseConfig, { shadow: 'rgba(15,23,42,0.25)', padding: 0, // No padding to fix large frame }); if (qrImage) { if (qrImage instanceof fabric.Image) { qrImage.uniformScaling = true; // Lock aspect ratio } qrImage.lockScalingFlip = true; qrImage.padding = 0; qrImage.cornerColor = 'transparent'; qrImage.borderScaleFactor = 1; // Prevent border inflation on scale } console.log('QR DataURL:', qrCodeDataUrl ? 'Loaded' : 'Fallback'); return qrImage; } console.log('QR Fallback used - DataURL missing'); return new fabric.Rect({ ...baseConfig, width: element.width, height: element.height, fill: 'white', stroke: secondaryColor, strokeWidth: 2, rx: 20, ry: 20, }); default: return new fabric.Textbox(element.content ?? '', { ...baseConfig, width: element.width, height: element.height, fontSize: element.fontSize ?? 24, fill: secondaryColor, fontFamily: element.fontFamily ?? 'Lora', textAlign: mapTextAlign(element.align), }); } } export function createTextBadge({ baseConfig, text, width, height, backgroundColor, textColor, fontSize, cornerRadius = 12, lineHeight = 1.5, letterSpacing = 0.5, }: { baseConfig: FabricObjectWithId; text: string; width: number; height: number; backgroundColor: string; textColor: string; fontSize: number; cornerRadius?: number; lineHeight?: number; letterSpacing?: 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, fontFamily: 'Montserrat', originY: 'center', textAlign: 'center', lineHeight, charSpacing: letterSpacing, 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; padding?: number }, abortSignal?: AbortSignal, ): 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 || resolved) { 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, padding: options?.padding ?? 0, }); 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) => { if (resolved) return; console.warn('[Invites][Fabric] failed to load image', source, error); resolveSafely(null); }; const abortHandler = () => { if (resolved) return; console.debug('[Invites][Fabric] Image load aborted', { source }); resolveSafely(null); }; if (abortSignal) { abortSignal.addEventListener('abort', abortHandler); } try { if (isDataUrl) { const imageElement = new Image(); imageElement.onload = () => { if (resolved) return; 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 { // Use direct Image constructor approach for better compatibility const img = new Image(); img.onload = () => { if (resolved) return; console.debug('[Invites][Fabric] image loaded', { source: source.slice(0, 48), width: img.width, height: img.height, }); onImageLoaded(img); }; img.onerror = onError; img.src = source; } } catch (error) { onError(error); } const timeoutId = window.setTimeout(() => { if (resolved) return; resolveSafely(null); }, 3000); return () => { clearTimeout(timeoutId); if (abortSignal) { abortSignal.removeEventListener('abort', abortHandler); } }; }); } export function mapTextAlign(align?: LayoutElement['align']): 'left' | 'center' | 'right' { switch (align) { case 'center': return 'center'; case 'right': return 'right'; default: return 'left'; } }