Fix foldable background layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-24 10:41:24 +01:00
parent b11f010938
commit ce43cac145
4 changed files with 201 additions and 48 deletions

View File

@@ -495,11 +495,11 @@ function resolveSlots(layout: EventQrInviteLayout | null, isFoldable: boolean, o
const baseSlots = isFoldable const baseSlots = isFoldable
? { ? {
headline: { x: 0.08, y: 0.15, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' as const }, headline: { x: 0.08, y: 0.24, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' as const },
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' as const }, subtitle: { x: 0.1, y: 0.28, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' as const },
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' as const }, description: { x: 0.42, y: 0.6, w: 0.58, fontSize: 23, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'left' as const },
instructions: { x: 0.12, y: 0.36, w: 0.76, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.3 }, instructions: { x: 0.42, y: 0.42, w: 0.58, fontSize: 15, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.3, align: 'left' as const },
qr: { x: 0.3, y: 0.3, w: 0.28 }, qr: { x: 0, y: 0.3, w: 0.26 },
} }
: getDefaultSlots(); : getDefaultSlots();
@@ -539,6 +539,8 @@ function buildFabricOptions({
qrPngUrl, qrPngUrl,
baseWidth = CANVAS_WIDTH, baseWidth = CANVAS_WIDTH,
baseHeight = CANVAS_HEIGHT, baseHeight = CANVAS_HEIGHT,
renderWidth = CANVAS_WIDTH,
renderHeight = CANVAS_HEIGHT,
slotOverrides = {}, slotOverrides = {},
}: { }: {
layout: EventQrInviteLayout | null; layout: EventQrInviteLayout | null;
@@ -551,10 +553,12 @@ function buildFabricOptions({
qrPngUrl?: string | null; qrPngUrl?: string | null;
baseWidth?: number; baseWidth?: number;
baseHeight?: number; baseHeight?: number;
renderWidth?: number;
renderHeight?: number;
slotOverrides?: SlotOverrides; slotOverrides?: SlotOverrides;
}) { }) {
const scaleX = CANVAS_WIDTH / baseWidth; const scaleX = renderWidth / baseWidth;
const scaleY = CANVAS_HEIGHT / baseHeight; const scaleY = renderHeight / baseHeight;
const panelWidth = isFoldable ? baseWidth / 2 : baseWidth; const panelWidth = isFoldable ? baseWidth / 2 : baseWidth;
const panelHeight = baseHeight; const panelHeight = baseHeight;
const slots = resolveSlots(layout, isFoldable, slotOverrides); const slots = resolveSlots(layout, isFoldable, slotOverrides);
@@ -563,20 +567,20 @@ function buildFabricOptions({
? [ ? [
{ {
url: backgroundImageUrl, url: backgroundImageUrl,
centerX: (panelWidth / 2) * scaleX, centerX: (renderWidth / 2) * 0.5,
centerY: (panelHeight / 2) * scaleY, centerY: renderHeight / 2,
width: panelWidth * scaleX, width: renderWidth / 2,
height: panelHeight * scaleY, height: renderHeight,
rotation: 90, rotation: 0,
mirrored: false, mirrored: false,
}, },
{ {
url: backgroundImageUrl, url: backgroundImageUrl,
centerX: (panelWidth + panelWidth / 2) * scaleX, centerX: (renderWidth / 2) * 1.5,
centerY: (panelHeight / 2) * scaleY, centerY: renderHeight / 2,
width: panelWidth * scaleX, width: renderWidth / 2,
height: panelHeight * scaleY, height: renderHeight,
rotation: -90, rotation: 0,
mirrored: true, mirrored: true,
}, },
] ]
@@ -706,6 +710,8 @@ function buildFabricOptions({
backgroundGradient, backgroundGradient,
backgroundImageUrl: backgroundPanels ? null : backgroundImageUrl, backgroundImageUrl: backgroundPanels ? null : backgroundImageUrl,
backgroundImagePanels: backgroundPanels ?? undefined, backgroundImagePanels: backgroundPanels ?? undefined,
canvasWidth: renderWidth,
canvasHeight: renderHeight,
readOnly: true, readOnly: true,
} as const; } as const;
} }
@@ -1093,6 +1099,13 @@ function PreviewStep({
const resolvedBgSolid = backgroundSolid ?? layout?.preview?.background ?? null; const resolvedBgSolid = backgroundSolid ?? layout?.preview?.background ?? null;
const backgroundImageUrl = presetSrc ?? null; const backgroundImageUrl = presetSrc ?? null;
const canvasBase = React.useMemo(() => resolveCanvasBase(layout, isFoldable), [layout, isFoldable]); const canvasBase = React.useMemo(() => resolveCanvasBase(layout, isFoldable), [layout, isFoldable]);
const renderCanvas = React.useMemo(
() => ({
width: isFoldable ? CANVAS_HEIGHT : CANVAS_WIDTH,
height: isFoldable ? CANVAS_WIDTH : CANVAS_HEIGHT,
}),
[isFoldable],
);
const resolvedSlots = React.useMemo(() => resolveSlots(layout, isFoldable, slotOverrides), [isFoldable, layout, slotOverrides]); const resolvedSlots = React.useMemo(() => resolveSlots(layout, isFoldable, slotOverrides), [isFoldable, layout, slotOverrides]);
const exportOptions = React.useMemo(() => { const exportOptions = React.useMemo(() => {
return buildFabricOptions({ return buildFabricOptions({
@@ -1106,9 +1119,25 @@ function PreviewStep({
qrPngUrl, qrPngUrl,
baseWidth: canvasBase.width, baseWidth: canvasBase.width,
baseHeight: canvasBase.height, baseHeight: canvasBase.height,
renderWidth: renderCanvas.width,
renderHeight: renderCanvas.height,
slotOverrides, slotOverrides,
}); });
}, [backgroundImageUrl, canvasBase.height, canvasBase.width, isFoldable, layout, qrPngUrl, qrUrl, resolvedBgGradient, resolvedBgSolid, slotOverrides, textFields]); }, [
backgroundImageUrl,
canvasBase.height,
canvasBase.width,
isFoldable,
layout,
qrPngUrl,
qrUrl,
renderCanvas.height,
renderCanvas.width,
resolvedBgGradient,
resolvedBgSolid,
slotOverrides,
textFields,
]);
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null); const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const [previewLoading, setPreviewLoading] = React.useState(false); const [previewLoading, setPreviewLoading] = React.useState(false);

View File

@@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { getEventQrInvites } from '../../api'; import { getEventQrInvites, updateEventQrInvite } from '../../api';
import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../invite-layout/schema';
const fixtures = vi.hoisted(() => ({ const fixtures = vi.hoisted(() => ({
event: { event: {
@@ -28,6 +29,25 @@ const fixtures = vi.hoisted(() => ({
], ],
})); }));
const exportUtilsMock = vi.hoisted(() => ({
generatePdfBytes: vi.fn(),
generatePngDataUrl: vi.fn().mockResolvedValue('data:image/png;base64,abc'),
triggerDownloadFromBlob: vi.fn(),
triggerDownloadFromDataUrl: vi.fn(),
}));
const translationMock = vi.hoisted(() => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') {
return fallback;
}
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
return fallback.defaultValue;
}
return key;
},
}));
vi.mock('react-router-dom', () => ({ vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(), useNavigate: () => vi.fn(),
useParams: () => ({ slug: fixtures.event.slug, tokenId: '1' }), useParams: () => ({ slug: fixtures.event.slug, tokenId: '1' }),
@@ -35,17 +55,7 @@ vi.mock('react-router-dom', () => ({
})); }));
vi.mock('react-i18next', () => ({ vi.mock('react-i18next', () => ({
useTranslation: () => ({ useTranslation: () => translationMock,
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') {
return fallback;
}
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
return fallback.defaultValue;
}
return key;
},
}),
})); }));
vi.mock('../hooks/useBackNavigation', () => ({ vi.mock('../hooks/useBackNavigation', () => ({
@@ -78,12 +88,7 @@ vi.mock('react-hot-toast', () => ({
}, },
})); }));
vi.mock('./invite-layout/export-utils', () => ({ vi.mock('../invite-layout/export-utils', () => exportUtilsMock);
generatePdfBytes: vi.fn(),
generatePngDataUrl: vi.fn().mockResolvedValue('data:image/png;base64,abc'),
triggerDownloadFromBlob: vi.fn(),
triggerDownloadFromDataUrl: vi.fn(),
}));
vi.mock('../components/MobileShell', () => ({ vi.mock('../components/MobileShell', () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
@@ -92,7 +97,19 @@ vi.mock('../components/MobileShell', () => ({
vi.mock('../components/Primitives', () => ({ vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>, CTAButton: ({
label,
onPress,
disabled,
}: {
label: string;
onPress?: () => void;
disabled?: boolean;
}) => (
<button type="button" disabled={disabled} onClick={onPress}>
{label}
</button>
),
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
})); }));
@@ -185,9 +202,65 @@ describe('MobileQrLayoutCustomizePage', () => {
}; };
vi.mocked(getEventQrInvites).mockResolvedValueOnce([foldableInvite]); vi.mocked(getEventQrInvites).mockResolvedValueOnce([foldableInvite]);
vi.mocked(updateEventQrInvite).mockResolvedValue(foldableInvite as any);
render(<MobileQrLayoutCustomizePage />); render(<MobileQrLayoutCustomizePage />);
expect(await screen.findByText('Blue Floral')).toBeInTheDocument(); expect(await screen.findByText('Blue Floral')).toBeInTheDocument();
}); });
it('scales foldable background panels to A5 halves', async () => {
const foldableInvite = {
...fixtures.invites[0],
metadata: {
layout_customization: {
background_preset: 'blue-floral',
},
},
layouts: [
{
...fixtures.invites[0].layouts[0],
orientation: 'landscape',
panel_mode: 'double-mirror',
},
],
};
vi.mocked(getEventQrInvites).mockResolvedValueOnce([foldableInvite]);
exportUtilsMock.generatePngDataUrl.mockResolvedValue('data:image/png;base64,abc');
exportUtilsMock.generatePngDataUrl.mockClear();
render(<MobileQrLayoutCustomizePage />);
const presetButton = await screen.findByText('Blue Floral');
fireEvent.click(presetButton);
const nextButton = await screen.findByText('Weiter');
fireEvent.click(nextButton);
const previewButton = await screen.findByText('Vorschau');
fireEvent.click(previewButton);
await waitFor(() => expect(exportUtilsMock.generatePngDataUrl).toHaveBeenCalled());
const options = exportUtilsMock.generatePngDataUrl.mock.calls.at(-1)?.[0];
expect(options).toBeDefined();
expect(options.backgroundImagePanels).toHaveLength(2);
expect(options.backgroundImagePanels[0]).toMatchObject({
centerX: CANVAS_HEIGHT / 4,
centerY: CANVAS_WIDTH / 2,
width: CANVAS_HEIGHT / 2,
height: CANVAS_WIDTH,
rotation: 0,
mirrored: false,
});
expect(options.backgroundImagePanels[1]).toMatchObject({
centerX: (CANVAS_HEIGHT / 4) * 3,
centerY: CANVAS_WIDTH / 2,
width: CANVAS_HEIGHT / 2,
height: CANVAS_WIDTH,
rotation: 0,
mirrored: true,
});
});
}); });

View File

@@ -464,6 +464,8 @@ export type FabricRenderOptions = {
backgroundGradient: { angle?: number; stops?: string[] } | null; backgroundGradient: { angle?: number; stops?: string[] } | null;
backgroundImageUrl?: string | null; backgroundImageUrl?: string | null;
backgroundImagePanels?: BackgroundImagePanel[]; backgroundImagePanels?: BackgroundImagePanel[];
canvasWidth?: number;
canvasHeight?: number;
readOnly: boolean; readOnly: boolean;
}; };
@@ -578,6 +580,8 @@ export async function applyBackground(
backgroundImagePanels?: BackgroundImagePanel[], backgroundImagePanels?: BackgroundImagePanel[],
): Promise<void> { ): Promise<void> {
try { try {
const canvasWidth = canvas.getWidth() || CANVAS_WIDTH;
const canvasHeight = canvas.getHeight() || CANVAS_HEIGHT;
if (typeof canvas.setBackgroundImage === 'function') { if (typeof canvas.setBackgroundImage === 'function') {
canvas.setBackgroundImage(null, canvas.requestRenderAll.bind(canvas)); canvas.setBackgroundImage(null, canvas.requestRenderAll.bind(canvas));
} else { } else {
@@ -587,7 +591,7 @@ export async function applyBackground(
} }
if (backgroundImagePanels?.length) { if (backgroundImagePanels?.length) {
applyBackgroundFill(canvas, color, gradient); applyBackgroundFill(canvas, color, gradient, canvasWidth, canvasHeight);
const panelImages = await Promise.all( const panelImages = await Promise.all(
backgroundImagePanels.map(async (panel) => ({ backgroundImagePanels.map(async (panel) => ({
@@ -600,10 +604,27 @@ export async function applyBackground(
if (!element) { if (!element) {
return; return;
} }
const clipPath = new fabric.Rect({
left: panel.centerX - panel.width / 2,
top: panel.centerY - panel.height / 2,
width: panel.width,
height: panel.height,
originX: 'left',
originY: 'top',
absolutePositioned: true,
selectable: false,
evented: false,
});
const needsSwap = Math.abs(panel.rotation) % 180 === 90; const needsSwap = Math.abs(panel.rotation) % 180 === 90;
const targetWidth = needsSwap ? panel.height : panel.width; const targetWidth = needsSwap ? panel.height : panel.width;
const targetHeight = needsSwap ? panel.width : panel.height; const targetHeight = needsSwap ? panel.width : panel.height;
const scale = Math.max(targetWidth / (element.width || targetWidth), targetHeight / (element.height || targetHeight)); const scale = getAdaptiveScale({
sourceWidth: element.width || targetWidth,
sourceHeight: element.height || targetHeight,
targetWidth,
targetHeight,
mode: 'contain',
});
const scaleX = panel.mirrored ? -scale : scale; const scaleX = panel.mirrored ? -scale : scale;
const scaleY = scale; const scaleY = scale;
element.set({ element.set({
@@ -616,6 +637,7 @@ export async function applyBackground(
angle: panel.rotation, angle: panel.rotation,
selectable: false, selectable: false,
evented: false, evented: false,
clipPath,
}); });
canvas.add(element); canvas.add(element);
}); });
@@ -628,9 +650,13 @@ export async function applyBackground(
try { try {
const image = await loadFabricImage(resolveAssetUrl(backgroundImageUrl)); const image = await loadFabricImage(resolveAssetUrl(backgroundImageUrl));
if (image) { if (image) {
const scaleX = CANVAS_WIDTH / (image.width || CANVAS_WIDTH); const scale = getAdaptiveScale({
const scaleY = CANVAS_HEIGHT / (image.height || CANVAS_HEIGHT); sourceWidth: image.width || canvasWidth,
const scale = Math.max(scaleX, scaleY); sourceHeight: image.height || canvasHeight,
targetWidth: canvasWidth,
targetHeight: canvasHeight,
mode: 'cover',
});
image.set({ image.set({
originX: 'left', originX: 'left',
originY: 'top', originY: 'top',
@@ -657,20 +683,22 @@ export async function applyBackground(
console.warn('[Fabric] applyBackground failed', error); console.warn('[Fabric] applyBackground failed', error);
} }
applyBackgroundFill(canvas, color, gradient); applyBackgroundFill(canvas, color, gradient, canvasWidth, canvasHeight);
} }
function applyBackgroundFill( function applyBackgroundFill(
canvas: fabric.Canvas, canvas: fabric.Canvas,
color: string, color: string,
gradient: { angle?: number; stops?: string[] } | null, gradient: { angle?: number; stops?: string[] } | null,
canvasWidth: number,
canvasHeight: number,
): void { ): void {
let background: string | fabric.Gradient<'linear'> = color; let background: string | fabric.Gradient<'linear'> = color;
if (gradient?.stops?.length) { if (gradient?.stops?.length) {
const angle = ((gradient.angle ?? 180) * Math.PI) / 180; const angle = ((gradient.angle ?? 180) * Math.PI) / 180;
const halfWidth = CANVAS_WIDTH / 2; const halfWidth = canvasWidth / 2;
const halfHeight = CANVAS_HEIGHT / 2; const halfHeight = canvasHeight / 2;
const x = Math.cos(angle); const x = Math.cos(angle);
const y = Math.sin(angle); const y = Math.sin(angle);
@@ -718,6 +746,27 @@ async function loadFabricImage(url: string): Promise<fabric.Image | null> {
}); });
} }
function getAdaptiveScale({
sourceWidth,
sourceHeight,
targetWidth,
targetHeight,
mode = 'cover',
}: {
sourceWidth: number;
sourceHeight: number;
targetWidth: number;
targetHeight: number;
mode?: 'cover' | 'contain';
}): number {
const safeSourceWidth = sourceWidth || targetWidth || 1;
const safeSourceHeight = sourceHeight || targetHeight || 1;
const widthRatio = targetWidth / safeSourceWidth;
const heightRatio = targetHeight / safeSourceHeight;
return mode === 'contain' ? Math.min(widthRatio, heightRatio) : Math.max(widthRatio, heightRatio);
}
function resolveAssetUrl(url: string): string { function resolveAssetUrl(url: string): string {
if (url.startsWith('http')) { if (url.startsWith('http')) {
return url; return url;

View File

@@ -15,8 +15,10 @@ export async function withFabricCanvas<T>(
handler: (canvas: fabric.Canvas, element: HTMLCanvasElement) => Promise<T>, handler: (canvas: fabric.Canvas, element: HTMLCanvasElement) => Promise<T>,
): Promise<T> { ): Promise<T> {
const canvasElement = document.createElement('canvas'); const canvasElement = document.createElement('canvas');
canvasElement.width = CANVAS_WIDTH; const targetWidth = options.canvasWidth ?? CANVAS_WIDTH;
canvasElement.height = CANVAS_HEIGHT; const targetHeight = options.canvasHeight ?? CANVAS_HEIGHT;
canvasElement.width = targetWidth;
canvasElement.height = targetHeight;
const canvas = new fabric.Canvas(canvasElement, { const canvas = new fabric.Canvas(canvasElement, {
selection: false, selection: false,