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
+10 -1
View File
@@ -176,7 +176,15 @@ les tables sauf les tables d'association.
zone, `NULL` = transparent, `#rrggbb` sinon 422 `badge.invalid_color`),
`auto_fit` (taille réduite par pas de 0,5 pt, plancher 4 pt, jusqu'à ce que
tout tienne — `app/badges/pdf.py:fit_text` ; sans, les lignes en trop ne
sont pas imprimées), `photo_shape` (`RECT` / `OVAL`, champ `PHOTO`).
sont pas imprimées), `photo_shape` (`RECT` / `OVAL`, champ `PHOTO`),
`z_order` (ordre d'empilement sur la face : les champs sont dessinés du
plus petit au plus grand, à égalité la photo dessous — avant, l'ordre
alphabétique des types faisait passer la photo par-dessus le nom et le
texte libre ; l'éditeur renvoie toujours des rangs distincts 0…n-1,
`badges/model.ts:stackOrder`/`restack`, boutons « Superposition » de
l'inspecteur, un champ activé arrive au-dessus, la photo dessous ; l'aperçu
suit le même ordre, le cadre du champ sélectionné restant par-dessus pour
qu'un champ recouvert se saisisse encore).
`FREE_TEXT` et `ROLE` sont centrés verticalement dans leur cadre (règle
dérivée du type), les autres en haut. `FULL_NAME` = prénom, puis nom à la
ligne. Édité visuellement depuis l'écran « Badges »
@@ -982,6 +990,7 @@ Aucune dépendance locale requise hors Docker.
| 51 | Refonte du créateur de badges d'après la maquette (`design/`) : champs sur le recto **et le verso** (`BadgeField.face`, verso en miroir dans le PDF), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration `0026`) ; éditeur en 3 colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler/flèches/zoom/repères, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération | à valider |
| 52 | Nombre de badges par bénévole (`Volunteer.badge_count`, 1 par défaut, migration `0027`) : champ « Nombre de badges » sur la fiche, autant d'exemplaires sur les planches, total à imprimer affiché à la génération, colonne dans l'export Excel | à valider |
| 53 | Vue par bénévole : tri des lignes par nom ou par total d'heures affectées, croissant / décroissant (boutons « Trier par » et clic sur l'en-tête « TOTAL H ») — à total égal, l'ordre par nom est conservé | à valider |
| 54 | Badges : ordre de superposition des champs (`BadgeField.z_order`, migration `0028` — champs existants renumérotés photo dessous) : boutons « Premier plan / Avancer / Reculer / Arrière-plan » dans l'inspecteur, aperçu et PDF dessinés dans cet ordre — la photo recouvrait le texte | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+8 -1
View File
@@ -86,7 +86,14 @@ def _fields(session: Session, edition_id: uuid.UUID) -> list[BadgeField]:
session.exec(
select(BadgeField)
.where(BadgeField.edition_id == edition_id)
.order_by(BadgeField.face, BadgeField.field_type)
# Ordre de dessin (voir `BadgeField.z_order`) ; à égalité, la
# photo dessous, pour ne jamais masquer un texte.
.order_by(
BadgeField.face,
BadgeField.z_order,
(BadgeField.field_type == BadgeFieldType.PHOTO).desc(),
BadgeField.field_type,
)
)
)
+2
View File
@@ -369,6 +369,8 @@ class BadgeFieldIn(ApiModel):
auto_fit: bool = False
# `PHOTO` seulement, ignoré sinon.
photo_shape: BadgePhotoShape = BadgePhotoShape.RECT
# Ordre d'empilement sur la face (voir `BadgeField.z_order`).
z_order: int = 0
class BadgeFieldsSet(ApiModel):
@@ -0,0 +1,45 @@
"""Ordre d'empilement des champs du badge
Revision ID: 0028_badge_field_z_order
Revises: 0027_volunteer_badge_count
Create Date: 2026-10-27
`badge_field.z_order` : les champs d'une face sont dessinés du plus petit au
plus grand. Avant, ils l'étaient dans l'ordre alphabétique de leur type, et
la photo recouvrait le nom et le texte libre. Les champs existants sont
numérotés photo d'abord (dessous), puis les autres par type.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0028_badge_field_z_order"
down_revision: str | None = "0027_volunteer_badge_count"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"badge_field",
sa.Column("z_order", sa.Integer(), nullable=False, server_default="0"),
)
op.execute(
"""
UPDATE badge_field AS b SET z_order = ranked.rank
FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY edition_id, face
ORDER BY (field_type = 'PHOTO') DESC, field_type
) - 1 AS rank
FROM badge_field
) AS ranked
WHERE b.id = ranked.id
"""
)
def downgrade() -> None:
op.drop_column("badge_field", "z_order")
+5
View File
@@ -509,6 +509,11 @@ class BadgeField(TimestampMixin, table=True):
server_default=BadgePhotoShape.RECT.value,
),
)
# Ordre d'empilement sur sa face : les champs sont dessinés du plus petit
# au plus grand, un champ recouvre donc ceux d'un `z_order` inférieur.
z_order: int = Field(
default=0, sa_column=Column(Integer, nullable=False, server_default="0")
)
# --------------------------------------------------------------------------- #
+38
View File
@@ -398,3 +398,41 @@ def test_badge_count_out_of_range_is_rejected(client, edition, session, count):
f"/editions/{edition.id}/volunteers/{volunteer.id}", json={"badge_count": count}
)
assert resp.status_code == 422
def test_fields_are_drawn_in_stacking_order(client, edition, session, monkeypatch):
import app.api.badges as badges_api
captured = {}
original = badges_api.generate_badge_sheets_pdf
def spy(badges, *args, **kwargs):
captured["badges"] = badges
return original(badges, *args, **kwargs)
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
_field(field_type="FREE_TEXT", text="Staff", z_order=2),
_field(field_type="FULL_NAME", z_order=0),
_field(field_type="PHOTO", z_order=1),
]
},
)
assert resp.status_code == 200
assert [f["field_type"] for f in resp.json()] == ["FULL_NAME", "PHOTO", "FREE_TEXT"]
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
assert client.post(f"{_badges_url(edition)}/generate", json={}).status_code == 200
(badge,) = captured["badges"]
assert [f.text for f in badge.fields] == ["Alex\nMartin", "AM", "Staff"]
def test_photo_is_drawn_below_text_at_equal_stacking_order(client, edition):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={"fields": [_field(field_type="FULL_NAME"), _field(field_type="PHOTO")]},
)
assert [f["field_type"] for f in resp.json()] == ["PHOTO", "FULL_NAME"]
+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;
}