Merge pull request #147 from IliesAllali/fix/fiche-modifier-ipad

Fiche : « Modifier » d'un clic, « ? » entier sur les cadres ronds, arbre jusqu'en bas sur iPad
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-25 18:19:46 +01:00
commit 3814a192f4
9 files changed
+76 -15

No files matched your search

+2
View File
@@ -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;
+1
View File
@@ -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é */}
+4
View File
@@ -1,4 +1,8 @@
.galaxy-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
cursor: default;
display: block;
touch-action: none;
+17 -10
View File
@@ -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
+8
View File
@@ -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); } }
+9 -1
View File
@@ -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 && <span className="pz-tag pz-tag--accent pz-tag--dot">{t('À vérifier')}</span>}
{!editMode && person.profession && <span className="pz-tag">{person.profession}</span>}
{!editMode && onQuickEdit && (
<button type="button" className="pc-quick-edit" onClick={() => { setTab('infos'); onQuickEdit() }}>
<Pencil size={13} strokeWidth={2} aria-hidden="true" />
{t('Modifier')}
</button>
)}
</div>
</div>
{editMode && ((unknownFirstName || !String(getEditValue('firstName', person.firstName) ?? '').trim())) && (
+11 -3
View File
@@ -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
@@ -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)
})
})
+1
View File
@@ -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',