From 8574dfed9efc4d8bfa1d5e0a07e744793e55ce95 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Fri, 16 Oct 2026 18:46:28 +0200 Subject: [PATCH] Retour au placement automatique : abandon du placement libre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le mainteneur préfère un réordonnancement des frères et soeurs au placement libre (coordonnées enregistrées). Ce commit annule le glisser libre, ses corrections, la migration des coordonnées et le bouton de remise à zéro ; le réordonnancement arrive dans les commits suivants. Co-Authored-By: Claude Opus 5.5 --- README.fr.md | 2 +- README.md | 2 +- backend/openapi/v1.yaml | 53 ------- .../migration.sql | 3 - backend/prisma/schema.prisma | 2 - backend/src/routes/contributions.ts | 13 -- backend/src/routes/trees.ts | 64 -------- backend/src/utils/graph-payload.ts | 7 - backend/test/api-security.integration.test.ts | 139 +----------------- backend/test/e2e-journeys.test.ts | 6 +- backend/test/graph-payload.test.ts | 4 - frontend/src/App.jsx | 71 +-------- frontend/src/api/treeApi.js | 10 -- frontend/src/components/AdminPanel.jsx | 60 +------- frontend/src/components/ContributionPanel.jsx | 5 +- frontend/src/components/Galaxy.jsx | 40 +---- frontend/src/components/galaxy/blockLayout.js | 4 +- .../src/components/galaxy/layoutPositions.js | 84 ----------- .../components/galaxy/layoutPositions.test.js | 137 ----------------- frontend/src/components/galaxy/renderers.js | 16 +- .../src/components/galaxy/renderers.test.js | 26 ---- .../galaxy/useGalaxyInteractions.js | 112 +------------- frontend/src/data/mockData.js | 2 - frontend/src/data/mockData.test.js | 3 - frontend/src/hooks/useEditMode.js | 5 - frontend/src/i18n/en/app.js | 1 - frontend/src/i18n/en/compte.js | 9 -- frontend/src/i18n/en/contribution.js | 4 - frontend/src/utils/graphAdapter.js | 2 - 29 files changed, 26 insertions(+), 860 deletions(-) delete mode 100644 backend/prisma/migrations/20261014_001_person_layout_positions/migration.sql delete mode 100644 frontend/src/components/galaxy/layoutPositions.js delete mode 100644 frontend/src/components/galaxy/layoutPositions.test.js delete mode 100644 frontend/src/components/galaxy/renderers.test.js diff --git a/README.fr.md b/README.fr.md index 064216f..910b661 100644 --- a/README.fr.md +++ b/README.fr.md @@ -8,7 +8,7 @@ Site et démo : [parenthese.io](https://parenthese.io) · [English version](READ ## Ce que fait l'application -- Un arbre par famille : personnes, unions, liens parent-enfant, mise en page automatique réagençable par glisser-déposer en mode édition. +- Un arbre par famille : personnes, unions, liens parent-enfant, mise en page automatique. - Des médias sur chaque personne : photos, vidéos (fichier ou lien YouTube), enregistrements audio pour garder une voix, documents, traces GPS. - Des annotations libres sur l'arbre : dessins, stickers, textes, photos posées où l'on veut. - Un partage par lien : chaque arbre a une adresse `/arbre/` protégée par un mot de passe de partage, qui permet de regarder et de proposer des ajouts. diff --git a/README.md b/README.md index de9817b..afedadb 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Website: [parenthese.io/en](https://parenthese.io/en/) · Demo, no account neede ## What it does -- One tree per family: people, couples, parent-child links, laid out automatically and rearrangeable by dragging people in edit mode. +- One tree per family: people, couples, parent-child links, laid out automatically. - Memories on each person: photos, videos (a file or a YouTube link), audio recordings to keep a voice, documents, GPS tracks. - Free annotations on the tree: drawings, stickers, text, photos placed anywhere. - Sharing by link: each tree has an address `/arbre/` protected by a sharing password. With it, relatives can look around and suggest additions. diff --git a/backend/openapi/v1.yaml b/backend/openapi/v1.yaml index 0de1110..981c01b 100644 --- a/backend/openapi/v1.yaml +++ b/backend/openapi/v1.yaml @@ -228,14 +228,6 @@ components: deathDate: type: string nullable: true - layoutX: - type: number - nullable: true - description: Manual tree-layout X coordinate for the person. - layoutY: - type: number - nullable: true - description: Manual tree-layout Y coordinate for the person. sex: type: string nullable: true @@ -353,16 +345,6 @@ components: deathDate: type: string nullable: true - layoutX: - type: number - nullable: true - minimum: -1000000 - maximum: 1000000 - layoutY: - type: number - nullable: true - minimum: -1000000 - maximum: 1000000 sex: type: string nullable: true @@ -1373,41 +1355,6 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' - /trees/{id}/layout/reset: - post: - tags: [Trees] - summary: Reset manual person positions so the tree is laid out automatically again (admin) - security: - - BearerAuth: [] - parameters: - - in: path - name: id - required: true - schema: - type: string - responses: - '200': - description: Positions cleared - content: - application/json: - schema: - type: object - properties: - personsReset: - type: integer - '401': - description: Authentication required - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - '403': - description: Forbidden (admin only) - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - /trees/{id}/unions: post: tags: [Trees] diff --git a/backend/prisma/migrations/20261014_001_person_layout_positions/migration.sql b/backend/prisma/migrations/20261014_001_person_layout_positions/migration.sql deleted file mode 100644 index cd9d369..0000000 --- a/backend/prisma/migrations/20261014_001_person_layout_positions/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE "persons" -ADD COLUMN "layoutX" DOUBLE PRECISION, -ADD COLUMN "layoutY" DOUBLE PRECISION; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b3d462e..a80a8d5 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -198,8 +198,6 @@ model Person { birthName String? birthDate DateTime? deathDate DateTime? - layoutX Float? - layoutY Float? birthPlace String? profession String? region String? diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index cb4712b..4c0f3fa 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -10,11 +10,8 @@ import { removeStoredFile } from '../lib/media-files.js' import { syncPersonMediaOrder } from '../lib/media-order.js' import type { Actor, MembershipRole } from '../types/auth.js' -import { LAYOUT_COORDINATE_LIMIT } from '../utils/graph-payload.js' import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js' -const layoutCoordinateSchema = z.number().finite().min(-LAYOUT_COORDINATE_LIMIT).max(LAYOUT_COORDINATE_LIMIT) - const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/ const treeIdParamSchema = z.object({ @@ -89,8 +86,6 @@ const personUpdateSchema = z birthName: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), deathDate: z.string().regex(isoDateRegex).optional().nullable(), - layoutX: layoutCoordinateSchema.optional().nullable(), - layoutY: layoutCoordinateSchema.optional().nullable(), birthPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), @@ -407,8 +402,6 @@ async function applyContributionChange( birthName: parsed.data.birthName !== undefined ? toTrimmedStringOrNull(parsed.data.birthName) : undefined, birthDate: parsed.data.birthDate !== undefined ? toDateOrNull(parsed.data.birthDate) : undefined, deathDate: parsed.data.deathDate !== undefined ? toDateOrNull(parsed.data.deathDate) : undefined, - layoutX: parsed.data.layoutX, - layoutY: parsed.data.layoutY, birthPlace: parsed.data.birthPlace !== undefined ? toTrimmedStringOrNull(parsed.data.birthPlace) : undefined, profession: parsed.data.profession !== undefined ? toTrimmedStringOrNull(parsed.data.profession) : undefined, region: parsed.data.region !== undefined ? toTrimmedStringOrNull(parsed.data.region) : undefined, @@ -784,8 +777,6 @@ async function applyContributionChange( } const SNAPSHOT_ENTITY_TYPES = new Set(['person', 'union', 'parent_child_link']) -// Champs qui ne créent jamais de conflit de relecture (positions dans l'arbre, déplacées à la main) -const NON_CONFLICTING_KEYS = new Set(['layoutX', 'layoutY']) // État actuel d'une personne, union ou lien, tel qu'on le montre à la relecture. null : introuvable dans l'arbre. async function snapshotEntity( @@ -803,8 +794,6 @@ async function snapshotEntity( birthName: true, birthDate: true, deathDate: true, - layoutX: true, - layoutY: true, birthPlace: true, profession: true, region: true, @@ -858,8 +847,6 @@ async function detectConflict( const snap = await snapshotEntity(tx, treeId, change.entityType, change.entityId) if (!snap) return true for (const key of Object.keys(before)) { - // La place dans l'arbre bouge à chaque glisser du propriétaire : ce n'est pas une modification concurrente - if (NON_CONFLICTING_KEYS.has(key)) continue if (key in snap && String(snap[key] ?? '') !== String(before[key] ?? '')) return true } return false diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index c80c36a..642db99 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -13,11 +13,8 @@ import { decryptSharePassword, encryptSharePassword } from '../lib/share-passwor import { purgeTrees } from '../lib/tree-purge.js' import { reserveTreeUnlockAttempt, serializePasswordCheck } from '../lib/unlock-guard.js' import type { MembershipRole } from '../types/auth.js' -import { LAYOUT_COORDINATE_LIMIT } from '../utils/graph-payload.js' import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js' -const layoutCoordinateSchema = z.number().finite().min(-LAYOUT_COORDINATE_LIMIT).max(LAYOUT_COORDINATE_LIMIT) - const MAX_TREES_PER_ACCOUNT = 10 const treeIdParamSchema = z.object({ @@ -104,8 +101,6 @@ const patchPersonSchema = z birthName: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), deathDate: z.string().regex(isoDateRegex).optional().nullable(), - layoutX: layoutCoordinateSchema.optional().nullable(), - layoutY: layoutCoordinateSchema.optional().nullable(), birthPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), @@ -1307,8 +1302,6 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { birthName?: string | null birthDate?: Date | null deathDate?: Date | null - layoutX?: number | null - layoutY?: number | null birthPlace?: string | null profession?: string | null region?: string | null @@ -1338,14 +1331,6 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { data.deathDate = toDateOrNull(payload.data.deathDate) } - if (payload.data.layoutX !== undefined) { - data.layoutX = payload.data.layoutX - } - - if (payload.data.layoutY !== undefined) { - data.layoutY = payload.data.layoutY - } - if (payload.data.birthPlace !== undefined) { data.birthPlace = toTrimmedStringOrNull(payload.data.birthPlace) } @@ -1410,55 +1395,6 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { return reply.send({ person }) }) - // Rétablit la disposition automatique : efface la place manuelle de toutes les personnes de l'arbre - app.post('/trees/:id/layout/reset', async (request, reply) => { - const user = requireUser(request, reply) - if (!user) { - return - } - - const params = treeIdParamSchema.safeParse(request.params) - if (!params.success) { - return reply.code(400).send({ error: 'invalid_tree_id' }) - } - - const membership = await getAdminMembership(app, user.userId, params.data.id) - if (!hasActiveAdminMembership(membership)) { - return reply.code(403).send({ error: 'forbidden' }) - } - - const count = await app.prisma.$transaction(async (tx) => { - const result = await tx.person.updateMany({ - where: { - treeId: params.data.id, - deletedAt: null, - OR: [{ layoutX: { not: null } }, { layoutY: { not: null } }], - }, - data: { - layoutX: null, - layoutY: null, - updatedBy: user.userId, - }, - }) - - await tx.auditLog.create({ - data: { - treeId: params.data.id, - actorType: 'user', - actorId: user.userId, - action: 'tree_layout_reset', - entityType: 'tree', - entityId: params.data.id, - payloadJson: { personsReset: result.count }, - }, - }) - - return result.count - }) - - return reply.send({ personsReset: count }) - }) - app.delete('/trees/:id/persons/:personId', async (request, reply) => { const user = requireUser(request, reply) if (!user) { diff --git a/backend/src/utils/graph-payload.ts b/backend/src/utils/graph-payload.ts index 9d912d9..ea847dc 100644 --- a/backend/src/utils/graph-payload.ts +++ b/backend/src/utils/graph-payload.ts @@ -2,9 +2,6 @@ import type { Annotation, MediaItem, ParentChildLink, Person, Union } from '@pri import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js' -// Espace des coordonnées de disposition manuelle (unités du graphe). Au-delà, rien de réel dans un arbre. -export const LAYOUT_COORDINATE_LIMIT = 1_000_000 - export type GraphPayload = { persons: Array<{ id: string @@ -13,8 +10,6 @@ export type GraphPayload = { birthName: string | null birthDate: string | null deathDate: string | null - layoutX: number | null - layoutY: number | null birthPlace: string | null profession: string | null region: string | null @@ -88,8 +83,6 @@ export function buildGraphPayload( birthName: person.birthName, birthDate: formatDate(person.birthDate), deathDate: formatDate(person.deathDate), - layoutX: person.layoutX ?? null, - layoutY: person.layoutY ?? null, birthPlace: person.birthPlace, profession: person.profession, region: person.region, diff --git a/backend/test/api-security.integration.test.ts b/backend/test/api-security.integration.test.ts index d13606b..4e1c299 100644 --- a/backend/test/api-security.integration.test.ts +++ b/backend/test/api-security.integration.test.ts @@ -456,15 +456,11 @@ describe('API security and authz', () => { payload: { firstName: 'Alice', notes: 'Mise a jour', - layoutX: 321.5, - layoutY: 654.25, }, }) expect(patchResponse.statusCode).toBe(200) - expect(prismaMock.person.updateMany).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ layoutX: 321.5, layoutY: 654.25 }), - })) + expect(prismaMock.person.updateMany).toHaveBeenCalled() prismaMock.union.updateMany.mockResolvedValueOnce({ count: 2 }) prismaMock.parentChildLink.updateMany.mockResolvedValueOnce({ count: 3 }) @@ -541,88 +537,6 @@ describe('API security and authz', () => { await app.close() }) - it('refuses a manual layout coordinate outside the tree space', async () => { - const app = createApp() - const token = await createUserToken(app) - - const response = await app.inject({ - method: 'PATCH', - url: '/trees/tree-1/persons/person-1', - headers: { authorization: `Bearer ${token}` }, - payload: { layoutX: 2_000_000, layoutY: 10 }, - }) - - expect(response.statusCode).toBe(400) - expect(prismaMock.person.updateMany).not.toHaveBeenCalled() - await app.close() - }) - - it('forbids a member from moving a person by hand', async () => { - const app = createApp() - const token = await createUserToken(app) - prismaMock.treeMembership.findUnique.mockResolvedValue({ - treeId: 'tree-1', - userId: 'user-1', - role: 'member', - tree: { deletedAt: null, rootPersonId: null }, - }) - - const response = await app.inject({ - method: 'PATCH', - url: '/trees/tree-1/persons/person-1', - headers: { authorization: `Bearer ${token}` }, - payload: { layoutX: 120, layoutY: 240 }, - }) - - expect(response.statusCode).toBe(403) - expect(prismaMock.person.updateMany).not.toHaveBeenCalled() - await app.close() - }) - - it('lets the owner put every person back to the automatic layout, with an audit entry', async () => { - const app = createApp() - const token = await createUserToken(app) - prismaMock.person.updateMany.mockResolvedValueOnce({ count: 4 }) - - const response = await app.inject({ - method: 'POST', - url: '/trees/tree-1/layout/reset', - headers: { authorization: `Bearer ${token}` }, - }) - - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ personsReset: 4 }) - expect(prismaMock.person.updateMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ treeId: 'tree-1', deletedAt: null }), - data: expect.objectContaining({ layoutX: null, layoutY: null }), - })) - expect(prismaMock.auditLog.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ treeId: 'tree-1', action: 'tree_layout_reset', payloadJson: { personsReset: 4 } }), - }) - await app.close() - }) - - it('forbids a member from resetting the automatic layout', async () => { - const app = createApp() - const token = await createUserToken(app) - prismaMock.treeMembership.findUnique.mockResolvedValue({ - treeId: 'tree-1', - userId: 'user-1', - role: 'member', - tree: { deletedAt: null, rootPersonId: null }, - }) - - const response = await app.inject({ - method: 'POST', - url: '/trees/tree-1/layout/reset', - headers: { authorization: `Bearer ${token}` }, - }) - - expect(response.statusCode).toBe(403) - expect(prismaMock.person.updateMany).not.toHaveBeenCalled() - await app.close() - }) - it('allows contributor access token to submit a pending contribution session', async () => { const app = createApp() await app.ready() @@ -1012,57 +926,6 @@ describe('API security and authz', () => { await app.close() }) - it('does not flag a pending person change as a conflict when the owner only moved that person', async () => { - const app = createApp() - const token = await createUserToken(app) - - prismaMock.contributionSession.findFirst.mockResolvedValue({ - id: 'session-1', - treeId: 'tree-1', - status: 'pending', - changes: [ - { - id: 'change-1', - entityType: 'person', - action: 'update', - entityId: 'person-1', - beforeJson: { firstName: 'Alice', layoutX: null, layoutY: null }, - afterJson: { firstName: 'Alicia' }, - conflictState: 'none', - }, - ], - }) - // Le propriétaire a glissé la personne depuis l'envoi : seule la place a changé - prismaMock.person.findFirst.mockResolvedValue({ - firstName: 'Alice', - lastName: 'Martin', - birthName: null, - birthDate: null, - deathDate: null, - layoutX: 300, - layoutY: 400, - birthPlace: null, - profession: null, - region: null, - nationality: null, - sex: null, - notes: null, - }) - - const response = await app.inject({ - method: 'POST', - url: '/trees/tree-1/contributions/sessions/session-1/review', - headers: { authorization: `Bearer ${token}` }, - payload: { decision: 'approved' }, - }) - - expect(response.statusCode).toBe(200) - expect(prismaMock.contributionChange.update).not.toHaveBeenCalledWith(expect.objectContaining({ - data: { conflictState: 'needs_review' }, - })) - await app.close() - }) - it('allows admin to list pending contribution sessions', async () => { const app = createApp() const token = await createUserToken(app) diff --git a/backend/test/e2e-journeys.test.ts b/backend/test/e2e-journeys.test.ts index 4c2651b..ce3c47e 100644 --- a/backend/test/e2e-journeys.test.ts +++ b/backend/test/e2e-journeys.test.ts @@ -545,7 +545,7 @@ describe('Journey 4 — Admin reviews a contribution session', () => { action: 'update', entityId: 'person-1', beforeJson: { firstName: 'Pierre' }, - afterJson: { notes: 'Surnomme Pierrot', layoutX: 234.5, layoutY: 678.25 }, + afterJson: { notes: 'Surnomme Pierrot' }, conflictState: 'none', }, ], @@ -581,9 +581,7 @@ describe('Journey 4 — Admin reviews a contribution session', () => { // Both changes should be applied expect(prismaMock.person.create).toHaveBeenCalled() - expect(prismaMock.person.updateMany).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ layoutX: 234.5, layoutY: 678.25 }), - })) + expect(prismaMock.person.updateMany).toHaveBeenCalled() // Audit log for review expect(prismaMock.auditLog.create).toHaveBeenCalledWith( diff --git a/backend/test/graph-payload.test.ts b/backend/test/graph-payload.test.ts index 3df24ab..2211b80 100644 --- a/backend/test/graph-payload.test.ts +++ b/backend/test/graph-payload.test.ts @@ -15,8 +15,6 @@ describe('buildGraphPayload', () => { birthName: 'Durand', birthDate: new Date('1994-06-02T00:00:00.000Z'), deathDate: null, - layoutX: 432.5, - layoutY: -19.25, sex: null, notes: null, avatarPath: 't-1/p-2/avatar.jpg', @@ -98,8 +96,6 @@ describe('buildGraphPayload', () => { expect(graph.persons[0].id).toBe('p-2') expect(graph.persons[0].birthDate).toBe('1994-06-02') expect(graph.persons[0].birthName).toBe('Durand') - expect(graph.persons[0].layoutX).toBe(432.5) - expect(graph.persons[0].layoutY).toBe(-19.25) expect(graph.persons[0].avatarUrlPath).toBe('/trees/t-1/persons/p-2/avatar') expect(graph.persons[1].avatarUrlPath).toBeNull() expect(graph.unions[0].id).toBe('u-1') diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 58c333c..57b551c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,7 +19,6 @@ 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' @@ -284,8 +283,6 @@ function buildContributionChangesFromDraft(draft = {}) { if (Object.prototype.hasOwnProperty.call(source, 'region')) after.region = (source.region || '').trim() || null if (Object.prototype.hasOwnProperty.call(source, 'nationality')) after.nationality = (source.nationality || '').trim() || null if (Object.prototype.hasOwnProperty.call(source, 'note')) after.notes = source.note || null - if (Object.prototype.hasOwnProperty.call(source, 'layoutX')) after.layoutX = source.layoutX - if (Object.prototype.hasOwnProperty.call(source, 'layoutY')) after.layoutY = source.layoutY if (Object.prototype.hasOwnProperty.call(source, 'isAlive') && source.isAlive === true) after.deathDate = null if (Object.keys(after).length > 0) { @@ -628,23 +625,6 @@ 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 {} - return buildManualPositionOverrides(editMode.restoreDraft().modifiedPersons) - }, [editMode.hasDraft, editMode.draftRevision, editMode.restoreDraft]) - const handlePersonPositionChange = useCallback((personId, position) => { - const draft = editMode.restoreDraft() - editMode.updateDraft({ - modifiedPersons: { - ...(draft.modifiedPersons || {}), - [String(personId)]: { - ...(draft.modifiedPersons?.[String(personId)] || {}), - ...position, - }, - }, - }) - }, [editMode.restoreDraft, editMode.updateDraft]) const localEntityCounterRef = useRef(0) const makeLocalId = useCallback((prefix) => { @@ -1179,12 +1159,6 @@ function App() { const timer = setTimeout(() => setDraftNotice(''), 4500) return () => clearTimeout(timer) }, [draftNotice]) - const [editSaveError, setEditSaveError] = useState('') - useEffect(() => { - if (!editSaveError) return undefined - const timer = setTimeout(() => setEditSaveError(''), 6000) - return () => clearTimeout(timer) - }, [editSaveError]) // Soumission des modifications const handleEditSubmit = useCallback(async () => { @@ -1202,7 +1176,6 @@ function App() { const treeId = tree.treeContext.treeId const draft = editMode.restoreDraft() - let failedPersonSaves = 0 if (token && treeId) { // Appliquer les modifications de personnes depuis le draft const modifiedPersons = draft.modifiedPersons || {} @@ -1219,14 +1192,11 @@ function App() { if (Object.prototype.hasOwnProperty.call(rawChanges, 'nationality')) payload.nationality = (rawChanges.nationality || '').trim() || null if (Object.prototype.hasOwnProperty.call(rawChanges, 'isAlive') && rawChanges.isAlive === true) payload.deathDate = null if (Object.prototype.hasOwnProperty.call(rawChanges, 'note')) payload.notes = rawChanges.note || null - if (Object.prototype.hasOwnProperty.call(rawChanges, 'layoutX')) payload.layoutX = rawChanges.layoutX - if (Object.prototype.hasOwnProperty.call(rawChanges, 'layoutY')) payload.layoutY = rawChanges.layoutY if (Object.keys(payload).length > 0) { try { await updatePerson(treeId, personId, payload, token) } catch (err) { - failedPersonSaves += 1 console.error('Failed to update person:', personId, err) } } @@ -1274,11 +1244,6 @@ function App() { } await tree.refreshCurrentTreeGraph({ relayout: false }) - if (failedPersonSaves > 0) { - // Brouillon gardé : rien n'est perdu, l'admin peut réessayer - setEditSaveError(t("Certaines modifications n'ont pas pu être enregistrées. Vérifiez puis réessayez.")) - return - } editMode.clearDraft() editMode.deactivate() } else { @@ -1322,16 +1287,6 @@ function App() { return } - // Comme un nom modifié, la place envoyée reste à l'écran jusqu'à la relecture - for (const [personId, changes] of Object.entries(draft.modifiedPersons || {})) { - if (!Number.isFinite(changes?.layoutX) || !Number.isFinite(changes?.layoutY)) continue - const localPerson = persons.find((candidate) => String(candidate.id) === String(personId)) - if (localPerson) { - localPerson.layoutX = changes.layoutX - localPerson.layoutY = changes.layoutY - } - } - editMode.clearDraft() editMode.deactivate() setShowContribModal(false) @@ -2555,22 +2510,6 @@ function App() { } } - // Admin : efface les positions manuelles de l'arbre, y compris celles restées dans son brouillon - const handleResetTreeLayout = useCallback(async () => { - const draft = editMode.restoreDraft() - if (draft.modifiedPersons) { - const modifiedPersons = {} - for (const [personId, changes] of Object.entries(draft.modifiedPersons)) { - const rest = { ...changes } - delete rest.layoutX - delete rest.layoutY - if (Object.keys(rest).length > 0) modifiedPersons[personId] = rest - } - editMode.updateDraft({ modifiedPersons }) - } - await refreshTreeAndKeepSelection(null, { relayout: true }) - }, [editMode.restoreDraft, editMode.updateDraft, refreshTreeAndKeepSelection]) - const handleGedcomImported = useCallback(async (result, treeIdOverride = null) => { await refreshTreeAndKeepSelection(null, { relayout: true }) const refs = Array.isArray(result?.photoRefs) ? result.photoRefs : [] @@ -2842,9 +2781,6 @@ function App() { {draftNotice && !contribSuccess && !showContribModal && (
{draftNotice}
)} - {editSaveError && ( -
{editSaveError}
- )} {contribSuccess && (
{t("Merci, c'est envoyé ! Ça apparaîtra dans l'arbre une fois relu.")} @@ -2890,9 +2826,6 @@ function App() { { setAdminPanelVisible(false); openPhotoStart() }} onClose={() => setAdminPanelVisible(false)} - hasManualPositions={persons.some((person) => Number.isFinite(person.layoutX) && Number.isFinite(person.layoutY))} - onLayoutReset={handleResetTreeLayout} /> { const [section, setSection] = useState('tree') const [loading, setLoading] = useState(false) @@ -50,7 +47,6 @@ const AdminPanel = ({ const [showDeleteModal, setShowDeleteModal] = useState(false) const [deleteConfirmName, setDeleteConfirmName] = useState('') - const [showLayoutResetModal, setShowLayoutResetModal] = useState(false) const [gedcomFile, setGedcomFile] = useState(null) const [importingGedcom, setImportingGedcom] = useState(false) const gedcomInputRef = useRef(null) @@ -67,7 +63,6 @@ const AdminPanel = ({ setSection('tree') setShowDeleteModal(false) setDeleteConfirmName('') - setShowLayoutResetModal(false) setGedcomFile(null) setImportingGedcom(false) @@ -235,25 +230,6 @@ const AdminPanel = ({ } } - const handleResetLayout = async () => { - if (!treeId || !authToken || saving) return - - setSaving(true) - setErrorMessage('') - setSuccessMessage('') - - try { - await resetTreeLayout(treeId, authToken) - await onLayoutReset?.() - setShowLayoutResetModal(false) - setSuccessMessage(t('La disposition automatique est rétablie.')) - } catch { - setErrorMessage(t('Impossible de rétablir la disposition.')) - } finally { - setSaving(false) - } - } - const handleDeleteTree = async () => { if (!treeId || !authToken || saving || !tree) return if (deleteConfirmName.trim() !== tree.name) { @@ -283,7 +259,7 @@ const AdminPanel = ({
{ - if (!showDeleteModal && !showLayoutResetModal) onClose?.() + if (!showDeleteModal) onClose?.() }} >
- {hasManualPositions && ( -
-
- {t("Disposition de l'arbre")} -

{t("Les personnes déplacées à la main reprennent la place que leur donne l'arbre.")}

-
-
- -
-
- )} -
{t('Supprimer cet arbre')} @@ -483,26 +445,6 @@ const AdminPanel = ({
- {showLayoutResetModal && ( -
{ event.stopPropagation(); if (!saving) setShowLayoutResetModal(false) }}> -
event.stopPropagation()}> -
-

{t('Dernière vérification')}

-

{t('Rétablir la disposition automatique ?')}

-

{t('Les déplacements faits à la main seront effacés pour toute la famille.')}

-
-
- - -
-
-
- )} - {showDeleteModal && (
{ event.stopPropagation(); if (!saving) setShowDeleteModal(false) }}>
event.stopPropagation()}> diff --git a/frontend/src/components/ContributionPanel.jsx b/frontend/src/components/ContributionPanel.jsx index 503fe56..bf59664 100644 --- a/frontend/src/components/ContributionPanel.jsx +++ b/frontend/src/components/ContributionPanel.jsx @@ -5,7 +5,6 @@ import { persons as treePersons } from '../data/mockData' import { Check, ChevronDown, Copy, RefreshCw, X } from 'lucide-react' import './SettingsPanels.css' import PzBusy from './PzBusy.jsx' -import { describePositionChange } from './galaxy/layoutPositions.js' import { HELP_URL } from '../utils/siteLinks.js' import { t, dateLocale } from '../i18n/index.js' @@ -131,7 +130,7 @@ function getFieldRows(change) { if (!after || typeof after !== 'object') return [] const before = (change.beforeJson && typeof change.beforeJson === 'object') ? change.beforeJson : null - const rows = Object.entries(after) + return Object.entries(after) .filter(([key]) => PERSON_FIELD_LABELS[key]) .map(([key, newVal]) => ({ key, @@ -143,8 +142,6 @@ function getFieldRows(change) { if (oldVal !== undefined) return true return newVal !== null && newVal !== undefined && newVal !== '' }) - const layoutRow = describePositionChange(before, after) - return layoutRow ? [...rows, layoutRow] : rows } function getAnnotationPreview(change, treeId) { diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index 6ef12a2..6d69962 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -1,5 +1,5 @@ import { memo, useEffect, useImperativeHandle, useRef, useState, useCallback } from 'react' -import { getPersonById, getPersonMedias, getParents, getChildren, getPersonUnions } from '../data/mockData' +import { getPersonMedias, getParents, getChildren, getPersonUnions } from '../data/mockData' import { PERSON_R, FLOAT_SPEED, FLOAT_AMPLITUDE, REPULSION_RADIUS, REPULSION_STRENGTH, MAX_ORBIT_MEDIAS, ENTRANCE_GEN_DELAY, ENTRANCE_NODE_DURATION, ENTRANCE_NODE_STAGGER, ENTRANCE_MAX_GEN_SPAN, ENTRANCE_MAX_NODE_SPAN, ENTRANCE_LINE_DELAY, ENTRANCE_LINE_DURATION, ENTRANCE_ORBIT_DELAY, ENTRANCE_ORBIT_DURATION, ENTRANCE_ORBIT_STAGGER } from './galaxy/constants' import { easeOutCubic } from './galaxy/utils' import { computeLayout, generateNodeRandomData, coupleBarMeta } from './galaxy/elkLayout' @@ -12,11 +12,6 @@ import { drawBackground, drawCoupleLinks, drawFiliations, drawAnnotations, drawP import { annotations as annotationData } from '../data/mockData' import { useImageCache } from './galaxy/useImageCache' import { useGalaxyInteractions } from './galaxy/useGalaxyInteractions' -import { - applyManualPersonPositions, - captureAutomaticPersonPositions, - positionUnionJunctions, -} from './galaxy/layoutPositions' import './Galaxy.css' // Assez bas pour cadrer un grand arbre entier à l'ouverture @@ -34,7 +29,7 @@ const MAX_SCALE = 3.2 const FOCUS_MIN_READABLE_SCALE = 0.5 const FOCUS_SCALE = 0.95 -const Galaxy = ({ onPersonSelect, onMediaSelect, onPersonPositionChange, editModeActive = false, manualPositionOverrides = {}, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, photoHint = false }) => { +const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, photoHint = false }) => { const canvasRef = useRef(null) // Lu au calcul du cadrage seulement : changer de personne de départ ne recadre pas une vue en cours const focusPersonIdRef = useRef(focusPersonId) @@ -43,10 +38,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, onPersonPositionChange, editMod // Propriétaire : un portrait vide survolé montre « + photo » const photoHintRef = useRef(photoHint) useEffect(() => { photoHintRef.current = photoHint }, [photoHint]) - const manualPositionOverridesRef = useRef(manualPositionOverrides) - useEffect(() => { manualPositionOverridesRef.current = manualPositionOverrides }, [manualPositionOverrides]) const layoutDataRef = useRef(null) - const automaticPersonPositionsRef = useRef(new Map()) const [layoutReady, setLayoutReady] = useState(false) const transformRef = useRef({ x: 0, y: 0, scale: 1 }) @@ -92,7 +84,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, onPersonPositionChange, editMod const getAnimatedPos = useCallback((node, time) => { const baseCx = node.x + node.width / 2 const baseCy = node.y + node.height / 2 - if (node._manualPosition) return { cx: baseCx, cy: baseCy } const rd = nodeRandomDataRef.current.get(node.id) if (!rd) return { cx: baseCx, cy: baseCy } @@ -119,14 +110,13 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, onPersonPositionChange, editMod const { isDragging, isZooming, zoomTimeoutRef, hasDragged, - handleMouseDown, handleMouseMove, handleMouseUp, cancelPersonDrag, + handleMouseDown, handleMouseMove, handleMouseUp, handleMouseLeave, handleWheel, handleClick, } = useGalaxyInteractions({ canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, hoveredNodeIdRef: hoveredNodeId, hoveredMediaKeyRef: hoveredMediaKey, mouseWorldPosRef: mouseWorldPos, getAnimatedPos, onPersonSelect, onMediaSelect, - onPersonPositionChange, editModeActive, annotationHandlers, minScale: MIN_SCALE, maxScale: MAX_SCALE, @@ -211,12 +201,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, onPersonPositionChange, editMod // Layout useEffect(() => { const result = USE_BLOCK_LAYOUT ? computeBlockLayout() : computeLayout() - automaticPersonPositionsRef.current = captureAutomaticPersonPositions(result) - applyManualPersonPositions(result, { - draftPositions: manualPositionOverridesRef.current, - getPersonById, - automaticPositions: automaticPersonPositionsRef.current, - }) const canvas = canvasRef.current if (!canvas) return @@ -346,15 +330,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, onPersonPositionChange, editMod setLayoutReady(true) }, [graphRevision]) - useEffect(() => { - applyManualPersonPositions(layoutDataRef.current, { - draftPositions: manualPositionOverrides, - getPersonById, - automaticPositions: automaticPersonPositionsRef.current, - }) - needsRedrawRef.current = true - }, [graphRevision, manualPositionOverrides]) - // Sync selectedPersonId useEffect(() => { selectedPersonIdRef.current = selectedPersonId @@ -419,7 +394,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, onPersonPositionChange, editMod layoutData.children.forEach(node => { posMap.set(node.id, getAnimatedPos(node, timestamp)) }) - positionUnionJunctions(coupleBarMeta, posMap) const nodeMap = new Map() layoutData.children.forEach(n => nodeMap.set(n.id, n)) @@ -706,8 +680,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, onPersonPositionChange, editMod handleMouseDown({ clientX: t.clientX, clientY: t.clientY, preventDefault: () => e.preventDefault() }) } else if (e.touches.length === 2) { e.preventDefault() - // Un second doigt annule le glisser d'une personne (rien n'est enregistré) puis arrête le pan - cancelPersonDrag() + // Stop any ongoing single-finger pan before starting pinch handleMouseUp() const [a, b] = e.touches const dx = a.clientX - b.clientX @@ -745,16 +718,15 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, onPersonPositionChange, editMod // and mark hasDragged=true so the lift doesn't fire an accidental click const t = e.touches[0] handleMouseUp() - handleMouseDown({ clientX: t.clientX, clientY: t.clientY, preventDefault: () => e.preventDefault() }, { panOnly: true }) + handleMouseDown({ clientX: t.clientX, clientY: t.clientY, preventDefault: () => e.preventDefault() }) hasDragged.current = true } else { handleMouseUp() } }} onTouchCancel={() => { - // System interruption (notification, keyboard, etc.) — reset all touch state, sans enregistrer de position + // System interruption (notification, keyboard, etc.) — reset all touch state pinchRef.current = null - cancelPersonDrag() handleMouseUp() }} onClick={handleClick} diff --git a/frontend/src/components/galaxy/blockLayout.js b/frontend/src/components/galaxy/blockLayout.js index 2398f83..1b57f7d 100644 --- a/frontend/src/components/galaxy/blockLayout.js +++ b/frontend/src/components/galaxy/blockLayout.js @@ -210,9 +210,7 @@ export function computeBlockLayout(graphData = null) { const renvoisByUnion = new Map() for (const e of result.edges) { const u = e.sources[0], c = e.targets[0] - if (!externalEdges.has(`${u}>${c}`)) continue - e._renvoiEligible = true - if (Math.abs(centerX(u) - centerX(c)) <= RENVOI_MIN_DX) continue + if (!externalEdges.has(`${u}>${c}`) || Math.abs(centerX(u) - centerX(c)) <= RENVOI_MIN_DX) continue e._renvoi = true e._renvoiIndex = renvoisByUnion.get(u) || 0 renvoisByUnion.set(u, e._renvoiIndex + 1) diff --git a/frontend/src/components/galaxy/layoutPositions.js b/frontend/src/components/galaxy/layoutPositions.js deleted file mode 100644 index cb4f5d7..0000000 --- a/frontend/src/components/galaxy/layoutPositions.js +++ /dev/null @@ -1,84 +0,0 @@ -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 } -} - -export function captureAutomaticPersonPositions(layoutData) { - return new Map( - layoutData.children - .filter((node) => node._type === 'person') - .map((node) => [node.id, { x: node.x, y: node.y }]), - ) -} - -export function applyManualPersonPositions( - layoutData, - { draftPositions = {}, getPersonById, automaticPositions = new Map() } = {}, -) { - if (!layoutData) return - - for (const node of layoutData.children) { - if (node._type !== 'person') continue - - const personId = String(node._data.id) - const draftPosition = readLayoutPosition(draftPositions[personId]) - const person = getPersonById?.(node._data.id) || node._data - const position = draftPosition || readLayoutPosition(person) - - if (position) { - node.x = position.x - node.width / 2 - node.y = position.y - node.height / 2 - node._manualPosition = true - continue - } - - const automaticPosition = automaticPositions.get(node.id) - if (automaticPosition) { - node.x = automaticPosition.x - node.y = automaticPosition.y - } - node._manualPosition = false - } -} - -// 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) - const partner2 = positionById.get(p2Key) - if (!partner1 || !partner2) continue - - positionById.set(elkId, { - cx: (partner1.cx + partner2.cx) / 2, - cy: (partner1.cy + partner2.cy) / 2, - }) - } -} diff --git a/frontend/src/components/galaxy/layoutPositions.test.js b/frontend/src/components/galaxy/layoutPositions.test.js deleted file mode 100644 index c7b65d2..0000000 --- a/frontend/src/components/galaxy/layoutPositions.test.js +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - applyManualPersonPositions, - buildManualPositionOverrides, - captureAutomaticPersonPositions, - describePositionChange, - positionUnionJunctions, -} from './layoutPositions' - -const makeLayout = () => ({ - children: [ - { id: 'p-1', _type: 'person', _data: { id: '1', layoutX: 300, layoutY: 400 }, x: 10, y: 20, width: 80, height: 80 }, - { id: 'p-2', _type: 'person', _data: { id: '2' }, x: 110, y: 120, width: 80, height: 80 }, - { id: 'u-1', _type: 'union', _data: { id: 'u-1' }, x: 50, y: 70, width: 20, height: 20 }, - ], - edges: [{ id: 'edge-1', sources: ['p-1'], targets: ['p-2'] }], -}) - -describe('manual person layout positions', () => { - it('uses draft coordinates ahead of persisted coordinates and leaves relationships intact', () => { - const layout = makeLayout() - const automaticPositions = captureAutomaticPersonPositions(layout) - - applyManualPersonPositions(layout, { - draftPositions: { 1: { layoutX: 500, layoutY: 600 } }, - automaticPositions, - }) - - expect(layout.children[0]).toMatchObject({ x: 460, y: 560, _manualPosition: true }) - expect(layout.edges).toEqual([{ id: 'edge-1', sources: ['p-1'], targets: ['p-2'] }]) - expect(layout.children[2].x).toBe(50) - }) - - it('restores automatic coordinates when draft positions are discarded', () => { - const layout = makeLayout() - layout.children[0]._data.layoutX = null - layout.children[0]._data.layoutY = null - const automaticPositions = captureAutomaticPersonPositions(layout) - layout.children[0].x = 900 - layout.children[0].y = 900 - - applyManualPersonPositions(layout, { automaticPositions }) - - expect(layout.children[0]).toMatchObject({ x: 10, y: 20, _manualPosition: false }) - }) - - it('ignores incomplete coordinates and falls back to saved person coordinates', () => { - const layout = makeLayout() - - applyManualPersonPositions(layout, { - draftPositions: { 1: { layoutX: 500 } }, - automaticPositions: captureAutomaticPersonPositions(layout), - }) - - expect(layout.children[0]).toMatchObject({ x: 260, y: 360, _manualPosition: true }) - }) -}) - -describe('dynamic union junction positions', () => { - it('keeps the junction centered between partners after their positions change', () => { - const positions = new Map([ - ['p-1', { cx: 400, cy: 250 }], - ['p-2', { cx: 700, cy: 350 }], - ['u-1', { cx: 100, cy: 100 }], - ]) - - positionUnionJunctions( - [{ elkId: 'u-1', p1Key: 'p-1', p2Key: 'p-2' }], - positions, - ) - - expect(positions.get('u-1')).toEqual({ cx: 550, cy: 300 }) - }) - - it('leaves a junction unchanged if either partner position is unavailable', () => { - const originalJunction = { cx: 100, cy: 100 } - const positions = new Map([ - ['p-1', { cx: 400, cy: 250 }], - ['u-1', originalJunction], - ]) - - positionUnionJunctions( - [{ elkId: 'u-1', p1Key: 'p-1', p2Key: 'p-2' }], - 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/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index e7c4eab..00fcbff 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -7,7 +7,6 @@ import { PERSON_R, UNKNOWN_R, UNION_W, UNION_H, COMPACT_R, COMPACT_SCALE, MAX_ORBIT_MEDIAS, ORBIT_MEDIA_SIZE, COLORS, - RENVOI_MIN_DX, LOD_SHADOW_MIN_SCALE, LOD_DETAIL_MIN_SCALE, LOD_LABEL_FADE_IN, LOD_LABEL_FADE_OUT, CULL_MARGIN, } from './constants' import { getEntranceProgress, drawPartialBezier } from './utils' @@ -102,11 +101,6 @@ const BUS_TURN = 56 // En dessous de cet écart union → enfant, pas de virage const BUS_STRAIGHT_DX = 30 -export function shouldRenderRenvoi(edge, sourcePos, targetPos) { - if (!edge._renvoiEligible) return Boolean(edge._renvoi) - return Math.abs(sourcePos.cx - targetPos.cx) > RENVOI_MIN_DX -} - function busY(sourceId, sy) { let h = 0 for (let i = 0; i < sourceId.length; i++) h = (h * 31 + sourceId.charCodeAt(i)) | 0 @@ -257,7 +251,6 @@ export function drawFiliations(ctx, edges, posMap, nodeMap, nodeRandomData, entr renvoiHitAreas.length = 0 renvoiPills.length = 0 if (!edges) return - const renvoiIndexBySource = new Map() edges.forEach(edge => { const sourceId = edge.sources[0] @@ -294,13 +287,8 @@ export function drawFiliations(ctx, edges, posMap, nodeMap, nodeRandomData, entr ctx.globalAlpha = Math.min(1, edgeProgress * 1.5) const childR = nodeRadius(targetNode) - const isRenvoi = shouldRenderRenvoi(edge, sourcePos, targetPos) - if (isRenvoi) { - const index = edge._renvoiEligible - ? (renvoiIndexBySource.get(sourceId) || 0) - : (edge._renvoiIndex || 0) - if (edge._renvoiEligible) renvoiIndexBySource.set(sourceId, index + 1) - drawRenvoi(ctx, { ...edge, _renvoiIndex: index }, sourcePos, targetPos, targetNode, childR, view) + if (edge._renvoi) { + drawRenvoi(ctx, edge, sourcePos, targetPos, targetNode, childR, view) ctx.globalAlpha = 1 ctx.setLineDash([]) return diff --git a/frontend/src/components/galaxy/renderers.test.js b/frontend/src/components/galaxy/renderers.test.js deleted file mode 100644 index a225044..0000000 --- a/frontend/src/components/galaxy/renderers.test.js +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { RENVOI_MIN_DX } from './constants' -import { shouldRenderRenvoi } from './renderers' - -describe('renvoi rendering with manual positions', () => { - it('replaces an eligible renvoi with a direct link once the nodes are close enough', () => { - expect(shouldRenderRenvoi( - { _renvoi: true, _renvoiEligible: true }, - { cx: 0, cy: 0 }, - { cx: RENVOI_MIN_DX, cy: 200 }, - )).toBe(false) - }) - - it('retains a renvoi for an eligible edge that is still far away', () => { - expect(shouldRenderRenvoi( - { _renvoiEligible: true }, - { cx: 0, cy: 0 }, - { cx: RENVOI_MIN_DX + 1, cy: 200 }, - )).toBe(true) - }) - - it('preserves the precomputed behavior for non-eligible edges', () => { - expect(shouldRenderRenvoi({ _renvoi: true }, { cx: 0 }, { cx: 0 })).toBe(true) - expect(shouldRenderRenvoi({}, { cx: 0 }, { cx: 1000 })).toBe(false) - }) -}) diff --git a/frontend/src/components/galaxy/useGalaxyInteractions.js b/frontend/src/components/galaxy/useGalaxyInteractions.js index 984a22c..2273403 100644 --- a/frontend/src/components/galaxy/useGalaxyInteractions.js +++ b/frontend/src/components/galaxy/useGalaxyInteractions.js @@ -1,4 +1,4 @@ -import { useRef, useCallback, useEffect } from 'react' +import { useRef, useCallback } from 'react' import { getPersonMedias } from '../../data/mockData' import { MAX_ORBIT_MEDIAS, ORBIT_MEDIA_SIZE } from './constants' import { nodeRadius, renvoiHitAreas } from './renderers' @@ -8,7 +8,6 @@ import { getEntranceProgress } from './utils' const CURSOR_GRAB = 'default' const CURSOR_GRABBING = 'grabbing' -const CURSOR_NODE_GRAB = 'grab' const CURSOR_POINTER = 'pointer' const CURSOR_CROSSHAIR = 'crosshair' @@ -23,7 +22,6 @@ export function useGalaxyInteractions({ layoutDataRef, nodeRandomDataRef, entranceRef, hoveredNodeIdRef, hoveredMediaKeyRef, mouseWorldPosRef, getAnimatedPos, onPersonSelect, onMediaSelect, - onPersonPositionChange, editModeActive = false, annotationHandlers, minScale = 0.2, maxScale = 4, @@ -34,11 +32,6 @@ export function useGalaxyInteractions({ const lastMouse = useRef({ x: 0, y: 0 }) const hasDragged = useRef(false) const annotationDragging = useRef(false) - const draggedPersonRef = useRef(null) - const handleMouseUpRef = useRef(null) - const handleGlobalMouseUp = useCallback(() => { - handleMouseUpRef.current?.() - }, []) const getWorldPos = useCallback((e) => { const rect = canvasRef.current?.getBoundingClientRect() @@ -50,8 +43,7 @@ export function useGalaxyInteractions({ } }, [canvasRef, transformRef]) - // panOnly : un doigt qui reste après un pincement ne saisit pas de personne, il ne fait que déplacer l'arbre - const handleMouseDown = useCallback((e, { panOnly = false } = {}) => { + const handleMouseDown = useCallback((e) => { const ah = annotationHandlers const worldPos = getWorldPos(e) @@ -73,41 +65,11 @@ export function useGalaxyInteractions({ } } - if (editModeActive && !panOnly && (!ah || ah.activeTool === 'pointer') && worldPos && layoutDataRef.current) { - const now = performance.now() - for (let index = layoutDataRef.current.children.length - 1; index >= 0; index -= 1) { - const node = layoutDataRef.current.children[index] - if (node._type !== 'person') continue - const pos = getAnimatedPos(node, now) - if (Math.hypot(pos.cx - worldPos.x, pos.cy - worldPos.y) >= nodeRadius(node)) continue - - draggedPersonRef.current = { - node, - offsetX: worldPos.x - pos.cx, - offsetY: worldPos.y - pos.cy, - startClientX: e.clientX, - startClientY: e.clientY, - // Place d'origine, pour un glisser annulé (second doigt, interruption) - originX: node.x, - originY: node.y, - originManual: node._manualPosition, - } - hoveredNodeIdRef.current = node.id - hasDragged.current = false - lastMouse.current = { x: e.clientX, y: e.clientY } - setCanvasCursor(canvasRef, CURSOR_GRABBING) - if (e.nativeEvent?.type === 'mousedown' && typeof window !== 'undefined' && handleMouseUpRef.current) { - window.addEventListener('mouseup', handleGlobalMouseUp) - } - return - } - } - isDragging.current = true hasDragged.current = false lastMouse.current = { x: e.clientX, y: e.clientY } setCanvasCursor(canvasRef, CURSOR_GRABBING) - }, [canvasRef, annotationHandlers, getWorldPos, editModeActive, layoutDataRef, getAnimatedPos, hoveredNodeIdRef, handleGlobalMouseUp]) + }, [canvasRef, annotationHandlers, getWorldPos]) const handleMouseMove = useCallback((e) => { const rect = canvasRef.current?.getBoundingClientRect() @@ -121,19 +83,6 @@ export function useGalaxyInteractions({ const ah = annotationHandlers - if (draggedPersonRef.current) { - const drag = draggedPersonRef.current - if (!hasDragged.current && Math.hypot(e.clientX - drag.startClientX, e.clientY - drag.startClientY) <= 2) return - hasDragged.current = true - drag.node.x = mouseWorldPosRef.current.x - drag.offsetX - drag.node.width / 2 - drag.node.y = mouseWorldPosRef.current.y - drag.offsetY - drag.node.height / 2 - drag.node._manualPosition = true - hoveredNodeIdRef.current = drag.node.id - lastMouse.current = { x: e.clientX, y: e.clientY } - setCanvasCursor(canvasRef, CURSOR_GRABBING) - return - } - // Drawing tool: accumulate points if (ah && ah.activeTool === 'drawing' && ah.drawingPathRef.current) { ah.onCanvasMouseMove(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y) @@ -193,9 +142,7 @@ export function useGalaxyInteractions({ hoveredMediaKeyRef.current = newHoveredMedia if (!ah || ah.activeTool === 'pointer') { - const cursor = editModeActive && newHoveredNode - ? CURSOR_NODE_GRAB - : isOverClickable ? CURSOR_POINTER : CURSOR_GRAB + const cursor = isOverClickable ? CURSOR_POINTER : CURSOR_GRAB setCanvasCursor(canvasRef, cursor) } } @@ -210,7 +157,7 @@ export function useGalaxyInteractions({ x: targetTransformRef.current.x + dx, y: targetTransformRef.current.y + dy, } - }, [canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, hoveredNodeIdRef, hoveredMediaKeyRef, mouseWorldPosRef, getAnimatedPos, annotationHandlers, editModeActive]) + }, [canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, hoveredNodeIdRef, hoveredMediaKeyRef, mouseWorldPosRef, getAnimatedPos, annotationHandlers]) const handleMouseUp = useCallback(() => { const ah = annotationHandlers @@ -230,58 +177,15 @@ export function useGalaxyInteractions({ return } - if (draggedPersonRef.current) { - const { node } = draggedPersonRef.current - draggedPersonRef.current = null - if (typeof window !== 'undefined') { - window.removeEventListener('mouseup', handleGlobalMouseUp) - } - if (hasDragged.current) { - onPersonPositionChange?.(node._data.id, { - layoutX: node.x + node.width / 2, - layoutY: node.y + node.height / 2, - }) - } - hoveredNodeIdRef.current = node.id - setCanvasCursor(canvasRef, editModeActive ? CURSOR_NODE_GRAB : CURSOR_POINTER) - return - } - isDragging.current = false const isOverClickable = hoveredNodeIdRef.current || hoveredMediaKeyRef.current setCanvasCursor(canvasRef, isOverClickable ? CURSOR_POINTER : CURSOR_GRAB) - }, [canvasRef, hoveredNodeIdRef, hoveredMediaKeyRef, mouseWorldPosRef, annotationHandlers, onPersonPositionChange, editModeActive, handleGlobalMouseUp]) - - useEffect(() => { - handleMouseUpRef.current = handleMouseUp - }, [handleMouseUp]) - - useEffect(() => () => { - if (typeof window !== 'undefined') { - window.removeEventListener('mouseup', handleGlobalMouseUp) - } - }, [handleGlobalMouseUp]) - - // Annule le glisser d'une personne sans rien enregistrer : la personne revient à sa place d'avant - const cancelPersonDrag = useCallback(() => { - const drag = draggedPersonRef.current - if (!drag) return - draggedPersonRef.current = null - const { node, originX, originY, originManual } = drag - node.x = originX - node.y = originY - node._manualPosition = originManual - if (typeof window !== 'undefined') { - window.removeEventListener('mouseup', handleGlobalMouseUp) - } - hoveredNodeIdRef.current = node.id - setCanvasCursor(canvasRef, editModeActive ? CURSOR_NODE_GRAB : CURSOR_POINTER) - }, [canvasRef, hoveredNodeIdRef, editModeActive, handleGlobalMouseUp]) + }, [canvasRef, hoveredNodeIdRef, hoveredMediaKeyRef, mouseWorldPosRef, annotationHandlers]) const handleMouseLeave = useCallback(() => { isDragging.current = false annotationDragging.current = false - setCanvasCursor(canvasRef, draggedPersonRef.current ? CURSOR_GRABBING : CURSOR_GRAB) + setCanvasCursor(canvasRef, CURSOR_GRAB) mouseWorldPosRef.current = { x: -9999, y: -9999 } hoveredNodeIdRef.current = null hoveredMediaKeyRef.current = null @@ -387,6 +291,6 @@ export function useGalaxyInteractions({ return { isDragging, isZooming, zoomTimeoutRef, hasDragged, handleMouseDown, handleMouseMove, handleMouseUp, - handleMouseLeave, handleWheel, handleClick, cancelPersonDrag, + handleMouseLeave, handleWheel, handleClick, } } diff --git a/frontend/src/data/mockData.js b/frontend/src/data/mockData.js index 57dd190..7a1084d 100644 --- a/frontend/src/data/mockData.js +++ b/frontend/src/data/mockData.js @@ -100,8 +100,6 @@ function mapRemotePersonToLocal(person, current = null) { return { id: person?.id ?? current?.id, - layoutX: Number.isFinite(person?.layoutX) ? person.layoutX : null, - layoutY: Number.isFinite(person?.layoutY) ? person.layoutY : null, firstName: person?.firstName || current?.firstName || t('Inconnu'), lastName: person?.lastName || current?.lastName || '', birthName: person?.birthName ?? current?.birthName ?? null, diff --git a/frontend/src/data/mockData.test.js b/frontend/src/data/mockData.test.js index e5b41c6..91ee070 100644 --- a/frontend/src/data/mockData.test.js +++ b/frontend/src/data/mockData.test.js @@ -13,8 +13,6 @@ describe('loadGraphData avatar mapping', () => { lastName: 'Martin', birthDate: null, deathDate: null, - layoutX: 420.5, - layoutY: 275.25, notes: null, avatarUrlPath: '/trees/t-1/persons/p-1/avatar', }, @@ -48,7 +46,6 @@ describe('loadGraphData avatar mapping', () => { ) expect(getPersonById('p-1')?.photo).toBe('/api/trees/t-1/persons/p-1/avatar?token=abc') - expect(getPersonById('p-1')).toMatchObject({ layoutX: 420.5, layoutY: 275.25 }) expect(getPersonById('p-2')?.photo).toBeNull() expect(getPersonMedias('p-2')[0]?.source).toBe('Album familial') }) diff --git a/frontend/src/hooks/useEditMode.js b/frontend/src/hooks/useEditMode.js index 9498090..d22e8aa 100644 --- a/frontend/src/hooks/useEditMode.js +++ b/frontend/src/hooks/useEditMode.js @@ -21,8 +21,6 @@ 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({}) @@ -153,7 +151,6 @@ 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) }, []) /** @@ -167,7 +164,6 @@ export function useEditMode({ treeId, canEdit }) { draftDataRef.current = {} setHasDraft(false) setDraftTimestamp(null) - setDraftRevision((current) => current + 1) } catch (err) { console.error('Failed to clear draft:', err) } @@ -185,7 +181,6 @@ export function useEditMode({ treeId, canEdit }) { isActive, hasDraft, draftTimestamp, - draftRevision, // Actions activate, diff --git a/frontend/src/i18n/en/app.js b/frontend/src/i18n/en/app.js index 922f6dd..323173a 100644 --- a/frontend/src/i18n/en/app.js +++ b/frontend/src/i18n/en/app.js @@ -97,5 +97,4 @@ export default { 'Ressource introuvable.': 'Not found.', 'Conflit détecté (email ou slug déjà utilisé).': 'Conflict detected (email or slug already in use).', "Erreur backend. Vérifiez que l'API et la base sont lancées.": 'Server error. Check that the API and database are running.', - "Certaines modifications n'ont pas pu être enregistrées. Vérifiez puis réessayez.": "Some changes couldn't be saved. Check them and try again.", } diff --git a/frontend/src/i18n/en/compte.js b/frontend/src/i18n/en/compte.js index d9f36c4..82a85cf 100644 --- a/frontend/src/i18n/en/compte.js +++ b/frontend/src/i18n/en/compte.js @@ -123,15 +123,6 @@ export default { "Dernière vérification": "Final check", "Supprimer « {name} »": "Delete “{name}”", "Tapez le nom exact de l'arbre pour confirmer.": "Type the exact name of the tree to confirm.", - "Disposition de l'arbre": "Tree layout", - "Les personnes déplacées à la main reprennent la place que leur donne l'arbre.": "People moved by hand go back to the place the tree gives them.", - "Rétablir la disposition automatique": "Restore the automatic layout", - "Rétablir la disposition automatique ?": "Restore the automatic layout?", - "Les déplacements faits à la main seront effacés pour toute la famille.": "Positions moved by hand will be erased for the whole family.", - "Rétablir": "Restore", - "Rétablissement en cours": "Restoring", - "La disposition automatique est rétablie.": "The automatic layout is restored.", - "Impossible de rétablir la disposition.": "Couldn't restore the layout.", // Onglet Visites (TreeVisitsSection) "téléphone": "phone", "tablette": "tablet", diff --git a/frontend/src/i18n/en/contribution.js b/frontend/src/i18n/en/contribution.js index 8ae1cb4..27e06d3 100644 --- a/frontend/src/i18n/en/contribution.js +++ b/frontend/src/i18n/en/contribution.js @@ -32,10 +32,6 @@ export default { 'Date de décès': 'Date of death', 'Lieu de naissance': 'Birthplace', 'Métier': 'Occupation', - "Position dans l'arbre": "Position in the tree", - 'Déplacée': 'Moved', - 'Replacée automatiquement': 'Placed automatically again', - 'Automatique': 'Automatic', 'Nationalité': 'Nationality', 'Région': 'Region', 'Notes': 'Notes', diff --git a/frontend/src/utils/graphAdapter.js b/frontend/src/utils/graphAdapter.js index d38037d..7fb09ca 100644 --- a/frontend/src/utils/graphAdapter.js +++ b/frontend/src/utils/graphAdapter.js @@ -46,8 +46,6 @@ export function adaptGraphForGalaxy(apiGraph, _treeId) { // Adapter les personnes const adaptedPersons = persons.map((p) => ({ id: p.id, - layoutX: Number.isFinite(p.layoutX) ? p.layoutX : null, - layoutY: Number.isFinite(p.layoutY) ? p.layoutY : null, firstName: p.firstName || '', lastName: p.lastName || '', birthName: p.birthName || null,