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

View File

@@ -1,6 +1,7 @@
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 { render, screen } from '@testing-library/react';
import { getEventQrInvites } from '../../api';
const fixtures = vi.hoisted(() => ({ const fixtures = vi.hoisted(() => ({
event: { event: {
@@ -170,4 +171,23 @@ describe('MobileQrLayoutCustomizePage', () => {
expect(screen.getByText('Text')).toBeInTheDocument(); expect(screen.getByText('Text')).toBeInTheDocument();
expect(screen.getByText('Vorschau')).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; qrCodeDataUrl: string | null;
logoDataUrl: string | null; logoDataUrl: string | null;
backgroundImageUrl?: string | null; backgroundImageUrl?: string | null;
backgroundImagePanels?: BackgroundImagePanel[];
scale?: number; scale?: number;
readOnly?: boolean; readOnly?: boolean;
layoutKey?: string; layoutKey?: string;
@@ -38,6 +39,7 @@ export function DesignerCanvas({
background, background,
gradient, gradient,
backgroundImageUrl = null, backgroundImageUrl = null,
backgroundImagePanels,
accent, accent,
text, text,
secondary, secondary,
@@ -348,6 +350,7 @@ export function DesignerCanvas({
background, background,
gradient, gradient,
backgroundImageUrl, backgroundImageUrl,
backgroundImagePanels,
readOnly, readOnly,
}); });
@@ -367,6 +370,7 @@ export function DesignerCanvas({
backgroundColor: background, backgroundColor: background,
backgroundGradient: gradient, backgroundGradient: gradient,
backgroundImageUrl, backgroundImageUrl,
backgroundImagePanels,
readOnly, readOnly,
}).catch((error) => { }).catch((error) => {
console.error('[Fabric] Failed to render layout', error); console.error('[Fabric] Failed to render layout', error);
@@ -382,6 +386,7 @@ export function DesignerCanvas({
background, background,
gradient, gradient,
backgroundImageUrl, backgroundImageUrl,
backgroundImagePanels,
readOnly, readOnly,
]); ]);
@@ -458,9 +463,20 @@ export type FabricRenderOptions = {
backgroundColor: string; backgroundColor: string;
backgroundGradient: { angle?: number; stops?: string[] } | null; backgroundGradient: { angle?: number; stops?: string[] } | null;
backgroundImageUrl?: string | null; backgroundImageUrl?: string | null;
backgroundImagePanels?: BackgroundImagePanel[];
readOnly: boolean; readOnly: boolean;
}; };
export type BackgroundImagePanel = {
url: string;
centerX: number;
centerY: number;
width: number;
height: number;
rotation: number;
mirrored?: boolean;
};
export async function renderFabricLayout( export async function renderFabricLayout(
canvas: fabric.Canvas, canvas: fabric.Canvas,
options: FabricRenderOptions, options: FabricRenderOptions,
@@ -476,6 +492,7 @@ export async function renderFabricLayout(
backgroundColor, backgroundColor,
backgroundGradient, backgroundGradient,
backgroundImageUrl, backgroundImageUrl,
backgroundImagePanels,
readOnly, readOnly,
} = options; } = options;
@@ -489,7 +506,7 @@ export async function renderFabricLayout(
} }
canvas.clear(); canvas.clear();
await applyBackground(canvas, backgroundColor, backgroundGradient, backgroundImageUrl); await applyBackground(canvas, backgroundColor, backgroundGradient, backgroundImageUrl, backgroundImagePanels);
console.debug('[Invites][Fabric] render', { console.debug('[Invites][Fabric] render', {
elementCount: elements.length, elementCount: elements.length,
@@ -558,6 +575,7 @@ export async function applyBackground(
color: string, color: string,
gradient: { angle?: number; stops?: string[] } | null, gradient: { angle?: number; stops?: string[] } | null,
backgroundImageUrl?: string | null, backgroundImageUrl?: string | null,
backgroundImagePanels?: BackgroundImagePanel[],
): Promise<void> { ): Promise<void> {
try { try {
if (typeof canvas.setBackgroundImage === 'function') { if (typeof canvas.setBackgroundImage === 'function') {
@@ -568,25 +586,47 @@ export async function applyBackground(
canvas.requestRenderAll(); 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) { if (backgroundImageUrl) {
try { try {
const resolvedUrl = backgroundImageUrl.startsWith('http') const image = await loadFabricImage(resolveAssetUrl(backgroundImageUrl));
? 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;
});
if (image) { if (image) {
const scaleX = CANVAS_WIDTH / (image.width || CANVAS_WIDTH); const scaleX = CANVAS_WIDTH / (image.width || CANVAS_WIDTH);
const scaleY = CANVAS_HEIGHT / (image.height || CANVAS_HEIGHT); const scaleY = CANVAS_HEIGHT / (image.height || CANVAS_HEIGHT);
@@ -617,6 +657,14 @@ export async function applyBackground(
console.warn('[Fabric] applyBackground failed', error); 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; let background: string | fabric.Gradient<'linear'> = color;
if (gradient?.stops?.length) { 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 = { export type FabricObjectFactoryContext = {
element: LayoutElement; element: LayoutElement;
accentColor: string; accentColor: string;