Files
fotospiel-app/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx
2025-10-31 20:19:09 +01:00

720 lines
18 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 };
const DEFAULT_MIN_SCALE = 0.15;
const DEFAULT_MAX_SCALE = 0.85;
export function DesignerCanvas({
elements,
selectedId,
onSelect,
onChange,
background,
gradient,
accent,
text,
secondary,
badge,
qrCodeDataUrl,
logoDataUrl,
scale,
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 destroyCanvas = React.useCallback((canvas: fabric.Canvas | null) => {
if (!canvas) {
return;
}
if (fabricCanvasRef.current === canvas) {
fabricCanvasRef.current = null;
}
const upperEl = canvas.upperCanvasEl 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 (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,
});
fabricCanvasRef.current = canvas;
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 (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;
}
onSelect(active.elementId);
};
const handleSelectionCleared = () => {
if (readOnly) {
return;
}
onSelect(null);
};
const handleObjectModified = (event: fabric.IEvent<fabric.Object>) => {
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(true, true);
const nextPatch: Partial<LayoutElement> = {
x: clamp(Math.round(bounds.left ?? 0), 0, CANVAS_WIDTH),
y: clamp(Math.round(bounds.top ?? 0), 0, CANVAS_HEIGHT),
width: clamp(Math.round(bounds.width ?? target.width ?? 0), 40, CANVAS_WIDTH),
height: clamp(Math.round(bounds.height ?? target.height ?? 0), 40, CANVAS_HEIGHT),
};
target.set({
scaleX: 1,
scaleY: 1,
left: nextPatch.x,
top: nextPatch.y,
width: nextPatch.width,
height: nextPatch.height,
});
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;
}
renderFabricLayout(canvas, {
elements,
accentColor: accent,
textColor: text,
secondaryColor: secondary,
badgeColor: badge,
qrCodeDataUrl,
logoDataUrl,
backgroundColor: background,
backgroundGradient: gradient,
readOnly,
selectedId,
}).catch((error) => {
console.error('[Fabric] Failed to render layout', error);
});
}, [
elements,
accent,
text,
secondary,
badge,
qrCodeDataUrl,
logoDataUrl,
background,
gradient,
selectedId,
readOnly,
]);
React.useEffect(() => {
const canvas = fabricCanvasRef.current;
if (!canvas) {
return;
}
canvas.setZoom(scale);
canvas.setDimensions(
{
width: CANVAS_WIDTH * scale,
height: CANVAS_HEIGHT * scale,
},
{ cssOnly: true },
);
canvas.requestRenderAll();
}, [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;
selectedId?: string | null;
};
export async function renderFabricLayout(
canvas: fabric.Canvas,
options: FabricRenderOptions,
): Promise<void> {
const {
elements,
accentColor,
textColor,
secondaryColor,
badgeColor,
qrCodeDataUrl,
logoDataUrl,
backgroundColor,
backgroundGradient,
readOnly,
selectedId,
} = options;
canvas.discardActiveObject();
canvas.getObjects().forEach((object) => canvas.remove(object));
applyBackground(canvas, backgroundColor, backgroundGradient);
console.debug('[Invites][Fabric] render', {
elementCount: elements.length,
backgroundColor,
hasGradient: Boolean(backgroundGradient),
readOnly,
});
const objectPromises = elements.map((element) =>
createFabricObject({
element,
accentColor,
textColor,
secondaryColor,
badgeColor,
qrCodeDataUrl,
logoDataUrl,
readOnly,
}),
);
const fabricObjects = await Promise.all(objectPromises);
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(true, true);
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);
if (!readOnly && selectedId) {
const match = canvas
.getObjects()
.find((object) => (object as FabricObjectWithId).elementId === selectedId);
if (match) {
canvas.setActiveObject(match);
}
}
canvas.renderAll();
}
export function applyBackground(
canvas: fabric.Canvas,
color: string,
gradient: { angle?: number; stops?: string[] } | null,
): void {
let background: string | fabric.Gradient = 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, 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 ?? 26,
fill: textColor,
textAlign: mapTextAlign(element.align),
});
case 'link':
return new fabric.Textbox(element.content ?? '', {
...baseConfig,
width: element.width,
height: element.height,
fontSize: element.fontSize ?? 24,
fill: accentColor,
underline: true,
textAlign: mapTextAlign(element.align),
});
case 'badge':
return createTextBadge({
baseConfig,
text: element.content ?? '',
width: element.width,
height: element.height,
backgroundColor: badgeColor,
textColor: '#ffffff',
fontSize: element.fontSize ?? 22,
});
case 'cta':
return createTextBadge({
baseConfig,
text: element.content ?? '',
width: element.width,
height: element.height,
backgroundColor: accentColor,
textColor: '#ffffff',
fontSize: element.fontSize ?? 24,
cornerRadius: 18,
});
case 'logo':
if (logoDataUrl) {
return loadImageObject(logoDataUrl, element, baseConfig, {
objectFit: 'contain',
});
}
return null;
case 'qr':
if (qrCodeDataUrl) {
return loadImageObject(qrCodeDataUrl, element, baseConfig, {
shadow: 'rgba(15,23,42,0.25)',
});
}
return new fabric.Rect({
...baseConfig,
width: element.width,
height: element.height,
fill: secondaryColor,
rx: 20,
ry: 20,
});
default:
return new fabric.Textbox(element.content ?? '', {
...baseConfig,
width: element.width,
height: element.height,
fontSize: element.fontSize ?? 24,
fill: secondaryColor,
textAlign: mapTextAlign(element.align),
});
}
}
export function createTextBadge({
baseConfig,
text,
width,
height,
backgroundColor,
textColor,
fontSize,
cornerRadius = 12,
}: {
baseConfig: FabricObjectWithId;
text: string;
width: number;
height: number;
backgroundColor: string;
textColor: string;
fontSize: number;
cornerRadius?: 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,
originY: 'center',
textAlign: 'center',
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 },
): Promise<fabric.Object | null> {
return new Promise((resolve) => {
fabric.Image.fromURL(
source,
(image) => {
if (!image) {
resolve(null);
return;
}
const scaleX = element.width / (image.width ?? element.width);
const scaleY = element.height / (image.height ?? element.height);
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({
scaleX: ratio,
scaleY: ratio,
left: element.x + (element.width - (image.width ?? 0) * ratio) / 2,
top: element.y + (element.height - (image.height ?? 0) * ratio) / 2,
});
}
resolve(image);
},
{ crossOrigin: 'anonymous' },
);
});
}
export function mapTextAlign(align?: LayoutElement['align']): 'left' | 'center' | 'right' {
switch (align) {
case 'center':
return 'center';
case 'right':
return 'right';
default:
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>
);
}