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,