Enable foldable background presets
This commit is contained in:
@@ -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}>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user