Fix foldable background layout
This commit is contained in:
@@ -495,11 +495,11 @@ function resolveSlots(layout: EventQrInviteLayout | null, isFoldable: boolean, o
|
||||
|
||||
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 },
|
||||
subtitle: { x: 0.1, y: 0.21, 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 },
|
||||
instructions: { x: 0.12, y: 0.36, w: 0.76, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.3 },
|
||||
qr: { x: 0.3, y: 0.3, w: 0.28 },
|
||||
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.28, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, 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.42, y: 0.42, w: 0.58, fontSize: 15, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.3, align: 'left' as const },
|
||||
qr: { x: 0, y: 0.3, w: 0.26 },
|
||||
}
|
||||
: getDefaultSlots();
|
||||
|
||||
@@ -539,6 +539,8 @@ function buildFabricOptions({
|
||||
qrPngUrl,
|
||||
baseWidth = CANVAS_WIDTH,
|
||||
baseHeight = CANVAS_HEIGHT,
|
||||
renderWidth = CANVAS_WIDTH,
|
||||
renderHeight = CANVAS_HEIGHT,
|
||||
slotOverrides = {},
|
||||
}: {
|
||||
layout: EventQrInviteLayout | null;
|
||||
@@ -551,10 +553,12 @@ function buildFabricOptions({
|
||||
qrPngUrl?: string | null;
|
||||
baseWidth?: number;
|
||||
baseHeight?: number;
|
||||
renderWidth?: number;
|
||||
renderHeight?: number;
|
||||
slotOverrides?: SlotOverrides;
|
||||
}) {
|
||||
const scaleX = CANVAS_WIDTH / baseWidth;
|
||||
const scaleY = CANVAS_HEIGHT / baseHeight;
|
||||
const scaleX = renderWidth / baseWidth;
|
||||
const scaleY = renderHeight / baseHeight;
|
||||
const panelWidth = isFoldable ? baseWidth / 2 : baseWidth;
|
||||
const panelHeight = baseHeight;
|
||||
const slots = resolveSlots(layout, isFoldable, slotOverrides);
|
||||
@@ -563,20 +567,20 @@ function buildFabricOptions({
|
||||
? [
|
||||
{
|
||||
url: backgroundImageUrl,
|
||||
centerX: (panelWidth / 2) * scaleX,
|
||||
centerY: (panelHeight / 2) * scaleY,
|
||||
width: panelWidth * scaleX,
|
||||
height: panelHeight * scaleY,
|
||||
rotation: 90,
|
||||
centerX: (renderWidth / 2) * 0.5,
|
||||
centerY: renderHeight / 2,
|
||||
width: renderWidth / 2,
|
||||
height: renderHeight,
|
||||
rotation: 0,
|
||||
mirrored: false,
|
||||
},
|
||||
{
|
||||
url: backgroundImageUrl,
|
||||
centerX: (panelWidth + panelWidth / 2) * scaleX,
|
||||
centerY: (panelHeight / 2) * scaleY,
|
||||
width: panelWidth * scaleX,
|
||||
height: panelHeight * scaleY,
|
||||
rotation: -90,
|
||||
centerX: (renderWidth / 2) * 1.5,
|
||||
centerY: renderHeight / 2,
|
||||
width: renderWidth / 2,
|
||||
height: renderHeight,
|
||||
rotation: 0,
|
||||
mirrored: true,
|
||||
},
|
||||
]
|
||||
@@ -706,6 +710,8 @@ function buildFabricOptions({
|
||||
backgroundGradient,
|
||||
backgroundImageUrl: backgroundPanels ? null : backgroundImageUrl,
|
||||
backgroundImagePanels: backgroundPanels ?? undefined,
|
||||
canvasWidth: renderWidth,
|
||||
canvasHeight: renderHeight,
|
||||
readOnly: true,
|
||||
} as const;
|
||||
}
|
||||
@@ -1093,6 +1099,13 @@ function PreviewStep({
|
||||
const resolvedBgSolid = backgroundSolid ?? layout?.preview?.background ?? null;
|
||||
const backgroundImageUrl = presetSrc ?? null;
|
||||
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 exportOptions = React.useMemo(() => {
|
||||
return buildFabricOptions({
|
||||
@@ -1106,9 +1119,25 @@ function PreviewStep({
|
||||
qrPngUrl,
|
||||
baseWidth: canvasBase.width,
|
||||
baseHeight: canvasBase.height,
|
||||
renderWidth: renderCanvas.width,
|
||||
renderHeight: renderCanvas.height,
|
||||
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 [previewLoading, setPreviewLoading] = React.useState(false);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { getEventQrInvites } from '../../api';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { getEventQrInvites, updateEventQrInvite } from '../../api';
|
||||
import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../invite-layout/schema';
|
||||
|
||||
const fixtures = vi.hoisted(() => ({
|
||||
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', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ slug: fixtures.event.slug, tokenId: '1' }),
|
||||
@@ -35,17 +55,7 @@ vi.mock('react-router-dom', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
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;
|
||||
},
|
||||
}),
|
||||
useTranslation: () => translationMock,
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useBackNavigation', () => ({
|
||||
@@ -78,12 +88,7 @@ vi.mock('react-hot-toast', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./invite-layout/export-utils', () => ({
|
||||
generatePdfBytes: vi.fn(),
|
||||
generatePngDataUrl: vi.fn().mockResolvedValue('data:image/png;base64,abc'),
|
||||
triggerDownloadFromBlob: vi.fn(),
|
||||
triggerDownloadFromDataUrl: vi.fn(),
|
||||
}));
|
||||
vi.mock('../invite-layout/export-utils', () => exportUtilsMock);
|
||||
|
||||
vi.mock('../components/MobileShell', () => ({
|
||||
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
@@ -92,7 +97,19 @@ vi.mock('../components/MobileShell', () => ({
|
||||
|
||||
vi.mock('../components/Primitives', () => ({
|
||||
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>,
|
||||
}));
|
||||
|
||||
@@ -185,9 +202,65 @@ describe('MobileQrLayoutCustomizePage', () => {
|
||||
};
|
||||
|
||||
vi.mocked(getEventQrInvites).mockResolvedValueOnce([foldableInvite]);
|
||||
vi.mocked(updateEventQrInvite).mockResolvedValue(foldableInvite as any);
|
||||
|
||||
render(<MobileQrLayoutCustomizePage />);
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -464,6 +464,8 @@ export type FabricRenderOptions = {
|
||||
backgroundGradient: { angle?: number; stops?: string[] } | null;
|
||||
backgroundImageUrl?: string | null;
|
||||
backgroundImagePanels?: BackgroundImagePanel[];
|
||||
canvasWidth?: number;
|
||||
canvasHeight?: number;
|
||||
readOnly: boolean;
|
||||
};
|
||||
|
||||
@@ -578,6 +580,8 @@ export async function applyBackground(
|
||||
backgroundImagePanels?: BackgroundImagePanel[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const canvasWidth = canvas.getWidth() || CANVAS_WIDTH;
|
||||
const canvasHeight = canvas.getHeight() || CANVAS_HEIGHT;
|
||||
if (typeof canvas.setBackgroundImage === 'function') {
|
||||
canvas.setBackgroundImage(null, canvas.requestRenderAll.bind(canvas));
|
||||
} else {
|
||||
@@ -587,7 +591,7 @@ export async function applyBackground(
|
||||
}
|
||||
|
||||
if (backgroundImagePanels?.length) {
|
||||
applyBackgroundFill(canvas, color, gradient);
|
||||
applyBackgroundFill(canvas, color, gradient, canvasWidth, canvasHeight);
|
||||
|
||||
const panelImages = await Promise.all(
|
||||
backgroundImagePanels.map(async (panel) => ({
|
||||
@@ -600,10 +604,27 @@ export async function applyBackground(
|
||||
if (!element) {
|
||||
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 targetWidth = needsSwap ? panel.height : panel.width;
|
||||
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 scaleY = scale;
|
||||
element.set({
|
||||
@@ -616,6 +637,7 @@ export async function applyBackground(
|
||||
angle: panel.rotation,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
clipPath,
|
||||
});
|
||||
canvas.add(element);
|
||||
});
|
||||
@@ -628,9 +650,13 @@ export async function applyBackground(
|
||||
try {
|
||||
const image = await loadFabricImage(resolveAssetUrl(backgroundImageUrl));
|
||||
if (image) {
|
||||
const scaleX = CANVAS_WIDTH / (image.width || CANVAS_WIDTH);
|
||||
const scaleY = CANVAS_HEIGHT / (image.height || CANVAS_HEIGHT);
|
||||
const scale = Math.max(scaleX, scaleY);
|
||||
const scale = getAdaptiveScale({
|
||||
sourceWidth: image.width || canvasWidth,
|
||||
sourceHeight: image.height || canvasHeight,
|
||||
targetWidth: canvasWidth,
|
||||
targetHeight: canvasHeight,
|
||||
mode: 'cover',
|
||||
});
|
||||
image.set({
|
||||
originX: 'left',
|
||||
originY: 'top',
|
||||
@@ -657,20 +683,22 @@ export async function applyBackground(
|
||||
console.warn('[Fabric] applyBackground failed', error);
|
||||
}
|
||||
|
||||
applyBackgroundFill(canvas, color, gradient);
|
||||
applyBackgroundFill(canvas, color, gradient, canvasWidth, canvasHeight);
|
||||
}
|
||||
|
||||
function applyBackgroundFill(
|
||||
canvas: fabric.Canvas,
|
||||
color: string,
|
||||
gradient: { angle?: number; stops?: string[] } | null,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
): void {
|
||||
let background: string | fabric.Gradient<'linear'> = color;
|
||||
|
||||
if (gradient?.stops?.length) {
|
||||
const angle = ((gradient.angle ?? 180) * Math.PI) / 180;
|
||||
const halfWidth = CANVAS_WIDTH / 2;
|
||||
const halfHeight = CANVAS_HEIGHT / 2;
|
||||
const halfWidth = canvasWidth / 2;
|
||||
const halfHeight = canvasHeight / 2;
|
||||
const x = Math.cos(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 {
|
||||
if (url.startsWith('http')) {
|
||||
return url;
|
||||
|
||||
@@ -15,8 +15,10 @@ export async function withFabricCanvas<T>(
|
||||
handler: (canvas: fabric.Canvas, element: HTMLCanvasElement) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const canvasElement = document.createElement('canvas');
|
||||
canvasElement.width = CANVAS_WIDTH;
|
||||
canvasElement.height = CANVAS_HEIGHT;
|
||||
const targetWidth = options.canvasWidth ?? CANVAS_WIDTH;
|
||||
const targetHeight = options.canvasHeight ?? CANVAS_HEIGHT;
|
||||
canvasElement.width = targetWidth;
|
||||
canvasElement.height = targetHeight;
|
||||
|
||||
const canvas = new fabric.Canvas(canvasElement, {
|
||||
selection: false,
|
||||
|
||||
Reference in New Issue
Block a user