fixed layout canvas including elements
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user