Merge pull request #95 from IliesAllali/feat/recadrage-portrait

Recadrage du portrait, photo entière conservée
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-19 00:02:56 +02:00
commit edbbf7b163
20 files changed
+975 -42

No files matched your search

@@ -0,0 +1,3 @@
-- Cadrage du portrait (feat/recadrage-portrait). Migration additive : colonne nullable, sans défaut.
-- AlterTable
ALTER TABLE "persons" ADD COLUMN "avatarCrop" JSONB;
+4
View File
@@ -230,6 +230,10 @@ model Person {
nationality String?
avatarPath String?
avatarMimeType String?
// Cadrage du portrait, appliqué à l'affichage sur l'original (jamais de fichier dérivé) :
// { "x": 0..1, "y": 0..1, "zoom": 1..4 }, centre du cadrage en proportion de l'image, zoom 1 = image entière
// recouvrant le cadre. Null = cadrage par défaut (centré, zoom 1).
avatarCrop Json?
sex String?
notes String?
createdBy String?
+34
View File
@@ -0,0 +1,34 @@
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 }
export const AVATAR_CROP_MAX_ZOOM = 4
export const avatarCropInputSchema = z.object({
crop: 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),
})
.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).
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 (normalized.x === 0.5 && normalized.y === 0.5 && normalized.zoom === 1) return null
return normalized
}
// Lecture de la colonne JSON : tout ce qui ne respecte pas la forme est ignoré (portrait affiché tel quel).
export function readAvatarCrop(value: unknown): AvatarCrop | null {
const parsed = avatarCropInputSchema.shape.crop.safeParse(value)
return parsed.success ? parsed.data : null
}
+2 -2
View File
@@ -1,4 +1,4 @@
import type { Prisma, PrismaClient } from '@prisma/client'
import { Prisma, type PrismaClient } from '@prisma/client'
type Db = PrismaClient | Prisma.TransactionClient
@@ -32,7 +32,7 @@ export async function softDeletePersonCascade(
await db.person.updateMany({
where: { id: personId, treeId, deletedAt: null },
data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null, immichPersonId: null },
data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null, avatarCrop: Prisma.DbNull, immichPersonId: null },
})
const unions = await db.union.findMany({
+5
View File
@@ -1,3 +1,4 @@
import { Prisma } from '@prisma/client'
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
@@ -472,6 +473,8 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
data: {
avatarPath: relativePath,
avatarMimeType: prepared.mimeType,
// Nouveau portrait, nouveau cadrage à faire
avatarCrop: Prisma.DbNull,
immichPersonId,
updatedBy: request.actor.userId,
},
@@ -551,10 +554,12 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
updatedBy: string
avatarPath?: null
avatarMimeType?: null
avatarCrop?: typeof Prisma.DbNull
} = { immichPersonId: null, updatedBy: request.actor.userId }
if (avatarFromImmich) {
updateData.avatarPath = null
updateData.avatarMimeType = null
updateData.avatarCrop = Prisma.DbNull
}
// Base d'abord, fichier ensuite : le portrait disparaît seulement une fois la base à jour.
+69
View File
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { Prisma } from '@prisma/client'
import type { FastifyPluginAsync, FastifyRequest } from 'fastify'
import { z } from 'zod'
@@ -10,6 +11,7 @@ import {
applyMediaResponseHeaders,
extensionForMime,
InvalidMediaError,
isImmichAvatarPath,
isMimeAllowedForType,
removeStoredFile,
sanitizeImage,
@@ -17,6 +19,7 @@ import {
sniffMimeType,
type BinaryMediaType,
} from '../lib/media-files.js'
import { avatarCropInputSchema, readAvatarCrop, toStoredAvatarCrop } from '../lib/avatar-crop.js'
import { syncPersonMediaOrder } from '../lib/media-order.js'
import { resolveTreeMediaDirectory } from '../lib/tree-purge.js'
import { canReadTree, canReadTreeWithMediaToken } from '../lib/tree-access.js'
@@ -600,6 +603,8 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
data: {
avatarPath: relativePath,
avatarMimeType: prepared.mimeType,
// Nouveau portrait : le cadrage de l'ancien ne s'applique pas à celui-ci
avatarCrop: Prisma.DbNull,
updatedBy: request.actor.userId,
// Un portrait déposé à la main remplace aussi le lien Immich : sans cela, l'association
// survivrait invisible en base (le graphe ne publie que l'origine du portrait) et ne
@@ -655,6 +660,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
data: {
avatarPath: null,
avatarMimeType: null,
avatarCrop: Prisma.DbNull,
// Retirer le portrait retire aussi le lien Immich : l'association ne survit pas au portrait.
immichPersonId: null,
updatedBy: request.actor.userId,
@@ -681,6 +687,69 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.send({ ok: true })
})
// Cadrage du portrait : l'original reste en place, seul le cadrage est enregistré. Même droit que changer la photo.
app.put('/trees/:id/persons/:personId/avatar/crop', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const params = treePersonParamsSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_params' })
}
const payload = avatarCropInputSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() })
}
const membership = await requireAdminMembership(app, request.actor.userId, params.data.id)
if (!membership) {
return reply.code(403).send({ error: 'forbidden' })
}
const person = await app.prisma.person.findFirst({
where: { id: params.data.personId, treeId: params.data.id, deletedAt: null },
select: { avatarPath: true },
})
if (!person) {
return reply.code(404).send({ error: 'person_not_found' })
}
if (!person.avatarPath) {
return reply.code(404).send({ error: 'avatar_not_found' })
}
// Hors périmètre pour l'instant : les portraits venus d'Immich ne se recadrent pas
if (isImmichAvatarPath(person.avatarPath)) {
return reply.code(409).send({ error: 'avatar_from_immich' })
}
const avatarCrop = toStoredAvatarCrop(payload.data.crop)
await app.prisma.person.updateMany({
where: { id: params.data.personId, treeId: params.data.id, deletedAt: null },
data: {
avatarCrop: avatarCrop ?? Prisma.DbNull,
updatedBy: request.actor.userId,
},
})
await app.prisma.auditLog.create({
data: {
treeId: params.data.id,
actorType: 'user',
actorId: request.actor.userId,
action: 'person_avatar_cropped',
entityType: 'person',
entityId: params.data.personId,
},
})
return reply.send({ ok: true, avatarCrop: readAvatarCrop(avatarCrop) })
})
app.patch('/trees/:id/persons/:personId/media/reorder', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
+3
View File
@@ -1,6 +1,7 @@
import type { Annotation, MediaItem, ParentChildLink, Person, Union } from '@prisma/client'
import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
import { readAvatarCrop, type AvatarCrop } from '../lib/avatar-crop.js'
import { isImmichAvatarPath } from '../lib/media-files.js'
export type GraphPayload = {
@@ -22,6 +23,7 @@ export type GraphPayload = {
sex: string | null
notes: string | null
avatarFromImmich: boolean
avatarCrop: AvatarCrop | null
}>
unions: Array<{
id: string
@@ -99,6 +101,7 @@ export function buildGraphPayload(
sex: person.sex,
notes: person.notes,
avatarFromImmich: isImmichAvatarPath(person.avatarPath),
avatarCrop: person.avatarPath ? readAvatarCrop(person.avatarCrop) : null,
})),
unions: unions.map((union) => ({
id: union.id,
+217
View File
@@ -0,0 +1,217 @@
import { randomUUID } from 'node:crypto'
import { mkdtemp, rm } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { Prisma } from '@prisma/client'
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
user: {
count: vi.fn(),
},
person: {
findFirst: vi.fn(),
updateMany: vi.fn(),
},
treeMembership: {
findUnique: vi.fn(),
},
auditLog: {
create: vi.fn(),
},
$transaction: vi.fn(),
$disconnect: vi.fn(),
},
}))
vi.mock('../src/lib/prisma.js', () => ({
prisma: prismaMock,
}))
let createApp: () => FastifyInstance
let storageRoot: string
const TREE = 'tree-1'
const PERSON = 'person-1'
const url = `/trees/${TREE}/persons/${PERSON}/avatar/crop`
async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise<string> {
await app.ready()
return (app as any).jwt.sign(
{ kind: 'user', sub: userId, jti: randomUUID(), userId, email: 'user@example.com' },
{ expiresIn: '1h' },
)
}
function asAdmin(role: 'owner' | 'admin' | 'member' = 'owner') {
prismaMock.treeMembership.findUnique.mockResolvedValue({ role, tree: { deletedAt: null } })
}
beforeAll(async () => {
process.env.NODE_ENV = 'test'
process.env.PORT = '4000'
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.JWT_EXPIRES_IN = '7d'
process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h'
process.env.CORS_ORIGIN = 'http://localhost:5173'
storageRoot = await mkdtemp(path.join(os.tmpdir(), 'parenthese-crop-'))
process.env.MEDIA_STORAGE_PATH = storageRoot
const module = await import('../src/app.js')
createApp = module.createApp
})
afterAll(async () => {
await rm(storageRoot, { recursive: true, force: true })
})
beforeEach(() => {
vi.clearAllMocks()
prismaMock.user.count.mockResolvedValue(1)
prismaMock.person.updateMany.mockResolvedValue({ count: 1 })
prismaMock.person.findFirst.mockResolvedValue({ avatarPath: `${TREE}/${PERSON}/avatar-a.jpg` })
asAdmin('owner')
})
describe('PUT /trees/:id/persons/:personId/avatar/crop', () => {
it('refuse sans session', async () => {
const app = createApp()
const response = await app.inject({ method: 'PUT', url, payload: { crop: null } })
expect(response.statusCode).toBe(401)
expect(prismaMock.person.updateMany).not.toHaveBeenCalled()
})
it('réserve le cadrage aux administrateurs de l arbre, comme le changement de photo', async () => {
const app = createApp()
const token = await createUserToken(app)
asAdmin('member')
const response = await app.inject({
method: 'PUT',
url,
headers: { authorization: `Bearer ${token}` },
payload: { crop: { x: 0.4, y: 0.4, zoom: 2 } },
})
expect(response.statusCode).toBe(403)
expect(prismaMock.person.updateMany).not.toHaveBeenCalled()
})
it('refuse un cadrage hors plage ou mal formé', async () => {
const app = createApp()
const token = await createUserToken(app)
const headers = { authorization: `Bearer ${token}` }
const tooZoomed = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.5, y: 0.5, zoom: 5 } } })
const outside = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 1.2, y: 0.5, zoom: 1 } } })
const missing = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.5 } } })
const noBody = await app.inject({ method: 'PUT', url, headers, payload: {} })
expect(tooZoomed.statusCode).toBe(400)
expect(outside.statusCode).toBe(400)
expect(missing.statusCode).toBe(400)
expect(noBody.statusCode).toBe(400)
expect(prismaMock.person.updateMany).not.toHaveBeenCalled()
})
it('enregistre le cadrage arrondi et audite le changement', async () => {
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({
method: 'PUT',
url,
headers: { authorization: `Bearer ${token}` },
payload: { crop: { x: 0.123456, y: 0.7, zoom: 1.8 } },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ ok: true, avatarCrop: { x: 0.1235, y: 0.7, zoom: 1.8 } })
const call = prismaMock.person.updateMany.mock.calls[0][0]
expect(call.where).toEqual({ id: PERSON, treeId: TREE, deletedAt: null })
expect(call.data.avatarCrop).toEqual({ x: 0.1235, y: 0.7, zoom: 1.8 })
// La photo elle-même n'est jamais touchée
expect(call.data).not.toHaveProperty('avatarPath')
expect(prismaMock.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: 'person_avatar_cropped', entityId: PERSON, treeId: TREE }),
})
})
it('remet le cadrage par défaut à zéro (null) plutôt que de le stocker', async () => {
const app = createApp()
const token = await createUserToken(app)
const reset = await app.inject({
method: 'PUT',
url,
headers: { authorization: `Bearer ${token}` },
payload: { crop: null },
})
expect(reset.statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toBe(Prisma.DbNull)
prismaMock.person.updateMany.mockClear()
await app.inject({
method: 'PUT',
url,
headers: { authorization: `Bearer ${token}` },
payload: { crop: { x: 0.5, y: 0.5, zoom: 1 } },
})
expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toBe(Prisma.DbNull)
})
it('répond 404 sans portrait, et 409 pour un portrait venu d Immich', async () => {
const app = createApp()
const token = await createUserToken(app)
const headers = { authorization: `Bearer ${token}` }
prismaMock.person.findFirst.mockResolvedValueOnce({ avatarPath: null })
const noAvatar = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.4, y: 0.4, zoom: 2 } } })
expect(noAvatar.statusCode).toBe(404)
prismaMock.person.findFirst.mockResolvedValueOnce({ avatarPath: `${TREE}/${PERSON}/immich-abc.jpg` })
const fromImmich = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.4, y: 0.4, zoom: 2 } } })
expect(fromImmich.statusCode).toBe(409)
expect(fromImmich.json()).toEqual({ error: 'avatar_from_immich' })
expect(prismaMock.person.updateMany).not.toHaveBeenCalled()
})
})
describe('DELETE avatar : le cadrage disparaît avec la photo', () => {
it('remet avatarCrop à null en même temps que le portrait', async () => {
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({
method: 'DELETE',
url: `/trees/${TREE}/persons/${PERSON}/avatar`,
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
const data = prismaMock.person.updateMany.mock.calls[0][0].data
expect(data.avatarPath).toBeNull()
expect(data.avatarCrop).toBe(Prisma.DbNull)
})
})
describe('lecture du cadrage stocké', () => {
it('ignore toute valeur mal formée au lieu de déformer le portrait', async () => {
const { readAvatarCrop, toStoredAvatarCrop } = await import('../src/lib/avatar-crop.js')
expect(readAvatarCrop(null)).toBeNull()
expect(readAvatarCrop('oui')).toBeNull()
expect(readAvatarCrop({ x: 0.2, y: 0.3 })).toBeNull()
expect(readAvatarCrop({ x: 0.2, y: 0.3, zoom: 9 })).toBeNull()
expect(readAvatarCrop({ x: 0.2, y: 0.3, zoom: 2 })).toEqual({ x: 0.2, y: 0.3, zoom: 2 })
expect(toStoredAvatarCrop({ x: 0.5, y: 0.5, zoom: 1 })).toBeNull()
expect(toStoredAvatarCrop(null)).toBeNull()
})
})
+27
View File
@@ -43,6 +43,7 @@ import {
fetchSharePassword,
rememberSharePassword,
resolveTreeBySlug,
saveAvatarCrop,
uploadPersonAvatar,
updatePerson,
uploadAnnotationPhoto,
@@ -1617,8 +1618,33 @@ function App() {
trackAppEvent('portrait_added', { via: 'card' })
await refreshTreeAndKeepSelection(personId, { relayout: true })
setAvatarActionState({ loading: false, error: '' })
// Aperçu de la photo envoyée, pour la fenêtre de recadrage qui s'ouvre tout de suite
return { previewUrl: URL.createObjectURL(prepared) }
} catch (error) {
setAvatarActionState({ loading: false, error: getAccountErrorMessage(error) })
return null
}
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection])
// Recadrage du portrait enregistré : true si c'est fait, la fenêtre se ferme alors
const handlePersonAvatarCrop = useCallback(async (personId, crop) => {
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
if (!accessToken || !tree.treeContext.treeId) return false
setAvatarActionState({ loading: true, error: '' })
try {
await saveAvatarCrop(tree.treeContext.treeId, personId, crop, accessToken)
await refreshTreeAndKeepSelection(personId, { relayout: false, avatarCacheBust: Date.now() })
setAvatarActionState({ loading: false, error: '' })
return true
} catch (error) {
setAvatarActionState({
loading: false,
error: error?.payload?.error === 'avatar_from_immich'
? t('Les photos venues d’Immich ne se recadrent pas pour l’instant.')
: getAccountErrorMessage(error),
})
return false
}
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection])
@@ -3085,6 +3111,7 @@ function App() {
onManageMedia={media.handleOpenMediaManager}
onAvatarUpload={tree.canEditCurrentTree ? handlePersonAvatarUpload : undefined}
onAvatarDelete={tree.canEditCurrentTree ? handlePersonAvatarDelete : undefined}
onAvatarCrop={tree.canEditCurrentTree ? handlePersonAvatarCrop : undefined}
treeId={tree.treeContext.treeId || null}
immichToken={tree.treeContext.accessToken || auth.userAuth.token}
onImmichChange={tree.canEditCurrentTree ? handleImmichChange : undefined}
+13
View File
@@ -482,6 +482,19 @@ export async function uploadPersonAvatar(treeId, personId, payload, token) {
return data
}
// Cadrage du portrait ({ x, y, zoom }) ou null pour le cadrage par défaut
export async function saveAvatarCrop(treeId, personId, crop, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar/crop`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ crop }),
})
return data
}
export async function deletePersonAvatar(treeId, personId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar`, {
method: 'DELETE',
+1 -7
View File
@@ -38,7 +38,7 @@
.pc-head { --pc-name-h: 42px; --pc-name-gap: 6px; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px 16px; align-items: start; padding-right: 36px; }
.pc-photo-wrap { position: relative; display: grid; gap: 8px; justify-items: center; flex: none; }
.pc-photo {
width: 84px; height: 84px; border-radius: 24%; overflow: hidden;
position: relative; width: 84px; height: 84px; border-radius: 24%; overflow: hidden;
background: var(--pz-a100); box-shadow: 0 0 0 1px rgba(42, 38, 34, .06), var(--pz-shadow);
display: grid; place-items: center;
}
@@ -75,12 +75,6 @@
.pc-photo-badge:active:not(:disabled) { transform: scale(.94); }
.pc-photo-badge:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
.pc-photo-badge:disabled { opacity: .5; cursor: default; }
.pc-photo-btn {
border: 0; background: none; padding: 2px 4px; cursor: pointer;
font: 500 12px var(--pz-sans); color: var(--pz-a700); text-decoration: underline; text-underline-offset: 2px;
}
.pc-photo-btn--danger { color: var(--pz-danger); justify-self: center; }
.pc-photo-btn:disabled { opacity: .5; cursor: default; }
.pc-id { display: grid; gap: 6px; min-width: 0; padding-top: 4px; }
.pc-name { margin: 0; font: 400 28px/1.08 var(--pz-serif); letter-spacing: -.01em; color: var(--pz-ink); text-wrap: balance; }
+94 -15
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, forwardRef } from 'react'
import { createPortal } from 'react-dom'
import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus, Camera, Sparkles, ChevronLeft, ChevronRight } from 'lucide-react'
import { getParents, getChildren, getPersonUnions, getPersonMedias, getDemoLife, filiations, persons, unions } from '../data/mockData'
import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus, Camera, Crop, Sparkles, ChevronLeft, ChevronRight } from 'lucide-react'
import { getParents, getChildren, getPersonUnions, getPersonMedias, getDemoLife, getPersonById, filiations, persons, unions } from '../data/mockData'
import { fetchPersonLife } from '../api/treeApi.js'
import LifeTimeline from './LifeTimeline.jsx'
import MomentSheet from './MomentSheet.jsx'
@@ -10,6 +10,8 @@ import GeoMediaThumbnail from './GeoMediaThumbnail'
import ImmichFacePanel from './ImmichFacePanel.jsx'
import './PersonCard.css'
import PzBusy from './PzBusy.jsx'
import PortraitCropModal from './PortraitCropModal.jsx'
import { imagePlacement } from '../utils/portraitCrop.js'
import { t, dateLocale } from '../i18n/index.js'
import { formatPlace } from '../utils/place.js'
import { firstNameLabel, fullNameLabel, isFirstNameUnknown } from '../utils/personName.js'
@@ -48,6 +50,7 @@ const PersonCard = ({
onManageMedia,
onAvatarUpload,
onAvatarDelete,
onAvatarCrop,
treeId = null,
immichToken = null,
onImmichChange,
@@ -120,6 +123,11 @@ const PersonCard = ({
const [showSourceMenu, setShowSourceMenu] = useState(false)
const [sourceMenuPos, setSourceMenuPos] = useState({ top: 0, left: 0 })
const sourceBtnRef = useRef(null)
// Menu du badge appareil photo (position, ou null fermé) et fenêtre de recadrage ({ src, mode, ownsUrl }, ou null)
const [photoMenuPos, setPhotoMenuPos] = useState(null)
const [cropState, setCropState] = useState(null)
// Taille naturelle de la photo affichée dans la fiche, pour placer le portrait recadré
const [photoSize, setPhotoSize] = useState(null)
// Inline relation search state
const [activeRelationSearch, setActiveRelationSearch] = useState(null) // 'parent' | 'spouse' | 'child' | null
@@ -144,6 +152,9 @@ const PersonCard = ({
setShowSourceMenu(false)
setMomentSheet(null)
setLifeNotice('')
setPhotoMenuPos(null)
setCropState(null)
setPhotoSize(null)
setOpenedFields([])
setFocusField(null)
}, [person.id])
@@ -177,6 +188,39 @@ const PersonCard = ({
return () => window.removeEventListener('keydown', onKeyDown)
}, [showSourceMenu])
// Escape ferme le menu du badge
useEffect(() => {
if (!photoMenuPos) return undefined
const onKeyDown = (event) => { if (event.key === 'Escape') setPhotoMenuPos(null) }
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [photoMenuPos])
const openPhotoMenu = () => {
if (avatarActionLoading) return
const rect = sourceBtnRef.current?.getBoundingClientRect()
if (rect) {
setPhotoMenuPos({
top: Math.round(rect.bottom) + 6,
left: Math.round(Math.min(rect.left, window.innerWidth - 232)),
})
}
}
// Fermer la fenêtre de recadrage ; l'aperçu envoyé est libéré
const closeCrop = () => {
if (cropState?.ownsUrl) URL.revokeObjectURL(cropState.src)
setCropState(null)
}
// Placement du portrait dans la fiche (carrée), comme le canevas
const photoPlacement = photoSize ? imagePlacement(photoSize.w, photoSize.h, 1, person.avatarCrop) : null
const photoStyle = photoPlacement
? { position: 'absolute', maxWidth: 'none', left: `${photoPlacement.left}%`, top: `${photoPlacement.top}%`, width: `${photoPlacement.width}%`, height: `${photoPlacement.height}%` }
: undefined
// Même forme de cadre que dans l'arbre (renderers.js)
const treeFrameType = getPersonById(person.id)?.frameType || person.frameType
const immichUsable = isAdmin && Boolean(immichConfigured || person.avatarFromImmich)
const openAvatarPicker = () => {
if (avatarActionLoading) return
@@ -301,7 +345,9 @@ const PersonCard = ({
const file = event.target.files?.[0]
event.target.value = ''
if (!file || !onAvatarUpload) return
await onAvatarUpload(person.id, file)
const result = await onAvatarUpload(person.id, file)
// Juste après l'envoi, la fenêtre de recadrage s'ouvre (« Garder tel quel » laisse la photo entière)
if (result?.previewUrl) setCropState({ src: result.previewUrl, mode: 'new', ownsUrl: true })
}
// Relation actions
@@ -588,7 +634,7 @@ const PersonCard = ({
) : (
<div className="pc-photo">
{person.photo
? <img src={person.photo} alt={fullNameLabel(person)} />
? <img src={person.photo} alt={fullNameLabel(person)} onLoad={(event) => setPhotoSize({ w: event.currentTarget.naturalWidth, h: event.currentTarget.naturalHeight })} style={photoStyle} />
: <span className="pc-photo-initials">{initials}</span>}
</div>
)}
@@ -597,7 +643,7 @@ const PersonCard = ({
ref={sourceBtnRef}
type="button"
className="pc-photo-badge"
onClick={openAvatarPicker}
onClick={openPhotoMenu}
disabled={avatarActionLoading}
aria-label={t('Changer la photo')}
title={t('Changer la photo')}
@@ -607,16 +653,6 @@ const PersonCard = ({
<Camera size={14} strokeWidth={2} aria-hidden="true" />
</button>
)}
{editMode && onAvatarUpload && person.photo && (
<button
type="button"
className="pc-photo-btn pc-photo-btn--danger"
onClick={() => onAvatarDelete?.(person.id, person.avatarFromImmich)}
disabled={avatarActionLoading}
>
{t('Retirer')}
</button>
)}
</div>
<div className="pc-id">
@@ -1009,6 +1045,49 @@ const PersonCard = ({
)
)}
{/* ── Menu du badge : changer, recadrer, retirer ── */}
{photoMenuPos && (
createPortal(
<>
<div className="pc-source-backdrop" onClick={() => setPhotoMenuPos(null)} />
<div className="pc-source-menu pz-menu" role="menu" aria-label={t('Options du portrait')} style={{ top: `${photoMenuPos.top}px`, left: `${photoMenuPos.left}px` }}>
<button type="button" role="menuitem" className="pz-menu-item" onClick={() => { setPhotoMenuPos(null); openAvatarPicker() }}>
<ImagePlus size={16} aria-hidden="true" /> {t('Changer la photo')}
</button>
{person.photo && !person.avatarFromImmich && onAvatarCrop && (
<button type="button" role="menuitem" className="pz-menu-item" onClick={() => { setPhotoMenuPos(null); setCropState({ src: person.photo, mode: 'recrop', ownsUrl: false }) }}>
<Crop size={16} aria-hidden="true" /> {t('Recadrer')}
</button>
)}
{person.photo && (
<button type="button" role="menuitem" className="pz-menu-item pz-menu-item--danger" onClick={() => { setPhotoMenuPos(null); onAvatarDelete?.(person.id, person.avatarFromImmich) }} disabled={avatarActionLoading}>
<Trash2 size={16} aria-hidden="true" /> {t('Retirer')}
</button>
)}
</div>
</>,
document.body,
)
)}
{/* ── Recadrage du portrait (fenêtre pz) ── */}
{cropState && onAvatarCrop && (
<PortraitCropModal
src={cropState.src}
frameType={treeFrameType}
name={fullNameLabel(person)}
mode={cropState.mode}
initialCrop={cropState.mode === 'new' ? null : person.avatarCrop}
saving={avatarActionLoading}
error={avatarActionError}
onSave={async (crop) => {
const saved = await onAvatarCrop(person.id, crop)
if (saved) closeCrop()
}}
onClose={closeCrop}
/>
)}
{/* ── Association d'un visage Immich (modale) ── */}
{showImmichPanel && treeId && (
<ImmichFacePanel
@@ -0,0 +1,57 @@
/* ============================================================
Recadrage du portrait (fenêtre pz)
Le masque garde la forme du cadre de la personne dans l'arbre. La photo glisse dessous.
Les variables --pz-* viennent de src/styles/pz.css.
============================================================ */
.pcrop-modal { width: min(100%, 440px); }
.pcrop-body { display: grid; gap: 14px; min-width: 0; }
.pcrop-stage {
position: relative; width: min(100%, 300px); aspect-ratio: 1 / 1; margin: 0 auto;
overflow: hidden; border-radius: 18px; background: var(--pz-line-soft, #efe9e1);
touch-action: none; cursor: grab; user-select: none; -webkit-user-select: none;
outline: none;
}
.pcrop-stage.is-dragging { cursor: grabbing; }
.pcrop-stage:focus-visible { box-shadow: 0 0 0 2px var(--pz-a500); }
/* La photo, placée en pourcentages du conteneur par portraitCrop.js */
.pcrop-img {
position: absolute; max-width: none; pointer-events: none;
transition: left 220ms var(--pz-ease), top 220ms var(--pz-ease), width 220ms var(--pz-ease), height 220ms var(--pz-ease);
}
.pcrop-stage.is-dragging .pcrop-img { transition: none; }
/* Masque : l'ombre étendue assombrit tout ce qui est hors du cadre */
.pcrop-window {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); pointer-events: none;
box-shadow: 0 0 0 999px rgba(42, 38, 34, .55), inset 0 0 0 1px rgba(255, 255, 255, .75);
}
.pcrop-window--round { border-radius: 50%; }
.pcrop-window--square { border-radius: 4px; }
.pcrop-window--rect { border-radius: 6px; }
/* Polaroid : une bordure blanche autour de la zone photo */
.pcrop-window--polaroid { border-radius: 2px; box-shadow: 0 0 0 7px #fff, 0 0 0 999px rgba(42, 38, 34, .55); }
.pcrop-loading { position: absolute; inset: 0; display: grid; place-items: center; color: var(--pz-muted); }
.pcrop-hint { margin: 0; text-align: center; color: var(--pz-muted); }
.pcrop-zoom {
display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 14px;
min-height: 40px; max-width: 300px; width: 100%; margin: 0 auto;
}
.pcrop-zoom input[type="range"] { width: 100%; min-height: 40px; accent-color: var(--pz-a500); cursor: pointer; }
.pcrop-zoom input[type="range"]:disabled { opacity: .5; cursor: default; }
.pcrop-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
@media (hover: hover) and (pointer: fine) {
.pcrop-modal .pz-btn--ghost:hover:not(:disabled) { background: var(--pz-line-soft); }
}
@media (prefers-reduced-motion: reduce) {
.pcrop-img { transition: none; }
}
@@ -0,0 +1,252 @@
import { useEffect, useRef, useState } from 'react'
import { Crosshair, X } from 'lucide-react'
import { createPortal } from 'react-dom'
import { t } from '../i18n/index.js'
import PzBusy from './PzBusy.jsx'
import {
DEFAULT_CROP,
clampCrop,
cropForSave,
cropViewport,
frameAspect,
imagePlacement,
normalizeCrop,
resolveFrameKind,
} from '../utils/portraitCrop.js'
import './PortraitCropModal.css'
// Taille du masque par rapport à la zone de recadrage (carrée)
const WINDOW_SIZE = 0.8
// Déplacement clavier : part de la zone visible (4 %, 16 % avec Maj)
const KEY_STEP = 0.04
const KEY_STEP_LARGE = 0.16
const KEY_ZOOM_STEP = 0.2
// 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).
const PortraitCropModal = ({ src, frameType, name, mode = 'recrop', initialCrop = null, saving = false, error = '', onSave, onClose }) => {
const stageRef = useRef(null)
const dragRef = useRef(null)
const pointersRef = useRef(new Map())
const pinchRef = useRef(null)
const [size, setSize] = useState(null)
const [loadFailed, setLoadFailed] = useState(false)
const [crop, setCrop] = useState(() => normalizeCrop(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 winW = aspect >= 1 ? WINDOW_SIZE : WINDOW_SIZE * aspect
const winH = aspect >= 1 ? WINDOW_SIZE / aspect : WINDOW_SIZE
// Toute valeur passe par clampCrop : l'affichage et l'enregistrement restent identiques
const apply = (next) => {
if (!size) return
setCrop(clampCrop(size.w, size.h, aspect, next))
}
const current = size ? clampCrop(size.w, size.h, aspect, crop) : crop
const placement = size ? imagePlacement(size.w, size.h, aspect, current, winW, winH) : null
// Molette : zoom autour du centre. Listener natif, pour pouvoir annuler le défilement de la page.
useEffect(() => {
const stage = stageRef.current
if (!stage) return undefined
const onWheel = (event) => {
event.preventDefault()
setCrop((prev) => {
const c = size ? clampCrop(size.w, size.h, aspect, prev) : normalizeCrop(prev)
const zoom = Math.min(4, Math.max(1, c.zoom * Math.exp(-event.deltaY * 0.002)))
return size ? clampCrop(size.w, size.h, aspect, { ...c, zoom }) : { ...c, zoom }
})
}
stage.addEventListener('wheel', onWheel, { passive: false })
return () => stage.removeEventListener('wheel', onWheel)
}, [size, aspect])
// Escape ferme la fenêtre, sauf pendant l'enregistrement
useEffect(() => {
const onKeyDown = (event) => {
if (event.key === 'Escape' && !saving) onClose?.()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [saving, onClose])
const startDrag = (pointer) => {
dragRef.current = { x: pointer.x, y: pointer.y, crop: current }
setDragging(true)
}
const onPointerDown = (event) => {
if (!size || saving) return
event.currentTarget.setPointerCapture?.(event.pointerId)
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
if (pointersRef.current.size === 1) {
startDrag({ x: event.clientX, y: event.clientY })
} else if (pointersRef.current.size === 2) {
// Deux doigts : pincement, le glissement s'arrête
const [a, b] = [...pointersRef.current.values()]
pinchRef.current = { distance: Math.hypot(a.x - b.x, a.y - b.y), zoom: current.zoom }
dragRef.current = null
}
}
const onPointerMove = (event) => {
if (!pointersRef.current.has(event.pointerId) || !size) return
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
if (pointersRef.current.size === 2 && pinchRef.current) {
const [a, b] = [...pointersRef.current.values()]
const distance = Math.hypot(a.x - b.x, a.y - b.y)
const zoom = pinchRef.current.zoom * (distance / (pinchRef.current.distance || distance))
apply({ ...current, zoom: Math.min(4, Math.max(1, zoom)) })
return
}
const drag = dragRef.current
const rect = stageRef.current?.getBoundingClientRect()
if (!drag || !rect || !rect.width) return
// Le glissement déplace la photo : le centre visible va dans le sens inverse
const viewport = cropViewport(size.w, size.h, aspect, drag.crop)
const dxStage = (event.clientX - drag.x) / rect.width
const dyStage = (event.clientY - drag.y) / rect.height
const cx = drag.crop.x * size.w - (dxStage * viewport.sw) / winW
const cy = drag.crop.y * size.h - (dyStage * viewport.sh) / winH
apply({ x: cx / size.w, y: cy / size.h, zoom: drag.crop.zoom })
}
const onPointerUp = (event) => {
pointersRef.current.delete(event.pointerId)
pinchRef.current = null
if (pointersRef.current.size === 1) {
const [remaining] = pointersRef.current.values()
startDrag(remaining)
} else {
dragRef.current = null
setDragging(false)
}
}
const onKeyDown = (event) => {
if (!size) return
const viewport = cropViewport(size.w, size.h, aspect, current)
const step = event.shiftKey ? KEY_STEP_LARGE : KEY_STEP
const moves = {
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1],
}
if (moves[event.key]) {
event.preventDefault()
const [dx, dy] = moves[event.key]
apply({
x: (current.x * size.w + dx * step * viewport.sw) / size.w,
y: (current.y * size.h + dy * step * viewport.sh) / size.h,
zoom: current.zoom,
})
} else if (event.key === '+' || event.key === '=') {
event.preventDefault()
apply({ ...current, zoom: current.zoom + KEY_ZOOM_STEP })
} else if (event.key === '-' || event.key === '_') {
event.preventDefault()
apply({ ...current, zoom: current.zoom - KEY_ZOOM_STEP })
}
}
const recenter = () => apply(DEFAULT_CROP)
const setZoom = (zoom) => apply({ ...current, zoom: Number(zoom) })
const save = () => {
if (!size || saving) return
onSave?.(cropForSave(size.w, size.h, aspect, current))
}
const isNew = mode === 'new'
return createPortal(
<div className="pz-overlay pcrop-overlay" onClick={(event) => { if (event.target === event.currentTarget && !saving) onClose?.() }}>
<div className="pz-modal pcrop-modal" role="dialog" aria-modal="true" aria-labelledby="pcropTitle">
<div className="pz-modal-head">
<h1 id="pcropTitle" className="pz-title">{t('Recadrer la photo')}</h1>
{name && <p className="pz-sub">{name}</p>}
</div>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pz-modal-close" onClick={onClose} disabled={saving} aria-label={t('Fermer')}>
<X size={18} aria-hidden="true" />
</button>
<div className="pcrop-body">
<div
ref={stageRef}
className={`pcrop-stage ${dragging ? 'is-dragging' : ''}`}
tabIndex={0}
role="group"
aria-label={t('Zone de recadrage')}
aria-describedby="pcropHint"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
>
{src && (
<img
className="pcrop-img"
src={src}
alt=""
draggable={false}
onLoad={(event) => setSize({ w: event.currentTarget.naturalWidth, h: event.currentTarget.naturalHeight })}
onError={() => setLoadFailed(true)}
style={placement ? { left: `${placement.left}%`, top: `${placement.top}%`, width: `${placement.width}%`, height: `${placement.height}%` } : { visibility: 'hidden' }}
/>
)}
<div
className={`pcrop-window pcrop-window--${frameKind}`}
style={{ width: `${winW * 100}%`, height: `${winH * 100}%` }}
aria-hidden="true"
/>
{!size && !loadFailed && <div className="pcrop-loading"><PzBusy busy busyLabel={t('Chargement de la photo')} /></div>}
</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>
<label className="pcrop-zoom">
<span className="pz-small">{t('Zoom')}</span>
<input
type="range"
min="1"
max="4"
step="0.01"
value={current.zoom}
onChange={(event) => setZoom(event.target.value)}
disabled={!size || saving}
/>
</label>
{loadFailed && <p className="pz-error">{t('Impossible d’afficher cette photo.')}</p>}
{error && <p className="pz-error">{error}</p>}
</div>
<div className="pz-modal-actions pz-modal-actions--split">
<button type="button" className="pz-btn pz-btn--ghost" onClick={recenter} disabled={!size || saving}>
<Crosshair size={16} aria-hidden="true" />
{t('Recentrer')}
</button>
<div className="pcrop-actions">
<button type="button" className="pz-btn pz-btn--ghost" onClick={onClose} disabled={saving}>
{isNew ? t('Garder tel quel') : t('Annuler')}
</button>
<button type="button" className="pz-btn pz-btn--primary" onClick={save} disabled={!size || saving || loadFailed}>
<PzBusy busy={saving} busyLabel={t('Enregistrement en cours')}>{t('Enregistrer')}</PzBusy>
</button>
</div>
</div>
</div>
</div>,
document.body,
)
}
export default PortraitCropModal
+5 -18
View File
@@ -1,5 +1,6 @@
import { t } from '../../i18n/index.js'
import { getPersonById, getPersonMedias } from '../../data/mockData'
import { cropViewport } 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'
@@ -992,7 +993,7 @@ function drawFrame(ctx, frameType, person, img, hint = 0) {
// ============================================================
function drawPersonImage(ctx, img, person, x, y, w, h, hint = 0) {
if (img) {
drawImageCover(ctx, img, x, y, w, h)
drawImageCover(ctx, img, x, y, w, h, person.avatarCrop)
if (!person.isAlive) {
ctx.fillStyle = 'rgba(235, 230, 223, 0.25)'
ctx.fillRect(x, y, w, h)
@@ -1024,7 +1025,8 @@ function drawPersonImage(ctx, img, person, x, y, w, h, hint = 0) {
}
}
function drawImageCover(ctx, img, x, y, w, h) {
// Recadre la photo dans la zone (cover par défaut, cadrage du portrait sinon)
function drawImageCover(ctx, img, x, y, w, h, crop = null) {
const sourceW = img.naturalWidth || img.width
const sourceH = img.naturalHeight || img.height
@@ -1032,22 +1034,7 @@ function drawImageCover(ctx, img, x, y, w, h) {
return
}
const sourceRatio = sourceW / sourceH
const targetRatio = w / h
let sx = 0
let sy = 0
let sw = sourceW
let sh = sourceH
if (sourceRatio > targetRatio) {
sw = sourceH * targetRatio
sx = (sourceW - sw) / 2
} else if (sourceRatio < targetRatio) {
sh = sourceW / targetRatio
sy = (sourceH - sh) / 2
}
const { sx, sy, sw, sh } = cropViewport(sourceW, sourceH, w / h, crop)
ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h)
}
+1
View File
@@ -122,6 +122,7 @@ function mapRemotePersonToLocal(person, current = null) {
sex: person?.sex ?? current?.sex ?? null,
photo: current?.photo || null,
avatarFromImmich: person?.avatarFromImmich ?? false,
avatarCrop: person ? (person.avatarCrop ?? null) : (current?.avatarCrop ?? null),
}
}
+12
View File
@@ -415,4 +415,16 @@ export default {
'Prénom inconnu': 'Unknown first name',
'Je ne connais pas son prénom': "I don't know their first name",
'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',
'Recadrer': 'Crop',
'Options du portrait': 'Portrait options',
'Zone de recadrage': 'Crop area',
'Glissez la photo, ou utilisez les flèches. Molette, pincement ou curseur pour zoomer.': 'Drag the photo, or use the arrow keys. Use the wheel, a pinch or the slider to zoom.',
'Zoom': 'Zoom',
'Recentrer': 'Re-center',
'Garder tel quel': 'Keep as is',
'Chargement de la photo': 'Loading photo',
'Impossible d’afficher cette photo.': 'Unable to display this photo.',
'Les photos venues d’Immich ne se recadrent pas pour l’instant.': 'Photos from Immich cannot be cropped for now.',
}
+1
View File
@@ -59,6 +59,7 @@ export function adaptGraphForGalaxy(apiGraph, _treeId) {
deathDate: p.deathDate,
photo: p.avatarUrlPath ? `/api${p.avatarUrlPath}` : null,
avatarFromImmich: Boolean(p.avatarFromImmich),
avatarCrop: p.avatarCrop ?? null,
frameType: 'round', // Default
}))
+87
View File
@@ -0,0 +1,87 @@
// Cadrage d'un portrait. Un cadrage = { x, y, zoom } : centre en proportion de l'image (0 à 1) et zoom
// (1 = l'image entière recouvre le cadre, 4 = gros plan). Null = cadrage par défaut (centré, zoom 1).
// Ce module ne fait que des calculs : l'éditeur, la fiche et le canevas s'y réfèrent tous.
export const CROP_MAX_ZOOM = 4
export const DEFAULT_CROP = { x: 0.5, y: 0.5, zoom: 1 }
const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
// 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) {
if (imageRatio >= 1.28) return 'rect'
if (imageRatio <= 0.78) return 'polaroid'
return frameType || 'round'
}
// Rapport largeur / hauteur de la zone visible, comme drawFrame le dessine.
export function frameAspect(frameKind, imageRatio) {
if (frameKind === 'rect') {
// Paysage : 80 x 40 ; portrait : 60 x 68 (renderers.js, drawFrame)
return imageRatio > 1.05 ? 2 : 60 / 68
}
// round, polaroid et square : la zone photo est carrée
return 1
}
// Cadrage lu depuis l'API ou le canevas : valeurs bornées, défaut si absent ou invalide.
export function normalizeCrop(crop) {
if (!crop || typeof crop !== 'object') return DEFAULT_CROP
const x = Number(crop.x)
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) }
}
// Zone de l'image visible dans le cadre, en pixels de l'image. Le centre est borné pour qu'il n'y ait jamais
// de vide dans le cadre : la zone reste entièrement sur la photo.
export function cropViewport(imageW, imageH, aspect, crop) {
const c = normalizeCrop(crop)
const imageRatio = imageW / imageH
// Zone de base : le plus grand rectangle de rapport `aspect` tenu dans l'image (cover)
let baseW = imageW
let baseH = imageH
if (imageRatio > aspect) baseW = imageH * aspect
else baseH = imageW / aspect
const w = baseW / c.zoom
const h = baseH / c.zoom
const cx = clamp(c.x * imageW, w / 2, imageW - w / 2)
const cy = clamp(c.y * imageH, h / 2, imageH - h / 2)
return { sx: cx - w / 2, sy: cy - h / 2, sw: w, sh: h }
}
// Cadrage borné et ramené aux proportions : ce qu'on enregistre et ce qu'on affiche sont identiques.
export function clampCrop(imageW, imageH, aspect, crop) {
const c = normalizeCrop(crop)
const v = cropViewport(imageW, imageH, aspect, c)
return {
x: (v.sx + v.sw / 2) / imageW,
y: (v.sy + v.sh / 2) / imageH,
zoom: c.zoom,
}
}
// Place l'image dans un conteneur carré, en pourcentages. `windowW` et `windowH` sont la taille de la zone
// visible en fraction du conteneur (centrée). La fiche, carrée, prend 1 et 1.
export function imagePlacement(imageW, imageH, aspect, crop, windowW = 1, windowH = 1) {
const v = cropViewport(imageW, imageH, aspect, crop)
const kx = windowW / v.sw
const ky = windowH / v.sh
return {
left: ((1 - windowW) / 2 - v.sx * kx) * 100,
top: ((1 - windowH) / 2 - v.sy * ky) * 100,
width: imageW * kx * 100,
height: imageH * ky * 100,
}
}
// Ce qu'il faut à l'enregistrement : null pour le cadrage par défaut, sinon le cadrage borné.
export function cropForSave(imageW, imageH, aspect, crop) {
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 (normalized.x === 0.5 && normalized.y === 0.5 && normalized.zoom === 1) return null
return normalized
}
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_CROP,
clampCrop,
cropForSave,
cropViewport,
frameAspect,
imagePlacement,
normalizeCrop,
resolveFrameKind,
} from './portraitCrop.js'
// Le cadrage visible ne sort jamais de la photo : aucun vide dans le cadre
function insideImage(v, w, h) {
return v.sx >= -1e-9 && v.sy >= -1e-9 && v.sx + v.sw <= w + 1e-9 && v.sy + v.sh <= h + 1e-9
}
describe('portraitCrop', () => {
it('le cadrage par défaut reprend le recouvrement centré (cover)', () => {
// Photo 800x400 dans un cadre carré : on garde le centre, 400x400
expect(cropViewport(800, 400, 1, null)).toEqual({ sx: 200, sy: 0, sw: 400, sh: 400 })
// Photo 400x800 dans un cadre carré : on garde le centre, 400x400 aussi
expect(cropViewport(400, 800, 1, DEFAULT_CROP)).toEqual({ sx: 0, sy: 200, sw: 400, sh: 400 })
})
it('le zoom réduit la zone visible autour du centre', () => {
const v = cropViewport(800, 800, 1, { x: 0.5, y: 0.5, zoom: 2 })
expect(v.sw).toBeCloseTo(400)
expect(v.sh).toBeCloseTo(400)
expect(v.sx).toBeCloseTo(200)
})
it('borne le centre pour ne jamais laisser de vide, même avec des valeurs extrêmes', () => {
const corners = [
{ x: 0, y: 0, zoom: 4 },
{ x: 1, y: 1, zoom: 4 },
{ x: 0, y: 1, zoom: 3 },
{ x: 1, y: 0, zoom: 1.5 },
]
for (const crop of corners) {
for (const [w, h, aspect] of [[1200, 800, 1], [800, 1200, 1], [1200, 800, 2], [800, 1200, 60 / 68]]) {
const v = cropViewport(w, h, aspect, crop)
expect(insideImage(v, w, h)).toBe(true)
expect(v.sw / v.sh).toBeCloseTo(aspect, 6)
}
}
})
it('clampCrop ramène un centre hors photo sur le bord, sans toucher au zoom', () => {
const c = clampCrop(800, 400, 1, { x: 0.99, y: 0.01, zoom: 2 })
expect(c.zoom).toBe(2)
// zone de 200 px de large : le centre ne peut pas dépasser 700 px ni descendre sous 100 px
expect(c.x * 800).toBeCloseTo(700)
expect(c.y * 400).toBeCloseTo(100)
})
it('normalizeCrop borne le zoom entre 1 et 4 et ignore les valeurs absentes', () => {
expect(normalizeCrop({ x: 0.2, y: 0.3, zoom: 9 })).toEqual({ x: 0.2, y: 0.3, zoom: 4 })
expect(normalizeCrop({ x: 0.2, y: 0.3, zoom: 0.2 })).toEqual({ x: 0.2, y: 0.3, zoom: 1 })
expect(normalizeCrop({ x: 'n', y: 0.3, zoom: 1 })).toEqual(DEFAULT_CROP)
expect(normalizeCrop(undefined)).toEqual(DEFAULT_CROP)
})
it('cropForSave enregistre null pour le cadrage par défaut et arrondit sinon', () => {
expect(cropForSave(800, 800, 1, { x: 0.5, y: 0.5, zoom: 1 })).toBeNull()
// Centre 0,456789 à zoom 2 : rien n'est borné, seul l'arrondi à 4 décimales compte
expect(cropForSave(800, 800, 1, { x: 0.456789, y: 0.5, zoom: 2 })).toEqual({ x: 0.4568, y: 0.5, zoom: 2 })
})
it('imagePlacement couvre toujours la fenêtre visible (pas de vide en pourcentages)', () => {
const p = imagePlacement(800, 400, 1, { x: 0, y: 0, zoom: 1 }, 0.8, 0.8)
// La photo commence au bord gauche de la fenêtre et couvre au moins sa hauteur
expect(p.left).toBeLessThanOrEqual(10 + 1e-9)
expect(p.top).toBeCloseTo(10)
expect(p.height).toBeGreaterThanOrEqual(80 - 1e-9)
expect(p.width).toBeGreaterThanOrEqual(80 - 1e-9)
})
it('la forme du cadre suit le canevas (renderers.js)', () => {
expect(resolveFrameKind('round', 1)).toBe('round')
expect(resolveFrameKind('round', 2)).toBe('rect')
expect(resolveFrameKind('rect', 0.5)).toBe('polaroid')
expect(frameAspect('round', 1)).toBe(1)
expect(frameAspect('polaroid', 1)).toBe(1)
expect(frameAspect('rect', 1.5)).toBe(2)
expect(frameAspect('rect', 1)).toBeCloseTo(60 / 68)
})
})