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;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user