From 81cdee428e4f2f49f35b7914920306b59b0961aa Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 31 Oct 2025 23:20:52 +0100 Subject: [PATCH] fixed layout canvas including elements --- resources/js/admin/dev-tools.ts | 117 ++++----- .../InviteLayoutCustomizerPanel.tsx | 105 ++------ .../invite-layout/DesignerCanvas.tsx | 227 ++++++++++++------ 3 files changed, 230 insertions(+), 219 deletions(-) diff --git a/resources/js/admin/dev-tools.ts b/resources/js/admin/dev-tools.ts index af74513..b22e3df 100644 --- a/resources/js/admin/dev-tools.ts +++ b/resources/js/admin/dev-tools.ts @@ -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 { - 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 { + 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 { diff --git a/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx b/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx index 80dba0c..3f831d7 100644 --- a/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx +++ b/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx @@ -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([]); const [activeElementId, setActiveElementId] = React.useState(null); - const [canvasScale, setCanvasScale] = React.useState(0.45); - const [autoScaleEnabled, setAutoScaleEnabled] = React.useState(true); const [showFloatingActions, setShowFloatingActions] = React.useState(false); const actionsSentinelRef = React.useRef(null); const historyRef = React.useRef([]); @@ -345,26 +340,6 @@ export function InviteLayoutCustomizerPanel({ const prevFormRef = React.useRef(form); const initializedLayoutsRef = React.useRef>({}); const prevInviteRef = React.useRef(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({
-
- { - setAutoScaleEnabled(false); - setCanvasScale(clampScale(value)); - }} - /> -
- - -
+
+ +
@@ -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'}`} />
diff --git a/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx b/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx index 7d3721a..a50e5bf 100644 --- a/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx +++ b/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx @@ -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(null); const pendingTimeoutRef = React.useRef(null); + const lastRenderSignatureRef = React.useRef(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).__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 (
@@ -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 { 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 ( -
- Zoom - onChange(Number(event.target.value))} - className="h-1 w-32 overflow-hidden rounded-full" - /> - {Math.round(scale * 100)}% -
- ); -}