469 lines
15 KiB
TypeScript
469 lines
15 KiB
TypeScript
/**
|
|
* Badge layout types and helpers — shared between preview and API payload.
|
|
*/
|
|
|
|
export type BadgeFieldId = "name" | "title" | "org" | "badge_type" | "qr" | "photo";
|
|
|
|
export type BadgeTextAlign = "left" | "center" | "right";
|
|
|
|
export type BadgeLayoutKind = "field" | "custom_text" | "custom_image";
|
|
|
|
export type BadgeBackgroundImageLayout = {
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
};
|
|
|
|
/** Preview selection id for the optional background image layer. */
|
|
export const BADGE_BACKGROUND_IMAGE_ID = "__badge_bg__";
|
|
|
|
const BG_IMAGE_MIN_SIZE = 10;
|
|
const BG_IMAGE_MAX_SIZE = 500;
|
|
const BG_IMAGE_MIN_POS = -400;
|
|
const BG_IMAGE_MAX_POS = 400;
|
|
|
|
export function defaultBackgroundImageLayout(): BadgeBackgroundImageLayout {
|
|
return { x: 0, y: 0, w: 100, h: 100 };
|
|
}
|
|
|
|
export function parseBackgroundImageLayout(raw: unknown): BadgeBackgroundImageLayout | null {
|
|
if (!raw || typeof raw !== "object") return null;
|
|
const o = raw as Record<string, unknown>;
|
|
if (
|
|
typeof o.x !== "number" ||
|
|
typeof o.y !== "number" ||
|
|
typeof o.w !== "number" ||
|
|
typeof o.h !== "number"
|
|
) {
|
|
return null;
|
|
}
|
|
return applyBackgroundImageLayoutPatch({ x: o.x, y: o.y, w: o.w, h: o.h });
|
|
}
|
|
|
|
/** Allow bleed outside the badge — only soft sanity limits for save/drag. */
|
|
export function applyBackgroundImageLayoutPatch(
|
|
layout: BadgeBackgroundImageLayout,
|
|
patch: Partial<BadgeBackgroundImageLayout> = {},
|
|
): BadgeBackgroundImageLayout {
|
|
const w = Math.min(BG_IMAGE_MAX_SIZE, Math.max(BG_IMAGE_MIN_SIZE, patch.w ?? layout.w));
|
|
const h = Math.min(BG_IMAGE_MAX_SIZE, Math.max(BG_IMAGE_MIN_SIZE, patch.h ?? layout.h));
|
|
const x = Math.min(BG_IMAGE_MAX_POS, Math.max(BG_IMAGE_MIN_POS, patch.x ?? layout.x));
|
|
const y = Math.min(BG_IMAGE_MAX_POS, Math.max(BG_IMAGE_MIN_POS, patch.y ?? layout.y));
|
|
return { x, y, w, h };
|
|
}
|
|
|
|
/** @deprecated Use applyBackgroundImageLayoutPatch */
|
|
export function clampBackgroundImageLayout(
|
|
layout: BadgeBackgroundImageLayout,
|
|
patch: Partial<BadgeBackgroundImageLayout> = {},
|
|
): BadgeBackgroundImageLayout {
|
|
return applyBackgroundImageLayoutPatch(layout, patch);
|
|
}
|
|
|
|
export type BadgeLayoutElement = {
|
|
id: string;
|
|
kind: BadgeLayoutKind;
|
|
field?: BadgeFieldId;
|
|
text?: string;
|
|
imageUrl?: string;
|
|
/** Uploaded image width ÷ height */
|
|
imageAspectRatio?: number;
|
|
x: number;
|
|
y: number;
|
|
w?: number;
|
|
h?: number;
|
|
fontSizePct?: number;
|
|
fontFamily?: string;
|
|
color?: string;
|
|
fontWeight?: "normal" | "bold";
|
|
/** Default true — span full badge width with centered text */
|
|
fullWidthCenter?: boolean;
|
|
/** Default true — reduce font size when text overflows */
|
|
shrinkToFit?: boolean;
|
|
textAlign?: BadgeTextAlign;
|
|
};
|
|
|
|
export const DEFAULT_TEXT_LAYOUT = {
|
|
fullWidthCenter: true,
|
|
shrinkToFit: true,
|
|
textAlign: "center" as BadgeTextAlign,
|
|
x: 0,
|
|
w: 100,
|
|
};
|
|
|
|
/** Treat undefined as true for backward-compatible defaults */
|
|
export function usesFullWidthCenter(el: BadgeLayoutElement): boolean {
|
|
return el.fullWidthCenter !== false;
|
|
}
|
|
|
|
export function usesShrinkToFit(el: BadgeLayoutElement): boolean {
|
|
return el.shrinkToFit !== false;
|
|
}
|
|
|
|
export function textAlignFor(el: BadgeLayoutElement): BadgeTextAlign {
|
|
if (usesFullWidthCenter(el)) return el.textAlign ?? "center";
|
|
return el.textAlign ?? "left";
|
|
}
|
|
|
|
export function isResizableElement(el: BadgeLayoutElement): boolean {
|
|
return el.field === "qr" || el.field === "photo" || el.kind === "custom_image";
|
|
}
|
|
|
|
export function isSquareResizable(el: BadgeLayoutElement): boolean {
|
|
return el.field === "qr";
|
|
}
|
|
|
|
export function elementSizePct(el: BadgeLayoutElement): number {
|
|
return el.w ?? (el.field === "qr" ? 28 : el.kind === "custom_image" ? 25 : 18);
|
|
}
|
|
|
|
/** Square elements sized by width % of badge — height as % of badge height */
|
|
export function squareElementHeightPct(widthPct: number, badgeAspectRatio: number): number {
|
|
const ar = badgeAspectRatio > 0 ? badgeAspectRatio : 1;
|
|
return widthPct * ar;
|
|
}
|
|
|
|
export function clampElementSize(el: BadgeLayoutElement, w: number): number {
|
|
return Math.min(RESIZABLE_SIZE_MAX, Math.max(RESIZABLE_SIZE_MIN, w));
|
|
}
|
|
|
|
export function clampHeightPct(h: number): number {
|
|
return Math.min(RESIZABLE_SIZE_MAX, Math.max(RESIZABLE_SIZE_MIN, h));
|
|
}
|
|
|
|
/** Height % of badge for a given width % preserving uploaded image aspect */
|
|
export function heightPctForImageWidth(
|
|
w: number,
|
|
imageAspectRatio: number,
|
|
badgeAspectRatio: number,
|
|
): number {
|
|
const ar = imageAspectRatio > 0 ? imageAspectRatio : 1;
|
|
return clampHeightPct((w * badgeAspectRatio) / ar);
|
|
}
|
|
|
|
export function loadImageAspectRatio(url: string): Promise<number> {
|
|
return new Promise((resolve) => {
|
|
const img = new Image();
|
|
img.onload = () => resolve(img.naturalWidth > 0 && img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 1);
|
|
img.onerror = () => resolve(1);
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
export function readFileImageAspectRatio(file: File): Promise<number> {
|
|
const blob = URL.createObjectURL(file);
|
|
return loadImageAspectRatio(blob).finally(() => URL.revokeObjectURL(blob));
|
|
}
|
|
|
|
/** Apply new width/height and re-clamp position so element stays on badge */
|
|
export function applyElementSize(
|
|
el: BadgeLayoutElement,
|
|
w: number,
|
|
badgeAspectRatio: number,
|
|
): Partial<BadgeLayoutElement> {
|
|
const size = clampElementSize(el, w);
|
|
const patch: Partial<BadgeLayoutElement> = { w: size };
|
|
if (isSquareResizable(el) || el.field === "photo") {
|
|
patch.h = squareElementHeightPct(size, badgeAspectRatio);
|
|
} else if (el.kind === "custom_image") {
|
|
const ar = el.imageAspectRatio ?? 1;
|
|
patch.h = heightPctForImageWidth(size, ar, badgeAspectRatio);
|
|
}
|
|
const merged = { ...el, ...patch };
|
|
return { ...patch, ...clampElementPosition(merged, merged.x, merged.y, badgeAspectRatio) };
|
|
}
|
|
|
|
/** Size in % used for drag clamping (w=0 → no horizontal clamp) */
|
|
export function elementBoundsPct(el: BadgeLayoutElement, badgeAspectRatio = 1): { w: number; h: number } {
|
|
if (el.field === "qr" || el.field === "photo") {
|
|
const s = elementSizePct(el);
|
|
return { w: s, h: squareElementHeightPct(s, badgeAspectRatio) };
|
|
}
|
|
if (el.kind === "custom_image") {
|
|
return { w: el.w ?? 25, h: el.h ?? 25 };
|
|
}
|
|
if (isTextElement(el)) {
|
|
if (usesFullWidthCenter(el)) {
|
|
return { w: 0, h: Math.max(4, (el.fontSizePct ?? 5) * 2) };
|
|
}
|
|
return { w: el.w ?? 72, h: Math.max(4, (el.fontSizePct ?? 5) * 2.5) };
|
|
}
|
|
return { w: 0, h: 0 };
|
|
}
|
|
|
|
export function clampElementPosition(
|
|
el: BadgeLayoutElement,
|
|
x: number,
|
|
y: number,
|
|
badgeAspectRatio = 1,
|
|
): { x: number; y: number } {
|
|
const bounds = elementBoundsPct(el, badgeAspectRatio);
|
|
const maxX = bounds.w > 0 ? Math.max(0, 100 - bounds.w) : 100;
|
|
const maxY = bounds.h > 0 ? Math.max(0, 100 - bounds.h) : 100;
|
|
let cx = Math.min(maxX, Math.max(0, x));
|
|
const cy = Math.min(maxY, Math.max(0, y));
|
|
if (isTextElement(el) && usesFullWidthCenter(el)) cx = 0;
|
|
return { x: cx, y: cy };
|
|
}
|
|
|
|
export const BADGE_FONT_FAMILIES = [
|
|
{ id: "Inter", label: "Inter", css: "var(--font-inter), Inter, sans-serif" },
|
|
{ id: "Noto Sans", label: "Noto Sans", css: "var(--font-noto-sans), 'Noto Sans', sans-serif" },
|
|
{ id: "Arial", label: "Arial", css: "Arial, sans-serif" },
|
|
{ id: "Georgia", label: "Georgia", css: "Georgia, serif" },
|
|
{ id: "Times New Roman", label: "Times New Roman", css: "'Times New Roman', Times, serif" },
|
|
] as const;
|
|
|
|
export const DEFAULT_TEXT_COLOR = "#0f172a";
|
|
export const MAX_CUSTOM_TEXT_FIELDS = 5;
|
|
export const MAX_CUSTOM_IMAGE_FIELDS = 5;
|
|
export const RESIZABLE_SIZE_MIN = 8;
|
|
export const RESIZABLE_SIZE_MAX = 65;
|
|
|
|
const PRESETS_MM: Record<string, { w: number; h: number }> = {
|
|
a6: { w: 105, h: 148 },
|
|
a7: { w: 74, h: 105 },
|
|
"4x3in": { w: 101.6, h: 76.2 },
|
|
"4x6in": { w: 101.6, h: 152.4 },
|
|
};
|
|
|
|
export function getBadgeAspectRatio(
|
|
sizePreset: "a6" | "a7" | "4x3in" | "4x6in" | "custom",
|
|
orientation: "portrait" | "landscape",
|
|
customW?: string,
|
|
customH?: string,
|
|
): number {
|
|
let w: number;
|
|
let h: number;
|
|
if (sizePreset === "custom") {
|
|
w = parseFloat(customW || "105") || 105;
|
|
h = parseFloat(customH || "148") || 148;
|
|
} else {
|
|
const p = PRESETS_MM[sizePreset] || PRESETS_MM.a6;
|
|
w = p.w;
|
|
h = p.h;
|
|
}
|
|
if (orientation === "landscape") [w, h] = [h, w];
|
|
return w / h;
|
|
}
|
|
|
|
export function contrastTextColor(hex: string): string {
|
|
const h = (hex || "#000000").replace("#", "");
|
|
const r = parseInt(h.slice(0, 2), 16) || 0;
|
|
const g = parseInt(h.slice(2, 4), 16) || 0;
|
|
const b = parseInt(h.slice(4, 6), 16) || 0;
|
|
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
return lum > 0.55 ? "#0f172a" : "#ffffff";
|
|
}
|
|
|
|
function textDefaults(bgColor: string, bgMode: "color" | "image") {
|
|
const color = bgMode === "color" ? contrastTextColor(bgColor) : DEFAULT_TEXT_COLOR;
|
|
return {
|
|
color,
|
|
fontFamily: "Inter",
|
|
fontWeight: "normal" as const,
|
|
...DEFAULT_TEXT_LAYOUT,
|
|
};
|
|
}
|
|
|
|
function defaultElementForField(fieldId: BadgeFieldId, bgColor: string, bgMode: "color" | "image"): BadgeLayoutElement {
|
|
const base = { kind: "field" as const, field: fieldId, id: fieldId, ...textDefaults(bgColor, bgMode) };
|
|
|
|
switch (fieldId) {
|
|
case "name":
|
|
return { ...base, y: 8, fontSizePct: 7, fontWeight: "bold" };
|
|
case "title":
|
|
return { ...base, y: 22, fontSizePct: 4.5 };
|
|
case "org":
|
|
return { ...base, y: 32, fontSizePct: 4 };
|
|
case "badge_type":
|
|
return { ...base, y: 44, fontSizePct: 3.5 };
|
|
case "photo":
|
|
return { kind: "field", field: fieldId, id: fieldId, x: 74, y: 6, w: 18, h: 18 };
|
|
case "qr":
|
|
return { kind: "field", field: fieldId, id: fieldId, x: 36, y: 68, w: 28, h: 28 };
|
|
default:
|
|
return { ...base, y: 8, fontSizePct: 5 };
|
|
}
|
|
}
|
|
|
|
export function defaultLayoutForFields(
|
|
fieldIds: string[],
|
|
bgColor: string,
|
|
bgMode: "color" | "image",
|
|
): BadgeLayoutElement[] {
|
|
return fieldIds
|
|
.filter((id): id is BadgeFieldId => ["name", "title", "org", "badge_type", "qr", "photo"].includes(id))
|
|
.map((id) => defaultElementForField(id, bgColor, bgMode));
|
|
}
|
|
|
|
export function defaultCustomTextElement(
|
|
id: string,
|
|
text: string,
|
|
bgColor: string,
|
|
bgMode: "color" | "image",
|
|
index: number,
|
|
): BadgeLayoutElement {
|
|
return {
|
|
id,
|
|
kind: "custom_text",
|
|
text,
|
|
y: 55 + index * 8,
|
|
fontSizePct: 4,
|
|
fontWeight: "normal",
|
|
...textDefaults(bgColor, bgMode),
|
|
};
|
|
}
|
|
|
|
export function newCustomTextId(): string {
|
|
const hex = Math.random().toString(16).slice(2, 10);
|
|
return `custom_${hex}`;
|
|
}
|
|
|
|
export function newCustomImageId(): string {
|
|
const hex = Math.random().toString(16).slice(2, 10);
|
|
return `cimg_${hex}`;
|
|
}
|
|
|
|
export function buildCustomImageElement(
|
|
id: string,
|
|
imageUrl: string,
|
|
index: number,
|
|
badgeAspectRatio: number,
|
|
imageAspectRatio: number,
|
|
): BadgeLayoutElement {
|
|
const w = 25;
|
|
const h = heightPctForImageWidth(w, imageAspectRatio, badgeAspectRatio);
|
|
return {
|
|
id,
|
|
kind: "custom_image",
|
|
imageUrl,
|
|
imageAspectRatio,
|
|
x: 10 + index * 4,
|
|
y: 50 + index * 6,
|
|
w,
|
|
h,
|
|
};
|
|
}
|
|
|
|
export function imageUrlForElement(
|
|
el: BadgeLayoutElement,
|
|
customImages: { id: string; imageUrl: string }[],
|
|
): string {
|
|
return (el.imageUrl ?? customImages.find((c) => c.id === el.id)?.imageUrl ?? "").trim();
|
|
}
|
|
|
|
export function fontCss(family?: string): string {
|
|
const f = BADGE_FONT_FAMILIES.find((x) => x.id === family);
|
|
return f?.css ?? BADGE_FONT_FAMILIES[0].css;
|
|
}
|
|
|
|
export type BadgePreviewSample = {
|
|
name: string;
|
|
title: string;
|
|
org: string;
|
|
badgeType: string;
|
|
};
|
|
|
|
export function samplePreviewData(badgeTypeLabel: string): BadgePreviewSample {
|
|
return {
|
|
name: "Jane Doe",
|
|
title: "Keynote Speaker",
|
|
org: "Acme Inc.",
|
|
badgeType: badgeTypeLabel.trim() || "Attendee",
|
|
};
|
|
}
|
|
|
|
const PREVIEW_AVATAR_COLORS = ["#2563eb", "#16a34a", "#d97706", "#7c3aed", "#0891b2", "#be185d", "#0d9488", "#b45309"];
|
|
|
|
export function previewInitialsFromName(name: string): string {
|
|
const parts = name.split(/\s+/).filter(Boolean);
|
|
if (parts.length >= 2) return `${parts[0].charAt(0)}${parts[parts.length - 1].charAt(0)}`.toUpperCase();
|
|
return (parts[0]?.charAt(0) || "J").toUpperCase();
|
|
}
|
|
|
|
export function previewAvatarColor(seed: string): string {
|
|
let n = 0;
|
|
for (let i = 0; i < seed.length; i++) n += seed.charCodeAt(i);
|
|
return PREVIEW_AVATAR_COLORS[n % PREVIEW_AVATAR_COLORS.length];
|
|
}
|
|
|
|
export function previewTextForElement(
|
|
el: BadgeLayoutElement,
|
|
sample: BadgePreviewSample,
|
|
): string {
|
|
if (el.kind === "custom_text") return el.text?.trim() || "Your text";
|
|
switch (el.field) {
|
|
case "name":
|
|
return sample.name;
|
|
case "title":
|
|
return sample.title;
|
|
case "org":
|
|
return sample.org;
|
|
case "badge_type":
|
|
return sample.badgeType;
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
export function isTextElement(el: BadgeLayoutElement): boolean {
|
|
if (el.kind === "custom_text") return true;
|
|
return el.field === "name" || el.field === "title" || el.field === "org" || el.field === "badge_type";
|
|
}
|
|
|
|
export function syncLayoutWithFields(
|
|
prev: BadgeLayoutElement[],
|
|
enabledFields: string[],
|
|
customTexts: { id: string; text: string }[],
|
|
customImages: { id: string; imageUrl: string }[],
|
|
bgColor: string,
|
|
bgMode: "color" | "image",
|
|
badgeAspectRatio: number,
|
|
): BadgeLayoutElement[] {
|
|
const fieldSet = new Set(enabledFields);
|
|
const customTextMap = new Map(customTexts.map((c) => [c.id, c.text]));
|
|
const customImageMap = new Map(customImages.map((c) => [c.id, c.imageUrl]));
|
|
|
|
const keptFields = prev.filter((el) => el.kind === "field" && el.field && fieldSet.has(el.field));
|
|
const keptCustomText = prev.filter((el) => el.kind === "custom_text" && customTextMap.has(el.id));
|
|
const keptCustomImage = prev.filter((el) => el.kind === "custom_image" && customImageMap.has(el.id));
|
|
|
|
const existingFieldIds = new Set(keptFields.map((e) => e.field));
|
|
const addedFields = enabledFields
|
|
.filter((id): id is BadgeFieldId => ["name", "title", "org", "badge_type", "qr", "photo"].includes(id))
|
|
.filter((id) => !existingFieldIds.has(id))
|
|
.map((id) => defaultElementForField(id, bgColor, bgMode));
|
|
|
|
const existingTextIds = new Set(keptCustomText.map((e) => e.id));
|
|
const addedCustomText = customTexts
|
|
.filter((c) => !existingTextIds.has(c.id))
|
|
.map((c, i) => defaultCustomTextElement(c.id, c.text, bgColor, bgMode, i));
|
|
|
|
const existingImageIds = new Set(keptCustomImage.map((e) => e.id));
|
|
const addedCustomImage = customImages
|
|
.filter((c) => !existingImageIds.has(c.id))
|
|
.map((c, i) => buildCustomImageElement(c.id, c.imageUrl, i, badgeAspectRatio, 1));
|
|
|
|
const mergedCustomText = [
|
|
...keptCustomText.map((el) => ({ ...el, text: customTextMap.get(el.id) ?? el.text })),
|
|
...addedCustomText,
|
|
];
|
|
const mergedCustomImage = [
|
|
...keptCustomImage.map((el) => {
|
|
const imageUrl = customImageMap.get(el.id) ?? el.imageUrl;
|
|
const ar = el.imageAspectRatio ?? 1;
|
|
const w = el.w ?? 25;
|
|
return {
|
|
...el,
|
|
imageUrl,
|
|
h: heightPctForImageWidth(w, ar, badgeAspectRatio),
|
|
};
|
|
}),
|
|
...addedCustomImage,
|
|
];
|
|
|
|
return [...keptFields, ...addedFields, ...mergedCustomText, ...mergedCustomImage];
|
|
}
|