From 92c1f25efb730a319ddb9882f13a3432fc61d244 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 12:28:39 +0200 Subject: [PATCH] feat(recadrage): choisir la forme du cadre (rond, carre, polaroid, vertical, horizontal) Le cadre suivait le format de la photo : une photo couchee donnait toujours un cadre couche. Le recadrage propose maintenant les cinq formes de l'arbre, en dessin, et la forme choisie est gardee avec le cadrage (avatarCrop.shape, sans migration). L'arbre la respecte ; sans choix, rien ne change. Co-Authored-By: Claude Opus 5.5 --- backend/src/lib/avatar-crop.ts | 13 ++++--- backend/test/avatar-crop.test.ts | 13 +++++++ frontend/src/components/PortraitCropModal.css | 13 +++++++ frontend/src/components/PortraitCropModal.jsx | 33 ++++++++++++++--- frontend/src/components/galaxy/renderers.js | 30 +++++----------- frontend/src/i18n/en/fiche.js | 6 ++++ frontend/src/utils/portraitCrop.js | 35 +++++++++++++++---- frontend/src/utils/portraitCrop.test.js | 27 ++++++++++++++ 8 files changed, 132 insertions(+), 38 deletions(-) diff --git a/backend/src/lib/avatar-crop.ts b/backend/src/lib/avatar-crop.ts index c93aee0..d386377 100644 --- a/backend/src/lib/avatar-crop.ts +++ b/backend/src/lib/avatar-crop.ts @@ -1,9 +1,10 @@ import { z } from 'zod' -// Cadrage d'un portrait : centre (proportions de l'image) et zoom. Zoom 1 = l'image entière recouvre le cadre. -// Les bornes qui empêchent le vide sont appliquées à l'affichage (frontend, portraitCrop.js), le serveur -// vérifie seulement la forme et les plages. -export type AvatarCrop = { x: number; y: number; zoom: number } +// Cadrage d'un portrait : centre (proportions de l'image), zoom, et forme du cadre si on l'a choisie. +// Zoom 1 = l'image entière recouvre le cadre. Les bornes qui empêchent le vide sont appliquées à +// l'affichage (frontend, portraitCrop.js), le serveur vérifie seulement la forme et les plages. +export const FRAME_SHAPES = ['round', 'square', 'polaroid', 'portrait', 'landscape'] as const +export type AvatarCrop = { x: number; y: number; zoom: number; shape?: (typeof FRAME_SHAPES)[number] } export const AVATAR_CROP_MAX_ZOOM = 4 @@ -13,16 +14,18 @@ export const avatarCropInputSchema = z.object({ x: z.number().min(0).max(1), y: z.number().min(0).max(1), zoom: z.number().min(1).max(AVATAR_CROP_MAX_ZOOM), + shape: z.enum(FRAME_SHAPES).optional(), }) .nullable(), }) const round4 = (value: number) => Math.round(value * 10000) / 10000 -// Forme à écrire en base. Le cadrage par défaut se stocke comme absence de cadrage (null). +// Forme à écrire en base. Le cadrage par défaut, sans forme choisie, se stocke comme absence de cadrage (null). export function toStoredAvatarCrop(crop: AvatarCrop | null): AvatarCrop | null { if (!crop) return null const normalized = { x: round4(crop.x), y: round4(crop.y), zoom: round4(crop.zoom) } + if (crop.shape) return { ...normalized, shape: crop.shape } if (normalized.x === 0.5 && normalized.y === 0.5 && normalized.zoom === 1) return null return normalized } diff --git a/backend/test/avatar-crop.test.ts b/backend/test/avatar-crop.test.ts index 0dfb78a..5a1a2d9 100644 --- a/backend/test/avatar-crop.test.ts +++ b/backend/test/avatar-crop.test.ts @@ -166,6 +166,19 @@ describe('PUT /trees/:id/persons/:personId/avatar/crop', () => { expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toBe(Prisma.DbNull) }) + it('garde la forme choisie, même avec un cadrage centré, et refuse une forme inconnue', async () => { + const app = createApp() + const token = await createUserToken(app) + const put = (crop: unknown) => app.inject({ method: 'PUT', url, headers: { authorization: `Bearer ${token}` }, payload: { crop } }) + + const saved = await put({ x: 0.5, y: 0.5, zoom: 1, shape: 'round' }) + expect(saved.statusCode).toBe(200) + expect(saved.json()).toEqual({ ok: true, avatarCrop: { x: 0.5, y: 0.5, zoom: 1, shape: 'round' } }) + expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toEqual({ x: 0.5, y: 0.5, zoom: 1, shape: 'round' }) + + expect((await put({ x: 0.5, y: 0.5, zoom: 1, shape: 'hexagone' })).statusCode).toBe(400) + }) + it('répond 404 sans portrait, et 409 pour un portrait venu d Immich', async () => { const app = createApp() const token = await createUserToken(app) diff --git a/frontend/src/components/PortraitCropModal.css b/frontend/src/components/PortraitCropModal.css index 7391117..8909225 100644 --- a/frontend/src/components/PortraitCropModal.css +++ b/frontend/src/components/PortraitCropModal.css @@ -55,3 +55,16 @@ @media (prefers-reduced-motion: reduce) { .pcrop-img { transition: none; } } + +/* Forme du cadre : chaque choix montre sa forme, son nom est dans aria-label et title */ +.pcrop-shapes { width: min(100%, 300px); margin: 0 auto; } +.pcrop-shapes .pz-tab { display: grid; place-items: center; min-height: 40px; padding: 0; color: var(--pz-muted); } +.pcrop-shapes .pz-tab.is-active { color: var(--pz-a700, #93402A); } +.pcrop-shapes .pz-tab:disabled { cursor: default; opacity: .5; } +.pcrop-ico { display: block; border: 1.75px solid currentColor; } +.pcrop-ico--round { width: 20px; height: 20px; border-radius: 50%; } +.pcrop-ico--square { width: 20px; height: 20px; border-radius: 3px; } +/* Polaroïd : la marge du bas plus large que les côtés */ +.pcrop-ico--polaroid { width: 20px; height: 22px; border-radius: 2px; border-bottom-width: 5px; } +.pcrop-ico--portrait { width: 16px; height: 22px; border-radius: 3px; } +.pcrop-ico--landscape { width: 26px; height: 16px; border-radius: 3px; } diff --git a/frontend/src/components/PortraitCropModal.jsx b/frontend/src/components/PortraitCropModal.jsx index 445da28..9c49f05 100644 --- a/frontend/src/components/PortraitCropModal.jsx +++ b/frontend/src/components/PortraitCropModal.jsx @@ -8,11 +8,13 @@ import { DEFAULT_CROP, clampCrop, cropForSave, + cropShape, cropViewport, frameAspect, imagePlacement, normalizeCrop, resolveFrameKind, + shapeOfKind, } from '../utils/portraitCrop.js' import './PortraitCropModal.css' @@ -23,8 +25,11 @@ const KEY_STEP = 0.04 const KEY_STEP_LARGE = 0.16 const KEY_ZOOM_STEP = 0.2 +const SHAPE_LABELS = { round: t('Rond'), square: t('Carré'), polaroid: t('Polaroïd'), portrait: t('Vertical'), landscape: t('Horizontal') } + // Recadrage d'un portrait. Le masque garde la forme du cadre de la personne dans l'arbre ; la photo -// glisse dessous. `mode` : 'recrop' (depuis le menu) ou 'new' (juste après le choix d'une photo). +// glisse dessous. La forme se choisit aussi ici : sans choix, elle suit la photo ou le tirage de la personne. +// `mode` : 'recrop' (depuis le menu) ou 'new' (juste après le choix d'une photo). const PortraitCropModal = ({ src, frameType, name, mode = 'recrop', initialCrop = null, saving = false, error = '', onSave, onClose }) => { const stageRef = useRef(null) const dragRef = useRef(null) @@ -33,11 +38,13 @@ const PortraitCropModal = ({ src, frameType, name, mode = 'recrop', initialCrop const [size, setSize] = useState(null) const [loadFailed, setLoadFailed] = useState(false) const [crop, setCrop] = useState(() => normalizeCrop(initialCrop)) + const [shape, setShape] = useState(() => cropShape(initialCrop)) const [dragging, setDragging] = useState(false) const imageRatio = size ? size.w / size.h : 1 - const frameKind = resolveFrameKind(frameType, imageRatio) - const aspect = frameAspect(frameKind, imageRatio) + const frameKind = resolveFrameKind(frameType, imageRatio, shape) + const aspect = frameAspect(frameKind, imageRatio, shape) + const shownShape = shape ?? shapeOfKind(frameKind, imageRatio) const winW = aspect >= 1 ? WINDOW_SIZE : WINDOW_SIZE * aspect const winH = aspect >= 1 ? WINDOW_SIZE / aspect : WINDOW_SIZE @@ -159,7 +166,7 @@ const PortraitCropModal = ({ src, frameType, name, mode = 'recrop', initialCrop const setZoom = (zoom) => apply({ ...current, zoom: Number(zoom) }) const save = () => { if (!size || saving) return - onSave?.(cropForSave(size.w, size.h, aspect, current)) + onSave?.(cropForSave(size.w, size.h, aspect, current, shape)) } const isNew = mode === 'new' @@ -208,6 +215,24 @@ const PortraitCropModal = ({ src, frameType, name, mode = 'recrop', initialCrop {!size && !loadFailed &&
} +
+ {Object.entries(SHAPE_LABELS).map(([value, label]) => ( + + ))} +
+

{t('Glissez la photo, ou utilisez les flèches. Molette, pincement ou curseur pour zoomer.')}

diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index 3c8dd8f..eb7e2c8 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -1,6 +1,6 @@ import { t } from '../../i18n/index.js' import { getPersonById, getPersonMedias } from '../../data/mockData' -import { cropViewport } from '../../utils/portraitCrop.js' +import { cropShape, cropViewport, isLandscapeRect, resolveFrameKind } from '../../utils/portraitCrop.js' // Tracés Lucide dessinés sur le canvas : données publiques de @lucide/icons (Map renommé, il masquerait // le Map global utilisé plus bas) import { Map as mapIcon, MapPin as mapPinIcon, Route as routeIcon } from '@lucide/icons' @@ -891,26 +891,12 @@ function drawMediaContent(ctx, media, ms, imageCache) { // ============================================================ // Dessiner le cadre photo (polaroid, rect, round) // ============================================================ +// Forme choisie au recadrage d'abord ; sinon une photo très large ou très haute impose la sienne, et une photo +// carrée ou presque garde la forme tirée au hasard pour la personne (pickFrameType), pour que l'arbre ne soit +// pas uniforme. Même règle que la fenêtre de recadrage (portraitCrop.js). function resolveFrameType(person, img) { - const fallback = person.frameType || 'round' - - if (!img || !img.naturalWidth || !img.naturalHeight) { - return fallback - } - - const ratio = img.naturalWidth / img.naturalHeight - - if (ratio >= 1.28) { - return 'rect' - } - - if (ratio <= 0.78) { - return 'polaroid' - } - - // Photo carrée ou presque : chaque personne garde sa forme tirée au hasard - // (person.frameType, voir pickFrameType), pour que l'arbre ne soit pas uniforme - return fallback + const ratio = img?.naturalWidth && img?.naturalHeight ? img.naturalWidth / img.naturalHeight : 1 + return resolveFrameKind(person.frameType || 'round', ratio, cropShape(person.avatarCrop)) } function drawFrame(ctx, frameType, person, img, hint = 0) { @@ -952,8 +938,8 @@ function drawFrame(ctx, frameType, person, img, hint = 0) { const ratio = img?.naturalWidth && img?.naturalHeight ? img.naturalWidth / img.naturalHeight : 1 - // Photo carrée : cadre vertical, qui garde le visage entier - const isLandscape = ratio > 1.05 + // Couché ou debout selon la forme choisie, sinon selon la photo (carrée : debout, le visage reste entier) + const isLandscape = isLandscapeRect(ratio, cropShape(person.avatarCrop)) const rw = isLandscape ? PERSON_R + 12 : PERSON_R + 2 const rh = isLandscape ? PERSON_R - 8 : PERSON_R + 6 const pad = 3 diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index 81ac17a..75018d8 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -418,6 +418,12 @@ export default { 'Il faut au moins un nom si le prénom est inconnu.': 'A last name is required when the first name is unknown.', // Recadrage du portrait (PortraitCropModal, menu du badge, App) 'Recadrer la photo': 'Crop photo', + 'Forme du cadre': 'Frame shape', + 'Rond': 'Round', + 'Carré': 'Square', + 'Polaroïd': 'Polaroid', + 'Vertical': 'Vertical', + 'Horizontal': 'Horizontal', 'Recadrer': 'Crop', 'Options du portrait': 'Portrait options', 'Zone de recadrage': 'Crop area', diff --git a/frontend/src/utils/portraitCrop.js b/frontend/src/utils/portraitCrop.js index b1b7b8a..e50fbd7 100644 --- a/frontend/src/utils/portraitCrop.js +++ b/frontend/src/utils/portraitCrop.js @@ -7,19 +7,37 @@ export const DEFAULT_CROP = { x: 0.5, y: 0.5, zoom: 1 } const clamp = (value, min, max) => Math.min(max, Math.max(min, value)) +// Formes qu'on choisit au recadrage, gardées dans le cadrage (crop.shape). Sans choix, la forme suit la photo +// (très large ou très haute) ou le cadre tiré au hasard pour la personne. +export const FRAME_SHAPES = ['round', 'square', 'polaroid', 'portrait', 'landscape'] +export const cropShape = (crop) => (FRAME_SHAPES.includes(crop?.shape) ? crop.shape : null) + // Forme du cadre dans le canevas : même règle que resolveFrameType et drawFrame (renderers.js). -// Une photo très large ou très haute change la forme ; sinon le cadre tiré pour la personne (frameType) reste. -export function resolveFrameKind(frameType, imageRatio) { +// La forme choisie l'emporte ; sinon une photo très large ou très haute change la forme, et à défaut +// le cadre tiré pour la personne (frameType) reste. +export function resolveFrameKind(frameType, imageRatio, shape = null) { + if (shape) return shape === 'portrait' || shape === 'landscape' ? 'rect' : shape if (imageRatio >= 1.28) return 'rect' if (imageRatio <= 0.78) return 'polaroid' return frameType || 'round' } +// Cadre rectangle : couché si on l'a choisi, sinon selon la photo (carrée : debout, le visage reste entier) +export function isLandscapeRect(imageRatio, shape = null) { + return shape ? shape === 'landscape' : imageRatio > 1.05 +} + +// Nom de la forme affichée, pour le sélecteur +export function shapeOfKind(frameKind, imageRatio) { + if (frameKind !== 'rect') return frameKind + return isLandscapeRect(imageRatio) ? 'landscape' : 'portrait' +} + // Rapport largeur / hauteur de la zone visible, comme drawFrame le dessine. -export function frameAspect(frameKind, imageRatio) { +export function frameAspect(frameKind, imageRatio, shape = null) { if (frameKind === 'rect') { // Paysage : 80 x 40 ; portrait : 60 x 68 (renderers.js, drawFrame) - return imageRatio > 1.05 ? 2 : 60 / 68 + return isLandscapeRect(imageRatio, shape) ? 2 : 60 / 68 } // round, polaroid et square : la zone photo est carrée return 1 @@ -32,7 +50,8 @@ export function normalizeCrop(crop) { const y = Number(crop.y) const zoom = Number(crop.zoom) if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(zoom)) return DEFAULT_CROP - return { x: clamp(x, 0, 1), y: clamp(y, 0, 1), zoom: clamp(zoom, 1, CROP_MAX_ZOOM) } + const shape = cropShape(crop) + return { x: clamp(x, 0, 1), y: clamp(y, 0, 1), zoom: clamp(zoom, 1, CROP_MAX_ZOOM), ...(shape ? { shape } : {}) } } // Zone de l'image visible dans le cadre, en pixels de l'image. Le centre est borné pour qu'il n'y ait jamais @@ -77,11 +96,13 @@ export function imagePlacement(imageW, imageH, aspect, crop, windowW = 1, window } } -// Ce qu'il faut à l'enregistrement : null pour le cadrage par défaut, sinon le cadrage borné. -export function cropForSave(imageW, imageH, aspect, crop) { +// Ce qu'il faut à l'enregistrement : null pour le cadrage par défaut sans forme choisie, sinon le cadrage +// borné, avec la forme quand on l'a choisie. +export function cropForSave(imageW, imageH, aspect, crop, shape = null) { const c = clampCrop(imageW, imageH, aspect, crop) const round = (value) => Math.round(value * 10000) / 10000 const normalized = { x: round(c.x), y: round(c.y), zoom: round(c.zoom) } + if (shape) return { ...normalized, shape } if (normalized.x === 0.5 && normalized.y === 0.5 && normalized.zoom === 1) return null return normalized } diff --git a/frontend/src/utils/portraitCrop.test.js b/frontend/src/utils/portraitCrop.test.js index 6b4eff8..c11bff2 100644 --- a/frontend/src/utils/portraitCrop.test.js +++ b/frontend/src/utils/portraitCrop.test.js @@ -8,6 +8,7 @@ import { imagePlacement, normalizeCrop, resolveFrameKind, + shapeOfKind, } from './portraitCrop.js' // Le cadrage visible ne sort jamais de la photo : aucun vide dans le cadre @@ -86,3 +87,29 @@ describe('portraitCrop', () => { expect(frameAspect('rect', 1)).toBeCloseTo(60 / 68) }) }) + +describe('forme choisie au recadrage', () => { + it('l emporte sur le format de la photo et sur le tirage de la personne', () => { + // Photo très large : cadre couché par défaut, rond si on le choisit + expect(resolveFrameKind('polaroid', 1.6)).toBe('rect') + expect(resolveFrameKind('polaroid', 1.6, 'round')).toBe('round') + expect(frameAspect(resolveFrameKind('polaroid', 1.6, 'round'), 1.6, 'round')).toBe(1) + // Vertical choisi sur une photo couchée : la zone visible est debout + expect(resolveFrameKind('round', 1.6, 'portrait')).toBe('rect') + expect(frameAspect('rect', 1.6, 'portrait')).toBeCloseTo(60 / 68) + expect(frameAspect('rect', 0.7, 'landscape')).toBe(2) + }) + + it('le sélecteur montre la forme en cours quand rien n est choisi', () => { + expect(shapeOfKind('rect', 1.6)).toBe('landscape') + expect(shapeOfKind('rect', 1)).toBe('portrait') + expect(shapeOfKind('polaroid', 0.7)).toBe('polaroid') + }) + + it('est gardée à la lecture et à l enregistrement, même sans recadrage', () => { + expect(normalizeCrop({ x: 0.2, y: 0.3, zoom: 2, shape: 'square' })).toEqual({ x: 0.2, y: 0.3, zoom: 2, shape: 'square' }) + expect(normalizeCrop({ x: 0.2, y: 0.3, zoom: 2, shape: 'hexagone' })).toEqual({ x: 0.2, y: 0.3, zoom: 2 }) + expect(cropForSave(400, 400, 1, DEFAULT_CROP)).toBeNull() + expect(cropForSave(400, 400, 1, DEFAULT_CROP, 'round')).toEqual({ x: 0.5, y: 0.5, zoom: 1, shape: 'round' }) + }) +})