Fix foldable background layout
This commit is contained in:
@@ -495,11 +495,11 @@ function resolveSlots(layout: EventQrInviteLayout | null, isFoldable: boolean, o
|
|||||||
|
|
||||||
const baseSlots = isFoldable
|
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 },
|
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.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_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.1, y: 0.66, w: 0.8, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, 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.12, y: 0.36, w: 0.76, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.3 },
|
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.3, y: 0.3, w: 0.28 },
|
qr: { x: 0, y: 0.3, w: 0.26 },
|
||||||
}
|
}
|
||||||
: getDefaultSlots();
|
: getDefaultSlots();
|
||||||
|
|
||||||
@@ -539,6 +539,8 @@ function buildFabricOptions({
|
|||||||
qrPngUrl,
|
qrPngUrl,
|
||||||
baseWidth = CANVAS_WIDTH,
|
baseWidth = CANVAS_WIDTH,
|
||||||
baseHeight = CANVAS_HEIGHT,
|
baseHeight = CANVAS_HEIGHT,
|
||||||
|
renderWidth = CANVAS_WIDTH,
|
||||||
|
renderHeight = CANVAS_HEIGHT,
|
||||||
slotOverrides = {},
|
slotOverrides = {},
|
||||||
}: {
|
}: {
|
||||||
layout: EventQrInviteLayout | null;
|
layout: EventQrInviteLayout | null;
|
||||||
@@ -551,10 +553,12 @@ function buildFabricOptions({
|
|||||||
qrPngUrl?: string | null;
|
qrPngUrl?: string | null;
|
||||||
baseWidth?: number;
|
baseWidth?: number;
|
||||||
baseHeight?: number;
|
baseHeight?: number;
|
||||||
|
renderWidth?: number;
|
||||||
|
renderHeight?: number;
|
||||||
slotOverrides?: SlotOverrides;
|
slotOverrides?: SlotOverrides;
|
||||||
}) {
|
}) {
|
||||||
const scaleX = CANVAS_WIDTH / baseWidth;
|
const scaleX = renderWidth / baseWidth;
|
||||||
const scaleY = CANVAS_HEIGHT / baseHeight;
|
const scaleY = renderHeight / baseHeight;
|
||||||
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);
|
||||||
@@ -563,20 +567,20 @@ function buildFabricOptions({
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
url: backgroundImageUrl,
|
url: backgroundImageUrl,
|
||||||
centerX: (panelWidth / 2) * scaleX,
|
centerX: (renderWidth / 2) * 0.5,
|
||||||
centerY: (panelHeight / 2) * scaleY,
|
centerY: renderHeight / 2,
|
||||||
width: panelWidth * scaleX,
|
width: renderWidth / 2,
|
||||||
height: panelHeight * scaleY,
|
height: renderHeight,
|
||||||
rotation: 90,
|
rotation: 0,
|
||||||
mirrored: false,
|
mirrored: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: backgroundImageUrl,
|
url: backgroundImageUrl,
|
||||||
centerX: (panelWidth + panelWidth / 2) * scaleX,
|
centerX: (renderWidth / 2) * 1.5,
|
||||||
centerY: (panelHeight / 2) * scaleY,
|
centerY: renderHeight / 2,
|
||||||
width: panelWidth * scaleX,
|
width: renderWidth / 2,
|
||||||
height: panelHeight * scaleY,
|
height: renderHeight,
|
||||||
rotation: -90,
|
rotation: 0,
|
||||||
mirrored: true,
|
mirrored: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -706,6 +710,8 @@ function buildFabricOptions({
|
|||||||
backgroundGradient,
|
backgroundGradient,
|
||||||
backgroundImageUrl: backgroundPanels ? null : backgroundImageUrl,
|
backgroundImageUrl: backgroundPanels ? null : backgroundImageUrl,
|
||||||
backgroundImagePanels: backgroundPanels ?? undefined,
|
backgroundImagePanels: backgroundPanels ?? undefined,
|
||||||
|
canvasWidth: renderWidth,
|
||||||
|
canvasHeight: renderHeight,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
@@ -1093,6 +1099,13 @@ function PreviewStep({
|
|||||||
const resolvedBgSolid = backgroundSolid ?? layout?.preview?.background ?? null;
|
const resolvedBgSolid = backgroundSolid ?? layout?.preview?.background ?? null;
|
||||||
const backgroundImageUrl = presetSrc ?? null;
|
const backgroundImageUrl = presetSrc ?? null;
|
||||||
const canvasBase = React.useMemo(() => resolveCanvasBase(layout, isFoldable), [layout, isFoldable]);
|
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 resolvedSlots = React.useMemo(() => resolveSlots(layout, isFoldable, slotOverrides), [isFoldable, layout, slotOverrides]);
|
||||||
const exportOptions = React.useMemo(() => {
|
const exportOptions = React.useMemo(() => {
|
||||||
return buildFabricOptions({
|
return buildFabricOptions({
|
||||||
@@ -1106,9 +1119,25 @@ function PreviewStep({
|
|||||||
qrPngUrl,
|
qrPngUrl,
|
||||||
baseWidth: canvasBase.width,
|
baseWidth: canvasBase.width,
|
||||||
baseHeight: canvasBase.height,
|
baseHeight: canvasBase.height,
|
||||||
|
renderWidth: renderCanvas.width,
|
||||||
|
renderHeight: renderCanvas.height,
|
||||||
slotOverrides,
|
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<string | null>(null);
|
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
|
||||||
const [previewLoading, setPreviewLoading] = React.useState(false);
|
const [previewLoading, setPreviewLoading] = React.useState(false);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
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 { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import { getEventQrInvites } from '../../api';
|
import { getEventQrInvites, updateEventQrInvite } from '../../api';
|
||||||
|
import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../invite-layout/schema';
|
||||||
|
|
||||||
const fixtures = vi.hoisted(() => ({
|
const fixtures = vi.hoisted(() => ({
|
||||||
event: {
|
event: {
|
||||||
@@ -28,14 +29,14 @@ const fixtures = vi.hoisted(() => ({
|
|||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('react-router-dom', () => ({
|
const exportUtilsMock = vi.hoisted(() => ({
|
||||||
useNavigate: () => vi.fn(),
|
generatePdfBytes: vi.fn(),
|
||||||
useParams: () => ({ slug: fixtures.event.slug, tokenId: '1' }),
|
generatePngDataUrl: vi.fn().mockResolvedValue('data:image/png;base64,abc'),
|
||||||
useSearchParams: () => [new URLSearchParams(), vi.fn()],
|
triggerDownloadFromBlob: vi.fn(),
|
||||||
|
triggerDownloadFromDataUrl: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('react-i18next', () => ({
|
const translationMock = vi.hoisted(() => ({
|
||||||
useTranslation: () => ({
|
|
||||||
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
||||||
if (typeof fallback === 'string') {
|
if (typeof fallback === 'string') {
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -45,7 +46,16 @@ vi.mock('react-i18next', () => ({
|
|||||||
}
|
}
|
||||||
return key;
|
return key;
|
||||||
},
|
},
|
||||||
}),
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-router-dom', () => ({
|
||||||
|
useNavigate: () => vi.fn(),
|
||||||
|
useParams: () => ({ slug: fixtures.event.slug, tokenId: '1' }),
|
||||||
|
useSearchParams: () => [new URLSearchParams(), vi.fn()],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => translationMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../hooks/useBackNavigation', () => ({
|
vi.mock('../hooks/useBackNavigation', () => ({
|
||||||
@@ -78,12 +88,7 @@ vi.mock('react-hot-toast', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./invite-layout/export-utils', () => ({
|
vi.mock('../invite-layout/export-utils', () => exportUtilsMock);
|
||||||
generatePdfBytes: vi.fn(),
|
|
||||||
generatePngDataUrl: vi.fn().mockResolvedValue('data:image/png;base64,abc'),
|
|
||||||
triggerDownloadFromBlob: vi.fn(),
|
|
||||||
triggerDownloadFromDataUrl: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../components/MobileShell', () => ({
|
vi.mock('../components/MobileShell', () => ({
|
||||||
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
@@ -92,7 +97,19 @@ vi.mock('../components/MobileShell', () => ({
|
|||||||
|
|
||||||
vi.mock('../components/Primitives', () => ({
|
vi.mock('../components/Primitives', () => ({
|
||||||
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
|
CTAButton: ({
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => (
|
||||||
|
<button type="button" disabled={disabled} onClick={onPress}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -185,9 +202,65 @@ describe('MobileQrLayoutCustomizePage', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(getEventQrInvites).mockResolvedValueOnce([foldableInvite]);
|
vi.mocked(getEventQrInvites).mockResolvedValueOnce([foldableInvite]);
|
||||||
|
vi.mocked(updateEventQrInvite).mockResolvedValue(foldableInvite as any);
|
||||||
|
|
||||||
render(<MobileQrLayoutCustomizePage />);
|
render(<MobileQrLayoutCustomizePage />);
|
||||||
|
|
||||||
expect(await screen.findByText('Blue Floral')).toBeInTheDocument();
|
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(<MobileQrLayoutCustomizePage />);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -464,6 +464,8 @@ export type FabricRenderOptions = {
|
|||||||
backgroundGradient: { angle?: number; stops?: string[] } | null;
|
backgroundGradient: { angle?: number; stops?: string[] } | null;
|
||||||
backgroundImageUrl?: string | null;
|
backgroundImageUrl?: string | null;
|
||||||
backgroundImagePanels?: BackgroundImagePanel[];
|
backgroundImagePanels?: BackgroundImagePanel[];
|
||||||
|
canvasWidth?: number;
|
||||||
|
canvasHeight?: number;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -578,6 +580,8 @@ export async function applyBackground(
|
|||||||
backgroundImagePanels?: BackgroundImagePanel[],
|
backgroundImagePanels?: BackgroundImagePanel[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const canvasWidth = canvas.getWidth() || CANVAS_WIDTH;
|
||||||
|
const canvasHeight = canvas.getHeight() || CANVAS_HEIGHT;
|
||||||
if (typeof canvas.setBackgroundImage === 'function') {
|
if (typeof canvas.setBackgroundImage === 'function') {
|
||||||
canvas.setBackgroundImage(null, canvas.requestRenderAll.bind(canvas));
|
canvas.setBackgroundImage(null, canvas.requestRenderAll.bind(canvas));
|
||||||
} else {
|
} else {
|
||||||
@@ -587,7 +591,7 @@ export async function applyBackground(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (backgroundImagePanels?.length) {
|
if (backgroundImagePanels?.length) {
|
||||||
applyBackgroundFill(canvas, color, gradient);
|
applyBackgroundFill(canvas, color, gradient, canvasWidth, canvasHeight);
|
||||||
|
|
||||||
const panelImages = await Promise.all(
|
const panelImages = await Promise.all(
|
||||||
backgroundImagePanels.map(async (panel) => ({
|
backgroundImagePanels.map(async (panel) => ({
|
||||||
@@ -600,10 +604,27 @@ export async function applyBackground(
|
|||||||
if (!element) {
|
if (!element) {
|
||||||
return;
|
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 needsSwap = Math.abs(panel.rotation) % 180 === 90;
|
||||||
const targetWidth = needsSwap ? panel.height : panel.width;
|
const targetWidth = needsSwap ? panel.height : panel.width;
|
||||||
const targetHeight = needsSwap ? panel.width : panel.height;
|
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 scaleX = panel.mirrored ? -scale : scale;
|
||||||
const scaleY = scale;
|
const scaleY = scale;
|
||||||
element.set({
|
element.set({
|
||||||
@@ -616,6 +637,7 @@ export async function applyBackground(
|
|||||||
angle: panel.rotation,
|
angle: panel.rotation,
|
||||||
selectable: false,
|
selectable: false,
|
||||||
evented: false,
|
evented: false,
|
||||||
|
clipPath,
|
||||||
});
|
});
|
||||||
canvas.add(element);
|
canvas.add(element);
|
||||||
});
|
});
|
||||||
@@ -628,9 +650,13 @@ export async function applyBackground(
|
|||||||
try {
|
try {
|
||||||
const image = await loadFabricImage(resolveAssetUrl(backgroundImageUrl));
|
const image = await loadFabricImage(resolveAssetUrl(backgroundImageUrl));
|
||||||
if (image) {
|
if (image) {
|
||||||
const scaleX = CANVAS_WIDTH / (image.width || CANVAS_WIDTH);
|
const scale = getAdaptiveScale({
|
||||||
const scaleY = CANVAS_HEIGHT / (image.height || CANVAS_HEIGHT);
|
sourceWidth: image.width || canvasWidth,
|
||||||
const scale = Math.max(scaleX, scaleY);
|
sourceHeight: image.height || canvasHeight,
|
||||||
|
targetWidth: canvasWidth,
|
||||||
|
targetHeight: canvasHeight,
|
||||||
|
mode: 'cover',
|
||||||
|
});
|
||||||
image.set({
|
image.set({
|
||||||
originX: 'left',
|
originX: 'left',
|
||||||
originY: 'top',
|
originY: 'top',
|
||||||
@@ -657,20 +683,22 @@ export async function applyBackground(
|
|||||||
console.warn('[Fabric] applyBackground failed', error);
|
console.warn('[Fabric] applyBackground failed', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
applyBackgroundFill(canvas, color, gradient);
|
applyBackgroundFill(canvas, color, gradient, canvasWidth, canvasHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBackgroundFill(
|
function applyBackgroundFill(
|
||||||
canvas: fabric.Canvas,
|
canvas: fabric.Canvas,
|
||||||
color: string,
|
color: string,
|
||||||
gradient: { angle?: number; stops?: string[] } | null,
|
gradient: { angle?: number; stops?: string[] } | null,
|
||||||
|
canvasWidth: number,
|
||||||
|
canvasHeight: number,
|
||||||
): void {
|
): void {
|
||||||
let background: string | fabric.Gradient<'linear'> = color;
|
let background: string | fabric.Gradient<'linear'> = color;
|
||||||
|
|
||||||
if (gradient?.stops?.length) {
|
if (gradient?.stops?.length) {
|
||||||
const angle = ((gradient.angle ?? 180) * Math.PI) / 180;
|
const angle = ((gradient.angle ?? 180) * Math.PI) / 180;
|
||||||
const halfWidth = CANVAS_WIDTH / 2;
|
const halfWidth = canvasWidth / 2;
|
||||||
const halfHeight = CANVAS_HEIGHT / 2;
|
const halfHeight = canvasHeight / 2;
|
||||||
const x = Math.cos(angle);
|
const x = Math.cos(angle);
|
||||||
const y = Math.sin(angle);
|
const y = Math.sin(angle);
|
||||||
|
|
||||||
@@ -718,6 +746,27 @@ async function loadFabricImage(url: string): Promise<fabric.Image | null> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function resolveAssetUrl(url: string): string {
|
||||||
if (url.startsWith('http')) {
|
if (url.startsWith('http')) {
|
||||||
return url;
|
return url;
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ export async function withFabricCanvas<T>(
|
|||||||
handler: (canvas: fabric.Canvas, element: HTMLCanvasElement) => Promise<T>,
|
handler: (canvas: fabric.Canvas, element: HTMLCanvasElement) => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const canvasElement = document.createElement('canvas');
|
const canvasElement = document.createElement('canvas');
|
||||||
canvasElement.width = CANVAS_WIDTH;
|
const targetWidth = options.canvasWidth ?? CANVAS_WIDTH;
|
||||||
canvasElement.height = CANVAS_HEIGHT;
|
const targetHeight = options.canvasHeight ?? CANVAS_HEIGHT;
|
||||||
|
canvasElement.width = targetWidth;
|
||||||
|
canvasElement.height = targetHeight;
|
||||||
|
|
||||||
const canvas = new fabric.Canvas(canvasElement, {
|
const canvas = new fabric.Canvas(canvasElement, {
|
||||||
selection: false,
|
selection: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user