fixed layout canvas including elements

This commit is contained in:
Codex Agent
2025-10-31 23:20:52 +01:00
parent eb0c31c90b
commit 81cdee428e
3 changed files with 230 additions and 219 deletions

View File

@@ -115,72 +115,73 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true
globalThis.fotospielDemoAuth = api;
}
async function requestAuthorization(url: string, fallbackRedirect?: string): Promise<URL> {
const requestUrl = new URL(url, window.location.origin);
let response: Response;
try {
response = await fetch(requestUrl.toString(), {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json, text/plain, */*',
'X-Requested-With': 'XMLHttpRequest',
},
redirect: 'manual',
});
} catch (error) {
throw new Error('Authorize request failed');
}
const status = response.status;
const isSuccess = (status >= 200 && status < 400) || status === 0;
if (!isSuccess) {
throw new Error(`Authorize failed with ${status}`);
}
const contentType = response.headers.get('Content-Type') ?? '';
if (contentType.includes('application/json')) {
try {
const payload = (await response.json()) as {
code?: string;
state?: string | null;
redirect_url?: string | null;
};
const target = payload.redirect_url ?? fallbackRedirect;
if (!target) {
throw new Error('Authorize response missing redirect target');
function requestAuthorization(url: string, fallbackRedirect?: string): Promise<URL> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.withCredentials = true;
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
const requestUrl = new URL(url, window.location.origin);
xhr.onreadystatechange = () => {
if (xhr.readyState !== XMLHttpRequest.DONE) {
return;
}
const finalUrl = new URL(target, window.location.origin);
if (payload.code && !finalUrl.searchParams.has('code')) {
finalUrl.searchParams.set('code', payload.code);
}
if (payload.state && !finalUrl.searchParams.has('state')) {
finalUrl.searchParams.set('state', payload.state);
const isSuccess = (xhr.status >= 200 && xhr.status < 400) || xhr.status === 0;
if (!isSuccess) {
reject(new Error(`Authorize failed with ${xhr.status}`));
return;
}
return finalUrl;
} catch (error) {
throw error instanceof Error ? error : new Error(String(error));
}
}
const contentType = xhr.getResponseHeader('Content-Type') ?? '';
if (contentType.includes('application/json')) {
try {
const payload = JSON.parse(xhr.responseText ?? '{}') as {
code?: string;
state?: string | null;
redirect_url?: string | null;
};
const target = payload.redirect_url ?? fallbackRedirect;
if (!target) {
throw new Error('Authorize response missing redirect target');
}
const locationHeader = response.headers.get('Location');
if (locationHeader) {
return new URL(locationHeader, window.location.origin);
}
const finalUrl = new URL(target, window.location.origin);
if (payload.code && !finalUrl.searchParams.has('code')) {
finalUrl.searchParams.set('code', payload.code);
}
if (payload.state && !finalUrl.searchParams.has('state')) {
finalUrl.searchParams.set('state', payload.state);
}
if (response.url && response.url !== requestUrl.toString()) {
return new URL(response.url, window.location.origin);
}
resolve(finalUrl);
return;
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)));
return;
}
}
if (fallbackRedirect) {
return new URL(fallbackRedirect, window.location.origin);
}
const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location');
if (responseUrl) {
const finalUrl = new URL(responseUrl, window.location.origin);
if (finalUrl.searchParams.has('code') || finalUrl.toString() !== requestUrl.toString()) {
resolve(finalUrl);
return;
}
}
throw new Error('Authorize response missing redirect target');
if (fallbackRedirect) {
resolve(new URL(fallbackRedirect, window.location.origin));
return;
}
reject(new Error('Authorize response missing redirect target'));
};
xhr.onerror = () => reject(new Error('Authorize request failed'));
xhr.send();
});
}
function verifyState(returnedState: string | null, expectedState: string): void {

View File

@@ -45,7 +45,7 @@ import {
normalizeElements,
payloadToElements,
} from './invite-layout/schema';
import { CanvasScaleControl, DesignerCanvas } from './invite-layout/DesignerCanvas';
import { DesignerCanvas } from './invite-layout/DesignerCanvas';
import { CANVAS_HEIGHT, CANVAS_WIDTH } from './invite-layout/schema';
import {
generatePdfBytes,
@@ -181,9 +181,6 @@ type InviteLayoutCustomizerPanelProps = {
};
const MAX_INSTRUCTIONS = 5;
const MIN_CANVAS_SCALE = 0.15;
const MAX_CANVAS_SCALE = 0.85;
const SCALE_EPSILON = 0.005;
export function InviteLayoutCustomizerPanel({
invite,
@@ -215,8 +212,6 @@ export function InviteLayoutCustomizerPanel({
const [printBusy, setPrintBusy] = React.useState(false);
const [elements, setElements] = React.useState<LayoutElement[]>([]);
const [activeElementId, setActiveElementId] = React.useState<string | null>(null);
const [canvasScale, setCanvasScale] = React.useState(0.45);
const [autoScaleEnabled, setAutoScaleEnabled] = React.useState(true);
const [showFloatingActions, setShowFloatingActions] = React.useState(false);
const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null);
const historyRef = React.useRef<LayoutElement[][]>([]);
@@ -345,26 +340,6 @@ export function InviteLayoutCustomizerPanel({
const prevFormRef = React.useRef(form);
const initializedLayoutsRef = React.useRef<Record<string, boolean>>({});
const prevInviteRef = React.useRef<number | string | null>(null);
const clampScale = React.useCallback(
(value: number) => Math.min(MAX_CANVAS_SCALE, Math.max(MIN_CANVAS_SCALE, value)),
[],
);
const updateAutoScale = React.useCallback(() => {
if (!autoScaleEnabled) {
return;
}
const viewport = designerViewportRef.current;
const availableWidth = viewport?.clientWidth ?? 0;
const widthScale = availableWidth > 0 ? availableWidth / CANVAS_WIDTH : NaN;
const heightBudget = Math.max(window.innerHeight * 0.75 - 48, 200);
const heightScale = heightBudget / CANVAS_HEIGHT;
const resolvedWidthScale = Number.isFinite(widthScale) ? widthScale : Number.POSITIVE_INFINITY;
const candidate = Math.min(resolvedWidthScale, heightScale, MAX_CANVAS_SCALE);
const targetScale = clampScale(Number.isFinite(candidate) ? candidate : canvasScale);
setCanvasScale((prev) => (Math.abs(prev - targetScale) > SCALE_EPSILON ? targetScale : prev));
}, [autoScaleEnabled, canvasScale, clampScale]);
const activeLayout = React.useMemo(() => {
if (!availableLayouts.length) {
return null;
@@ -431,9 +406,7 @@ export function InviteLayoutCustomizerPanel({
resetHistory(defaults);
setActiveElementId(null);
initializedLayoutsRef.current[activeLayout.id] = true;
setAutoScaleEnabled(true);
updateAutoScale();
}, [activeLayout, eventName, activeLayoutQrSize, commitElements, elements, elementsAreEqual, resetHistory, updateAutoScale]);
}, [activeLayout, eventName, activeLayoutQrSize, commitElements, elements, elementsAreEqual, resetHistory]);
React.useEffect(() => {
if (!invite) {
@@ -454,7 +427,6 @@ export function InviteLayoutCustomizerPanel({
}
return layouts[0]?.id;
});
setAutoScaleEnabled(true);
}, [invite?.id, initialCustomization?.layout_id]);
React.useEffect(() => {
@@ -541,27 +513,6 @@ export function InviteLayoutCustomizerPanel({
});
}, [availableLayouts, initialCustomization?.layout_id]);
React.useEffect(() => {
updateAutoScale();
}, [updateAutoScale]);
React.useEffect(() => {
setAutoScaleEnabled(true);
updateAutoScale();
}, [activeLayout?.id, updateAutoScale]);
React.useEffect(() => {
const viewport = designerViewportRef.current;
if (!viewport || typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(() => updateAutoScale());
observer.observe(viewport);
return () => observer.disconnect();
}, [updateAutoScale]);
React.useEffect(() => {
if (!invite || !activeLayout) {
setForm({});
@@ -1751,36 +1702,27 @@ export function InviteLayoutCustomizerPanel({
<div ref={actionsSentinelRef} className="h-1 w-full" />
</form>
<div className="flex flex-col gap-4 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
<div className="flex flex-wrap items-center justify-between gap-3">
<CanvasScaleControl
scale={canvasScale}
onChange={(value) => {
setAutoScaleEnabled(false);
setCanvasScale(clampScale(value));
}}
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUndo}
disabled={!canUndo}
>
<Undo2 className="mr-1 h-4 w-4" />
{t('invites.customizer.actions.undo', 'Rückgängig')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRedo}
disabled={!canRedo}
>
<Redo2 className="mr-1 h-4 w-4" />
{t('invites.customizer.actions.redo', 'Wiederholen')}
</Button>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUndo}
disabled={!canUndo}
>
<Undo2 className="mr-1 h-4 w-4" />
{t('invites.customizer.actions.undo', 'Rückgängig')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRedo}
disabled={!canRedo}
>
<Redo2 className="mr-1 h-4 w-4" />
{t('invites.customizer.actions.redo', 'Wiederholen')}
</Button>
</div>
<div className="flex justify-center">
@@ -1802,7 +1744,6 @@ export function InviteLayoutCustomizerPanel({
badge={form.badge_color ?? form.accent_color ?? '#2563EB'}
qrCodeDataUrl={qrCodeDataUrl}
logoDataUrl={form.logo_data_url ?? form.logo_url ?? null}
scale={canvasScale}
layoutKey={`designer:${invite?.id ?? 'unknown'}:${activeLayout?.id ?? 'default'}`}
/>
</div>

View File

@@ -22,16 +22,12 @@ type DesignerCanvasProps = {
badge: string;
qrCodeDataUrl: string | null;
logoDataUrl: string | null;
scale: number;
layoutKey?: string;
readOnly?: boolean;
};
type FabricObjectWithId = fabric.Object & { elementId?: string };
const DEFAULT_MIN_SCALE = 0.15;
const DEFAULT_MAX_SCALE = 0.85;
export function DesignerCanvas({
elements,
selectedId,
@@ -45,7 +41,6 @@ export function DesignerCanvas({
badge,
qrCodeDataUrl,
logoDataUrl,
scale,
layoutKey,
readOnly = false,
}: DesignerCanvasProps): React.JSX.Element {
@@ -55,6 +50,7 @@ export function DesignerCanvas({
const disposeTokenRef = React.useRef(0);
const pendingDisposeRef = React.useRef<number | null>(null);
const pendingTimeoutRef = React.useRef<number | null>(null);
const lastRenderSignatureRef = React.useRef<string | null>(null);
const destroyCanvas = React.useCallback((canvas: fabric.Canvas | null) => {
if (!canvas) {
@@ -145,6 +141,7 @@ export function DesignerCanvas({
});
fabricCanvasRef.current = canvas;
lastRenderSignatureRef.current = null;
const disposeToken = ++disposeTokenRef.current;
(window as unknown as Record<string, unknown>).__inviteCanvas = canvas;
@@ -265,6 +262,24 @@ export function DesignerCanvas({
return;
}
const signature = JSON.stringify({
elements,
accent,
text,
secondary,
badge,
qrCodeDataUrl,
logoDataUrl,
background,
gradient,
readOnly,
});
if (lastRenderSignatureRef.current === signature) {
return;
}
lastRenderSignatureRef.current = signature;
renderFabricLayout(canvas, {
elements,
accentColor: accent,
@@ -276,7 +291,6 @@ export function DesignerCanvas({
backgroundColor: background,
backgroundGradient: gradient,
readOnly,
selectedId,
}).catch((error) => {
console.error('[Fabric] Failed to render layout', error);
});
@@ -290,7 +304,6 @@ export function DesignerCanvas({
logoDataUrl,
background,
gradient,
selectedId,
readOnly,
]);
@@ -299,16 +312,47 @@ export function DesignerCanvas({
if (!canvas) {
return;
}
canvas.setZoom(scale);
if (readOnly) {
canvas.discardActiveObject();
canvas.requestRenderAll();
return;
}
if (!selectedId) {
canvas.discardActiveObject();
canvas.requestRenderAll();
return;
}
const match = canvas
.getObjects()
.find((object) => (object as FabricObjectWithId).elementId === selectedId);
if (match) {
canvas.setActiveObject(match);
} else {
canvas.discardActiveObject();
}
canvas.requestRenderAll();
}, [selectedId, readOnly]);
React.useEffect(() => {
const canvas = fabricCanvasRef.current;
if (!canvas) {
return;
}
canvas.setZoom(1);
canvas.setDimensions(
{
width: CANVAS_WIDTH * scale,
height: CANVAS_HEIGHT * scale,
width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
},
{ cssOnly: true },
);
canvas.requestRenderAll();
}, [scale]);
}, []);
return (
<div ref={containerRef} className="relative inline-block max-w-full">
@@ -333,7 +377,6 @@ export type FabricRenderOptions = {
backgroundColor: string;
backgroundGradient: { angle?: number; stops?: string[] } | null;
readOnly: boolean;
selectedId?: string | null;
};
export async function renderFabricLayout(
@@ -351,7 +394,6 @@ export async function renderFabricLayout(
backgroundColor,
backgroundGradient,
readOnly,
selectedId,
} = options;
canvas.discardActiveObject();
@@ -380,6 +422,10 @@ export async function renderFabricLayout(
);
const fabricObjects = await Promise.all(objectPromises);
console.debug('[Invites][Fabric] resolved objects', {
count: fabricObjects.length,
nulls: fabricObjects.filter((obj) => !obj).length,
});
fabricObjects.forEach((object) => {
if (!object) {
@@ -412,15 +458,6 @@ export async function renderFabricLayout(
console.warn('[Invites][Fabric] object count', canvas.getObjects().length);
if (!readOnly && selectedId) {
const match = canvas
.getObjects()
.find((object) => (object as FabricObjectWithId).elementId === selectedId);
if (match) {
canvas.setActiveObject(match);
}
}
canvas.renderAll();
}
@@ -556,6 +593,11 @@ export async function createFabricObject({
return null;
case 'qr':
if (qrCodeDataUrl) {
console.debug(
'[Invites][Fabric] qr image source',
qrCodeDataUrl.length,
qrCodeDataUrl.slice(0, 48),
);
return loadImageObject(qrCodeDataUrl, element, baseConfig, {
shadow: 'rgba(15,23,42,0.25)',
});
@@ -639,43 +681,98 @@ export async function loadImageObject(
options?: { objectFit?: 'contain' | 'cover'; shadow?: string },
): Promise<fabric.Object | null> {
return new Promise((resolve) => {
fabric.Image.fromURL(
source,
(image) => {
if (!image) {
resolve(null);
return;
}
let resolved = false;
const resolveSafely = (value: fabric.Object | null) => {
if (resolved) {
return;
}
resolved = true;
resolve(value);
};
const scaleX = element.width / (image.width ?? element.width);
const scaleY = element.height / (image.height ?? element.height);
const isDataUrl = source.startsWith('data:');
const onImageLoaded = (img?: HTMLImageElement | HTMLCanvasElement | null) => {
if (!img) {
console.warn('[Invites][Fabric] image load returned empty', { source });
resolveSafely(null);
return;
}
const image = new fabric.Image(img, { ...baseConfig });
const intrinsicWidth = image.width ?? element.width;
const intrinsicHeight = image.height ?? element.height;
const scaleX = element.width / intrinsicWidth;
const scaleY = element.height / intrinsicHeight;
image.set({
...baseConfig,
width: element.width,
height: element.height,
scaleX,
scaleY,
});
if (options?.shadow) {
image.set('shadow', options.shadow);
}
if (options?.objectFit === 'contain') {
const ratio = Math.min(scaleX, scaleY);
image.set({
...baseConfig,
width: element.width,
height: element.height,
scaleX,
scaleY,
scaleX: ratio,
scaleY: ratio,
left: element.x + (element.width - intrinsicWidth * ratio) / 2,
top: element.y + (element.height - intrinsicHeight * ratio) / 2,
});
}
if (options?.shadow) {
image.set('shadow', options.shadow);
}
resolveSafely(image);
};
if (options?.objectFit === 'contain') {
const ratio = Math.min(scaleX, scaleY);
image.set({
scaleX: ratio,
scaleY: ratio,
left: element.x + (element.width - (image.width ?? 0) * ratio) / 2,
top: element.y + (element.height - (image.height ?? 0) * ratio) / 2,
const onError = (error?: unknown) => {
console.warn('[Invites][Fabric] failed to load image', source, error);
resolveSafely(null);
};
try {
if (isDataUrl) {
const imageElement = new Image();
imageElement.onload = () => {
console.debug('[Invites][Fabric] image loaded (data-url)', {
source: source.slice(0, 48),
width: imageElement.naturalWidth,
height: imageElement.naturalHeight,
});
}
onImageLoaded(imageElement);
};
imageElement.onerror = onError;
imageElement.src = source;
} else {
fabric.util.loadImage(
source,
(img) => {
if (!img) {
onError();
return;
}
console.debug('[Invites][Fabric] image loaded', {
source: source.slice(0, 48),
width: (img as HTMLImageElement).width,
height: (img as HTMLImageElement).height,
});
onImageLoaded(img);
},
undefined,
'anonymous',
);
}
} catch (error) {
onError(error);
}
resolve(image);
},
{ crossOrigin: 'anonymous' },
);
window.setTimeout(() => resolveSafely(null), 3000);
});
}
@@ -689,31 +786,3 @@ export function mapTextAlign(align?: LayoutElement['align']): 'left' | 'center'
return 'left';
}
}
export function CanvasScaleControl({
scale,
min = DEFAULT_MIN_SCALE,
max = DEFAULT_MAX_SCALE,
onChange,
}: {
scale: number;
min?: number;
max?: number;
onChange: (value: number) => void;
}): React.JSX.Element {
return (
<div className="flex items-center gap-3 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] px-4 py-2 text-xs">
<span className="font-medium text-muted-foreground">Zoom</span>
<input
type="range"
min={min}
max={max}
step={0.025}
value={scale}
onChange={(event) => onChange(Number(event.target.value))}
className="h-1 w-32 overflow-hidden rounded-full"
/>
<span className="tabular-nums text-muted-foreground">{Math.round(scale * 100)}%</span>
</div>
);
}