Badges : ordre de superposition des champs
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:
co-authored by
Claude Opus 5.5
parent
4f86398ccb
commit
1026d9b9e1
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user