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 &&
{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 8a93de2..1063d40 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' @@ -897,26 +897,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) { @@ -958,8 +944,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' }) + }) +})