Des weiteren: neue Blogartikel und howto-Artikel von ChatGPT. Das QR-Code-Canvas funktioniert nun noch besser. die Layouts sehen besser aus. Der PaketSeeder enthält nun die Paddle Sandbox ProductIDs
908 lines
25 KiB
TypeScript
908 lines
25 KiB
TypeScript
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<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;
|
|
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<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(() => {
|
|
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;
|
|
}
|
|
requestedSelectionRef.current = active.elementId ?? null;
|
|
onSelect(active.elementId);
|
|
};
|
|
|
|
const handleSelectionCleared = (event?: fabric.IEvent<MouseEvent>) => {
|
|
if (readOnly) {
|
|
return;
|
|
}
|
|
const triggeredByPointer = Boolean(event?.e);
|
|
if (!triggeredByPointer && requestedSelectionRef.current) {
|
|
return;
|
|
}
|
|
requestedSelectionRef.current = null;
|
|
onSelect(null);
|
|
};
|
|
|
|
const handleObjectModified = (e: any) => {
|
|
if (readOnly) {
|
|
return;
|
|
}
|
|
const target = e.target as FabricObjectWithId | undefined;
|
|
if (!target || typeof target.elementId !== 'string') {
|
|
return;
|
|
}
|
|
const elementId = target.elementId;
|
|
|
|
const bounds = target.getBoundingRect();
|
|
let 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 !== target && (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, (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);
|
|
|
|
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.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,
|
|
}, abortController.signal),
|
|
);
|
|
|
|
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) {
|
|
(qrImage as any).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, reject) => {
|
|
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';
|
|
}
|
|
}
|