fix(arbre): les positions du brouillon restent toutes à l'écran

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 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-16 18:13:18 +02:00
1 parent d17cee20df
commit de897070db
4 files changed
+88 -8

No files matched your search

+4 -8
View File
@@ -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({
@@ -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)
@@ -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()
})
})
+5
View File
@@ -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,