Implement package limit notification system
This commit is contained in:
@@ -34,19 +34,21 @@ import type { EventQrInvite, EventQrInviteLayout } from '../../api';
|
||||
import { authorizedFetch } from '../../auth/tokens';
|
||||
|
||||
import {
|
||||
CANVAS_HEIGHT,
|
||||
CANVAS_WIDTH,
|
||||
QrLayoutCustomization,
|
||||
LayoutElement,
|
||||
LayoutElementPayload,
|
||||
LayoutElementType,
|
||||
LayoutSerializationContext,
|
||||
buildDefaultElements,
|
||||
clamp,
|
||||
clampElement,
|
||||
elementsToPayload,
|
||||
normalizeElements,
|
||||
payloadToElements,
|
||||
} from './invite-layout/schema';
|
||||
import { DesignerCanvas } from './invite-layout/DesignerCanvas';
|
||||
import { CANVAS_HEIGHT, CANVAS_WIDTH } from './invite-layout/schema';
|
||||
import {
|
||||
generatePdfBytes,
|
||||
generatePngDataUrl,
|
||||
@@ -181,6 +183,9 @@ type InviteLayoutCustomizerPanelProps = {
|
||||
};
|
||||
|
||||
const MAX_INSTRUCTIONS = 5;
|
||||
const ZOOM_MIN = 0.1;
|
||||
const ZOOM_MAX = 2;
|
||||
const ZOOM_STEP = 0.05;
|
||||
|
||||
export function InviteLayoutCustomizerPanel({
|
||||
invite,
|
||||
@@ -213,6 +218,10 @@ export function InviteLayoutCustomizerPanel({
|
||||
const [elements, setElements] = React.useState<LayoutElement[]>([]);
|
||||
const [activeElementId, setActiveElementId] = React.useState<string | null>(null);
|
||||
const [showFloatingActions, setShowFloatingActions] = React.useState(false);
|
||||
const [zoomScale, setZoomScale] = React.useState(1);
|
||||
const [fitScale, setFitScale] = React.useState(1);
|
||||
const fitScaleRef = React.useRef(1);
|
||||
const manualZoomRef = React.useRef(false);
|
||||
const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const historyRef = React.useRef<LayoutElement[][]>([]);
|
||||
const historyIndexRef = React.useRef(-1);
|
||||
@@ -223,6 +232,83 @@ export function InviteLayoutCustomizerPanel({
|
||||
const canvasContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const isAdvanced = true;
|
||||
|
||||
const clampZoom = React.useCallback(
|
||||
(value: number) => clamp(Number.isFinite(value) ? value : 1, ZOOM_MIN, ZOOM_MAX),
|
||||
[],
|
||||
);
|
||||
|
||||
const recomputeFitScale = React.useCallback(() => {
|
||||
const viewport = designerViewportRef.current;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { clientWidth, clientHeight } = viewport;
|
||||
if (!clientWidth || !clientHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(viewport);
|
||||
const paddingX = parseFloat(style.paddingLeft ?? '0') + parseFloat(style.paddingRight ?? '0');
|
||||
const paddingY = parseFloat(style.paddingTop ?? '0') + parseFloat(style.paddingBottom ?? '0');
|
||||
|
||||
const availableWidth = clientWidth - paddingX;
|
||||
const availableHeight = clientHeight - paddingY;
|
||||
|
||||
if (availableWidth <= 0 || availableHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const widthScale = availableWidth / CANVAS_WIDTH;
|
||||
const heightScale = availableHeight / CANVAS_HEIGHT;
|
||||
const nextRaw = Math.min(widthScale, heightScale);
|
||||
const baseScale = Number.isFinite(nextRaw) && nextRaw > 0 ? Math.min(nextRaw, 1) : 1;
|
||||
const clamped = clampZoom(baseScale);
|
||||
|
||||
fitScaleRef.current = clamped;
|
||||
setFitScale((prev) => (Math.abs(prev - clamped) < 0.001 ? prev : clamped));
|
||||
if (!manualZoomRef.current) {
|
||||
setZoomScale((prev) => (Math.abs(prev - clamped) < 0.001 ? prev : clamped));
|
||||
}
|
||||
|
||||
console.debug('[Invites][Zoom] viewport size', {
|
||||
availableWidth,
|
||||
availableHeight,
|
||||
widthScale,
|
||||
heightScale,
|
||||
clamped,
|
||||
});
|
||||
}, [clampZoom]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
recomputeFitScale();
|
||||
}, [recomputeFitScale]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const viewport = designerViewportRef.current;
|
||||
|
||||
const handleResize = () => {
|
||||
recomputeFitScale();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
let observer: ResizeObserver | null = null;
|
||||
if (viewport && typeof ResizeObserver === 'function') {
|
||||
observer = new ResizeObserver(() => recomputeFitScale());
|
||||
observer.observe(viewport);
|
||||
}
|
||||
|
||||
recomputeFitScale();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
}, [recomputeFitScale]);
|
||||
|
||||
const cloneElements = React.useCallback(
|
||||
(items: LayoutElement[]): LayoutElement[] => items.map((item) => ({ ...item })),
|
||||
[]
|
||||
@@ -355,6 +441,11 @@ export function InviteLayoutCustomizerPanel({
|
||||
return availableLayouts[0];
|
||||
}, [availableLayouts, selectedLayoutId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
manualZoomRef.current = false;
|
||||
recomputeFitScale();
|
||||
}, [recomputeFitScale, activeLayout?.id, invite?.id]);
|
||||
|
||||
const activeLayoutQrSize = React.useMemo(() => {
|
||||
const qrElement = elements.find((element) => element.type === 'qr');
|
||||
if (qrElement && typeof qrElement.width === 'number' && qrElement.width > 0) {
|
||||
@@ -371,6 +462,12 @@ export function InviteLayoutCustomizerPanel({
|
||||
return activeLayout?.preview?.qr_size_px ?? 500;
|
||||
}, [elements, initialCustomization?.mode, initialCustomization?.elements, activeLayout?.preview?.qr_size_px]);
|
||||
|
||||
const effectiveScale = React.useMemo(
|
||||
() => clampZoom(Number.isFinite(zoomScale) && zoomScale > 0 ? zoomScale : fitScale),
|
||||
[clampZoom, zoomScale, fitScale],
|
||||
);
|
||||
const zoomPercent = Math.round(effectiveScale * 100);
|
||||
|
||||
const updateElement = React.useCallback(
|
||||
(id: string, updater: Partial<LayoutElement> | ((element: LayoutElement) => Partial<LayoutElement>), options?: { silent?: boolean }) => {
|
||||
commitElements(
|
||||
@@ -1702,27 +1799,62 @@ 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-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 className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t('invites.customizer.controls.zoom', 'Zoom')}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={ZOOM_MIN}
|
||||
max={ZOOM_MAX}
|
||||
step={ZOOM_STEP}
|
||||
value={effectiveScale}
|
||||
onChange={(event) => {
|
||||
manualZoomRef.current = true;
|
||||
setZoomScale(clampZoom(Number(event.target.value)));
|
||||
}}
|
||||
className="h-1 w-36 overflow-hidden rounded-full"
|
||||
disabled={false}
|
||||
aria-label={t('invites.customizer.controls.zoom', 'Zoom')}
|
||||
/>
|
||||
<span className="tabular-nums text-sm text-muted-foreground">{zoomPercent}%</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
manualZoomRef.current = false;
|
||||
const fitValue = clampZoom(fitScaleRef.current);
|
||||
setZoomScale(fitValue);
|
||||
}}
|
||||
disabled={Math.abs(effectiveScale - clampZoom(fitScaleRef.current)) < 0.001}
|
||||
>
|
||||
{t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div className="flex justify-center">
|
||||
@@ -1744,6 +1876,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
badge={form.badge_color ?? form.accent_color ?? '#2563EB'}
|
||||
qrCodeDataUrl={qrCodeDataUrl}
|
||||
logoDataUrl={form.logo_data_url ?? form.logo_url ?? null}
|
||||
scale={effectiveScale}
|
||||
layoutKey={`designer:${invite?.id ?? 'unknown'}:${activeLayout?.id ?? 'default'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user