From e3b356e810cbb2ec931fee92e4079f9549d79b74 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sat, 24 Jan 2026 09:02:52 +0100 Subject: [PATCH] Enable foldable background presets --- .../js/admin/mobile/QrLayoutCustomizePage.tsx | 65 +++++++---- .../__tests__/QrLayoutCustomizePage.test.tsx | 20 ++++ .../mobile/invite-layout/DesignerCanvas.tsx | 108 +++++++++++++++--- 3 files changed, 153 insertions(+), 40 deletions(-) diff --git a/resources/js/admin/mobile/QrLayoutCustomizePage.tsx b/resources/js/admin/mobile/QrLayoutCustomizePage.tsx index 889a707..07476d1 100644 --- a/resources/js/admin/mobile/QrLayoutCustomizePage.tsx +++ b/resources/js/admin/mobile/QrLayoutCustomizePage.tsx @@ -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 ( - onSelectPreset(preset.id)} style={{ width: '48%' }}> + onSelectPreset(preset.id)} style={{ width: '32%' }}> - - - - {t(preset.labelKey, preset.label)} - - {isSelected ? {t('common.selected', 'Ausgewählt')} : null} - + + + + + {t(preset.labelKey, preset.label)} + + {isSelected ? {t('common.selected', 'Ausgewählt')} : null} + + ); @@ -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.foldableBackgroundNote', 'Für A5 Tischkarten bitte Gradient oder Vollfarbe wählen.')} - - )} + ) : null} diff --git a/resources/js/admin/mobile/__tests__/QrLayoutCustomizePage.test.tsx b/resources/js/admin/mobile/__tests__/QrLayoutCustomizePage.test.tsx index 7b5ad61..037e8e8 100644 --- a/resources/js/admin/mobile/__tests__/QrLayoutCustomizePage.test.tsx +++ b/resources/js/admin/mobile/__tests__/QrLayoutCustomizePage.test.tsx @@ -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(); + + expect(await screen.findByText('Blue Floral')).toBeInTheDocument(); + }); }); diff --git a/resources/js/admin/mobile/invite-layout/DesignerCanvas.tsx b/resources/js/admin/mobile/invite-layout/DesignerCanvas.tsx index 5867ec2..e8c4643 100644 --- a/resources/js/admin/mobile/invite-layout/DesignerCanvas.tsx +++ b/resources/js/admin/mobile/invite-layout/DesignerCanvas.tsx @@ -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 { 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((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 { + return new Promise((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;