From de897070db8d0fdaadc387368e1c7d7369f3f208 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Fri, 16 Oct 2026 18:13:18 +0200 Subject: [PATCH] =?UTF-8?q?fix(arbre):=20les=20positions=20du=20brouillon?= =?UTF-8?q?=20restent=20toutes=20=C3=A0=20l'=C3=A9cran?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La liste des positions manuelles était mise en cache sur un hasDraft et une fonction qui ne changent plus après le premier glisser : une seule personne (la première) restait placée. Le brouillon expose maintenant un draftRevision incrémenté à chaque écriture, et le calcul des positions passe par buildManualPositionOverrides (testé). Co-Authored-By: Claude Opus 5.5 --- frontend/src/App.jsx | 12 ++--- .../src/components/galaxy/layoutPositions.js | 29 +++++++++++ .../components/galaxy/layoutPositions.test.js | 50 +++++++++++++++++++ frontend/src/hooks/useEditMode.js | 5 ++ 4 files changed, 88 insertions(+), 8 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 59d4359..47c77a8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,6 +19,7 @@ import EditConfirmModal from './components/EditConfirmModal' import DraftRestoreBanner from './components/DraftRestoreBanner' import AddPersonPanel from './components/AddPersonPanel' import ContributionSessionModal from './components/ContributionSessionModal' +import { buildManualPositionOverrides } from './components/galaxy/layoutPositions' import { ContextualNavbar } from './components/navbar' import ZoomControl from './components/ZoomControl' import { ExpandableSearch } from './components/search' @@ -627,16 +628,11 @@ function App() { treeId: tree.treeContext.treeId, canEdit: canEditInCurrentMode, }) + // draftRevision : la valeur lue par restoreDraft (une ref) ne change pas, il faut un signal qui bouge à chaque écriture const manualPositionOverrides = useMemo(() => { if (!editMode.hasDraft) return {} - const positions = {} - for (const [personId, changes] of Object.entries(editMode.restoreDraft().modifiedPersons || {})) { - if (Number.isFinite(changes?.layoutX) && Number.isFinite(changes?.layoutY)) { - positions[String(personId)] = { layoutX: changes.layoutX, layoutY: changes.layoutY } - } - } - return positions - }, [editMode.hasDraft, editMode.restoreDraft]) + return buildManualPositionOverrides(editMode.restoreDraft().modifiedPersons) + }, [editMode.hasDraft, editMode.draftRevision, editMode.restoreDraft]) const handlePersonPositionChange = useCallback((personId, position) => { const draft = editMode.restoreDraft() editMode.updateDraft({ diff --git a/frontend/src/components/galaxy/layoutPositions.js b/frontend/src/components/galaxy/layoutPositions.js index 8d27dfa..cb4f5d7 100644 --- a/frontend/src/components/galaxy/layoutPositions.js +++ b/frontend/src/components/galaxy/layoutPositions.js @@ -1,3 +1,5 @@ +import { t } from '../../i18n/index.js' + function readLayoutPosition(person) { if (!Number.isFinite(person?.layoutX) || !Number.isFinite(person?.layoutY)) return null return { x: person.layoutX, y: person.layoutY } @@ -41,6 +43,33 @@ export function applyManualPersonPositions( } } +// Positions manuelles en attente dans le brouillon, par id de personne +export function buildManualPositionOverrides(modifiedPersons = {}) { + const positions = {} + for (const [personId, changes] of Object.entries(modifiedPersons || {})) { + if (Number.isFinite(changes?.layoutX) && Number.isFinite(changes?.layoutY)) { + positions[String(personId)] = { layoutX: changes.layoutX, layoutY: changes.layoutY } + } + } + return positions +} + +// Ligne « Position dans l'arbre » d'une contribution en relecture. Jamais de coordonnées brutes. +export function describePositionChange(before, after) { + if (!after || !Object.prototype.hasOwnProperty.call(after, 'layoutX')) return null + const stateLabel = (value) => (value === null || value === undefined ? t('Replacée automatiquement') : t('Déplacée')) + const newLabel = stateLabel(after.layoutX) + const hasBefore = Boolean(before) && Object.prototype.hasOwnProperty.call(before, 'layoutX') + const oldLabel = hasBefore ? (before.layoutX === null || before.layoutX === undefined ? t('Automatique') : t('Déplacée')) : undefined + return { + key: 'layout', + label: t("Position dans l'arbre"), + // Déjà déplacée avant, déplacée encore : le libellé ne change pas, on n'affiche qu'une valeur + oldVal: oldLabel === newLabel ? undefined : oldLabel, + newVal: newLabel, + } +} + export function positionUnionJunctions(coupleBarMeta, positionById) { for (const { elkId, p1Key, p2Key } of coupleBarMeta) { const partner1 = positionById.get(p1Key) diff --git a/frontend/src/components/galaxy/layoutPositions.test.js b/frontend/src/components/galaxy/layoutPositions.test.js index 6aa1d1b..c7b65d2 100644 --- a/frontend/src/components/galaxy/layoutPositions.test.js +++ b/frontend/src/components/galaxy/layoutPositions.test.js @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { applyManualPersonPositions, + buildManualPositionOverrides, captureAutomaticPersonPositions, + describePositionChange, positionUnionJunctions, } from './layoutPositions' @@ -85,3 +87,51 @@ describe('dynamic union junction positions', () => { expect(positions.get('u-1')).toBe(originalJunction) }) }) + +describe('positions manuelles du brouillon', () => { + it('reprend toutes les personnes déplacées dans le brouillon, pas seulement la dernière', () => { + const draft = { + '1': { layoutX: 10, layoutY: 20 }, + '2': { layoutX: 30, layoutY: 40, firstName: 'Hugo' }, + } + expect(buildManualPositionOverrides(draft)).toEqual({ + '1': { layoutX: 10, layoutY: 20 }, + '2': { layoutX: 30, layoutY: 40 }, + }) + }) + + it('ignore les personnes sans position complète', () => { + expect(buildManualPositionOverrides({ 3: { layoutX: 5 }, 4: { firstName: 'Anne' } })).toEqual({}) + expect(buildManualPositionOverrides(undefined)).toEqual({}) + }) +}) + +describe("ligne « Position dans l'arbre » en relecture", () => { + it('dit déplacée ou replacée automatiquement, sans coordonnée brute', () => { + expect(describePositionChange(null, { layoutX: 320.5, layoutY: 110 })).toEqual({ + key: 'layout', + label: "Position dans l'arbre", + oldVal: undefined, + newVal: 'Déplacée', + }) + expect(describePositionChange({ layoutX: 320, layoutY: 110 }, { layoutX: null, layoutY: null })).toMatchObject({ + oldVal: 'Déplacée', + newVal: 'Replacée automatiquement', + }) + }) + + it('montre le passage automatique vers déplacée, et rien si la ligne ne bouge pas de libellé', () => { + expect(describePositionChange({ layoutX: null, layoutY: null }, { layoutX: 1, layoutY: 2 })).toMatchObject({ + oldVal: 'Automatique', + newVal: 'Déplacée', + }) + expect(describePositionChange({ layoutX: 1, layoutY: 2 }, { layoutX: 3, layoutY: 4 })).toMatchObject({ + oldVal: undefined, + newVal: 'Déplacée', + }) + }) + + it('ne concerne pas une contribution qui ne touche pas la position', () => { + expect(describePositionChange({ firstName: 'Anne' }, { firstName: 'Ana' })).toBeNull() + }) +}) diff --git a/frontend/src/hooks/useEditMode.js b/frontend/src/hooks/useEditMode.js index d22e8aa..9498090 100644 --- a/frontend/src/hooks/useEditMode.js +++ b/frontend/src/hooks/useEditMode.js @@ -21,6 +21,8 @@ export function useEditMode({ treeId, canEdit }) { const [isActive, setIsActive] = useState(false) const [hasDraft, setHasDraft] = useState(false) const [draftTimestamp, setDraftTimestamp] = useState(null) + // Change à chaque écriture du brouillon : les valeurs dérivées (positions manuelles) s'y recalculent + const [draftRevision, setDraftRevision] = useState(0) const autoSaveTimerRef = useRef(null) const draftDataRef = useRef({}) @@ -151,6 +153,7 @@ export function useEditMode({ treeId, canEdit }) { } // Considérer qu'il y a désormais un draft actif (permet le bouton "Mettre à jour l'arbre") setHasDraft(true) + setDraftRevision((current) => current + 1) }, []) /** @@ -164,6 +167,7 @@ export function useEditMode({ treeId, canEdit }) { draftDataRef.current = {} setHasDraft(false) setDraftTimestamp(null) + setDraftRevision((current) => current + 1) } catch (err) { console.error('Failed to clear draft:', err) } @@ -181,6 +185,7 @@ export function useEditMode({ treeId, canEdit }) { isActive, hasDraft, draftTimestamp, + draftRevision, // Actions activate,