Badges : ordre de superposition des champs
ci-cd / build-backend (push) Failing after 1m5s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped

La photo recouvrait le nom et le texte libre (champs dessinés dans l'ordre
alphabétique de leur type). BadgeField.z_order (migration 0028, champs
existants renumérotés photo dessous) fixe l'ordre de dessin du PDF et de
l'aperçu ; boutons « Premier plan / Avancer / Reculer / Arrière-plan »
dans l'inspecteur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 22:33:10 +01:00
co-authored by Claude Opus 5.5
parent 4f86398ccb
commit 1026d9b9e1
13 changed files with 327 additions and 50 deletions
+68 -41
View File
@@ -20,6 +20,7 @@ import {
isVerticallyCentered,
renderField,
SAFE_MARGIN_MM,
stackOrder,
type BadgeData,
type FieldState,
} from './model';
@@ -84,7 +85,8 @@ export function BadgeCanvas({
onDropImage,
}: BadgeCanvasProps) {
const [dropping, setDropping] = useState(false);
const shown = fields.filter((f) => f.enabled);
// 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 (
@@ -148,10 +150,11 @@ export function BadgeCanvas({
style={{ inset: SAFE_MARGIN_MM * scale }}
/>
) : null}
{shown.map((field) => (
{shown.map((field, i) => (
<FieldBox
key={field.field_type}
field={field}
layer={i + 1}
data={data}
volunteer={volunteer}
photoUrl={photoUrl}
@@ -171,6 +174,7 @@ export function BadgeCanvas({
function FieldBox({
field,
layer,
data,
volunteer,
photoUrl,
@@ -183,6 +187,8 @@ function FieldBox({
onChange,
}: {
field: FieldState;
/** Rang dans la pile de la face (`z-index`). */
layer: number;
data: BadgeData;
volunteer: VolunteerRead | null;
photoUrl: string | null;
@@ -263,45 +269,58 @@ function FieldBox({
const layout = rendered && rendered.kind !== 'photo' ? rendered.layout : null;
const truncated = layout?.truncated ?? false;
return (
<div
onPointerDown={start('move')}
onPointerMove={move}
onPointerUp={end}
onPointerCancel={end}
className="absolute cursor-move touch-none"
style={{
left: field.x_mm * scale,
top: field.y_mm * scale,
width: field.width_mm * scale,
height: field.height_mm * scale,
outline: selected
? '2px solid #2A6BFF'
: truncated
? '2px dashed #FF5C39'
: showGuides
? '1px dashed rgba(20,17,14,.45)'
: 'none',
// Le champ sélectionné passe devant les autres : sinon un champ
// recouvert (ex. la photo sous un texte) ne se saisit plus.
zIndex: selected ? 10 : undefined,
}}
>
{rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? (
<PhotoContent field={field} volunteer={volunteer} photoUrl={photoUrl} scale={scale} />
) : rendered?.kind === 'table' ? (
<TableContent field={field} layout={rendered.layout} scale={scale} />
) : rendered?.kind === 'text' ? (
<TextContent field={field} layout={rendered.layout} scale={scale} />
) : null}
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,
};
{truncated ? (
<span className="pointer-events-none absolute -left-[3px] -top-[19px] whitespace-nowrap border-2 border-ink bg-orange px-1 py-[2px] font-display text-[9px] font-extrabold leading-none">
{t('badges.preview.truncated')}
</span>
) : null}
return (
<>
{/* Contenu à son rang dans la pile, comme sur le badge imprimé. */}
<div
{...(selected ? {} : handlers)}
className={cx('absolute touch-none', selected ? 'pointer-events-none' : 'cursor-move')}
style={{
...geometry,
outline: selected
? 'none'
: truncated
? '2px dashed #FF5C39'
: showGuides
? '1px dashed rgba(20,17,14,.45)'
: 'none',
zIndex: layer,
}}
>
{rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? (
<PhotoContent field={field} volunteer={volunteer} photoUrl={photoUrl} scale={scale} />
) : rendered?.kind === 'table' ? (
<TableContent field={field} layout={rendered.layout} scale={scale} />
) : rendered?.kind === 'text' ? (
<TextContent field={field} layout={rendered.layout} scale={scale} />
) : null}
{truncated && !selected ? <TruncatedTag /> : null}
</div>
{/* 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 ? (
<>
<div
{...handlers}
className="absolute z-[15] cursor-move touch-none"
style={{ ...geometry, outline: '2px solid #2A6BFF' }}
>
{truncated ? <TruncatedTag /> : null}
<span className="pointer-events-none absolute -bottom-[19px] left-0 z-10 whitespace-nowrap bg-blue-strong px-[5px] py-[2px] font-mono text-[9px] font-semibold leading-tight text-white">
{t('badges.preview.dims', { width: fmt(field.width_mm), height: fmt(field.height_mm) })}
{layout?.reduced
@@ -315,9 +334,17 @@ function FieldBox({
onPointerCancel={end}
className="absolute -bottom-[7px] -right-[7px] h-[15px] w-[15px] cursor-nwse-resize touch-none border-2 border-ink bg-yellow lg:h-[13px] lg:w-[13px]"
/>
</>
</div>
) : null}
</div>
</>
);
}
function TruncatedTag() {
return (
<span className="pointer-events-none absolute -left-[3px] -top-[19px] whitespace-nowrap border-2 border-ink bg-orange px-1 py-[2px] font-display text-[9px] font-extrabold leading-none">
{t('badges.preview.truncated')}
</span>
);
}