676 lines
18 KiB
TypeScript
676 lines
18 KiB
TypeScript
import type { EventQrInviteLayout } from '../../api';
|
|
|
|
export const CANVAS_WIDTH = 1240;
|
|
export const CANVAS_HEIGHT = 1754;
|
|
|
|
export type LayoutElementType =
|
|
| 'qr'
|
|
| 'headline'
|
|
| 'subtitle'
|
|
| 'description'
|
|
| 'link'
|
|
| 'badge'
|
|
| 'logo'
|
|
| 'cta'
|
|
| 'text';
|
|
|
|
export type LayoutTextAlign = 'left' | 'center' | 'right';
|
|
|
|
export interface LayoutElement {
|
|
id: string;
|
|
type: LayoutElementType;
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
rotation?: number;
|
|
fontSize?: number;
|
|
align?: LayoutTextAlign;
|
|
content?: string | null;
|
|
fontFamily?: string | null;
|
|
letterSpacing?: number;
|
|
lineHeight?: number;
|
|
fill?: string | null;
|
|
locked?: boolean;
|
|
initial?: boolean;
|
|
}
|
|
|
|
type PresetValue = number | ((context: LayoutPresetContext) => number);
|
|
|
|
type LayoutPresetElement = {
|
|
id: string;
|
|
type: LayoutElementType;
|
|
x: PresetValue;
|
|
y: PresetValue;
|
|
width?: PresetValue;
|
|
height?: PresetValue;
|
|
fontSize?: number;
|
|
align?: LayoutTextAlign;
|
|
locked?: boolean;
|
|
initial?: boolean;
|
|
};
|
|
|
|
type LayoutPreset = LayoutPresetElement[];
|
|
|
|
interface LayoutPresetContext {
|
|
qrSize: number;
|
|
canvasWidth: number;
|
|
canvasHeight: number;
|
|
}
|
|
|
|
export interface LayoutElementPayload {
|
|
id: string;
|
|
type: LayoutElementType;
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
rotation?: number;
|
|
font_size?: number;
|
|
align?: LayoutTextAlign;
|
|
content?: string | null;
|
|
font_family?: string | null;
|
|
letter_spacing?: number;
|
|
line_height?: number;
|
|
fill?: string | null;
|
|
locked?: boolean;
|
|
initial?: boolean;
|
|
}
|
|
|
|
export interface LayoutSerializationContext {
|
|
form: QrLayoutCustomization;
|
|
eventName: string;
|
|
inviteUrl: string;
|
|
instructions: string[];
|
|
qrSize: number;
|
|
badgeFallback: string;
|
|
logoUrl: string | null;
|
|
}
|
|
|
|
export type QrLayoutCustomization = {
|
|
layout_id?: string;
|
|
headline?: string;
|
|
subtitle?: string;
|
|
description?: string;
|
|
badge_label?: string;
|
|
instructions_heading?: string;
|
|
instructions?: string[];
|
|
link_heading?: string;
|
|
link_label?: string;
|
|
cta_label?: string;
|
|
accent_color?: string;
|
|
text_color?: string;
|
|
background_color?: string;
|
|
secondary_color?: string;
|
|
badge_color?: string;
|
|
background_gradient?: { angle?: number; stops?: string[] } | null;
|
|
logo_data_url?: string | null;
|
|
logo_url?: string | null;
|
|
mode?: 'standard' | 'advanced';
|
|
elements?: LayoutElementPayload[];
|
|
};
|
|
|
|
export const MIN_QR_SIZE = 240;
|
|
export const MAX_QR_SIZE = 720;
|
|
export const MIN_TEXT_WIDTH = 160;
|
|
export const MIN_TEXT_HEIGHT = 80;
|
|
|
|
export function clamp(value: number, min: number, max: number): number {
|
|
if (Number.isNaN(value)) {
|
|
return min;
|
|
}
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
export function clampElement(element: LayoutElement): LayoutElement {
|
|
return {
|
|
...element,
|
|
x: clamp(element.x, 0, CANVAS_WIDTH - element.width),
|
|
y: clamp(element.y, 0, CANVAS_HEIGHT - element.height),
|
|
width: clamp(element.width, 40, CANVAS_WIDTH),
|
|
height: clamp(element.height, 40, CANVAS_HEIGHT),
|
|
};
|
|
}
|
|
|
|
const DEFAULT_TYPE_STYLES: Record<LayoutElementType, { width: number; height: number; fontSize?: number; align?: LayoutTextAlign; locked?: boolean }> = {
|
|
headline: { width: 900, height: 240, fontSize: 82, align: 'left' },
|
|
subtitle: { width: 760, height: 170, fontSize: 40, align: 'left' },
|
|
description: { width: 920, height: 340, fontSize: 32, align: 'left' },
|
|
link: { width: 520, height: 130, fontSize: 30, align: 'center' },
|
|
badge: { width: 420, height: 100, fontSize: 26, align: 'center' },
|
|
logo: { width: 320, height: 220, align: 'center' },
|
|
cta: { width: 520, height: 130, fontSize: 28, align: 'center' },
|
|
qr: { width: 640, height: 640 },
|
|
text: { width: 720, height: 260, fontSize: 28, align: 'left' },
|
|
};
|
|
|
|
const DEFAULT_PRESET: LayoutPreset = [
|
|
{ id: 'badge', type: 'badge', x: 140, y: 160, width: 440, height: 100, align: 'center', fontSize: 28 },
|
|
{
|
|
id: 'headline',
|
|
type: 'headline',
|
|
x: 140,
|
|
y: 300,
|
|
width: (context) => context.canvasWidth - 280,
|
|
height: 240,
|
|
fontSize: 84,
|
|
align: 'left',
|
|
},
|
|
{
|
|
id: 'subtitle',
|
|
type: 'subtitle',
|
|
x: 140,
|
|
y: 560,
|
|
width: (context) => context.canvasWidth - 280,
|
|
height: 170,
|
|
fontSize: 42,
|
|
align: 'left',
|
|
},
|
|
{
|
|
id: 'description',
|
|
type: 'description',
|
|
x: 140,
|
|
y: 750,
|
|
width: (context) => context.canvasWidth - 280,
|
|
height: 340,
|
|
fontSize: 32,
|
|
align: 'left',
|
|
},
|
|
{
|
|
id: 'qr',
|
|
type: 'qr',
|
|
x: (context) => context.canvasWidth - Math.min(context.qrSize, 680) - 180,
|
|
y: 360,
|
|
width: (context) => Math.min(context.qrSize, 680),
|
|
height: (context) => Math.min(context.qrSize, 680),
|
|
},
|
|
{
|
|
id: 'link',
|
|
type: 'link',
|
|
x: (context) => context.canvasWidth - 540,
|
|
y: (context) => 420 + Math.min(context.qrSize, 680),
|
|
width: 520,
|
|
height: 130,
|
|
fontSize: 28,
|
|
align: 'center',
|
|
},
|
|
{
|
|
id: 'cta',
|
|
type: 'cta',
|
|
x: (context) => context.canvasWidth - 540,
|
|
y: (context) => 460 + Math.min(context.qrSize, 680) + 160,
|
|
width: 520,
|
|
height: 130,
|
|
fontSize: 30,
|
|
align: 'center',
|
|
},
|
|
];
|
|
const evergreenVowsPreset: LayoutPreset = [
|
|
{ id: 'logo', type: 'logo', x: 160, y: 140, width: 340, height: 240 },
|
|
{ id: 'badge', type: 'badge', x: 540, y: 160, width: 420, height: 100, align: 'center', fontSize: 28 },
|
|
{
|
|
id: 'headline',
|
|
type: 'headline',
|
|
x: 160,
|
|
y: 360,
|
|
width: (context) => context.canvasWidth - 320,
|
|
height: 250,
|
|
fontSize: 86,
|
|
align: 'left',
|
|
},
|
|
{
|
|
id: 'subtitle',
|
|
type: 'subtitle',
|
|
x: 160,
|
|
y: 630,
|
|
width: (context) => context.canvasWidth - 320,
|
|
height: 180,
|
|
fontSize: 42,
|
|
align: 'left',
|
|
},
|
|
{
|
|
id: 'description',
|
|
type: 'description',
|
|
x: 160,
|
|
y: 840,
|
|
width: (context) => context.canvasWidth - 320,
|
|
height: 360,
|
|
fontSize: 34,
|
|
align: 'left',
|
|
},
|
|
{
|
|
id: 'qr',
|
|
type: 'qr',
|
|
x: (context) => context.canvasWidth - Math.min(context.qrSize, 640) - 200,
|
|
y: 420,
|
|
width: (context) => Math.min(context.qrSize, 640),
|
|
height: (context) => Math.min(context.qrSize, 640),
|
|
},
|
|
{
|
|
id: 'link',
|
|
type: 'link',
|
|
x: (context) => context.canvasWidth - 560,
|
|
y: (context) => 480 + Math.min(context.qrSize, 640),
|
|
width: 520,
|
|
height: 130,
|
|
align: 'center',
|
|
},
|
|
{
|
|
id: 'cta',
|
|
type: 'cta',
|
|
x: (context) => context.canvasWidth - 560,
|
|
y: (context) => 520 + Math.min(context.qrSize, 640) + 180,
|
|
width: 520,
|
|
height: 130,
|
|
align: 'center',
|
|
},
|
|
];
|
|
|
|
const midnightGalaPreset: LayoutPreset = [
|
|
{ id: 'badge', type: 'badge', x: (context) => context.canvasWidth / 2 - 300, y: 180, width: 600, height: 120, align: 'center', fontSize: 32 },
|
|
{
|
|
id: 'headline',
|
|
type: 'headline',
|
|
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 220) / 2,
|
|
y: 340,
|
|
width: (context) => context.canvasWidth - 220,
|
|
height: 260,
|
|
fontSize: 90,
|
|
align: 'center',
|
|
},
|
|
{
|
|
id: 'subtitle',
|
|
type: 'subtitle',
|
|
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 320) / 2,
|
|
y: 640,
|
|
width: (context) => context.canvasWidth - 320,
|
|
height: 200,
|
|
fontSize: 46,
|
|
align: 'center',
|
|
},
|
|
{
|
|
id: 'qr',
|
|
type: 'qr',
|
|
x: (context) => (context.canvasWidth - Math.min(context.qrSize, 640)) / 2,
|
|
y: 880,
|
|
width: (context) => Math.min(context.qrSize, 640),
|
|
height: (context) => Math.min(context.qrSize, 640),
|
|
},
|
|
{
|
|
id: 'link',
|
|
type: 'link',
|
|
x: (context) => (context.canvasWidth - 560) / 2,
|
|
y: (context) => 940 + Math.min(context.qrSize, 640),
|
|
width: 560,
|
|
height: 140,
|
|
align: 'center',
|
|
},
|
|
{
|
|
id: 'cta',
|
|
type: 'cta',
|
|
x: (context) => (context.canvasWidth - 560) / 2,
|
|
y: (context) => 980 + Math.min(context.qrSize, 640) + 200,
|
|
width: 560,
|
|
height: 140,
|
|
align: 'center',
|
|
},
|
|
{
|
|
id: 'description',
|
|
type: 'description',
|
|
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 240) / 2,
|
|
y: 1250,
|
|
width: (context) => context.canvasWidth - 240,
|
|
height: 360,
|
|
fontSize: 34,
|
|
align: 'center',
|
|
},
|
|
];
|
|
|
|
const gardenBrunchPreset: LayoutPreset = [
|
|
{ id: 'badge', type: 'badge', x: 180, y: 180, width: 500, height: 110, align: 'center', fontSize: 30 },
|
|
{ id: 'headline', type: 'headline', x: 180, y: 340, width: (context) => context.canvasWidth - 360, height: 260, fontSize: 86, align: 'left' },
|
|
{ id: 'description', type: 'description', x: 180, y: 630, width: (context) => context.canvasWidth - 360, height: 360, fontSize: 34, align: 'left' },
|
|
{
|
|
id: 'qr',
|
|
type: 'qr',
|
|
x: 180,
|
|
y: 1000,
|
|
width: (context) => Math.min(context.qrSize, 660),
|
|
height: (context) => Math.min(context.qrSize, 660),
|
|
},
|
|
{
|
|
id: 'link',
|
|
type: 'link',
|
|
x: 180,
|
|
y: (context) => 1060 + Math.min(context.qrSize, 660),
|
|
width: 520,
|
|
height: 140,
|
|
align: 'center',
|
|
},
|
|
{
|
|
id: 'cta',
|
|
type: 'cta',
|
|
x: 180,
|
|
y: (context) => 1100 + Math.min(context.qrSize, 660) + 190,
|
|
width: 520,
|
|
height: 140,
|
|
align: 'center',
|
|
},
|
|
{ id: 'subtitle', type: 'subtitle', x: (context) => context.canvasWidth - 460, y: 360, width: 420, height: 200, fontSize: 38, align: 'left' },
|
|
{ id: 'text-strip', type: 'text', x: (context) => context.canvasWidth - 460, y: 620, width: 420, height: 360, fontSize: 28, align: 'left' },
|
|
];
|
|
|
|
const sparklerSoireePreset: LayoutPreset = [
|
|
{ id: 'badge', type: 'badge', x: (context) => context.canvasWidth / 2 - 320, y: 200, width: 640, height: 120, align: 'center', fontSize: 32 },
|
|
{
|
|
id: 'headline',
|
|
type: 'headline',
|
|
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 220) / 2,
|
|
y: 360,
|
|
width: (context) => context.canvasWidth - 220,
|
|
height: 280,
|
|
fontSize: 94,
|
|
align: 'center',
|
|
},
|
|
{
|
|
id: 'subtitle',
|
|
type: 'subtitle',
|
|
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 320) / 2,
|
|
y: 660,
|
|
width: (context) => context.canvasWidth - 320,
|
|
height: 210,
|
|
fontSize: 46,
|
|
align: 'center',
|
|
},
|
|
{
|
|
id: 'description',
|
|
type: 'description',
|
|
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 320) / 2,
|
|
y: 920,
|
|
width: (context) => context.canvasWidth - 320,
|
|
height: 380,
|
|
fontSize: 34,
|
|
align: 'center',
|
|
},
|
|
{
|
|
id: 'qr',
|
|
type: 'qr',
|
|
x: (context) => (context.canvasWidth - Math.min(context.qrSize, 680)) / 2,
|
|
y: 1200,
|
|
width: (context) => Math.min(context.qrSize, 680),
|
|
height: (context) => Math.min(context.qrSize, 680),
|
|
},
|
|
{
|
|
id: 'link',
|
|
type: 'link',
|
|
x: (context) => (context.canvasWidth - 580) / 2,
|
|
y: (context) => 1260 + Math.min(context.qrSize, 680),
|
|
width: 580,
|
|
height: 150,
|
|
align: 'center',
|
|
},
|
|
{
|
|
id: 'cta',
|
|
type: 'cta',
|
|
x: (context) => (context.canvasWidth - 580) / 2,
|
|
y: (context) => 1300 + Math.min(context.qrSize, 680) + 200,
|
|
width: 580,
|
|
height: 150,
|
|
align: 'center',
|
|
},
|
|
];
|
|
|
|
const confettiBashPreset: LayoutPreset = [
|
|
{ id: 'badge', type: 'badge', x: 180, y: 220, width: 520, height: 120, align: 'center', fontSize: 32 },
|
|
{
|
|
id: 'headline',
|
|
type: 'headline',
|
|
x: 180,
|
|
y: 380,
|
|
width: (context) => context.canvasWidth - 360,
|
|
height: 260,
|
|
fontSize: 90,
|
|
align: 'left',
|
|
},
|
|
{
|
|
id: 'subtitle',
|
|
type: 'subtitle',
|
|
x: 180,
|
|
y: 660,
|
|
width: (context) => context.canvasWidth - 360,
|
|
height: 200,
|
|
fontSize: 46,
|
|
align: 'left',
|
|
},
|
|
{
|
|
id: 'description',
|
|
type: 'description',
|
|
x: 180,
|
|
y: 910,
|
|
width: (context) => context.canvasWidth - 360,
|
|
height: 360,
|
|
fontSize: 34,
|
|
align: 'left',
|
|
},
|
|
{
|
|
id: 'qr',
|
|
type: 'qr',
|
|
x: (context) => context.canvasWidth - Math.min(context.qrSize, 680) - 200,
|
|
y: 460,
|
|
width: (context) => Math.min(context.qrSize, 680),
|
|
height: (context) => Math.min(context.qrSize, 680),
|
|
},
|
|
{
|
|
id: 'link',
|
|
type: 'link',
|
|
x: (context) => context.canvasWidth - 560,
|
|
y: (context) => 520 + Math.min(context.qrSize, 680),
|
|
width: 520,
|
|
height: 140,
|
|
align: 'center',
|
|
},
|
|
{
|
|
id: 'cta',
|
|
type: 'cta',
|
|
x: (context) => context.canvasWidth - 560,
|
|
y: (context) => 560 + Math.min(context.qrSize, 680) + 200,
|
|
width: 520,
|
|
height: 140,
|
|
align: 'center',
|
|
},
|
|
{
|
|
id: 'text-strip',
|
|
type: 'text',
|
|
x: 180,
|
|
y: 1220,
|
|
width: (context) => context.canvasWidth - 360,
|
|
height: 360,
|
|
fontSize: 30,
|
|
align: 'left',
|
|
},
|
|
];
|
|
|
|
const LAYOUT_PRESETS: Record<string, LayoutPreset> = {
|
|
'default': DEFAULT_PRESET,
|
|
'evergreen-vows': evergreenVowsPreset,
|
|
'midnight-gala': midnightGalaPreset,
|
|
'garden-brunch': gardenBrunchPreset,
|
|
'sparkler-soiree': sparklerSoireePreset,
|
|
'confetti-bash': confettiBashPreset,
|
|
};
|
|
|
|
function resolvePresetValue(value: PresetValue | undefined, context: LayoutPresetContext, fallback: number): number {
|
|
if (typeof value === 'function') {
|
|
const resolved = value(context);
|
|
return typeof resolved === 'number' ? resolved : fallback;
|
|
}
|
|
if (typeof value === 'number') {
|
|
return value;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
export function buildDefaultElements(
|
|
layout: EventQrInviteLayout,
|
|
form: QrLayoutCustomization,
|
|
eventName: string,
|
|
qrSize: number
|
|
): LayoutElement[] {
|
|
const size = clamp(qrSize, MIN_QR_SIZE, MAX_QR_SIZE);
|
|
const context: LayoutPresetContext = {
|
|
qrSize: size,
|
|
canvasWidth: CANVAS_WIDTH,
|
|
canvasHeight: CANVAS_HEIGHT,
|
|
};
|
|
|
|
const preset = LAYOUT_PRESETS[layout.id ?? ''] ?? DEFAULT_PRESET;
|
|
|
|
const instructionsHeading = form.instructions_heading ?? layout.instructions_heading ?? "So funktioniert's";
|
|
const instructionsList = Array.isArray(form.instructions) && form.instructions.length
|
|
? form.instructions
|
|
: (layout.instructions ?? []);
|
|
|
|
const baseContent: Record<string, string | null> = {
|
|
headline: form.headline ?? eventName,
|
|
subtitle: form.subtitle ?? layout.subtitle ?? '',
|
|
description: form.description ?? layout.description ?? '',
|
|
badge: form.badge_label ?? layout.badge_label ?? 'Digitale Gästebox',
|
|
link: form.link_label ?? '',
|
|
cta: form.cta_label ?? layout.cta_label ?? 'Scan mich & starte direkt',
|
|
instructions_heading: instructionsHeading,
|
|
instructions_text: instructionsList[0] ?? null,
|
|
};
|
|
|
|
const elements = preset.map((config) => {
|
|
const typeStyle = DEFAULT_TYPE_STYLES[config.type] ?? { width: 420, height: 160 };
|
|
const widthFallback = config.type === 'qr' ? size : typeStyle.width;
|
|
const heightFallback = config.type === 'qr' ? size : typeStyle.height;
|
|
const element: LayoutElement = {
|
|
id: config.id,
|
|
type: config.type,
|
|
x: resolvePresetValue(config.x, context, 0),
|
|
y: resolvePresetValue(config.y, context, 0),
|
|
width: resolvePresetValue(config.width, context, widthFallback),
|
|
height: resolvePresetValue(config.height, context, heightFallback),
|
|
fontSize: config.fontSize ?? typeStyle.fontSize,
|
|
align: config.align ?? typeStyle.align ?? 'left',
|
|
content: null,
|
|
locked: config.locked ?? typeStyle.locked ?? false,
|
|
initial: config.initial ?? true,
|
|
};
|
|
|
|
if (config.type === 'description') {
|
|
element.lineHeight = 1.4;
|
|
}
|
|
|
|
switch (config.id) {
|
|
case 'headline':
|
|
element.content = baseContent.headline;
|
|
break;
|
|
case 'subtitle':
|
|
element.content = baseContent.subtitle;
|
|
break;
|
|
case 'description':
|
|
element.content = baseContent.description;
|
|
break;
|
|
case 'badge':
|
|
element.content = baseContent.badge;
|
|
break;
|
|
case 'link':
|
|
element.content = baseContent.link;
|
|
break;
|
|
case 'cta':
|
|
element.content = baseContent.cta;
|
|
break;
|
|
case 'text-strip':
|
|
element.content = instructionsList.join('\n').trim() || layout.description || 'Nutze diesen Bereich für zusätzliche Hinweise oder Storytelling.';
|
|
break;
|
|
case 'logo':
|
|
element.content = form.logo_data_url ?? form.logo_url ?? layout.logo_url ?? null;
|
|
break;
|
|
default:
|
|
if (config.type === 'text') {
|
|
element.content = element.content ?? 'Individualisiere diesen Textblock mit eigenen Infos.';
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (config.type === 'qr') {
|
|
element.locked = false;
|
|
}
|
|
|
|
const clamped = clampElement(element);
|
|
return {
|
|
...clamped,
|
|
initial: element.initial ?? true,
|
|
};
|
|
});
|
|
|
|
return elements;
|
|
}
|
|
|
|
export function payloadToElements(payload?: LayoutElementPayload[] | null): LayoutElement[] {
|
|
if (!Array.isArray(payload)) {
|
|
return [];
|
|
}
|
|
|
|
return payload.map((entry) =>
|
|
clampElement({
|
|
id: entry.id,
|
|
type: entry.type,
|
|
x: Number(entry.x ?? 0),
|
|
y: Number(entry.y ?? 0),
|
|
width: Number(entry.width ?? 100),
|
|
height: Number(entry.height ?? 100),
|
|
rotation: typeof entry.rotation === 'number' ? entry.rotation : 0,
|
|
fontSize: typeof entry.font_size === 'number' ? entry.font_size : undefined,
|
|
align: entry.align ?? 'left',
|
|
content: entry.content ?? null,
|
|
fontFamily: entry.font_family ?? null,
|
|
letterSpacing: entry.letter_spacing ?? undefined,
|
|
lineHeight: entry.line_height ?? undefined,
|
|
fill: entry.fill ?? null,
|
|
locked: Boolean(entry.locked),
|
|
initial: Boolean(entry.initial),
|
|
})
|
|
);
|
|
}
|
|
|
|
export function elementsToPayload(elements: LayoutElement[]): LayoutElementPayload[] {
|
|
return elements.map((element) => ({
|
|
id: element.id,
|
|
type: element.type,
|
|
x: element.x,
|
|
y: element.y,
|
|
width: element.width,
|
|
height: element.height,
|
|
rotation: element.rotation ?? 0,
|
|
font_size: element.fontSize,
|
|
align: element.align,
|
|
content: element.content ?? null,
|
|
font_family: element.fontFamily ?? null,
|
|
letter_spacing: element.letterSpacing,
|
|
line_height: element.lineHeight,
|
|
fill: element.fill ?? null,
|
|
locked: element.locked ?? false,
|
|
initial: element.initial ?? false,
|
|
}));
|
|
}
|
|
|
|
|
|
export function normalizeElements(elements: LayoutElement[]): LayoutElement[] {
|
|
const seen = new Set<string>();
|
|
return elements
|
|
.filter((element) => {
|
|
if (!element.id) {
|
|
return false;
|
|
}
|
|
if (seen.has(element.id)) {
|
|
return false;
|
|
}
|
|
seen.add(element.id);
|
|
return true;
|
|
})
|
|
.map(clampElement);
|
|
}
|