diff --git a/resources/js/admin/mobile/QrLayoutCustomizePage.tsx b/resources/js/admin/mobile/QrLayoutCustomizePage.tsx index 07476d1..3d6b65f 100644 --- a/resources/js/admin/mobile/QrLayoutCustomizePage.tsx +++ b/resources/js/admin/mobile/QrLayoutCustomizePage.tsx @@ -495,11 +495,11 @@ function resolveSlots(layout: EventQrInviteLayout | null, isFoldable: boolean, o const baseSlots = isFoldable ? { - headline: { x: 0.08, y: 0.15, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' as const }, - subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' as const }, - description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' as const }, - instructions: { x: 0.12, y: 0.36, w: 0.76, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.3 }, - qr: { x: 0.3, y: 0.3, w: 0.28 }, + headline: { x: 0.08, y: 0.24, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' as const }, + subtitle: { x: 0.1, y: 0.28, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' as const }, + description: { x: 0.42, y: 0.6, w: 0.58, fontSize: 23, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'left' as const }, + instructions: { x: 0.42, y: 0.42, w: 0.58, fontSize: 15, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.3, align: 'left' as const }, + qr: { x: 0, y: 0.3, w: 0.26 }, } : getDefaultSlots(); @@ -539,6 +539,8 @@ function buildFabricOptions({ qrPngUrl, baseWidth = CANVAS_WIDTH, baseHeight = CANVAS_HEIGHT, + renderWidth = CANVAS_WIDTH, + renderHeight = CANVAS_HEIGHT, slotOverrides = {}, }: { layout: EventQrInviteLayout | null; @@ -551,10 +553,12 @@ function buildFabricOptions({ qrPngUrl?: string | null; baseWidth?: number; baseHeight?: number; + renderWidth?: number; + renderHeight?: number; slotOverrides?: SlotOverrides; }) { - const scaleX = CANVAS_WIDTH / baseWidth; - const scaleY = CANVAS_HEIGHT / baseHeight; + const scaleX = renderWidth / baseWidth; + const scaleY = renderHeight / baseHeight; const panelWidth = isFoldable ? baseWidth / 2 : baseWidth; const panelHeight = baseHeight; const slots = resolveSlots(layout, isFoldable, slotOverrides); @@ -563,20 +567,20 @@ function buildFabricOptions({ ? [ { url: backgroundImageUrl, - centerX: (panelWidth / 2) * scaleX, - centerY: (panelHeight / 2) * scaleY, - width: panelWidth * scaleX, - height: panelHeight * scaleY, - rotation: 90, + centerX: (renderWidth / 2) * 0.5, + centerY: renderHeight / 2, + width: renderWidth / 2, + height: renderHeight, + rotation: 0, mirrored: false, }, { url: backgroundImageUrl, - centerX: (panelWidth + panelWidth / 2) * scaleX, - centerY: (panelHeight / 2) * scaleY, - width: panelWidth * scaleX, - height: panelHeight * scaleY, - rotation: -90, + centerX: (renderWidth / 2) * 1.5, + centerY: renderHeight / 2, + width: renderWidth / 2, + height: renderHeight, + rotation: 0, mirrored: true, }, ] @@ -706,6 +710,8 @@ function buildFabricOptions({ backgroundGradient, backgroundImageUrl: backgroundPanels ? null : backgroundImageUrl, backgroundImagePanels: backgroundPanels ?? undefined, + canvasWidth: renderWidth, + canvasHeight: renderHeight, readOnly: true, } as const; } @@ -1093,6 +1099,13 @@ function PreviewStep({ const resolvedBgSolid = backgroundSolid ?? layout?.preview?.background ?? null; const backgroundImageUrl = presetSrc ?? null; const canvasBase = React.useMemo(() => resolveCanvasBase(layout, isFoldable), [layout, isFoldable]); + const renderCanvas = React.useMemo( + () => ({ + width: isFoldable ? CANVAS_HEIGHT : CANVAS_WIDTH, + height: isFoldable ? CANVAS_WIDTH : CANVAS_HEIGHT, + }), + [isFoldable], + ); const resolvedSlots = React.useMemo(() => resolveSlots(layout, isFoldable, slotOverrides), [isFoldable, layout, slotOverrides]); const exportOptions = React.useMemo(() => { return buildFabricOptions({ @@ -1106,9 +1119,25 @@ function PreviewStep({ qrPngUrl, baseWidth: canvasBase.width, baseHeight: canvasBase.height, + renderWidth: renderCanvas.width, + renderHeight: renderCanvas.height, slotOverrides, }); - }, [backgroundImageUrl, canvasBase.height, canvasBase.width, isFoldable, layout, qrPngUrl, qrUrl, resolvedBgGradient, resolvedBgSolid, slotOverrides, textFields]); + }, [ + backgroundImageUrl, + canvasBase.height, + canvasBase.width, + isFoldable, + layout, + qrPngUrl, + qrUrl, + renderCanvas.height, + renderCanvas.width, + resolvedBgGradient, + resolvedBgSolid, + slotOverrides, + textFields, + ]); const [previewUrl, setPreviewUrl] = React.useState(null); const [previewLoading, setPreviewLoading] = React.useState(false); diff --git a/resources/js/admin/mobile/__tests__/QrLayoutCustomizePage.test.tsx b/resources/js/admin/mobile/__tests__/QrLayoutCustomizePage.test.tsx index 037e8e8..928b471 100644 --- a/resources/js/admin/mobile/__tests__/QrLayoutCustomizePage.test.tsx +++ b/resources/js/admin/mobile/__tests__/QrLayoutCustomizePage.test.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { getEventQrInvites } from '../../api'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { getEventQrInvites, updateEventQrInvite } from '../../api'; +import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../invite-layout/schema'; const fixtures = vi.hoisted(() => ({ event: { @@ -28,6 +29,25 @@ const fixtures = vi.hoisted(() => ({ ], })); +const exportUtilsMock = vi.hoisted(() => ({ + generatePdfBytes: vi.fn(), + generatePngDataUrl: vi.fn().mockResolvedValue('data:image/png;base64,abc'), + triggerDownloadFromBlob: vi.fn(), + triggerDownloadFromDataUrl: vi.fn(), +})); + +const translationMock = vi.hoisted(() => ({ + t: (key: string, fallback?: string | Record) => { + if (typeof fallback === 'string') { + return fallback; + } + if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') { + return fallback.defaultValue; + } + return key; + }, +})); + vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn(), useParams: () => ({ slug: fixtures.event.slug, tokenId: '1' }), @@ -35,17 +55,7 @@ vi.mock('react-router-dom', () => ({ })); vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, fallback?: string | Record) => { - if (typeof fallback === 'string') { - return fallback; - } - if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') { - return fallback.defaultValue; - } - return key; - }, - }), + useTranslation: () => translationMock, })); vi.mock('../hooks/useBackNavigation', () => ({ @@ -78,12 +88,7 @@ vi.mock('react-hot-toast', () => ({ }, })); -vi.mock('./invite-layout/export-utils', () => ({ - generatePdfBytes: vi.fn(), - generatePngDataUrl: vi.fn().mockResolvedValue('data:image/png;base64,abc'), - triggerDownloadFromBlob: vi.fn(), - triggerDownloadFromDataUrl: vi.fn(), -})); +vi.mock('../invite-layout/export-utils', () => exportUtilsMock); vi.mock('../components/MobileShell', () => ({ MobileShell: ({ children }: { children: React.ReactNode }) =>
{children}
, @@ -92,7 +97,19 @@ vi.mock('../components/MobileShell', () => ({ vi.mock('../components/Primitives', () => ({ MobileCard: ({ children }: { children: React.ReactNode }) =>
{children}
, - CTAButton: ({ label }: { label: string }) => , + CTAButton: ({ + label, + onPress, + disabled, + }: { + label: string; + onPress?: () => void; + disabled?: boolean; + }) => ( + + ), PillBadge: ({ children }: { children: React.ReactNode }) =>
{children}
, })); @@ -185,9 +202,65 @@ describe('MobileQrLayoutCustomizePage', () => { }; vi.mocked(getEventQrInvites).mockResolvedValueOnce([foldableInvite]); + vi.mocked(updateEventQrInvite).mockResolvedValue(foldableInvite as any); render(); expect(await screen.findByText('Blue Floral')).toBeInTheDocument(); }); + + it('scales foldable background panels to A5 halves', async () => { + const foldableInvite = { + ...fixtures.invites[0], + metadata: { + layout_customization: { + background_preset: 'blue-floral', + }, + }, + layouts: [ + { + ...fixtures.invites[0].layouts[0], + orientation: 'landscape', + panel_mode: 'double-mirror', + }, + ], + }; + + vi.mocked(getEventQrInvites).mockResolvedValueOnce([foldableInvite]); + exportUtilsMock.generatePngDataUrl.mockResolvedValue('data:image/png;base64,abc'); + exportUtilsMock.generatePngDataUrl.mockClear(); + + render(); + + const presetButton = await screen.findByText('Blue Floral'); + fireEvent.click(presetButton); + + const nextButton = await screen.findByText('Weiter'); + fireEvent.click(nextButton); + const previewButton = await screen.findByText('Vorschau'); + fireEvent.click(previewButton); + + await waitFor(() => expect(exportUtilsMock.generatePngDataUrl).toHaveBeenCalled()); + + const options = exportUtilsMock.generatePngDataUrl.mock.calls.at(-1)?.[0]; + + expect(options).toBeDefined(); + expect(options.backgroundImagePanels).toHaveLength(2); + expect(options.backgroundImagePanels[0]).toMatchObject({ + centerX: CANVAS_HEIGHT / 4, + centerY: CANVAS_WIDTH / 2, + width: CANVAS_HEIGHT / 2, + height: CANVAS_WIDTH, + rotation: 0, + mirrored: false, + }); + expect(options.backgroundImagePanels[1]).toMatchObject({ + centerX: (CANVAS_HEIGHT / 4) * 3, + centerY: CANVAS_WIDTH / 2, + width: CANVAS_HEIGHT / 2, + height: CANVAS_WIDTH, + rotation: 0, + mirrored: true, + }); + }); }); diff --git a/resources/js/admin/mobile/invite-layout/DesignerCanvas.tsx b/resources/js/admin/mobile/invite-layout/DesignerCanvas.tsx index e8c4643..010b729 100644 --- a/resources/js/admin/mobile/invite-layout/DesignerCanvas.tsx +++ b/resources/js/admin/mobile/invite-layout/DesignerCanvas.tsx @@ -464,6 +464,8 @@ export type FabricRenderOptions = { backgroundGradient: { angle?: number; stops?: string[] } | null; backgroundImageUrl?: string | null; backgroundImagePanels?: BackgroundImagePanel[]; + canvasWidth?: number; + canvasHeight?: number; readOnly: boolean; }; @@ -578,6 +580,8 @@ export async function applyBackground( backgroundImagePanels?: BackgroundImagePanel[], ): Promise { try { + const canvasWidth = canvas.getWidth() || CANVAS_WIDTH; + const canvasHeight = canvas.getHeight() || CANVAS_HEIGHT; if (typeof canvas.setBackgroundImage === 'function') { canvas.setBackgroundImage(null, canvas.requestRenderAll.bind(canvas)); } else { @@ -587,7 +591,7 @@ export async function applyBackground( } if (backgroundImagePanels?.length) { - applyBackgroundFill(canvas, color, gradient); + applyBackgroundFill(canvas, color, gradient, canvasWidth, canvasHeight); const panelImages = await Promise.all( backgroundImagePanels.map(async (panel) => ({ @@ -600,10 +604,27 @@ export async function applyBackground( if (!element) { return; } + const clipPath = new fabric.Rect({ + left: panel.centerX - panel.width / 2, + top: panel.centerY - panel.height / 2, + width: panel.width, + height: panel.height, + originX: 'left', + originY: 'top', + absolutePositioned: true, + selectable: false, + evented: false, + }); 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 scale = getAdaptiveScale({ + sourceWidth: element.width || targetWidth, + sourceHeight: element.height || targetHeight, + targetWidth, + targetHeight, + mode: 'contain', + }); const scaleX = panel.mirrored ? -scale : scale; const scaleY = scale; element.set({ @@ -616,6 +637,7 @@ export async function applyBackground( angle: panel.rotation, selectable: false, evented: false, + clipPath, }); canvas.add(element); }); @@ -628,9 +650,13 @@ export async function applyBackground( try { const image = await loadFabricImage(resolveAssetUrl(backgroundImageUrl)); if (image) { - const scaleX = CANVAS_WIDTH / (image.width || CANVAS_WIDTH); - const scaleY = CANVAS_HEIGHT / (image.height || CANVAS_HEIGHT); - const scale = Math.max(scaleX, scaleY); + const scale = getAdaptiveScale({ + sourceWidth: image.width || canvasWidth, + sourceHeight: image.height || canvasHeight, + targetWidth: canvasWidth, + targetHeight: canvasHeight, + mode: 'cover', + }); image.set({ originX: 'left', originY: 'top', @@ -657,20 +683,22 @@ export async function applyBackground( console.warn('[Fabric] applyBackground failed', error); } - applyBackgroundFill(canvas, color, gradient); + applyBackgroundFill(canvas, color, gradient, canvasWidth, canvasHeight); } function applyBackgroundFill( canvas: fabric.Canvas, color: string, gradient: { angle?: number; stops?: string[] } | null, + canvasWidth: number, + canvasHeight: number, ): void { let background: string | fabric.Gradient<'linear'> = color; if (gradient?.stops?.length) { const angle = ((gradient.angle ?? 180) * Math.PI) / 180; - const halfWidth = CANVAS_WIDTH / 2; - const halfHeight = CANVAS_HEIGHT / 2; + const halfWidth = canvasWidth / 2; + const halfHeight = canvasHeight / 2; const x = Math.cos(angle); const y = Math.sin(angle); @@ -718,6 +746,27 @@ async function loadFabricImage(url: string): Promise { }); } +function getAdaptiveScale({ + sourceWidth, + sourceHeight, + targetWidth, + targetHeight, + mode = 'cover', +}: { + sourceWidth: number; + sourceHeight: number; + targetWidth: number; + targetHeight: number; + mode?: 'cover' | 'contain'; +}): number { + const safeSourceWidth = sourceWidth || targetWidth || 1; + const safeSourceHeight = sourceHeight || targetHeight || 1; + const widthRatio = targetWidth / safeSourceWidth; + const heightRatio = targetHeight / safeSourceHeight; + + return mode === 'contain' ? Math.min(widthRatio, heightRatio) : Math.max(widthRatio, heightRatio); +} + function resolveAssetUrl(url: string): string { if (url.startsWith('http')) { return url; diff --git a/resources/js/admin/mobile/invite-layout/export-utils.ts b/resources/js/admin/mobile/invite-layout/export-utils.ts index b9bc96d..2387ef2 100644 --- a/resources/js/admin/mobile/invite-layout/export-utils.ts +++ b/resources/js/admin/mobile/invite-layout/export-utils.ts @@ -15,8 +15,10 @@ export async function withFabricCanvas( handler: (canvas: fabric.Canvas, element: HTMLCanvasElement) => Promise, ): Promise { const canvasElement = document.createElement('canvas'); - canvasElement.width = CANVAS_WIDTH; - canvasElement.height = CANVAS_HEIGHT; + const targetWidth = options.canvasWidth ?? CANVAS_WIDTH; + const targetHeight = options.canvasHeight ?? CANVAS_HEIGHT; + canvasElement.width = targetWidth; + canvasElement.height = targetHeight; const canvas = new fabric.Canvas(canvasElement, { selection: false,