Enable foldable background presets
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 09:02:52 +01:00
parent 6bd75b0788
commit e3b356e810
3 changed files with 153 additions and 40 deletions

View File

@@ -558,6 +558,29 @@ function buildFabricOptions({
const panelWidth = isFoldable ? baseWidth / 2 : baseWidth;
const panelHeight = baseHeight;
const slots = resolveSlots(layout, isFoldable, slotOverrides);
const backgroundPanels =
isFoldable && backgroundImageUrl
? [
{
url: backgroundImageUrl,
centerX: (panelWidth / 2) * scaleX,
centerY: (panelHeight / 2) * scaleY,
width: panelWidth * scaleX,
height: panelHeight * scaleY,
rotation: 90,
mirrored: false,
},
{
url: backgroundImageUrl,
centerX: (panelWidth + panelWidth / 2) * scaleX,
centerY: (panelHeight / 2) * scaleY,
width: panelWidth * scaleX,
height: panelHeight * scaleY,
rotation: -90,
mirrored: true,
},
]
: null;
const elements: LayoutElement[] = [];
const textColor = layout?.preview?.text ?? ADMIN_COLORS.backdrop;
@@ -681,7 +704,8 @@ function buildFabricOptions({
logoDataUrl: null,
backgroundColor: backgroundSolid ?? layout?.preview?.background ?? ADMIN_COLORS.surface,
backgroundGradient,
backgroundImageUrl,
backgroundImageUrl: backgroundPanels ? null : backgroundImageUrl,
backgroundImagePanels: backgroundPanels ?? undefined,
readOnly: true,
} as const;
}
@@ -740,7 +764,7 @@ function BackgroundStep({
paper: formatPaper,
orientation: orientationLabel,
});
const disablePresets = isFoldable;
const disablePresets = false;
const gradientPresets = [
{
angle: 180,
@@ -795,16 +819,16 @@ function BackgroundStep({
{presets.map((preset) => {
const isSelected = selectedPreset === preset.id;
return (
<Pressable key={preset.id ?? preset.labelKey} onPress={() => onSelectPreset(preset.id)} style={{ width: '48%' }}>
<Pressable key={preset.id ?? preset.labelKey} onPress={() => onSelectPreset(preset.id)} style={{ width: '32%' }}>
<YStack
aspectRatio={CANVAS_WIDTH / CANVAS_HEIGHT}
maxHeight={220}
style={{ position: 'relative', width: '100%', paddingTop: `${(297 / 210) * 100}%`, maxHeight: 160 }}
borderRadius={14}
overflow="hidden"
borderWidth={isSelected ? 2 : 1}
borderColor={isSelected ? primary : border}
backgroundColor={surfaceMuted}
>
<YStack style={{ position: 'absolute', inset: 0 }}>
<YStack
flex={1}
backgroundImage={`url(${preset.src})`}
@@ -818,6 +842,7 @@ function BackgroundStep({
{isSelected ? <PillBadge tone="muted">{t('common.selected', 'Ausgewählt')}</PillBadge> : null}
</XStack>
</YStack>
</YStack>
</Pressable>
);
})}
@@ -826,11 +851,7 @@ function BackgroundStep({
{t('events.qr.backgroundNote', 'Diese Presets sind für A4 Hochformat. Spiegelung erfolgt automatisch bei Tischkarten.')}
</Text>
</>
) : (
<Text fontSize="$xs" color={muted}>
{t('events.qr.foldableBackgroundNote', 'Für A5 Tischkarten bitte Gradient oder Vollfarbe wählen.')}
</Text>
)}
) : null}
<YStack space="$2" marginTop="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { getEventQrInvites } from '../../api';
const fixtures = vi.hoisted(() => ({
event: {
@@ -170,4 +171,23 @@ describe('MobileQrLayoutCustomizePage', () => {
expect(screen.getByText('Text')).toBeInTheDocument();
expect(screen.getByText('Vorschau')).toBeInTheDocument();
});
it('shows background presets for foldable layouts', async () => {
const foldableInvite = {
...fixtures.invites[0],
layouts: [
{
...fixtures.invites[0].layouts[0],
orientation: 'landscape',
panel_mode: 'double-mirror',
},
],
};
vi.mocked(getEventQrInvites).mockResolvedValueOnce([foldableInvite]);
render(<MobileQrLayoutCustomizePage />);
expect(await screen.findByText('Blue Floral')).toBeInTheDocument();
});
});

View File

@@ -23,6 +23,7 @@ type DesignerCanvasProps = {
qrCodeDataUrl: string | null;
logoDataUrl: string | null;
backgroundImageUrl?: string | null;
backgroundImagePanels?: BackgroundImagePanel[];
scale?: number;
readOnly?: boolean;
layoutKey?: string;
@@ -38,6 +39,7 @@ export function DesignerCanvas({
background,
gradient,
backgroundImageUrl = null,
backgroundImagePanels,
accent,
text,
secondary,
@@ -348,6 +350,7 @@ export function DesignerCanvas({
background,
gradient,
backgroundImageUrl,
backgroundImagePanels,
readOnly,
});
@@ -367,6 +370,7 @@ export function DesignerCanvas({
backgroundColor: background,
backgroundGradient: gradient,
backgroundImageUrl,
backgroundImagePanels,
readOnly,
}).catch((error) => {
console.error('[Fabric] Failed to render layout', error);
@@ -382,6 +386,7 @@ export function DesignerCanvas({
background,
gradient,
backgroundImageUrl,
backgroundImagePanels,
readOnly,
]);
@@ -458,9 +463,20 @@ export type FabricRenderOptions = {
backgroundColor: string;
backgroundGradient: { angle?: number; stops?: string[] } | null;
backgroundImageUrl?: string | null;
backgroundImagePanels?: BackgroundImagePanel[];
readOnly: boolean;
};
export type BackgroundImagePanel = {
url: string;
centerX: number;
centerY: number;
width: number;
height: number;
rotation: number;
mirrored?: boolean;
};
export async function renderFabricLayout(
canvas: fabric.Canvas,
options: FabricRenderOptions,
@@ -476,6 +492,7 @@ export async function renderFabricLayout(
backgroundColor,
backgroundGradient,
backgroundImageUrl,
backgroundImagePanels,
readOnly,
} = options;
@@ -489,7 +506,7 @@ export async function renderFabricLayout(
}
canvas.clear();
await applyBackground(canvas, backgroundColor, backgroundGradient, backgroundImageUrl);
await applyBackground(canvas, backgroundColor, backgroundGradient, backgroundImageUrl, backgroundImagePanels);
console.debug('[Invites][Fabric] render', {
elementCount: elements.length,
@@ -558,6 +575,7 @@ export async function applyBackground(
color: string,
gradient: { angle?: number; stops?: string[] } | null,
backgroundImageUrl?: string | null,
backgroundImagePanels?: BackgroundImagePanel[],
): Promise<void> {
try {
if (typeof canvas.setBackgroundImage === 'function') {
@@ -568,25 +586,47 @@ export async function applyBackground(
canvas.requestRenderAll();
}
if (backgroundImagePanels?.length) {
applyBackgroundFill(canvas, color, gradient);
const panelImages = await Promise.all(
backgroundImagePanels.map(async (panel) => ({
panel,
element: await loadFabricImage(resolveAssetUrl(panel.url)),
})),
);
panelImages.forEach(({ panel, element }) => {
if (!element) {
return;
}
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 scaleX = panel.mirrored ? -scale : scale;
const scaleY = scale;
element.set({
originX: 'center',
originY: 'center',
left: panel.centerX,
top: panel.centerY,
scaleX,
scaleY,
angle: panel.rotation,
selectable: false,
evented: false,
});
canvas.add(element);
});
canvas.requestRenderAll();
return;
}
if (backgroundImageUrl) {
try {
const resolvedUrl = backgroundImageUrl.startsWith('http')
? backgroundImageUrl
: `${window.location.origin}${backgroundImageUrl.startsWith('/') ? '' : '/'}${backgroundImageUrl}`;
const image = await new Promise<fabric.Image | null>((resolve) => {
const imgEl = new Image();
imgEl.crossOrigin = 'anonymous';
const timeoutId = window.setTimeout(() => resolve(null), 3000);
imgEl.onload = () => {
window.clearTimeout(timeoutId);
resolve(new fabric.Image(imgEl, { crossOrigin: 'anonymous' }));
};
imgEl.onerror = () => {
window.clearTimeout(timeoutId);
resolve(null);
};
imgEl.src = resolvedUrl;
});
const image = await loadFabricImage(resolveAssetUrl(backgroundImageUrl));
if (image) {
const scaleX = CANVAS_WIDTH / (image.width || CANVAS_WIDTH);
const scaleY = CANVAS_HEIGHT / (image.height || CANVAS_HEIGHT);
@@ -617,6 +657,14 @@ export async function applyBackground(
console.warn('[Fabric] applyBackground failed', error);
}
applyBackgroundFill(canvas, color, gradient);
}
function applyBackgroundFill(
canvas: fabric.Canvas,
color: string,
gradient: { angle?: number; stops?: string[] } | null,
): void {
let background: string | fabric.Gradient<'linear'> = color;
if (gradient?.stops?.length) {
@@ -653,6 +701,30 @@ export async function applyBackground(
}
}
async function loadFabricImage(url: string): Promise<fabric.Image | null> {
return new Promise<fabric.Image | null>((resolve) => {
const imgEl = new Image();
imgEl.crossOrigin = 'anonymous';
const timeoutId = window.setTimeout(() => resolve(null), 3000);
imgEl.onload = () => {
window.clearTimeout(timeoutId);
resolve(new fabric.Image(imgEl, { crossOrigin: 'anonymous' }));
};
imgEl.onerror = () => {
window.clearTimeout(timeoutId);
resolve(null);
};
imgEl.src = url;
});
}
function resolveAssetUrl(url: string): string {
if (url.startsWith('http')) {
return url;
}
return `${window.location.origin}${url.startsWith('/') ? '' : '/'}${url}`;
}
export type FabricObjectFactoryContext = {
element: LayoutElement;
accentColor: string;