zu fabricjs gewechselt, noch nicht funktionsfähig
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,719 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import * as fabric from 'fabric';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
import { CANVAS_HEIGHT, CANVAS_WIDTH } from './schema';
|
||||
import { FabricRenderOptions, renderFabricLayout } from './DesignerCanvas';
|
||||
|
||||
const PDF_PAGE_SIZES: Record<string, { width: number; height: number }> = {
|
||||
a4: { width: 595.28, height: 841.89 },
|
||||
letter: { width: 612, height: 792 },
|
||||
};
|
||||
|
||||
export async function withFabricCanvas<T>(
|
||||
options: FabricRenderOptions,
|
||||
handler: (canvas: fabric.Canvas, element: HTMLCanvasElement) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const canvasElement = document.createElement('canvas');
|
||||
canvasElement.width = CANVAS_WIDTH;
|
||||
canvasElement.height = CANVAS_HEIGHT;
|
||||
|
||||
const canvas = new fabric.Canvas(canvasElement, {
|
||||
selection: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await renderFabricLayout(canvas, {
|
||||
...options,
|
||||
readOnly: true,
|
||||
selectedId: null,
|
||||
});
|
||||
return await handler(canvas, canvasElement);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
canvasElement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export async function generatePngDataUrl(
|
||||
options: FabricRenderOptions,
|
||||
multiplier = 2,
|
||||
): Promise<string> {
|
||||
return withFabricCanvas(options, async (canvas) =>
|
||||
canvas.toDataURL({ format: 'png', multiplier }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function generatePdfBytes(
|
||||
options: FabricRenderOptions,
|
||||
paper: string,
|
||||
orientation: string,
|
||||
multiplier = 2,
|
||||
): Promise<Uint8Array> {
|
||||
const dataUrl = await generatePngDataUrl(options, multiplier);
|
||||
return createPdfFromPng(dataUrl, paper, orientation);
|
||||
}
|
||||
|
||||
export async function createPdfFromPng(
|
||||
dataUrl: string,
|
||||
paper: string,
|
||||
orientation: string,
|
||||
): Promise<Uint8Array> {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const baseSize = PDF_PAGE_SIZES[paper.toLowerCase()] ?? PDF_PAGE_SIZES.a4;
|
||||
const landscape = orientation === 'landscape';
|
||||
const pageWidth = landscape ? baseSize.height : baseSize.width;
|
||||
const pageHeight = landscape ? baseSize.width : baseSize.height;
|
||||
|
||||
const page = pdfDoc.addPage([pageWidth, pageHeight]);
|
||||
const pngBytes = dataUrlToUint8Array(dataUrl);
|
||||
const pngImage = await pdfDoc.embedPng(pngBytes);
|
||||
|
||||
const imageWidth = pngImage.width;
|
||||
const imageHeight = pngImage.height;
|
||||
const scale = Math.min(pageWidth / imageWidth, pageHeight / imageHeight);
|
||||
const drawWidth = imageWidth * scale;
|
||||
const drawHeight = imageHeight * scale;
|
||||
|
||||
page.drawImage(pngImage, {
|
||||
x: (pageWidth - drawWidth) / 2,
|
||||
y: (pageHeight - drawHeight) / 2,
|
||||
width: drawWidth,
|
||||
height: drawHeight,
|
||||
});
|
||||
|
||||
return pdfDoc.save();
|
||||
}
|
||||
|
||||
export function triggerDownloadFromDataUrl(dataUrl: string, filename: string): Promise<void> {
|
||||
return fetch(dataUrl)
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => triggerDownloadFromBlob(blob, filename));
|
||||
}
|
||||
|
||||
export function triggerDownloadFromBlob(blob: Blob, filename: string): void {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
}
|
||||
|
||||
export async function openPdfInNewTab(pdfBytes: Uint8Array): Promise<void> {
|
||||
const blobUrl = URL.createObjectURL(new Blob([pdfBytes], { type: 'application/pdf' }));
|
||||
const printWindow = window.open(blobUrl, '_blank', 'noopener,noreferrer');
|
||||
|
||||
if (!printWindow) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
throw new Error('window-blocked');
|
||||
}
|
||||
|
||||
printWindow.onload = () => {
|
||||
try {
|
||||
printWindow.focus();
|
||||
printWindow.print();
|
||||
} catch (error) {
|
||||
console.error('[FabricExport] Browser print failed', error);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
}
|
||||
|
||||
function dataUrlToUint8Array(dataUrl: string): Uint8Array {
|
||||
const [, base64] = dataUrl.split(',');
|
||||
const decoded = atob(base64 ?? '');
|
||||
const bytes = new Uint8Array(decoded.length);
|
||||
for (let index = 0; index < decoded.length; index += 1) {
|
||||
bytes[index] = decoded.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
530
resources/js/admin/pages/components/invite-layout/schema.ts
Normal file
530
resources/js/admin/pages/components/invite-layout/schema.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
import type { EventQrInviteLayout } from '../../api';
|
||||
|
||||
export const CANVAS_WIDTH = 1240;
|
||||
export const CANVAS_HEIGHT = 1754;
|
||||
|
||||
export type LayoutElementType =
|
||||
| 'qr'
|
||||
| 'headline'
|
||||
| 'subtitle'
|
||||
| 'description'
|
||||
| 'link'
|
||||
| 'badge'
|
||||
| 'logo'
|
||||
| 'cta'
|
||||
| 'text';
|
||||
|
||||
export type LayoutTextAlign = 'left' | 'center' | 'right';
|
||||
|
||||
export interface LayoutElement {
|
||||
id: string;
|
||||
type: LayoutElementType;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation?: number;
|
||||
fontSize?: number;
|
||||
align?: LayoutTextAlign;
|
||||
content?: string | null;
|
||||
fontFamily?: string | null;
|
||||
letterSpacing?: number;
|
||||
lineHeight?: number;
|
||||
fill?: string | null;
|
||||
locked?: boolean;
|
||||
initial?: boolean;
|
||||
}
|
||||
|
||||
type PresetValue = number | ((context: LayoutPresetContext) => number);
|
||||
|
||||
type LayoutPresetElement = {
|
||||
id: string;
|
||||
type: LayoutElementType;
|
||||
x: PresetValue;
|
||||
y: PresetValue;
|
||||
width?: PresetValue;
|
||||
height?: PresetValue;
|
||||
fontSize?: number;
|
||||
align?: LayoutTextAlign;
|
||||
locked?: boolean;
|
||||
initial?: boolean;
|
||||
};
|
||||
|
||||
type LayoutPreset = LayoutPresetElement[];
|
||||
|
||||
interface LayoutPresetContext {
|
||||
qrSize: number;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
}
|
||||
|
||||
export interface LayoutElementPayload {
|
||||
id: string;
|
||||
type: LayoutElementType;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation?: number;
|
||||
font_size?: number;
|
||||
align?: LayoutTextAlign;
|
||||
content?: string | null;
|
||||
font_family?: string | null;
|
||||
letter_spacing?: number;
|
||||
line_height?: number;
|
||||
fill?: string | null;
|
||||
locked?: boolean;
|
||||
initial?: boolean;
|
||||
}
|
||||
|
||||
export interface LayoutSerializationContext {
|
||||
form: QrLayoutCustomization;
|
||||
eventName: string;
|
||||
inviteUrl: string;
|
||||
instructions: string[];
|
||||
qrSize: number;
|
||||
badgeFallback: string;
|
||||
logoUrl: string | null;
|
||||
}
|
||||
|
||||
export type QrLayoutCustomization = {
|
||||
layout_id?: string;
|
||||
headline?: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
badge_label?: string;
|
||||
instructions_heading?: string;
|
||||
instructions?: string[];
|
||||
link_heading?: string;
|
||||
link_label?: string;
|
||||
cta_label?: string;
|
||||
accent_color?: string;
|
||||
text_color?: string;
|
||||
background_color?: string;
|
||||
secondary_color?: string;
|
||||
badge_color?: string;
|
||||
background_gradient?: { angle?: number; stops?: string[] } | null;
|
||||
logo_data_url?: string | null;
|
||||
logo_url?: string | null;
|
||||
mode?: 'standard' | 'advanced';
|
||||
elements?: LayoutElementPayload[];
|
||||
};
|
||||
|
||||
export const MIN_QR_SIZE = 240;
|
||||
export const MAX_QR_SIZE = 720;
|
||||
export const MIN_TEXT_WIDTH = 160;
|
||||
export const MIN_TEXT_HEIGHT = 80;
|
||||
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
if (Number.isNaN(value)) {
|
||||
return min;
|
||||
}
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export function clampElement(element: LayoutElement): LayoutElement {
|
||||
return {
|
||||
...element,
|
||||
x: clamp(element.x, 0, CANVAS_WIDTH - element.width),
|
||||
y: clamp(element.y, 0, CANVAS_HEIGHT - element.height),
|
||||
width: clamp(element.width, 40, CANVAS_WIDTH),
|
||||
height: clamp(element.height, 40, CANVAS_HEIGHT),
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_TYPE_STYLES: Record<LayoutElementType, { width: number; height: number; fontSize?: number; align?: LayoutTextAlign; locked?: boolean }> = {
|
||||
headline: { width: 620, height: 200, fontSize: 68, align: 'left' },
|
||||
subtitle: { width: 580, height: 140, fontSize: 34, align: 'left' },
|
||||
description: { width: 620, height: 280, fontSize: 28, align: 'left' },
|
||||
link: { width: 400, height: 110, fontSize: 28, align: 'center' },
|
||||
badge: { width: 280, height: 80, fontSize: 24, align: 'center' },
|
||||
logo: { width: 240, height: 180, align: 'center' },
|
||||
cta: { width: 400, height: 110, fontSize: 26, align: 'center' },
|
||||
qr: { width: 520, height: 520 },
|
||||
text: { width: 560, height: 200, fontSize: 26, align: 'left' },
|
||||
};
|
||||
|
||||
const DEFAULT_PRESET: LayoutPreset = [
|
||||
{ id: 'badge', type: 'badge', x: 120, y: 140, width: 320, height: 80, align: 'center', fontSize: 24 },
|
||||
{ id: 'headline', type: 'headline', x: 120, y: 260, width: 620, height: 200, fontSize: 68, align: 'left' },
|
||||
{ id: 'subtitle', type: 'subtitle', x: 120, y: 440, width: 600, height: 140, fontSize: 34, align: 'left' },
|
||||
{ id: 'description', type: 'description', x: 120, y: 600, width: 620, height: 280, fontSize: 28, align: 'left' },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (context) => context.canvasWidth - context.qrSize - 140,
|
||||
y: 360,
|
||||
width: (context) => context.qrSize,
|
||||
height: (context) => context.qrSize,
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: (context) => context.canvasWidth - 420,
|
||||
y: (context) => 400 + context.qrSize,
|
||||
width: 400,
|
||||
height: 110,
|
||||
fontSize: 28,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'cta',
|
||||
type: 'cta',
|
||||
x: (context) => context.canvasWidth - 420,
|
||||
y: (context) => 420 + context.qrSize + 140,
|
||||
width: 400,
|
||||
height: 110,
|
||||
fontSize: 26,
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
const evergreenVowsPreset: LayoutPreset = [
|
||||
{ id: 'logo', type: 'logo', x: 120, y: 140, width: 240, height: 180 },
|
||||
{ id: 'badge', type: 'badge', x: 400, y: 160, width: 320, height: 80, align: 'center', fontSize: 24 },
|
||||
{ id: 'headline', type: 'headline', x: 120, y: 360, width: 620, height: 220, fontSize: 70, align: 'left' },
|
||||
{ id: 'subtitle', type: 'subtitle', x: 120, y: 560, width: 600, height: 140, fontSize: 34, align: 'left' },
|
||||
{ id: 'description', type: 'description', x: 120, y: 720, width: 620, height: 280, fontSize: 28, align: 'left' },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (context) => context.canvasWidth - context.qrSize - 160,
|
||||
y: 460,
|
||||
width: (context) => context.qrSize,
|
||||
height: (context) => context.qrSize,
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: (context) => context.canvasWidth - 420,
|
||||
y: (context) => 500 + context.qrSize,
|
||||
width: 400,
|
||||
height: 110,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'cta',
|
||||
type: 'cta',
|
||||
x: (context) => context.canvasWidth - 420,
|
||||
y: (context) => 520 + context.qrSize + 150,
|
||||
width: 400,
|
||||
height: 110,
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
const midnightGalaPreset: LayoutPreset = [
|
||||
{ id: 'badge', type: 'badge', x: 360, y: 160, width: 520, height: 90, align: 'center', fontSize: 26 },
|
||||
{ id: 'headline', type: 'headline', x: 220, y: 300, width: 800, height: 220, fontSize: 76, align: 'center' },
|
||||
{ id: 'subtitle', type: 'subtitle', x: 260, y: 520, width: 720, height: 140, fontSize: 36, align: 'center' },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (context) => (context.canvasWidth - context.qrSize) / 2,
|
||||
y: 700,
|
||||
width: (context) => context.qrSize,
|
||||
height: (context) => context.qrSize,
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: (context) => (context.canvasWidth - 420) / 2,
|
||||
y: (context) => 740 + context.qrSize,
|
||||
width: 420,
|
||||
height: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'cta',
|
||||
type: 'cta',
|
||||
x: (context) => (context.canvasWidth - 420) / 2,
|
||||
y: (context) => 770 + context.qrSize + 150,
|
||||
width: 420,
|
||||
height: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{ id: 'description', type: 'description', x: 200, y: 1040, width: 840, height: 260, fontSize: 28, align: 'center' },
|
||||
];
|
||||
|
||||
const gardenBrunchPreset: LayoutPreset = [
|
||||
{ id: 'badge', type: 'badge', x: 160, y: 160, width: 360, height: 80, align: 'center', fontSize: 24 },
|
||||
{ id: 'headline', type: 'headline', x: 160, y: 300, width: 560, height: 200, fontSize: 66, align: 'left' },
|
||||
{ id: 'description', type: 'description', x: 160, y: 520, width: 560, height: 260, fontSize: 28, align: 'left' },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: 160,
|
||||
y: 840,
|
||||
width: (context) => Math.min(context.qrSize, 520),
|
||||
height: (context) => Math.min(context.qrSize, 520),
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: 160,
|
||||
y: (context) => 880 + Math.min(context.qrSize, 520),
|
||||
width: 420,
|
||||
height: 110,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'cta',
|
||||
type: 'cta',
|
||||
x: 160,
|
||||
y: (context) => 910 + Math.min(context.qrSize, 520) + 140,
|
||||
width: 420,
|
||||
height: 110,
|
||||
align: 'center',
|
||||
},
|
||||
{ id: 'subtitle', type: 'subtitle', x: 780, y: 320, width: 320, height: 140, fontSize: 32, align: 'left' },
|
||||
{ id: 'text-strip', type: 'text', x: 780, y: 480, width: 320, height: 320, fontSize: 24, align: 'left' },
|
||||
];
|
||||
|
||||
const sparklerSoireePreset: LayoutPreset = [
|
||||
{ id: 'badge', type: 'badge', x: 360, y: 150, width: 520, height: 90, align: 'center', fontSize: 26 },
|
||||
{ id: 'headline', type: 'headline', x: 200, y: 300, width: 840, height: 220, fontSize: 72, align: 'center' },
|
||||
{ id: 'subtitle', type: 'subtitle', x: 260, y: 520, width: 720, height: 140, fontSize: 34, align: 'center' },
|
||||
{ id: 'description', type: 'description', x: 220, y: 680, width: 800, height: 240, fontSize: 28, align: 'center' },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (context) => (context.canvasWidth - context.qrSize) / 2,
|
||||
y: 960,
|
||||
width: (context) => context.qrSize,
|
||||
height: (context) => context.qrSize,
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: (context) => (context.canvasWidth - 420) / 2,
|
||||
y: (context) => 1000 + context.qrSize,
|
||||
width: 420,
|
||||
height: 110,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'cta',
|
||||
type: 'cta',
|
||||
x: (context) => (context.canvasWidth - 420) / 2,
|
||||
y: (context) => 1030 + context.qrSize + 140,
|
||||
width: 420,
|
||||
height: 110,
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
const confettiBashPreset: LayoutPreset = [
|
||||
{ id: 'badge', type: 'badge', x: 140, y: 180, width: 360, height: 90, align: 'center', fontSize: 24 },
|
||||
{ id: 'headline', type: 'headline', x: 140, y: 320, width: 520, height: 220, fontSize: 68, align: 'left' },
|
||||
{ id: 'subtitle', type: 'subtitle', x: 140, y: 520, width: 520, height: 140, fontSize: 34, align: 'left' },
|
||||
{ id: 'description', type: 'description', x: 140, y: 680, width: 520, height: 240, fontSize: 26, align: 'left' },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
x: (context) => context.canvasWidth - context.qrSize - 200,
|
||||
y: 360,
|
||||
width: (context) => context.qrSize,
|
||||
height: (context) => context.qrSize,
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
type: 'link',
|
||||
x: (context) => context.canvasWidth - 420,
|
||||
y: (context) => 400 + context.qrSize,
|
||||
width: 400,
|
||||
height: 110,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
id: 'cta',
|
||||
type: 'cta',
|
||||
x: (context) => context.canvasWidth - 420,
|
||||
y: (context) => 430 + context.qrSize + 140,
|
||||
width: 400,
|
||||
height: 110,
|
||||
align: 'center',
|
||||
},
|
||||
{ id: 'text-strip', type: 'text', x: 140, y: 960, width: 860, height: 220, fontSize: 26, align: 'left' },
|
||||
];
|
||||
|
||||
const LAYOUT_PRESETS: Record<string, LayoutPreset> = {
|
||||
'default': DEFAULT_PRESET,
|
||||
'evergreen-vows': evergreenVowsPreset,
|
||||
'midnight-gala': midnightGalaPreset,
|
||||
'garden-brunch': gardenBrunchPreset,
|
||||
'sparkler-soiree': sparklerSoireePreset,
|
||||
'confetti-bash': confettiBashPreset,
|
||||
};
|
||||
|
||||
function resolvePresetValue(value: PresetValue | undefined, context: LayoutPresetContext, fallback: number): number {
|
||||
if (typeof value === 'function') {
|
||||
const resolved = value(context);
|
||||
return typeof resolved === 'number' ? resolved : fallback;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function buildDefaultElements(
|
||||
layout: EventQrInviteLayout,
|
||||
form: QrLayoutCustomization,
|
||||
eventName: string,
|
||||
qrSize: number
|
||||
): LayoutElement[] {
|
||||
const size = clamp(qrSize, MIN_QR_SIZE, MAX_QR_SIZE);
|
||||
const context: LayoutPresetContext = {
|
||||
qrSize: size,
|
||||
canvasWidth: CANVAS_WIDTH,
|
||||
canvasHeight: CANVAS_HEIGHT,
|
||||
};
|
||||
|
||||
const preset = LAYOUT_PRESETS[layout.id ?? ''] ?? DEFAULT_PRESET;
|
||||
|
||||
const instructionsHeading = form.instructions_heading ?? layout.instructions_heading ?? "So funktioniert's";
|
||||
const instructionsList = Array.isArray(form.instructions) && form.instructions.length
|
||||
? form.instructions
|
||||
: (layout.instructions ?? []);
|
||||
|
||||
const baseContent: Record<string, string | null> = {
|
||||
headline: form.headline ?? eventName,
|
||||
subtitle: form.subtitle ?? layout.subtitle ?? '',
|
||||
description: form.description ?? layout.description ?? '',
|
||||
badge: form.badge_label ?? layout.badge_label ?? 'Digitale Gästebox',
|
||||
link: form.link_label ?? '',
|
||||
cta: form.cta_label ?? layout.cta_label ?? 'Scan mich & starte direkt',
|
||||
instructions_heading: instructionsHeading,
|
||||
instructions_text: instructionsList[0] ?? null,
|
||||
};
|
||||
|
||||
const elements = preset.map((config) => {
|
||||
const typeStyle = DEFAULT_TYPE_STYLES[config.type] ?? { width: 420, height: 160 };
|
||||
const widthFallback = config.type === 'qr' ? size : typeStyle.width;
|
||||
const heightFallback = config.type === 'qr' ? size : typeStyle.height;
|
||||
const element: LayoutElement = {
|
||||
id: config.id,
|
||||
type: config.type,
|
||||
x: resolvePresetValue(config.x, context, 0),
|
||||
y: resolvePresetValue(config.y, context, 0),
|
||||
width: resolvePresetValue(config.width, context, widthFallback),
|
||||
height: resolvePresetValue(config.height, context, heightFallback),
|
||||
fontSize: config.fontSize ?? typeStyle.fontSize,
|
||||
align: config.align ?? typeStyle.align ?? 'left',
|
||||
content: null,
|
||||
locked: config.locked ?? typeStyle.locked ?? false,
|
||||
initial: config.initial ?? true,
|
||||
};
|
||||
|
||||
if (config.type === 'description') {
|
||||
element.lineHeight = 1.4;
|
||||
}
|
||||
|
||||
switch (config.id) {
|
||||
case 'headline':
|
||||
element.content = baseContent.headline;
|
||||
break;
|
||||
case 'subtitle':
|
||||
element.content = baseContent.subtitle;
|
||||
break;
|
||||
case 'description':
|
||||
element.content = baseContent.description;
|
||||
break;
|
||||
case 'badge':
|
||||
element.content = baseContent.badge;
|
||||
break;
|
||||
case 'link':
|
||||
element.content = baseContent.link;
|
||||
break;
|
||||
case 'cta':
|
||||
element.content = baseContent.cta;
|
||||
break;
|
||||
case 'text-strip':
|
||||
element.content = instructionsList.join('\n').trim() || layout.description || 'Nutze diesen Bereich für zusätzliche Hinweise oder Storytelling.';
|
||||
break;
|
||||
case 'logo':
|
||||
element.content = form.logo_data_url ?? form.logo_url ?? layout.logo_url ?? null;
|
||||
break;
|
||||
default:
|
||||
if (config.type === 'text') {
|
||||
element.content = element.content ?? 'Individualisiere diesen Textblock mit eigenen Infos.';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (config.type === 'qr') {
|
||||
element.locked = false;
|
||||
}
|
||||
|
||||
const clamped = clampElement(element);
|
||||
return {
|
||||
...clamped,
|
||||
initial: element.initial ?? true,
|
||||
};
|
||||
});
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
export function payloadToElements(payload?: LayoutElementPayload[] | null): LayoutElement[] {
|
||||
if (!Array.isArray(payload)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return payload.map((entry) =>
|
||||
clampElement({
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
x: Number(entry.x ?? 0),
|
||||
y: Number(entry.y ?? 0),
|
||||
width: Number(entry.width ?? 100),
|
||||
height: Number(entry.height ?? 100),
|
||||
rotation: typeof entry.rotation === 'number' ? entry.rotation : 0,
|
||||
fontSize: typeof entry.font_size === 'number' ? entry.font_size : undefined,
|
||||
align: entry.align ?? 'left',
|
||||
content: entry.content ?? null,
|
||||
fontFamily: entry.font_family ?? null,
|
||||
letterSpacing: entry.letter_spacing ?? undefined,
|
||||
lineHeight: entry.line_height ?? undefined,
|
||||
fill: entry.fill ?? null,
|
||||
locked: Boolean(entry.locked),
|
||||
initial: Boolean(entry.initial),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function elementsToPayload(elements: LayoutElement[]): LayoutElementPayload[] {
|
||||
return elements.map((element) => ({
|
||||
id: element.id,
|
||||
type: element.type,
|
||||
x: element.x,
|
||||
y: element.y,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
rotation: element.rotation ?? 0,
|
||||
font_size: element.fontSize,
|
||||
align: element.align,
|
||||
content: element.content ?? null,
|
||||
font_family: element.fontFamily ?? null,
|
||||
letter_spacing: element.letterSpacing,
|
||||
line_height: element.lineHeight,
|
||||
fill: element.fill ?? null,
|
||||
locked: element.locked ?? false,
|
||||
initial: element.initial ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeElements(elements: LayoutElement[]): LayoutElement[] {
|
||||
const seen = new Set<string>();
|
||||
return elements
|
||||
.filter((element) => {
|
||||
if (!element.id) {
|
||||
return false;
|
||||
}
|
||||
if (seen.has(element.id)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(element.id);
|
||||
return true;
|
||||
})
|
||||
.map(clampElement);
|
||||
}
|
||||
Reference in New Issue
Block a user