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
+1
View File
@@ -1606,6 +1606,7 @@ export function createMockApi(): PlanningApi {
bg_color: f.bg_color ?? null,
auto_fit: f.auto_fit ?? true,
photo_shape: f.photo_shape ?? 'RECT',
z_order: f.z_order ?? 0,
id: `bf${state.seq++}`,
edition_id: state.edition.id,
}));
+3
View File
@@ -462,6 +462,9 @@ export interface BadgeFieldIn {
auto_fit: boolean;
/** `PHOTO` seulement : rectangle ou ovale inscrit dans le cadre. */
photo_shape: BadgePhotoShape;
/** Ordre d'empilement sur la face : un champ recouvre ceux d'un
* `z_order` inférieur (photo dessous à égalité). */
z_order: number;
/** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour
* un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */
text?: string | null;
+7
View File
@@ -480,6 +480,13 @@
"badges.inspector.h": "H",
"badges.inspector.center": "↔ Centrer",
"badges.inspector.fullWidth": "⇤⇥ Pleine largeur",
"badges.inspector.stack": "Superposition",
"badges.inspector.stack.position": "{rank} / {total} · 1 = dessus",
"badges.inspector.stack.front": "⤒ Premier plan",
"badges.inspector.stack.forward": "↑ Avancer",
"badges.inspector.stack.backward": "↓ Reculer",
"badges.inspector.stack.back": "⤓ Arrière-plan",
"badges.inspector.stack.help": "Quand des champs se chevauchent, celui du dessus recouvre les autres, sur l'aperçu comme à l'impression.",
"badges.inspector.hide": "Masquer ce champ",
"badges.inspector.empty.title": "Sélectionnez un champ sur le badge ou dans la liste de gauche.",
"badges.inspector.empty.hint": "Faites-le glisser pour le déplacer, tirez le carré jaune pour le redimensionner. Ses réglages apparaissent ici.",
+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>
);
}
@@ -52,11 +52,15 @@ import {
fitToBadge,
fmt,
renderField,
restack,
sameRef,
stackOrder,
toPayload,
zOrderOnEnable,
type BadgeData,
type FieldRef,
type FieldState,
type StackMove,
} from './model';
const HISTORY_LIMIT = 40;
@@ -95,22 +99,33 @@ type EditorAction =
badgeW: number;
badgeH: number;
}
| { type: 'restack'; ref: FieldRef; move: StackMove }
| { type: 'fit'; badgeW: number; badgeH: number }
| { type: 'undo' };
function editorReducer(state: EditorState, action: EditorAction): EditorState {
switch (action.type) {
case 'patch':
case 'patch': {
// Un champ qu'on active prend place en haut de la pile (la photo en bas).
const target = state.fields.find((f) => sameRef({ face: f.face, type: f.field_type }, action.ref));
const patch =
action.patch.enabled && target && !target.enabled
? { z_order: zOrderOnEnable(state.fields, action.ref), ...action.patch }
: action.patch;
return {
history: action.record
? [...state.history, state.fields].slice(-HISTORY_LIMIT)
: state.history,
fields: state.fields.map((f) =>
sameRef({ face: f.face, type: f.field_type }, action.ref)
? fitToBadge({ ...f, ...action.patch }, action.badgeW, action.badgeH)
: f,
f === target ? fitToBadge({ ...f, ...patch }, action.badgeW, action.badgeH) : f,
),
};
}
case 'restack':
return {
history: [...state.history, state.fields].slice(-HISTORY_LIMIT),
fields: restack(state.fields, action.ref, action.move),
};
case 'fit':
return {
...state,
@@ -540,9 +555,17 @@ function BadgeEditor({
}
photoCount={data.volunteers.filter((v) => v.has_photo).length}
volunteerCount={total}
stack={stackOrder(fields, selectedField.face).map((f) => f.field_type)}
onChange={(change) =>
patch({ face: selectedField.face, type: selectedField.field_type }, change)
}
onRestack={(move: StackMove) =>
dispatch({
type: 'restack',
ref: { face: selectedField.face, type: selectedField.field_type },
move,
})
}
onHide={() => {
patch({ face: selectedField.face, type: selectedField.field_type }, { enabled: false });
setSelected(null);
+58 -2
View File
@@ -1,11 +1,11 @@
import { useEffect, useState, type ReactNode } from 'react';
import type { BadgeAlign } from '@/api/schema';
import type { BadgeAlign, BadgeFieldType } from '@/api/schema';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
import { contrastRatio, FONT_STEP_PT, MAX_FONT_PT, MIN_CONTRAST, MIN_FONT_PT, MM_PER_PT } from './layout';
import { FACE_LABEL_KEY, FIELD_LABEL_KEY, fitToBadge, fmt, round1, type FieldState } from './model';
import { FACE_LABEL_KEY, FIELD_LABEL_KEY, fitToBadge, fmt, round1, type FieldState, type StackMove } from './model';
const TEXT_COLORS: Array<{ hex: string; key: string }> = [
{ hex: '#FFFFFF', key: 'badges.color.white' },
@@ -142,7 +142,9 @@ export function BadgeInspector({
effectiveFontSize,
photoCount,
volunteerCount,
stack,
onChange,
onRestack,
onHide,
}: {
field: FieldState;
@@ -152,7 +154,10 @@ export function BadgeInspector({
effectiveFontSize: number | null;
photoCount: number;
volunteerCount: number;
/** Types des champs activés de la face, du dessous vers le dessus. */
stack: BadgeFieldType[];
onChange: (patch: Partial<FieldState>) => void;
onRestack: (move: StackMove) => void;
onHide: () => void;
}) {
const isPhoto = field.field_type === 'PHOTO';
@@ -400,6 +405,8 @@ export function BadgeInspector({
</div>
</div>
<StackSection field={field} stack={stack} onRestack={onRestack} />
<button
type="button"
onClick={onHide}
@@ -411,3 +418,52 @@ export function BadgeInspector({
</div>
);
}
const STACK_MOVES: Array<{ move: StackMove; key: string; atTop: boolean }> = [
{ move: 'front', key: 'badges.inspector.stack.front', atTop: true },
{ move: 'forward', key: 'badges.inspector.stack.forward', atTop: true },
{ move: 'backward', key: 'badges.inspector.stack.backward', atTop: false },
{ move: 'back', key: 'badges.inspector.stack.back', atTop: false },
];
/** Ordre de superposition du champ parmi ceux de sa face — l'ordre dans
* lequel le PDF les dessine (`BadgeField.z_order`). */
function StackSection({
field,
stack,
onRestack,
}: {
field: FieldState;
stack: BadgeFieldType[];
onRestack: (move: StackMove) => void;
}) {
const index = stack.indexOf(field.field_type);
const isTop = index === stack.length - 1;
const isBottom = index === 0;
return (
<Section
title={t('badges.inspector.stack')}
aside={t('badges.inspector.stack.position', { rank: stack.length - index, total: stack.length })}
>
<div className="grid grid-cols-2 gap-[7px]">
{STACK_MOVES.map(({ move, key, atTop }) => {
const disabled = stack.length < 2 || (atTop ? isTop : isBottom);
return (
<button
key={move}
type="button"
disabled={disabled}
onClick={() => onRestack(move)}
className="cursor-pointer border-2 border-ink bg-cream p-2 font-display text-[10.5px] font-bold hover:bg-yellow-soft disabled:cursor-default disabled:opacity-40 disabled:hover:bg-cream"
>
{t(key)}
</button>
);
})}
</div>
<div className="mt-[7px] font-mono text-[9.5px] leading-relaxed opacity-65">
{t('badges.inspector.stack.help')}
</div>
</Section>
);
}
+55 -1
View File
@@ -67,7 +67,7 @@ export function sameRef(a: FieldRef | null, b: FieldRef | null): boolean {
type FieldDefaults = Omit<BadgeFieldIn, 'field_type' | 'face'>;
const COMMON = { bg_color: null, auto_fit: true, photo_shape: 'RECT' } as const;
const COMMON = { bg_color: null, auto_fit: true, photo_shape: 'RECT', z_order: 0 } as const;
// Position/taille par défaut à l'activation d'un champ — empilés
// verticalement pour ne pas se superposer (repères pour un badge CR80
@@ -148,6 +148,7 @@ export function fieldsFromServer(
bg_color: existing.bg_color ?? null,
auto_fit: existing.auto_fit ?? true,
photo_shape: existing.photo_shape ?? 'RECT',
z_order: existing.z_order ?? 0,
text: existing.text ?? null,
enabled: true,
}
@@ -161,6 +162,7 @@ export function fieldsFromServer(
/** Corps du `PUT .../badges/template/fields` : les champs activés des deux
* faces. */
export function toPayload(fields: FieldState[]): BadgeFieldIn[] {
const rank = stackRanks(fields);
return fields
.filter((f) => f.enabled)
.map((f) => ({
@@ -177,10 +179,62 @@ export function toPayload(fields: FieldState[]): BadgeFieldIn[] {
bg_color: f.bg_color,
auto_fit: f.auto_fit,
photo_shape: f.photo_shape,
z_order: rank.get(f) ?? 0,
text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null,
}));
}
// --------------------------------------------------------------------------- //
// Ordre d'empilement //
// --------------------------------------------------------------------------- //
/** Champs activés d'une face, du dessous vers le dessus — l'ordre de dessin
* du PDF. À `z_order` égal, la photo passe dessous, puis l'ordre de
* `FIELD_TYPES` (le serveur ne départage que la photo, mais les `z_order`
* envoyés sont toujours distincts : voir `stackRanks`). */
export function stackOrder(fields: FieldState[], face: BadgeFace): FieldState[] {
return fields
.filter((f) => f.enabled && f.face === face)
.sort(
(a, b) =>
a.z_order - b.z_order ||
FIELD_TYPES.indexOf(a.field_type) - FIELD_TYPES.indexOf(b.field_type),
);
}
/** Rang de chaque champ activé dans la pile de sa face (0 = dessous). */
export function stackRanks(fields: FieldState[]): Map<FieldState, number> {
const ranks = new Map<FieldState, number>();
for (const face of FACES) stackOrder(fields, face).forEach((f, i) => ranks.set(f, i));
return ranks;
}
export type StackMove = 'front' | 'forward' | 'backward' | 'back';
/** Déplace un champ dans la pile de sa face et renumérote la face (0…n-1). */
export function restack(fields: FieldState[], ref: FieldRef, move: StackMove): FieldState[] {
const stack = stackOrder(fields, ref.face);
const from = stack.findIndex((f) => f.field_type === ref.type);
if (from < 0) return fields;
const to = {
front: stack.length - 1,
forward: Math.min(from + 1, stack.length - 1),
backward: Math.max(from - 1, 0),
back: 0,
}[move];
const [moved] = stack.splice(from, 1);
stack.splice(to, 0, moved);
const rank = new Map(stack.map((f, i) => [f, i]));
return fields.map((f) => (rank.has(f) ? { ...f, z_order: rank.get(f) as number } : f));
}
/** `z_order` d'un champ qu'on active : au-dessus des autres — sauf la
* photo, dessous, pour ne pas masquer un texte déjà placé. */
export function zOrderOnEnable(fields: FieldState[], ref: FieldRef): number {
const stack = stackOrder(fields, ref.face);
if (!stack.length) return 0;
return ref.type === 'PHOTO' ? stack[0].z_order - 1 : stack[stack.length - 1].z_order + 1;
}
export function round1(value: number): number {
return Math.round(value * 10) / 10;
}