diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 91a095c..155304e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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} /> diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index b951718..c85398b 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -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, diff --git a/frontend/src/components/annotations/TextInputOverlay.jsx b/frontend/src/components/annotations/TextInputOverlay.jsx index 2b88228..3ea2384 100644 --- a/frontend/src/components/annotations/TextInputOverlay.jsx +++ b/frontend/src/components/annotations/TextInputOverlay.jsx @@ -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({ }} > ({ 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([]) + }) +}) diff --git a/frontend/src/hooks/useAnnotations.js b/frontend/src/hooks/useAnnotations.js index 770334d..59076e8 100644 --- a/frontend/src/hooks/useAnnotations.js +++ b/frontend/src/hooks/useAnnotations.js @@ -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)