diff --git a/frontend/src/App.css b/frontend/src/App.css
index 5ce57fb..798f612 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1,6 +1,8 @@
.app {
width: 100vw;
height: 100vh;
+ /* La hauteur visible réelle (barres de Safari comprises) là où le navigateur la connaît */
+ height: 100dvh;
position: relative;
overflow: hidden;
background: #FBFAF7;
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 869221b..d0524cc 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -3259,6 +3259,7 @@ function App() {
onMoveSibling={(delta) => galaxyZoomApiRef.current?.moveSibling?.(tree.selectedPerson.id, delta)}
onUploadMomentFile={tree.canEditCurrentTree || tree.canSubmitContribution ? media.uploadMomentFile : undefined}
onMomentSaved={media.refreshMomentMedia}
+ onQuickEdit={tree.canEditCurrentTree && userRole.isAdmin && !isDemoMode ? handleEditToggle : undefined}
/>
)}
{/* Crayon de modification masqué en démo : rien n'y est enregistré */}
diff --git a/frontend/src/components/Galaxy.css b/frontend/src/components/Galaxy.css
index e08c47c..ba1f5f9 100644
--- a/frontend/src/components/Galaxy.css
+++ b/frontend/src/components/Galaxy.css
@@ -1,4 +1,8 @@
.galaxy-canvas {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
cursor: default;
display: block;
touch-action: none;
diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx
index c85398b..3352f90 100644
--- a/frontend/src/components/Galaxy.jsx
+++ b/frontend/src/components/Galaxy.jsx
@@ -447,24 +447,31 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
}
}, [isZooming, targetTransformRef, zoomTimeoutRef])
- // Resize canvas to device pixel ratio
+ // Resize canvas to device pixel ratio. Le CSS donne au canvas toute la page (.galaxy-canvas) ; on lit sa taille
+ // réelle. Avec window.innerHeight, Safari sur iPad gardait une hauteur prise clavier ou barre ouverte, sans
+ // événement resize ensuite : l'arbre s'arrêtait net au-dessus du bas de l'écran.
useEffect(() => {
+ const canvas = canvasRef.current
+ if (!canvas) return undefined
const resize = () => {
- const canvas = canvasRef.current
- if (!canvas) return
- const cssW = window.innerWidth
- const cssH = window.innerHeight
+ const cssW = canvas.clientWidth || window.innerWidth
+ const cssH = canvas.clientHeight || window.innerHeight
const dpr = Math.min(window.devicePixelRatio || 1, 2)
+ const w = Math.floor(cssW * dpr), h = Math.floor(cssH * dpr)
+ if (canvas.width === w && canvas.height === h && dprRef.current === dpr) return
dprRef.current = dpr
- canvas.width = Math.floor(cssW * dpr)
- canvas.height = Math.floor(cssH * dpr)
- canvas.style.width = `${cssW}px`
- canvas.style.height = `${cssH}px`
+ canvas.width = w
+ canvas.height = h
needsRedrawRef.current = true
}
resize()
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize)
+ observer?.observe(canvas)
window.addEventListener('resize', resize)
- return () => window.removeEventListener('resize', resize)
+ return () => {
+ observer?.disconnect()
+ window.removeEventListener('resize', resize)
+ }
}, [])
// Layout
diff --git a/frontend/src/components/PersonCard.css b/frontend/src/components/PersonCard.css
index c0ea473..20e6a81 100644
--- a/frontend/src/components/PersonCard.css
+++ b/frontend/src/components/PersonCard.css
@@ -296,3 +296,11 @@
/* Titres de section de l'onglet Infos et Liens : casse normale, Newsreader 15 px, comme la maquette (.sec) */
.pc-infos .pz-eyebrow { text-transform: none; letter-spacing: 0; font: 400 15px/1.3 var(--pz-serif); color: var(--pz-ink); }
+
+/* « Modifier » de l'administrateur : une pastille de la ligne des repères, qui fait passer la fiche en modification */
+.pc-quick-edit {
+ display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 0 12px; border: 0; border-radius: 999px; cursor: pointer;
+ background: var(--pz-surface); color: var(--pz-ink); box-shadow: inset 0 0 0 1px var(--pz-line); font: 500 12.5px var(--pz-sans);
+}
+.pc-quick-edit:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
+@media (hover: hover) and (pointer: fine) { .pc-quick-edit:hover { background: var(--pz-line-soft); } }
diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx
index 210b863..3391a98 100644
--- a/frontend/src/components/PersonCard.jsx
+++ b/frontend/src/components/PersonCard.jsx
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, forwardRef } from 'react'
import { createPortal } from 'react-dom'
-import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus, Camera, Crop, Sparkles, ChevronLeft, ChevronRight } from 'lucide-react'
+import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus, Camera, Crop, Sparkles, ChevronLeft, ChevronRight, Pencil } from 'lucide-react'
import { getParents, getChildren, getPersonUnions, getPersonMedias, getPersonTaggedMedias, getDemoLife, getPersonById, filiations, persons, unions } from '../data/mockData'
import { fetchPersonLife } from '../api/treeApi.js'
import LifeTimeline from './LifeTimeline.jsx'
@@ -74,6 +74,8 @@ const PersonCard = ({
onUploadMomentFile,
onMomentSaved,
saveOnLeaveRef = null,
+ // Administrateur, hors démo : la fiche passe en modification d'un clic, sans chercher le crayon de l'arbre
+ onQuickEdit,
}) => {
// Souvenirs d'abord quand il y en a, ou quand on peut en ajouter : c'est là qu'est le bouton
const [tab, setTab] = useState(() => (getPersonMedias(person.id).length > 0 || getPersonTaggedMedias(person.id).length > 0 || canManageMedia ? 'medias' : 'infos'))
@@ -754,6 +756,12 @@ const PersonCard = ({
)}
{!editMode && person.toVerify && {t('À vérifier')}}
{!editMode && person.profession && {person.profession}}
+ {!editMode && onQuickEdit && (
+
+ )}
{editMode && ((unknownFirstName || !String(getEditValue('firstName', person.firstName) ?? '').trim())) && (
diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js
index 6097104..e9488b5 100644
--- a/frontend/src/components/galaxy/renderers.js
+++ b/frontend/src/components/galaxy/renderers.js
@@ -602,9 +602,7 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
ctx.shadowOffsetY = shadowY
}
- drawFrame(ctx, frameType, person, img)
- // « À vérifier » : petit rond terracotta avec un « ? » en haut à droite du cadre, absent au dézoom
- if (person.toVerify && withDetail) drawToVerifyMark(ctx)
+ drawPortrait(ctx, frameType, person, img, person.toVerify && withDetail)
ctx.restore()
// --- Labels (estompés puis absents en dézoom) ---
@@ -931,6 +929,16 @@ function resolveFrameType(person, img) {
return resolveFrameKind(person.frameType || 'round', ratio, cropShape(person.avatarCrop))
}
+// Cadre et portrait, puis la marque « À vérifier » (petit rond terracotta avec un « ? » en haut à droite du cadre,
+// absente au dézoom). Le cadre découpe le canvas à sa forme : save/restore autour, sinon la marque est rognée
+// (sur un cadre rond, elle tombe presque entière hors du cercle)
+export function drawPortrait(ctx, frameType, person, img, withMark) {
+ ctx.save()
+ drawFrame(ctx, frameType, person, img)
+ ctx.restore()
+ if (withMark) drawToVerifyMark(ctx)
+}
+
function drawToVerifyMark(ctx) {
const r = 9
const x = PERSON_R * 0.74, y = -PERSON_R * 0.74
diff --git a/frontend/src/components/galaxy/renderers.test.js b/frontend/src/components/galaxy/renderers.test.js
index a600ef0..91da576 100644
--- a/frontend/src/components/galaxy/renderers.test.js
+++ b/frontend/src/components/galaxy/renderers.test.js
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { traceSibship } from './renderers'
+import { drawPortrait, traceSibship } from './renderers'
// Contexte canvas minimal qui enregistre les appels de tracé
const recorder = () => {
@@ -49,3 +49,25 @@ describe('traceSibship (accolade franche)', () => {
expect(curve.slice(5)).toEqual([515, 480])
})
})
+
+describe('drawPortrait, marque « À vérifier »', () => {
+ const person = { firstName: 'Marie', lastName: 'Martin', isAlive: true }
+ for (const frame of ['round', 'square', 'polaroid', 'rect']) {
+ it(`cadre ${frame} : la marque est dessinée hors de la découpe du cadre`, () => {
+ const { ctx, calls } = recorder()
+ drawPortrait(ctx, frame, person, null, true)
+ const clip = calls.findIndex(c => c[0] === 'clip')
+ const restore = calls.findIndex((c, i) => i > clip && c[0] === 'restore')
+ const markText = calls.findIndex(c => c[0] === 'fillText' && c[1] === '?')
+ expect(clip).toBeGreaterThan(-1)
+ expect(restore).toBeGreaterThan(clip)
+ expect(markText).toBeGreaterThan(restore)
+ })
+ }
+
+ it('sans « À vérifier », pas de marque', () => {
+ const { ctx, calls } = recorder()
+ drawPortrait(ctx, 'round', person, null, false)
+ expect(calls.some(c => c[0] === 'fillText' && c[1] === '?')).toBe(false)
+ })
+})
diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js
index 03072e5..09a970f 100644
--- a/frontend/src/i18n/en/fiche.js
+++ b/frontend/src/i18n/en/fiche.js
@@ -1,6 +1,7 @@
// Traductions anglaises, clé = texte français exact passé à t().
export default {
// Fiche d'une personne (PersonCard)
+ 'Modifier': 'Edit',
'Photo': 'Photo',
'Vidéo': 'Video',
'Audio': 'Audio',