mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Merge pull request #108 from IliesAllali/feat/recadrage-forme
Recadrage : choisir la forme du cadre
This commit is contained in:
8 files changed
+132
-38
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
@@ -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 && <div className="pcrop-loading"><PzBusy busy busyLabel={t('Chargement de la photo')} /></div>}
|
||||
</div>
|
||||
|
||||
<div className="pz-tabs pcrop-shapes" role="radiogroup" aria-label={t('Forme du cadre')}>
|
||||
{Object.entries(SHAPE_LABELS).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={shownShape === value}
|
||||
className={`pz-tab ${shownShape === value ? 'is-active' : ''}`}
|
||||
onClick={() => setShape(value)}
|
||||
disabled={!size || saving}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
<span className={`pcrop-ico pcrop-ico--${value}`} aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p id="pcropHint" className="pz-small pcrop-hint">
|
||||
{t('Glissez la photo, ou utilisez les flèches. Molette, pincement ou curseur pour zoomer.')}
|
||||
</p>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user