fixed layout canvas including elements
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user