mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Merge pull request #141 from IliesAllali/fix/annotations-ancrees
Annotations rattachées à une personne, texte posé modifiable
This commit is contained in:
6 files changed
+249
-87
No files matched your search
+19
-1
@@ -834,12 +834,28 @@ function App() {
|
||||
}
|
||||
}, [accountEmail, linkedSelfPersonId, tree.graphRevision, tree.treeContext.treeId])
|
||||
|
||||
// Annotations canvas (dessin, stickers, texte)
|
||||
// Annotations canvas (dessin, stickers, texte). La galaxie remplit annotationViewRef à chaque image :
|
||||
// centres des portraits de l'onglet affiché et annotations dessinées, pour poser et toucher au bon endroit
|
||||
const annotationViewRef = useRef(null)
|
||||
const annot = useAnnotations({
|
||||
editModeActive: editMode.isActive,
|
||||
updateDraft: editMode.updateDraft,
|
||||
viewRef: annotationViewRef,
|
||||
})
|
||||
|
||||
// Anciennes annotations rattachées à leur personne par la galaxie : enregistrées une fois, style seul
|
||||
// (les autres champs ne sont pas envoyés). Sans droit d'administration, l'ancre reste calculée à chaque ouverture.
|
||||
const persistAnnotationAnchors = useCallback((anchored) => {
|
||||
const token = auth.userAuth.token
|
||||
const treeId = tree.treeContext.treeId
|
||||
if (isDemoMode || !userRole.isAdmin || !token || !treeId) return
|
||||
const update = anchored
|
||||
.filter((ann) => !String(ann.id).startsWith('local-'))
|
||||
.map((ann) => ({ id: ann.id, style: ann.style }))
|
||||
if (!update.length) return
|
||||
batchAnnotations(treeId, { update }, token).catch((err) => console.error('Failed to save annotation anchors:', err))
|
||||
}, [isDemoMode, userRole.isAdmin, auth.userAuth.token, tree.treeContext.treeId])
|
||||
|
||||
// Quitter la fiche (changer de personne, la fermer, sortir du mode modification, envoyer, ouvrir un
|
||||
// souvenir) : ses changements sont enregistrés d'abord. Faux si ça échoue : la fiche reste ouverte
|
||||
// avec le message d'erreur, la saisie n'est pas jetée. En démo rien n'est persisté : on ne sauvegarde pas.
|
||||
@@ -3203,6 +3219,8 @@ function App() {
|
||||
reorderEnabled={canReorderSiblings}
|
||||
onReorderSiblings={handleReorderSiblings}
|
||||
familyTabs={familyTabs}
|
||||
annotationViewRef={annotationViewRef}
|
||||
onAnnotationsAnchored={persistAnnotationAnchors}
|
||||
selfPersonId={linkedSelfPersonId || null}
|
||||
/>
|
||||
<ZoomControl zoomApiRef={galaxyZoomApiRef} />
|
||||
|
||||
@@ -10,7 +10,8 @@ const USE_BLOCK_LAYOUT = typeof window === 'undefined' || new URLSearchParams(wi
|
||||
import { findPathBFS } from './galaxy/pathfinding'
|
||||
import { drawBackground, drawCoupleLinks, drawFiliations, drawAnnotations, drawPathHighlight, drawNodes, drawRenvoiPills, nodeRadius } from './galaxy/renderers'
|
||||
import { buildSiblingGroups, moveInOrder, pickSlot, rubberBand, springStep, isSettled, buildOrderPayload, filiationsWithOrder } from './galaxy/siblingReorder'
|
||||
import { annotations as annotationData } from '../data/mockData'
|
||||
import { annotations as annotationData, updateAnnotation as updateAnnotationInStore } from '../data/mockData'
|
||||
import { anchorLegacy, layoutCenters, placeAnnotations, readAnchor } from './annotations/anchor'
|
||||
import { useImageCache } from './galaxy/useImageCache'
|
||||
import { useGalaxyInteractions } from './galaxy/useGalaxyInteractions'
|
||||
import { t as tr } from '../i18n/index.js'
|
||||
@@ -42,7 +43,7 @@ const DROP_SPRING = { stiffness: 480, dampingRatio: 0.72 }
|
||||
const centerX = (node) => node.x + node.width / 2
|
||||
const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true, selfPersonId = null }) => {
|
||||
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null }) => {
|
||||
const canvasRef = useRef(null)
|
||||
const layoutDataRef = useRef(null)
|
||||
// Onglets de familles (réglage `familyTabs`) : la liste des familles de l'arbre, calculée une fois par
|
||||
@@ -55,6 +56,8 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
const activeTabRef = useRef(null)
|
||||
const familyTabsRef = useRef(familyTabs)
|
||||
useEffect(() => { familyTabsRef.current = familyTabs }, [familyTabs])
|
||||
const onAnnotationsAnchoredRef = useRef(onAnnotationsAnchored)
|
||||
useEffect(() => { onAnnotationsAnchoredRef.current = onAnnotationsAnchored }, [onAnnotationsAnchored])
|
||||
const startRefRef = useRef(focusPersonId)
|
||||
useEffect(() => { startRefRef.current = focusPersonId; setActiveKey(focusPersonId != null ? String(focusPersonId) : null) }, [focusPersonId])
|
||||
// Lu au calcul du cadrage et par le réordonnancement : la référence de l'arbre affiché
|
||||
@@ -483,7 +486,16 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
// là, reliée par sa ligne directe (Patch, 21/10 : « je veux me voir tout le temps »)
|
||||
const line = tab?.personIds ? lineToFamily(selfPersonId ?? startRefRef.current ?? tabsCacheRef.current.tabs[0]?.resolvedRef, tab.personIds) : new Set()
|
||||
const graph = tab?.personIds ? subgraphOf(new Set([...tab.personIds, ...line])) : null
|
||||
// Annotations d'avant l'ancrage (x, y seuls) : rattachées une fois à la personne la plus proche dans le
|
||||
// placement de l'onglet de départ, puis enregistrées. Calculé avant l'arbre affiché (coupleBarMeta, voir plus haut)
|
||||
const legacy = annotationData.some((a) => !readAnchor(a))
|
||||
const departure = legacy && USE_BLOCK_LAYOUT && graph ? computeBlockLayout(null, startRefRef.current, { familyTabs }) : null
|
||||
const result = USE_BLOCK_LAYOUT ? computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs }) : computeLayout(null, focusPersonIdRef.current)
|
||||
if (legacy) {
|
||||
const anchored = anchorLegacy(annotationData, layoutCenters(departure || result))
|
||||
for (const ann of anchored) updateAnnotationInStore(ann.id, { style: ann.style })
|
||||
if (anchored.length) onAnnotationsAnchoredRef.current?.(anchored)
|
||||
}
|
||||
// Les pastilles portent le nom de l'onglet où elles mènent (prénoms ajoutés aux homonymes)
|
||||
for (const n of result.children) {
|
||||
const target = n._familyTab && tabsCacheRef.current.tabs.find((tb) => tb.key === n._familyTab.tabKey)
|
||||
@@ -912,51 +924,14 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
liftScales: liftScalesRef.current,
|
||||
})
|
||||
drawRenvoiPills(ctx)
|
||||
// Annotations dessinées APRES les noeuds pour que stickers/texte soient au-dessus
|
||||
// Annotations : ancrer aux personnes proches pour qu'elles suivent le layout
|
||||
const personPosMap = new Map()
|
||||
layoutData.children.forEach(node => {
|
||||
if (node._type === 'person') {
|
||||
const pos = posMap.get(node.id)
|
||||
if (pos) personPosMap.set(node._data.id, pos)
|
||||
}
|
||||
})
|
||||
const anchoredAnnotations = annotationData.map((ann) => {
|
||||
let anchorId = ann.anchorId || null
|
||||
let offsetX = ann.offsetX ?? 0
|
||||
let offsetY = ann.offsetY ?? 0
|
||||
|
||||
if (!anchorId) {
|
||||
// Trouver la personne la plus proche et stocker l'offset
|
||||
let nearestId = null
|
||||
let nearestD2 = Infinity
|
||||
for (const [pid, pos] of personPosMap.entries()) {
|
||||
const dx = ann.x - pos.cx
|
||||
const dy = ann.y - pos.cy
|
||||
const d2 = dx * dx + dy * dy
|
||||
if (d2 < nearestD2) {
|
||||
nearestD2 = d2
|
||||
nearestId = pid
|
||||
offsetX = dx
|
||||
offsetY = dy
|
||||
}
|
||||
}
|
||||
if (nearestId) {
|
||||
anchorId = nearestId
|
||||
ann.anchorId = nearestId
|
||||
ann.offsetX = offsetX
|
||||
ann.offsetY = offsetY
|
||||
}
|
||||
}
|
||||
|
||||
const base = anchorId ? personPosMap.get(anchorId) : null
|
||||
const drawX = base ? base.cx + offsetX : ann.x
|
||||
const drawY = base ? base.cy + offsetY : ann.y
|
||||
// Keep underlying data in sync so hitTest/drag use the drawn position
|
||||
ann.x = drawX
|
||||
ann.y = drawY
|
||||
return { ...ann, x: drawX, y: drawY }
|
||||
})
|
||||
// Annotations dessinées APRES les noeuds pour que stickers/texte soient au-dessus.
|
||||
// Chacune suit le portrait de sa personne dans le placement affiché, absente si la personne n'y est pas.
|
||||
// Les centres et les positions servent aussi à poser, toucher et déplacer (useAnnotations)
|
||||
// Un texte rouvert pour être modifié n'est pas dessiné sous sa saisie (editingId, posé par useAnnotations)
|
||||
const annotationCenters = layoutCenters(layoutData, offsetsRef.current)
|
||||
const editingId = annotationViewRef?.current?.editingId ?? null
|
||||
const anchoredAnnotations = placeAnnotations(annotationData, annotationCenters)
|
||||
if (annotationViewRef) annotationViewRef.current = { editingId, centers: annotationCenters, shown: anchoredAnnotations }
|
||||
|
||||
// Afficher les annotations en dernier, après l'animation d'entrée
|
||||
const stickerPreview = annotationHandlers?.activeTool === 'sticker' && annotationHandlers?.selectedSticker
|
||||
@@ -967,7 +942,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
if (entranceAlpha > 0) {
|
||||
drawAnnotations(
|
||||
ctx,
|
||||
anchoredAnnotations,
|
||||
editingId ? anchoredAnnotations.filter((a) => a.id !== editingId) : anchoredAnnotations,
|
||||
annotationHandlers?.selectedAnnotationId ?? null,
|
||||
annotationHandlers?.drawingPathRef?.current ?? null,
|
||||
stickerPreview,
|
||||
|
||||
@@ -18,6 +18,9 @@ export default function TextInputOverlay({
|
||||
|
||||
if (!textInputState) return null
|
||||
|
||||
// Texte déjà posé et rouvert : sa saisie et son style à lui
|
||||
const style = textInputState.style || currentStyle
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
@@ -46,14 +49,16 @@ export default function TextInputOverlay({
|
||||
}}
|
||||
>
|
||||
<input
|
||||
key={textInputState.editingId || 'new'}
|
||||
ref={inputRef}
|
||||
className="text-input-overlay__input"
|
||||
type="text"
|
||||
defaultValue={textInputState.text || ''}
|
||||
placeholder={t('Saisir du texte...')}
|
||||
style={{
|
||||
color: currentStyle.color,
|
||||
fontSize: currentStyle.fontSize,
|
||||
background: currentStyle.stabilo
|
||||
color: style.color,
|
||||
fontSize: style.fontSize,
|
||||
background: style.stabilo
|
||||
? 'rgba(237, 182, 164, 0.5)'
|
||||
: 'rgba(255, 255, 255, 0.85)',
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Ancre d'une annotation, rangée dans son style : la personne la plus proche au moment où on la pose et le
|
||||
// décalage à son portrait. Chaque onglet de famille recalcule le placement : x et y ne disent plus où la
|
||||
// redessiner, l'ancre si (Anaïs, 23/10 : un texte posé chez une famille réapparaissait au même endroit chez l'autre).
|
||||
|
||||
export function readAnchor(ann) {
|
||||
const a = ann?.style?.anchor
|
||||
if (!a || a.personId == null || !Number.isFinite(a.dx) || !Number.isFinite(a.dy)) return null
|
||||
return { personId: String(a.personId), dx: a.dx, dy: a.dy }
|
||||
}
|
||||
|
||||
// Centre de chaque portrait du placement, par id de personne. `offsets` : glissements en cours (fratrie réordonnée)
|
||||
export function layoutCenters(layout, offsets = null) {
|
||||
const centers = new Map()
|
||||
for (const node of layout?.children || []) {
|
||||
if (node._type !== 'person' || !node._data) continue
|
||||
centers.set(String(node._data.id), {
|
||||
cx: node.x + node.width / 2 + (offsets?.get(node.id)?.x || 0),
|
||||
cy: node.y + node.height / 2,
|
||||
})
|
||||
}
|
||||
return centers
|
||||
}
|
||||
|
||||
export function nearestAnchor(x, y, centers) {
|
||||
let best = null
|
||||
let bestD2 = Infinity
|
||||
for (const [personId, c] of centers || []) {
|
||||
const dx = x - c.cx
|
||||
const dy = y - c.cy
|
||||
const d2 = dx * dx + dy * dy
|
||||
if (d2 < bestD2) { bestD2 = d2; best = { personId, dx, dy } }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// Style rattaché à la personne la plus proche de (x, y) ; les autres clés du style sont gardées
|
||||
export function withAnchor(style, x, y, centers) {
|
||||
const anchor = nearestAnchor(x, y, centers)
|
||||
return anchor ? { ...(style || {}), anchor } : { ...(style || {}) }
|
||||
}
|
||||
|
||||
// Annotations à dessiner dans ce placement, à leur position : portrait de la personne + décalage. Celles dont
|
||||
// la personne n'est pas dessinée ici sont laissées de côté. Sans ancre (placement vide), elle reste à x, y.
|
||||
export function placeAnnotations(list, centers) {
|
||||
const shown = []
|
||||
for (const ann of list) {
|
||||
const a = readAnchor(ann)
|
||||
if (!a) { shown.push(ann); continue }
|
||||
const c = centers.get(a.personId)
|
||||
if (c) shown.push({ ...ann, x: c.cx + a.dx, y: c.cy + a.dy })
|
||||
}
|
||||
return shown
|
||||
}
|
||||
|
||||
// Annotations d'avant l'ancrage (x, y seuls) : rattachées à la personne la plus proche de leurs coordonnées
|
||||
// dans `centers`, le placement de l'onglet de départ. Seules celles qui ont trouvé une personne sont rendues.
|
||||
export function anchorLegacy(list, centers) {
|
||||
const anchored = []
|
||||
for (const ann of list) {
|
||||
if (readAnchor(ann)) continue
|
||||
const style = withAnchor(ann.style, ann.x, ann.y, centers)
|
||||
if (style.anchor) anchored.push({ ...ann, style })
|
||||
}
|
||||
return anchored
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { anchorLegacy, layoutCenters, nearestAnchor, placeAnnotations, readAnchor, withAnchor } from './anchor'
|
||||
|
||||
// Portrait de 100 × 100 posé en (x, y) : centre en (x + 50, y + 50)
|
||||
const node = (personId, x, y) => ({ id: `p-${personId}`, _type: 'person', _data: { id: personId }, x, y, width: 100, height: 100 })
|
||||
|
||||
// Deux onglets d'un même arbre : a et b dans la famille Combro, c et d dans la famille des Bois,
|
||||
// b est la charnière (dessinée des deux côtés, ailleurs à chaque fois)
|
||||
const combro = { children: [node('a', 0, 0), node('b', 300, 0), { id: 'u-1', _type: 'union', x: 200, y: 40, width: 10, height: 10 }] }
|
||||
const desBois = { children: [node('b', 900, 500), node('c', 1200, 500), node('d', 1500, 500)] }
|
||||
|
||||
describe('ancrage des annotations', () => {
|
||||
it('rattache à la personne la plus proche, avec le décalage à son portrait', () => {
|
||||
expect(nearestAnchor(30, -40, layoutCenters(combro))).toEqual({ personId: 'a', dx: -20, dy: -90 })
|
||||
expect(nearestAnchor(330, 60, layoutCenters(combro))).toEqual({ personId: 'b', dx: -20, dy: 10 })
|
||||
expect(nearestAnchor(0, 0, new Map())).toBeNull()
|
||||
})
|
||||
|
||||
it('ne compte que les personnes et suit le glissement d’un portrait en cours', () => {
|
||||
const centers = layoutCenters(combro, new Map([['p-a', { x: 40 }]]))
|
||||
expect([...centers.keys()]).toEqual(['a', 'b'])
|
||||
expect(centers.get('a')).toEqual({ cx: 90, cy: 50 })
|
||||
})
|
||||
|
||||
it('garde les autres clés du style', () => {
|
||||
const style = withAnchor({ color: '#93402A', fontSize: 18 }, 30, -40, layoutCenters(combro))
|
||||
expect(style).toEqual({ color: '#93402A', fontSize: 18, anchor: { personId: 'a', dx: -20, dy: -90 } })
|
||||
})
|
||||
|
||||
it('n’affiche un texte que dans l’onglet où sa personne est dessinée', () => {
|
||||
const texte = { id: 't1', type: 'text', x: 30, y: -40, content: 'Combro', style: { anchor: { personId: 'a', dx: -20, dy: -90 } } }
|
||||
expect(placeAnnotations([texte], layoutCenters(combro))).toMatchObject([{ id: 't1', x: 30, y: -40 }])
|
||||
expect(placeAnnotations([texte], layoutCenters(desBois))).toEqual([])
|
||||
})
|
||||
|
||||
it('suit sa personne quand le placement change (charnière des deux côtés)', () => {
|
||||
const texte = { id: 't2', type: 'text', x: 0, y: 0, content: 'b', style: { anchor: { personId: 'b', dx: -20, dy: 10 } } }
|
||||
expect(placeAnnotations([texte], layoutCenters(combro))[0]).toMatchObject({ x: 330, y: 60 })
|
||||
expect(placeAnnotations([texte], layoutCenters(desBois))[0]).toMatchObject({ x: 930, y: 560 })
|
||||
})
|
||||
|
||||
it('laisse à ses coordonnées une annotation sans ancre', () => {
|
||||
const libre = { id: 't3', type: 'text', x: 5, y: 6, content: 'x', style: {} }
|
||||
expect(placeAnnotations([libre], layoutCenters(desBois))).toEqual([libre])
|
||||
})
|
||||
|
||||
it('refuse une ancre incomplète', () => {
|
||||
expect(readAnchor({ style: { anchor: { personId: 'a', dx: 1 } } })).toBeNull()
|
||||
expect(readAnchor({ style: { anchor: { personId: 7, dx: 1, dy: 2 } } })).toEqual({ personId: '7', dx: 1, dy: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('ancrage à la volée des anciennes annotations', () => {
|
||||
it('rattache dans le placement de l’onglet de départ, sans toucher au reste', () => {
|
||||
const ancienne = { id: 'old', type: 'sticker', x: 330, y: 60, content: '⭐', style: { fontSize: 40, rotation: 0.1 }, zIndex: 10 }
|
||||
const [ancree] = anchorLegacy([ancienne], layoutCenters(combro))
|
||||
expect(ancree).toEqual({ ...ancienne, style: { fontSize: 40, rotation: 0.1, anchor: { personId: 'b', dx: -20, dy: 10 } } })
|
||||
// Puis elle s'affiche au même point dans l'onglet de départ
|
||||
expect(placeAnnotations([ancree], layoutCenters(combro))[0]).toMatchObject({ x: 330, y: 60 })
|
||||
})
|
||||
|
||||
it('ne repasse pas sur une annotation déjà ancrée', () => {
|
||||
const ancree = { id: 'a1', type: 'text', x: 0, y: 0, content: 'x', style: { anchor: { personId: 'a', dx: 0, dy: 0 } } }
|
||||
expect(anchorLegacy([ancree], layoutCenters(desBois))).toEqual([])
|
||||
})
|
||||
|
||||
it('n’ancre rien dans un placement sans personne', () => {
|
||||
expect(anchorLegacy([{ id: 'x', x: 0, y: 0, style: {} }], new Map())).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
updateAnnotation as updateAnnotationInStore,
|
||||
removeAnnotation as removeAnnotationFromStore,
|
||||
} from '../data/mockData'
|
||||
import { withAnchor } from '../components/annotations/anchor'
|
||||
|
||||
/**
|
||||
* Hook pour gerer les annotations canvas (dessin, stickers, texte).
|
||||
@@ -12,13 +13,14 @@ import {
|
||||
* @param {Object} options
|
||||
* @param {boolean} options.editModeActive
|
||||
* @param {Function} options.updateDraft - merge data into edit draft
|
||||
* @param {Object} options.viewRef - rempli par la galaxie : centres des portraits et annotations affichées
|
||||
* @returns {Object} API annotations
|
||||
*/
|
||||
export function useAnnotations({ editModeActive, updateDraft }) {
|
||||
export function useAnnotations({ editModeActive, updateDraft, viewRef = null }) {
|
||||
const [activeTool, setActiveTool] = useState('pointer')
|
||||
const [selectedAnnotationId, setSelectedAnnotationId] = useState(null)
|
||||
const [selectedSticker, setSelectedSticker] = useState(null)
|
||||
const [textInputState, setTextInputState] = useState(null) // { x, y, screenX, screenY }
|
||||
const [textInputState, setTextInputState] = useState(null) // { x, y, screenX, screenY, editingId?, text?, style? }
|
||||
const [revision, setRevision] = useState(0)
|
||||
|
||||
const [currentStyle, setCurrentStyle] = useState({
|
||||
@@ -38,6 +40,19 @@ export function useAnnotations({ editModeActive, updateDraft }) {
|
||||
const [isOverTrash, setIsOverTrash] = useState(false)
|
||||
const dragStartPos = useRef(null)
|
||||
const dragAnnotationOrigPos = useRef(null)
|
||||
// Vrai si l'annotation touchée était déjà sélectionnée avant l'appui : un texte se rouvre alors pour être modifié
|
||||
const pressedSelectedRef = useRef(false)
|
||||
|
||||
// Style rattaché à la personne la plus proche dans l'onglet affiché (voir annotations/anchor.js)
|
||||
const anchoredStyle = useCallback((style, x, y) => withAnchor(style, x, y, viewRef?.current?.centers), [viewRef])
|
||||
// Annotation telle que dessinée dans l'onglet affiché (position comprise)
|
||||
const shownOf = useCallback((id) => (viewRef?.current?.shown || annotationData).find((a) => a.id === id) || null, [viewRef])
|
||||
|
||||
// Texte rouvert : la galaxie ne le dessine pas sous sa saisie
|
||||
useEffect(() => {
|
||||
if (viewRef?.current) viewRef.current.editingId = textInputState?.editingId ?? null
|
||||
else if (viewRef) viewRef.current = { editingId: textInputState?.editingId ?? null }
|
||||
}, [viewRef, textInputState])
|
||||
|
||||
// Track added/modified/deleted for draft
|
||||
const addedIdsRef = useRef(new Set())
|
||||
@@ -133,15 +148,16 @@ export function useAnnotations({ editModeActive, updateDraft }) {
|
||||
x: worldX,
|
||||
y: worldY,
|
||||
content: JSON.stringify({ photoPath, url, w: naturalW, h: naturalH }),
|
||||
style: { size },
|
||||
style: anchoredStyle({ size }, worldX, worldY),
|
||||
zIndex: 5,
|
||||
})
|
||||
}, [addAnnotation])
|
||||
}, [addAnnotation, anchoredStyle])
|
||||
|
||||
// ---- Hit detection ----
|
||||
// Seules les annotations dessinées dans l'onglet affiché se touchent, à leur position dessinée
|
||||
const hitTest = useCallback((worldX, worldY) => {
|
||||
// Iterate in reverse z-order (top first)
|
||||
const sorted = [...annotationData].sort((a, b) => (b.zIndex || 0) - (a.zIndex || 0))
|
||||
const sorted = [...(viewRef?.current?.shown || annotationData)].sort((a, b) => (b.zIndex || 0) - (a.zIndex || 0))
|
||||
for (const ann of sorted) {
|
||||
if (ann.type === 'text') {
|
||||
const fontSize = ann.style?.fontSize || 18
|
||||
@@ -187,7 +203,7 @@ export function useAnnotations({ editModeActive, updateDraft }) {
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [])
|
||||
}, [viewRef])
|
||||
|
||||
// ---- Canvas event handlers (called from useGalaxyInteractions) ----
|
||||
|
||||
@@ -211,16 +227,11 @@ export function useAnnotations({ editModeActive, updateDraft }) {
|
||||
const dx = worldX - dragStartPos.current.x
|
||||
const dy = worldY - dragStartPos.current.y
|
||||
const ann = annotationData.find((a) => a.id === draggingIdRef.current)
|
||||
if (ann && ann.anchorId) {
|
||||
updateAnnotation(draggingIdRef.current, {
|
||||
offsetX: (dragAnnotationOrigPos.current.offsetX ?? 0) + dx,
|
||||
offsetY: (dragAnnotationOrigPos.current.offsetY ?? 0) + dy,
|
||||
})
|
||||
} else {
|
||||
updateAnnotation(draggingIdRef.current, {
|
||||
x: (dragAnnotationOrigPos.current.x ?? 0) + dx,
|
||||
y: (dragAnnotationOrigPos.current.y ?? 0) + dy,
|
||||
})
|
||||
if (ann) {
|
||||
// Déplacer recalcule l'ancre : la personne la plus proche du nouvel endroit
|
||||
const x = (dragAnnotationOrigPos.current.x ?? 0) + dx
|
||||
const y = (dragAnnotationOrigPos.current.y ?? 0) + dy
|
||||
updateAnnotation(draggingIdRef.current, { x, y, style: anchoredStyle(ann.style, x, y) })
|
||||
}
|
||||
// Check if over trash button in toolbar
|
||||
let overTrash = false
|
||||
@@ -239,7 +250,7 @@ export function useAnnotations({ editModeActive, updateDraft }) {
|
||||
isOverTrashRef.current = overTrash
|
||||
setIsOverTrash(overTrash)
|
||||
}
|
||||
}, [activeTool, updateAnnotation])
|
||||
}, [activeTool, updateAnnotation, anchoredStyle])
|
||||
|
||||
const onCanvasMouseUp = useCallback((_worldX, _worldY) => {
|
||||
// Finalize drawing
|
||||
@@ -253,7 +264,7 @@ export function useAnnotations({ editModeActive, updateDraft }) {
|
||||
|
||||
// If a drawing annotation is already active, append this stroke to it
|
||||
if (currentDrawingAnnId.current) {
|
||||
const existing = annotationData.find((a) => a.id === currentDrawingAnnId.current)
|
||||
const existing = shownOf(currentDrawingAnnId.current)
|
||||
if (existing) {
|
||||
const anchorX = existing.x
|
||||
const anchorY = existing.y
|
||||
@@ -292,10 +303,10 @@ export function useAnnotations({ editModeActive, updateDraft }) {
|
||||
width: drawingPathRef.current.width,
|
||||
}],
|
||||
}),
|
||||
style: {
|
||||
style: anchoredStyle({
|
||||
color: drawingPathRef.current.color,
|
||||
brushWidth: drawingPathRef.current.width,
|
||||
},
|
||||
}, firstPt.x, firstPt.y),
|
||||
zIndex: 0,
|
||||
})
|
||||
currentDrawingAnnId.current = created.id
|
||||
@@ -317,7 +328,7 @@ export function useAnnotations({ editModeActive, updateDraft }) {
|
||||
dragStartPos.current = null
|
||||
dragAnnotationOrigPos.current = null
|
||||
}
|
||||
}, [activeTool, addAnnotation, deleteAnnotation])
|
||||
}, [activeTool, addAnnotation, deleteAnnotation, anchoredStyle, shownOf])
|
||||
|
||||
const onCanvasClick = useCallback((worldX, worldY, transformRef) => {
|
||||
if (activeTool === 'text') {
|
||||
@@ -340,10 +351,10 @@ export function useAnnotations({ editModeActive, updateDraft }) {
|
||||
x: worldX,
|
||||
y: worldY,
|
||||
content: selectedSticker,
|
||||
style: {
|
||||
style: anchoredStyle({
|
||||
fontSize: 40,
|
||||
rotation: (Math.random() - 0.5) * 0.25,
|
||||
},
|
||||
}, worldX, worldY),
|
||||
zIndex: 10,
|
||||
})
|
||||
return true
|
||||
@@ -352,6 +363,21 @@ export function useAnnotations({ editModeActive, updateDraft }) {
|
||||
if (activeTool === 'pointer') {
|
||||
const hit = hitTest(worldX, worldY)
|
||||
if (hit) {
|
||||
// Un texte déjà sélectionné se rouvre au clic pour être modifié (double clic sur ordinateur)
|
||||
if (hit.type === 'text' && pressedSelectedRef.current && transformRef) {
|
||||
pressedSelectedRef.current = false
|
||||
const t = transformRef.current
|
||||
setTextInputState({
|
||||
x: hit.x,
|
||||
y: hit.y,
|
||||
screenX: hit.x * t.scale + t.x,
|
||||
screenY: hit.y * t.scale + t.y,
|
||||
editingId: hit.id,
|
||||
text: hit.content,
|
||||
style: hit.style,
|
||||
})
|
||||
return true
|
||||
}
|
||||
setSelectedAnnotationId(hit.id)
|
||||
// Prepare for potential drag
|
||||
isDraggingAnnotation.current = false
|
||||
@@ -363,14 +389,15 @@ export function useAnnotations({ editModeActive, updateDraft }) {
|
||||
}
|
||||
|
||||
return false
|
||||
}, [activeTool, selectedSticker, currentStyle, addAnnotation, hitTest])
|
||||
}, [activeTool, selectedSticker, addAnnotation, hitTest, anchoredStyle])
|
||||
|
||||
// Start drag — accepts annotationId so we can drag without prior selection
|
||||
const onAnnotationDragStart = useCallback((worldX, worldY, annotationId) => {
|
||||
const targetId = annotationId || selectedAnnotationId
|
||||
if (activeTool === 'pointer' && targetId) {
|
||||
const ann = annotationData.find((a) => a.id === targetId)
|
||||
const ann = shownOf(targetId)
|
||||
if (ann) {
|
||||
pressedSelectedRef.current = targetId === selectedAnnotationId
|
||||
setSelectedAnnotationId(targetId)
|
||||
draggingIdRef.current = targetId
|
||||
isDraggingAnnotation.current = true
|
||||
@@ -378,17 +405,12 @@ export function useAnnotations({ editModeActive, updateDraft }) {
|
||||
setIsDragging(true)
|
||||
setIsOverTrash(false)
|
||||
dragStartPos.current = { x: worldX, y: worldY }
|
||||
dragAnnotationOrigPos.current = {
|
||||
x: ann.x,
|
||||
y: ann.y,
|
||||
offsetX: ann.offsetX ?? 0,
|
||||
offsetY: ann.offsetY ?? 0,
|
||||
}
|
||||
dragAnnotationOrigPos.current = { x: ann.x, y: ann.y }
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, [activeTool, selectedAnnotationId])
|
||||
}, [activeTool, selectedAnnotationId, shownOf])
|
||||
|
||||
// Commit text from overlay input
|
||||
const commitTextInput = useCallback((text) => {
|
||||
@@ -397,21 +419,28 @@ export function useAnnotations({ editModeActive, updateDraft }) {
|
||||
return
|
||||
}
|
||||
|
||||
// Texte rouvert : seul son contenu change, il garde sa place et son style
|
||||
if (textInputState.editingId) {
|
||||
if (text.trim() !== textInputState.text) updateAnnotation(textInputState.editingId, { content: text.trim() })
|
||||
setTextInputState(null)
|
||||
return
|
||||
}
|
||||
|
||||
addAnnotation({
|
||||
type: 'text',
|
||||
x: textInputState.x,
|
||||
y: textInputState.y,
|
||||
content: text.trim(),
|
||||
style: {
|
||||
style: anchoredStyle({
|
||||
color: currentStyle.color,
|
||||
fontSize: currentStyle.fontSize,
|
||||
stabilo: currentStyle.stabilo,
|
||||
},
|
||||
}, textInputState.x, textInputState.y),
|
||||
zIndex: 0,
|
||||
})
|
||||
|
||||
setTextInputState(null)
|
||||
}, [textInputState, currentStyle, addAnnotation])
|
||||
}, [textInputState, currentStyle, addAnnotation, updateAnnotation, anchoredStyle])
|
||||
|
||||
const cancelTextInput = useCallback(() => {
|
||||
setTextInputState(null)
|
||||
|
||||
Reference in new issue
Block a user