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

View File

@@ -45,7 +45,7 @@ import {
normalizeElements, normalizeElements,
payloadToElements, payloadToElements,
} from './invite-layout/schema'; } 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 { CANVAS_HEIGHT, CANVAS_WIDTH } from './invite-layout/schema';
import { import {
generatePdfBytes, generatePdfBytes,
@@ -181,9 +181,6 @@ type InviteLayoutCustomizerPanelProps = {
}; };
const MAX_INSTRUCTIONS = 5; const MAX_INSTRUCTIONS = 5;
const MIN_CANVAS_SCALE = 0.15;
const MAX_CANVAS_SCALE = 0.85;
const SCALE_EPSILON = 0.005;
export function InviteLayoutCustomizerPanel({ export function InviteLayoutCustomizerPanel({
invite, invite,
@@ -215,8 +212,6 @@ export function InviteLayoutCustomizerPanel({
const [printBusy, setPrintBusy] = React.useState(false); const [printBusy, setPrintBusy] = React.useState(false);
const [elements, setElements] = React.useState<LayoutElement[]>([]); const [elements, setElements] = React.useState<LayoutElement[]>([]);
const [activeElementId, setActiveElementId] = React.useState<string | null>(null); 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 [showFloatingActions, setShowFloatingActions] = React.useState(false);
const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null); const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null);
const historyRef = React.useRef<LayoutElement[][]>([]); const historyRef = React.useRef<LayoutElement[][]>([]);
@@ -345,26 +340,6 @@ export function InviteLayoutCustomizerPanel({
const prevFormRef = React.useRef(form); const prevFormRef = React.useRef(form);
const initializedLayoutsRef = React.useRef<Record<string, boolean>>({}); const initializedLayoutsRef = React.useRef<Record<string, boolean>>({});
const prevInviteRef = React.useRef<number | string | null>(null); 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(() => { const activeLayout = React.useMemo(() => {
if (!availableLayouts.length) { if (!availableLayouts.length) {
return null; return null;
@@ -431,9 +406,7 @@ export function InviteLayoutCustomizerPanel({
resetHistory(defaults); resetHistory(defaults);
setActiveElementId(null); setActiveElementId(null);
initializedLayoutsRef.current[activeLayout.id] = true; initializedLayoutsRef.current[activeLayout.id] = true;
setAutoScaleEnabled(true); }, [activeLayout, eventName, activeLayoutQrSize, commitElements, elements, elementsAreEqual, resetHistory]);
updateAutoScale();
}, [activeLayout, eventName, activeLayoutQrSize, commitElements, elements, elementsAreEqual, resetHistory, updateAutoScale]);
React.useEffect(() => { React.useEffect(() => {
if (!invite) { if (!invite) {
@@ -454,7 +427,6 @@ export function InviteLayoutCustomizerPanel({
} }
return layouts[0]?.id; return layouts[0]?.id;
}); });
setAutoScaleEnabled(true);
}, [invite?.id, initialCustomization?.layout_id]); }, [invite?.id, initialCustomization?.layout_id]);
React.useEffect(() => { React.useEffect(() => {
@@ -541,27 +513,6 @@ export function InviteLayoutCustomizerPanel({
}); });
}, [availableLayouts, initialCustomization?.layout_id]); }, [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(() => { React.useEffect(() => {
if (!invite || !activeLayout) { if (!invite || !activeLayout) {
setForm({}); setForm({});
@@ -1751,36 +1702,27 @@ export function InviteLayoutCustomizerPanel({
<div ref={actionsSentinelRef} className="h-1 w-full" /> <div ref={actionsSentinelRef} className="h-1 w-full" />
</form> </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-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"> <div className="flex flex-wrap items-center justify-end gap-2">
<CanvasScaleControl <Button
scale={canvasScale} type="button"
onChange={(value) => { variant="outline"
setAutoScaleEnabled(false); size="sm"
setCanvasScale(clampScale(value)); onClick={handleUndo}
}} disabled={!canUndo}
/> >
<div className="flex flex-wrap gap-2"> <Undo2 className="mr-1 h-4 w-4" />
<Button {t('invites.customizer.actions.undo', 'Rückgängig')}
type="button" </Button>
variant="outline" <Button
size="sm" type="button"
onClick={handleUndo} variant="outline"
disabled={!canUndo} size="sm"
> onClick={handleRedo}
<Undo2 className="mr-1 h-4 w-4" /> disabled={!canRedo}
{t('invites.customizer.actions.undo', 'Rückgängig')} >
</Button> <Redo2 className="mr-1 h-4 w-4" />
<Button {t('invites.customizer.actions.redo', 'Wiederholen')}
type="button" </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> </div>
<div className="flex justify-center"> <div className="flex justify-center">
@@ -1802,7 +1744,6 @@ export function InviteLayoutCustomizerPanel({
badge={form.badge_color ?? form.accent_color ?? '#2563EB'} badge={form.badge_color ?? form.accent_color ?? '#2563EB'}
qrCodeDataUrl={qrCodeDataUrl} qrCodeDataUrl={qrCodeDataUrl}
logoDataUrl={form.logo_data_url ?? form.logo_url ?? null} logoDataUrl={form.logo_data_url ?? form.logo_url ?? null}
scale={canvasScale}
layoutKey={`designer:${invite?.id ?? 'unknown'}:${activeLayout?.id ?? 'default'}`} layoutKey={`designer:${invite?.id ?? 'unknown'}:${activeLayout?.id ?? 'default'}`}
/> />
</div> </div>

View File

@@ -22,16 +22,12 @@ type DesignerCanvasProps = {
badge: string; badge: string;
qrCodeDataUrl: string | null; qrCodeDataUrl: string | null;
logoDataUrl: string | null; logoDataUrl: string | null;
scale: number;
layoutKey?: string; layoutKey?: string;
readOnly?: boolean; readOnly?: boolean;
}; };
type FabricObjectWithId = fabric.Object & { elementId?: string }; type FabricObjectWithId = fabric.Object & { elementId?: string };
const DEFAULT_MIN_SCALE = 0.15;
const DEFAULT_MAX_SCALE = 0.85;
export function DesignerCanvas({ export function DesignerCanvas({
elements, elements,
selectedId, selectedId,
@@ -45,7 +41,6 @@ export function DesignerCanvas({
badge, badge,
qrCodeDataUrl, qrCodeDataUrl,
logoDataUrl, logoDataUrl,
scale,
layoutKey, layoutKey,
readOnly = false, readOnly = false,
}: DesignerCanvasProps): React.JSX.Element { }: DesignerCanvasProps): React.JSX.Element {
@@ -55,6 +50,7 @@ export function DesignerCanvas({
const disposeTokenRef = React.useRef(0); const disposeTokenRef = React.useRef(0);
const pendingDisposeRef = React.useRef<number | null>(null); const pendingDisposeRef = React.useRef<number | null>(null);
const pendingTimeoutRef = 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) => { const destroyCanvas = React.useCallback((canvas: fabric.Canvas | null) => {
if (!canvas) { if (!canvas) {
@@ -145,6 +141,7 @@ export function DesignerCanvas({
}); });
fabricCanvasRef.current = canvas; fabricCanvasRef.current = canvas;
lastRenderSignatureRef.current = null;
const disposeToken = ++disposeTokenRef.current; const disposeToken = ++disposeTokenRef.current;
(window as unknown as Record<string, unknown>).__inviteCanvas = canvas; (window as unknown as Record<string, unknown>).__inviteCanvas = canvas;
@@ -265,6 +262,24 @@ export function DesignerCanvas({
return; 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, { renderFabricLayout(canvas, {
elements, elements,
accentColor: accent, accentColor: accent,
@@ -276,7 +291,6 @@ export function DesignerCanvas({
backgroundColor: background, backgroundColor: background,
backgroundGradient: gradient, backgroundGradient: gradient,
readOnly, readOnly,
selectedId,
}).catch((error) => { }).catch((error) => {
console.error('[Fabric] Failed to render layout', error); console.error('[Fabric] Failed to render layout', error);
}); });
@@ -290,7 +304,6 @@ export function DesignerCanvas({
logoDataUrl, logoDataUrl,
background, background,
gradient, gradient,
selectedId,
readOnly, readOnly,
]); ]);
@@ -299,16 +312,47 @@ export function DesignerCanvas({
if (!canvas) { if (!canvas) {
return; 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( canvas.setDimensions(
{ {
width: CANVAS_WIDTH * scale, width: CANVAS_WIDTH,
height: CANVAS_HEIGHT * scale, height: CANVAS_HEIGHT,
}, },
{ cssOnly: true }, { cssOnly: true },
); );
canvas.requestRenderAll(); canvas.requestRenderAll();
}, [scale]); }, []);
return ( return (
<div ref={containerRef} className="relative inline-block max-w-full"> <div ref={containerRef} className="relative inline-block max-w-full">
@@ -333,7 +377,6 @@ export type FabricRenderOptions = {
backgroundColor: string; backgroundColor: string;
backgroundGradient: { angle?: number; stops?: string[] } | null; backgroundGradient: { angle?: number; stops?: string[] } | null;
readOnly: boolean; readOnly: boolean;
selectedId?: string | null;
}; };
export async function renderFabricLayout( export async function renderFabricLayout(
@@ -351,7 +394,6 @@ export async function renderFabricLayout(
backgroundColor, backgroundColor,
backgroundGradient, backgroundGradient,
readOnly, readOnly,
selectedId,
} = options; } = options;
canvas.discardActiveObject(); canvas.discardActiveObject();
@@ -380,6 +422,10 @@ export async function renderFabricLayout(
); );
const fabricObjects = await Promise.all(objectPromises); const fabricObjects = await Promise.all(objectPromises);
console.debug('[Invites][Fabric] resolved objects', {
count: fabricObjects.length,
nulls: fabricObjects.filter((obj) => !obj).length,
});
fabricObjects.forEach((object) => { fabricObjects.forEach((object) => {
if (!object) { if (!object) {
@@ -412,15 +458,6 @@ export async function renderFabricLayout(
console.warn('[Invites][Fabric] object count', canvas.getObjects().length); 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(); canvas.renderAll();
} }
@@ -556,6 +593,11 @@ export async function createFabricObject({
return null; return null;
case 'qr': case 'qr':
if (qrCodeDataUrl) { if (qrCodeDataUrl) {
console.debug(
'[Invites][Fabric] qr image source',
qrCodeDataUrl.length,
qrCodeDataUrl.slice(0, 48),
);
return loadImageObject(qrCodeDataUrl, element, baseConfig, { return loadImageObject(qrCodeDataUrl, element, baseConfig, {
shadow: 'rgba(15,23,42,0.25)', shadow: 'rgba(15,23,42,0.25)',
}); });
@@ -639,43 +681,98 @@ export async function loadImageObject(
options?: { objectFit?: 'contain' | 'cover'; shadow?: string }, options?: { objectFit?: 'contain' | 'cover'; shadow?: string },
): Promise<fabric.Object | null> { ): Promise<fabric.Object | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
fabric.Image.fromURL( let resolved = false;
source, const resolveSafely = (value: fabric.Object | null) => {
(image) => { if (resolved) {
if (!image) { return;
resolve(null); }
return; resolved = true;
} resolve(value);
};
const scaleX = element.width / (image.width ?? element.width); const isDataUrl = source.startsWith('data:');
const scaleY = element.height / (image.height ?? element.height);
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({ image.set({
...baseConfig, scaleX: ratio,
width: element.width, scaleY: ratio,
height: element.height, left: element.x + (element.width - intrinsicWidth * ratio) / 2,
scaleX, top: element.y + (element.height - intrinsicHeight * ratio) / 2,
scaleY,
}); });
}
if (options?.shadow) { resolveSafely(image);
image.set('shadow', options.shadow); };
}
if (options?.objectFit === 'contain') { const onError = (error?: unknown) => {
const ratio = Math.min(scaleX, scaleY); console.warn('[Invites][Fabric] failed to load image', source, error);
image.set({ resolveSafely(null);
scaleX: ratio, };
scaleY: ratio,
left: element.x + (element.width - (image.width ?? 0) * ratio) / 2, try {
top: element.y + (element.height - (image.height ?? 0) * ratio) / 2, 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); window.setTimeout(() => resolveSafely(null), 3000);
},
{ crossOrigin: 'anonymous' },
);
}); });
} }
@@ -689,31 +786,3 @@ export function mapTextAlign(align?: LayoutElement['align']): 'left' | 'center'
return 'left'; 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>
);
}