fixed layout canvas including elements

This commit is contained in:
Codex Agent
2025-10-31 23:20:52 +01:00
parent eb0c31c90b
commit 81cdee428e
3 changed files with 230 additions and 219 deletions

View File

@@ -22,16 +22,12 @@ type DesignerCanvasProps = {
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,
@@ -45,7 +41,6 @@ export function DesignerCanvas({
badge,
qrCodeDataUrl,
logoDataUrl,
scale,
layoutKey,
readOnly = false,
}: DesignerCanvasProps): React.JSX.Element {
@@ -55,6 +50,7 @@ export function DesignerCanvas({
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 destroyCanvas = React.useCallback((canvas: fabric.Canvas | null) => {
if (!canvas) {
@@ -145,6 +141,7 @@ export function DesignerCanvas({
});
fabricCanvasRef.current = canvas;
lastRenderSignatureRef.current = null;
const disposeToken = ++disposeTokenRef.current;
(window as unknown as Record<string, unknown>).__inviteCanvas = canvas;
@@ -265,6 +262,24 @@ export function DesignerCanvas({
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,
@@ -276,7 +291,6 @@ export function DesignerCanvas({
backgroundColor: background,
backgroundGradient: gradient,
readOnly,
selectedId,
}).catch((error) => {
console.error('[Fabric] Failed to render layout', error);
});
@@ -290,7 +304,6 @@ export function DesignerCanvas({
logoDataUrl,
background,
gradient,
selectedId,
readOnly,
]);
@@ -299,16 +312,47 @@ export function DesignerCanvas({
if (!canvas) {
return;
}
canvas.setZoom(scale);
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;
}
canvas.setZoom(1);
canvas.setDimensions(
{
width: CANVAS_WIDTH * scale,
height: CANVAS_HEIGHT * scale,
width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
},
{ cssOnly: true },
);
canvas.requestRenderAll();
}, [scale]);
}, []);
return (
<div ref={containerRef} className="relative inline-block max-w-full">
@@ -333,7 +377,6 @@ export type FabricRenderOptions = {
backgroundColor: string;
backgroundGradient: { angle?: number; stops?: string[] } | null;
readOnly: boolean;
selectedId?: string | null;
};
export async function renderFabricLayout(
@@ -351,7 +394,6 @@ export async function renderFabricLayout(
backgroundColor,
backgroundGradient,
readOnly,
selectedId,
} = options;
canvas.discardActiveObject();
@@ -380,6 +422,10 @@ export async function renderFabricLayout(
);
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) {
@@ -412,15 +458,6 @@ export async function renderFabricLayout(
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();
}
@@ -556,6 +593,11 @@ export async function createFabricObject({
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)',
});
@@ -639,43 +681,98 @@ export async function loadImageObject(
options?: { objectFit?: 'contain' | 'cover'; shadow?: string },
): Promise<fabric.Object | null> {
return new Promise((resolve) => {
fabric.Image.fromURL(
source,
(image) => {
if (!image) {
resolve(null);
return;
}
let resolved = false;
const resolveSafely = (value: fabric.Object | null) => {
if (resolved) {
return;
}
resolved = true;
resolve(value);
};
const scaleX = element.width / (image.width ?? element.width);
const scaleY = element.height / (image.height ?? element.height);
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({
...baseConfig,
width: element.width,
height: element.height,
scaleX,
scaleY,
scaleX: ratio,
scaleY: ratio,
left: element.x + (element.width - intrinsicWidth * ratio) / 2,
top: element.y + (element.height - intrinsicHeight * ratio) / 2,
});
}
if (options?.shadow) {
image.set('shadow', options.shadow);
}
resolveSafely(image);
};
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,
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);
}
resolve(image);
},
{ crossOrigin: 'anonymous' },
);
window.setTimeout(() => resolveSafely(null), 3000);
});
}
@@ -689,31 +786,3 @@ export function mapTextAlign(align?: LayoutElement['align']): 'left' | 'center'
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 (
<div className="flex items-center gap-3 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] px-4 py-2 text-xs">
<span className="font-medium text-muted-foreground">Zoom</span>
<input
type="range"
min={min}
max={max}
step={0.025}
value={scale}
onChange={(event) => onChange(Number(event.target.value))}
className="h-1 w-32 overflow-hidden rounded-full"
/>
<span className="tabular-nums text-muted-foreground">{Math.round(scale * 100)}%</span>
</div>
);
}