Enable foldable background presets
This commit is contained in:
@@ -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,28 +819,29 @@ 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
|
||||
flex={1}
|
||||
backgroundImage={`url(${preset.src})`}
|
||||
backgroundSize="cover"
|
||||
backgroundPosition="center"
|
||||
/>
|
||||
<XStack padding="$2" justifyContent="space-between" alignItems="center" backgroundColor="rgba(255,255,255,0.92)">
|
||||
<Text fontSize="$xs" color={textStrong}>
|
||||
{t(preset.labelKey, preset.label)}
|
||||
</Text>
|
||||
{isSelected ? <PillBadge tone="muted">{t('common.selected', 'Ausgewählt')}</PillBadge> : null}
|
||||
</XStack>
|
||||
<YStack style={{ position: 'absolute', inset: 0 }}>
|
||||
<YStack
|
||||
flex={1}
|
||||
backgroundImage={`url(${preset.src})`}
|
||||
backgroundSize="cover"
|
||||
backgroundPosition="center"
|
||||
/>
|
||||
<XStack padding="$2" justifyContent="space-between" alignItems="center" backgroundColor="rgba(255,255,255,0.92)">
|
||||
<Text fontSize="$xs" color={textStrong}>
|
||||
{t(preset.labelKey, preset.label)}
|
||||
</Text>
|
||||
{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}>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user