import { useRef, useState } from 'react'; import type { BadgeFace, VolunteerRead } from '@/api/schema'; import { t } from '@/i18n'; import { cx } from '@/lib/cx'; import { CELL_PAD_X_MM, CELL_PAD_Y_MM, FIRST_BASELINE, LINE_HEIGHT, MM_PER_PT, type TableLayout, type TextLayout, } from './layout'; import { FACE_LABEL_KEY, fmt, isVerticallyCentered, renderField, SAFE_MARGIN_MM, stackOrder, type BadgeData, type FieldState, } from './model'; /** Pas d'accroche du glisser-déposer (mm). */ const DRAG_SNAP_MM = 0.5; /** Plus petit cadre possible au redimensionnement (mm). */ const MIN_W_MM = 3; const MIN_H_MM = 2; /** Décalage entre la ligne de base d'une ligne CSS (interligne 1,25, police * Helvetica/Arial) et celle du PDF (0,85 × taille sous le haut du cadre). */ const CSS_BASELINE = 0.97; const FONT_STACK = 'Helvetica, Arial, sans-serif'; function snap(value: number): number { return Math.round(value / DRAG_SNAP_MM) * DRAG_SNAP_MM; } function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } export interface BadgeCanvasProps { face: BadgeFace; fields: FieldState[]; data: BadgeData; volunteer: VolunteerRead | null; photoUrl: string | null; imageUrl: string | null; badgeW: number; badgeH: number; scale: number; showGuides: boolean; selectedType: string | null; uploading: boolean; onSelect: (field: FieldState) => void; /** `record` : enregistrer l'état actuel dans l'historique d'annulation * avant d'appliquer `patch` (une seule fois par glisser-déposer). */ onChange: (field: FieldState, patch: Partial, record: boolean) => void; onDropImage: (file: File) => void; } /** Aperçu d'une face du badge, éditable (glisser, redimensionner) et zone de * dépôt de son image de fond. */ export function BadgeCanvas({ face, fields, data, volunteer, photoUrl, imageUrl, badgeW, badgeH, scale, showGuides, selectedType, uploading, onSelect, onChange, onDropImage, }: BadgeCanvasProps) { const [dropping, setDropping] = useState(false); // Du dessous vers le dessus, comme le PDF (`z_order`). const shown = stackOrder(fields, face); const isFaceSelected = shown.some((f) => f.field_type === selectedType); return (
{t(FACE_LABEL_KEY[face])} {t('badges.dims', { width: fmt(badgeW), height: fmt(badgeH) })}
{ if (!e.dataTransfer.types.includes('Files')) return; e.preventDefault(); setDropping(true); }} onDragLeave={() => setDropping(false)} onDrop={(e) => { e.preventDefault(); setDropping(false); const file = e.dataTransfer.files[0]; if (file) onDropImage(file); }} > {/* Invitation à déposer une image : seulement sur une face encore vide (elle passerait sous les champs sinon), ou par-dessus tout pendant un dépôt / un envoi. */} {uploading || dropping || (!imageUrl && shown.length === 0) ? (
{uploading ? t('badges.uploading') : t(face === 'FRONT' ? 'badges.drop.front' : 'badges.drop.back')}
) : null} {showGuides ? (
) : null} {shown.map((field, i) => ( onSelect(field)} onChange={(patch, record) => onChange(field, patch, record)} /> ))}
); } function FieldBox({ field, layer, data, volunteer, photoUrl, badgeW, badgeH, scale, showGuides, selected, onSelect, onChange, }: { field: FieldState; /** Rang dans la pile de la face (`z-index`). */ layer: number; data: BadgeData; volunteer: VolunteerRead | null; photoUrl: string | null; badgeW: number; badgeH: number; scale: number; showGuides: boolean; selected: boolean; onSelect: () => void; onChange: (patch: Partial, record: boolean) => void; }) { const dragRef = useRef<{ mode: 'move' | 'resize'; x: number; y: number; w: number; h: number; px: number; py: number; moved: boolean; } | null>(null); const start = (mode: 'move' | 'resize') => (e: React.PointerEvent) => { e.stopPropagation(); e.preventDefault(); onSelect(); (e.currentTarget as Element).setPointerCapture(e.pointerId); dragRef.current = { mode, x: field.x_mm, y: field.y_mm, w: field.width_mm, h: field.height_mm, px: e.clientX, py: e.clientY, moved: false, }; }; const move = (e: React.PointerEvent) => { const d = dragRef.current; if (!d) return; e.stopPropagation(); const dx = (e.clientX - d.px) / scale; const dy = (e.clientY - d.py) / scale; if (!d.moved && Math.abs(dx) < 0.2 && Math.abs(dy) < 0.2) return; const record = !d.moved; d.moved = true; if (d.mode === 'move') { // Bornes jamais négatives : un champ plus grand que le badge reste // saisissable (voir `fitToBadge`). onChange( { x_mm: clamp(snap(d.x + dx), 0, Math.max(0, badgeW - d.w)), y_mm: clamp(snap(d.y + dy), 0, Math.max(0, badgeH - d.h)), }, record, ); } else { const maxW = Math.max(0, badgeW - d.x); const maxH = Math.max(0, badgeH - d.y); onChange( { width_mm: clamp(snap(d.w + dx), Math.min(MIN_W_MM, maxW), maxW), height_mm: clamp(snap(d.h + dy), Math.min(MIN_H_MM, maxH), maxH), }, record, ); } }; const end = (e: React.PointerEvent) => { if (dragRef.current) e.stopPropagation(); dragRef.current = null; }; const rendered = volunteer ? renderField(field, volunteer, data) : null; const layout = rendered && rendered.kind !== 'photo' ? rendered.layout : null; const truncated = layout?.truncated ?? false; const geometry = { left: field.x_mm * scale, top: field.y_mm * scale, width: field.width_mm * scale, height: field.height_mm * scale, }; const handlers = { onPointerDown: start('move'), onPointerMove: move, onPointerUp: end, onPointerCancel: end, }; return ( <> {/* Contenu à son rang dans la pile, comme sur le badge imprimé. */}
{rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? ( ) : rendered?.kind === 'table' ? ( ) : rendered?.kind === 'text' ? ( ) : null} {truncated && !selected ? : null}
{/* Cadre de sélection par-dessus toute la pile : un champ recouvert (ex. la photo sous un texte) reste saisissable, sans que l'aperçu change l'ordre d'impression. */} {selected ? (
{truncated ? : null} {t('badges.preview.dims', { width: fmt(field.width_mm), height: fmt(field.height_mm) })} {layout?.reduced ? t('badges.preview.reducedTo', { size: fmt(layout.fontSize) }) : null}
) : null} ); } function TruncatedTag() { return ( {t('badges.preview.truncated')} ); } function TextContent({ field, layout, scale }: { field: FieldState; layout: TextLayout; scale: number }) { const fontPx = layout.fontSize * MM_PER_PT * scale; const lineHeightPx = fontPx * LINE_HEIGHT; // Bandeaux (texte libre, rôle) centrés verticalement, les autres en haut — // même calcul que le PDF : bloc = interlignes + une hauteur de caractère. const blockPx = Math.max(0, layout.visible.length - 1) * lineHeightPx + fontPx; const boxPx = field.height_mm * scale; const offset = isVerticallyCentered(field.field_type) ? Math.max(0, (boxPx - blockPx) / 2) : 0; const top = offset + (FIRST_BASELINE - CSS_BASELINE) * fontPx; return (
{layout.visible.map((line, i) => (
{line || ' '}
))}
); } /** Tableau des créneaux, mêmes colonnes, hauteurs de ligne et lignes * retenues que le PDF (`_draw_table`). */ function TableContent({ field, layout, scale }: { field: FieldState; layout: TableLayout; scale: number }) { const fontPx = layout.fontSize * MM_PER_PT * scale; const lineHeightPx = fontPx * LINE_HEIGHT; const rule = `${Math.max(0.5, 0.4 * MM_PER_PT * scale)}px solid ${field.color}`; const padX = CELL_PAD_X_MM * scale; const padY = CELL_PAD_Y_MM * scale; const fullWidth = layout.widthsMm.reduce((a, b) => a + b, 0) * scale; return (
{layout.rows.length ? (
{layout.rows.map((row, i) => (
0 ? rule : undefined, fontWeight: row.isTitle ? 700 : 400, }} > {row.cells.map((lines, col) => (
0 ? rule : undefined, boxSizing: 'border-box', }} > {lines.map((line, j) => (
{line || ' '}
))}
))}
))}
) : null}
); } function PhotoContent({ field, volunteer, photoUrl, }: { field: FieldState; volunteer: VolunteerRead | null; photoUrl: string | null; }) { const radius = field.photo_shape === 'OVAL' ? '50%' : 0; if (volunteer?.has_photo && photoUrl) { return ( ); } // Sans photo, rien n'est imprimé : juste un repère pointillé dans // l'éditeur (pas sur le badge) pour savoir où est le cadre. return (
{t('badges.preview.noPhoto')}
); }