924 lines
26 KiB
TypeScript
924 lines
26 KiB
TypeScript
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<LayoutElement>) => 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<HTMLCanvasElement | null>(null);
|
|
const fabricCanvasRef = React.useRef<fabric.Canvas | null>(null);
|
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
|
const disposeTokenRef = React.useRef(0);
|
|
const pendingDisposeRef = React.useRef<number | null>(null);
|
|
const pendingTimeoutRef = React.useRef<number | null>(null);
|
|
const lastRenderSignatureRef = React.useRef<string | null>(null);
|
|
const requestedSelectionRef = React.useRef<string | null>(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<string, unknown>) | 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<string, unknown>) | undefined;
|
|
if (lowerEl) {
|
|
if (lowerEl.__canvas === canvas) {
|
|
delete lowerEl.__canvas;
|
|
}
|
|
if (lowerEl.__fabricCanvas === canvas) {
|
|
delete lowerEl.__fabricCanvas;
|
|
}
|
|
}
|
|
|
|
const targetEl = canvas.getElement() as (HTMLCanvasElement & Record<string, unknown>) | 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<string, unknown>) }).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<string, unknown>).__inviteCanvas === canvas) {
|
|
delete (window as unknown as Record<string, unknown>).__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<string, unknown>).__inviteCanvas = canvas;
|
|
(element as unknown as { __fabricCanvas?: fabric.Canvas }).__fabricCanvas = canvas;
|
|
if (containerRef.current) {
|
|
const wrapper = containerRef.current as unknown as (HTMLElement & Record<string, unknown>);
|
|
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<MouseEvent>) => {
|
|
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<MouseEvent>) => {
|
|
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<LayoutElement> = {
|
|
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<MouseEvent> & { 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 (
|
|
<div ref={containerRef} className="relative inline-block max-w-full">
|
|
<canvas
|
|
ref={canvasElementRef}
|
|
width={CANVAS_WIDTH}
|
|
height={CANVAS_HEIGHT}
|
|
style={{ display: 'block' }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<void> {
|
|
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<fabric.Object | null> {
|
|
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<fabric.Object | null> {
|
|
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';
|
|
}
|
|
}
|