mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-13 08:22:03 +00:00
Retour au placement automatique : abandon du placement libre
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
8fa2808b5d
commit
8574dfed9e
29 files changed
+26
-860
No files matched your search
+1
-1
@@ -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/<nom>` protégée par un mot de passe de partage, qui permet de regarder et de proposer des ajouts.
|
||||
|
||||
@@ -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/<name>` protected by a sharing password. With it, relatives can look around and suggest additions.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
ALTER TABLE "persons"
|
||||
ADD COLUMN "layoutX" DOUBLE PRECISION,
|
||||
ADD COLUMN "layoutY" DOUBLE PRECISION;
|
||||
@@ -198,8 +198,6 @@ model Person {
|
||||
birthName String?
|
||||
birthDate DateTime?
|
||||
deathDate DateTime?
|
||||
layoutX Float?
|
||||
layoutY Float?
|
||||
birthPlace String?
|
||||
profession String?
|
||||
region String?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
+2
-69
@@ -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 && (
|
||||
<div className="contrib-success-toast" role="status">{draftNotice}</div>
|
||||
)}
|
||||
{editSaveError && (
|
||||
<div className="contrib-success-toast" role="alert">{editSaveError}</div>
|
||||
)}
|
||||
{contribSuccess && (
|
||||
<div className="contrib-success-toast" role="status">
|
||||
{t("Merci, c'est envoyé ! Ça apparaîtra dans l'arbre une fois relu.")}
|
||||
@@ -2890,9 +2826,6 @@ function App() {
|
||||
<Galaxy
|
||||
onPersonSelect={handlePersonSelect}
|
||||
onMediaSelect={handleMediaSelectFresh}
|
||||
onPersonPositionChange={handlePersonPositionChange}
|
||||
editModeActive={editMode.isActive}
|
||||
manualPositionOverrides={manualPositionOverrides}
|
||||
selectedPersonId={tree.selectedPerson?.id || null}
|
||||
searchHighlightIds={search.matchedPersonIds}
|
||||
graphRevision={tree.graphRevision}
|
||||
@@ -2984,8 +2917,6 @@ function App() {
|
||||
onGedcomImported={handleGedcomImported}
|
||||
onOpenPhotoStart={() => { setAdminPanelVisible(false); openPhotoStart() }}
|
||||
onClose={() => setAdminPanelVisible(false)}
|
||||
hasManualPositions={persons.some((person) => Number.isFinite(person.layoutX) && Number.isFinite(person.layoutY))}
|
||||
onLayoutReset={handleResetTreeLayout}
|
||||
/>
|
||||
<PhotoStartPanel
|
||||
visible={photoStart.visible}
|
||||
@@ -3037,3 +2968,5 @@ function App() {
|
||||
export default App
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -122,16 +122,6 @@ export async function deleteTree(treeId, token) {
|
||||
return data
|
||||
}
|
||||
|
||||
// Efface les positions manuelles de toutes les personnes (admin) : la disposition redevient automatique
|
||||
export async function resetTreeLayout(treeId, token) {
|
||||
return apiRequest(`/trees/${encodeURIComponent(treeId)}/layout/reset`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createPerson(treeId, payload, token) {
|
||||
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
fetchTreeSettings,
|
||||
getTree,
|
||||
importGedcom,
|
||||
resetTreeLayout,
|
||||
updateTree,
|
||||
updateTreeSettings,
|
||||
} from '../api/treeApi'
|
||||
@@ -32,8 +31,6 @@ const AdminPanel = ({
|
||||
onTreeDeleted,
|
||||
onGedcomImported,
|
||||
onOpenPhotoStart,
|
||||
hasManualPositions = false,
|
||||
onLayoutReset,
|
||||
}) => {
|
||||
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 = ({
|
||||
<div
|
||||
className="pz-overlay tree-settings-overlay"
|
||||
onClick={() => {
|
||||
if (!showDeleteModal && !showLayoutResetModal) onClose?.()
|
||||
if (!showDeleteModal) onClose?.()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -452,20 +428,6 @@ const AdminPanel = ({
|
||||
|
||||
{section === 'danger' && (
|
||||
<div className="ts-panel">
|
||||
{hasManualPositions && (
|
||||
<div className="ts-row">
|
||||
<div className="ts-row-text">
|
||||
<strong>{t("Disposition de l'arbre")}</strong>
|
||||
<p className="pz-small">{t("Les personnes déplacées à la main reprennent la place que leur donne l'arbre.")}</p>
|
||||
</div>
|
||||
<div className="ts-row-actions">
|
||||
<button type="button" className="pz-btn pz-btn--secondary pz-btn--sm" onClick={() => setShowLayoutResetModal(true)} disabled={saving}>
|
||||
{t('Rétablir la disposition automatique')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ts-danger">
|
||||
<div className="ts-row-text">
|
||||
<strong>{t('Supprimer cet arbre')}</strong>
|
||||
@@ -483,26 +445,6 @@ const AdminPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showLayoutResetModal && (
|
||||
<div className="pz-overlay ts-delete-overlay" onClick={(event) => { event.stopPropagation(); if (!saving) setShowLayoutResetModal(false) }}>
|
||||
<div className="pz-modal settings-delete-modal" role="dialog" aria-modal="true" aria-labelledby="treeLayoutResetTitle" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="pz-modal-head">
|
||||
<p className="pz-eyebrow">{t('Dernière vérification')}</p>
|
||||
<h3 id="treeLayoutResetTitle" className="pz-title">{t('Rétablir la disposition automatique ?')}</h3>
|
||||
<p className="pz-sub">{t('Les déplacements faits à la main seront effacés pour toute la famille.')}</p>
|
||||
</div>
|
||||
<div className="pz-modal-actions">
|
||||
<button type="button" className="pz-btn pz-btn--ghost" onClick={() => setShowLayoutResetModal(false)} disabled={saving}>
|
||||
{t('Annuler')}
|
||||
</button>
|
||||
<button type="button" className="pz-btn pz-btn--danger danger-action" onClick={handleResetLayout} disabled={saving}>
|
||||
<PzBusy busy={saving} busyLabel={t('Rétablissement en cours')}>{t('Rétablir')}</PzBusy>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDeleteModal && (
|
||||
<div className="pz-overlay ts-delete-overlay" onClick={(event) => { event.stopPropagation(); if (!saving) setShowDeleteModal(false) }}>
|
||||
<div className="pz-modal settings-delete-modal" role="dialog" aria-modal="true" aria-labelledby="treeDeleteTitle" onClick={(event) => event.stopPropagation()}>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user