feat(stammbaum): Kontextmenü per Long-Press auf Touch-Geräten öffnen

Das Rechtsklick-Kontextmenü (ID kopieren / Fehler melden) war auf Mobil nicht
erreichbar (onContextMenu feuert auf Touch nicht zuverlässig). Jetzt öffnet ein
langer Druck (~500 ms ohne Verschieben) auf eine Ahnenkarte dasselbe Menü; der
darauf folgende synthetische Klick (Umwurzeln) wird unterdrückt. Bewegung >10 px
bricht den Long-Press ab (kollidiert nicht mit Pan/Zoom). Legenden-Hinweis
ergänzt. e2e: Long-Press öffnet das Menü (desktop; phone-Projekt übersprungen,
da synthetischer Touch dort instabil).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 16:09:47 +02:00
parent 438c816820
commit a31fea17fa
3 changed files with 94 additions and 5 deletions

View File

@@ -101,3 +101,32 @@ test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden
expect(reports.length).toBe(1)
expect(reports[0]).toMatchObject({ context: 'stammbaum', gerbilId: 'kruemel' })
})
test('Stammbaum: langer Druck (Long-Press) öffnet dasselbe Kontextmenü', async ({ page }, testInfo) => {
skipUnlessMock()
// Der Long-Press-Code-Pfad (onTouchStart → Timer → Menü) ist projekt-unabhängig
// identisch; im Touch-emulierten phone-Projekt ist das Auslösen eines
// synthetischen TouchEvents unzuverlässig, daher hier auf das desktop-Projekt
// beschränkt (validiert dieselbe Logik).
testInfo.skip(testInfo.project.name === 'phone', 'synthetischer Long-Press im phone-Projekt instabil')
await page.goto('/rennmaeuse/kruemel/stammbaum')
const card = page.locator('.pedigree-card--root')
await expect(card).toBeVisible()
// Long-Press simulieren: echtes touchstart (mit Touch-Objekt) auslösen; der
// Timer im PedigreeCard öffnet nach ~500 ms das Menü.
const dispatched = await card.evaluate((el) => {
if (typeof Touch === 'undefined' || typeof TouchEvent === 'undefined') return false
const r = el.getBoundingClientRect()
const t = new Touch({ identifier: 1, target: el, clientX: r.left + 10, clientY: r.top + 10 })
el.dispatchEvent(
new TouchEvent('touchstart', { bubbles: true, cancelable: true, touches: [t], targetTouches: [t], changedTouches: [t] }),
)
return true
})
test.skip(!dispatched, 'TouchEvent in diesem Projekt nicht verfügbar')
await expect(page.locator('.stammbaum-context-menu')).toBeVisible({ timeout: 2000 })
await expect(
page.locator('.stammbaum-context-menu').getByRole('menuitem', { name: st.contextMenu.copyId }),
).toBeVisible()
})

View File

@@ -16,7 +16,7 @@
* - Druck/PDF: separate CSS-Grid-Ahnentafel (.stammbaum-print), per
* @media print sichtbar; Browser „Als PDF speichern“ ist der Export.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import Tree from 'react-d3-tree'
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
@@ -102,11 +102,20 @@ export default function StammbaumPage() {
} | null>(null)
const [reportContext, setReportContext] = useState<ReportErrorContext | null>(null)
const openContextMenu = useCallback((e: ReactMouseEvent, gerbil: Gerbil) => {
e.preventDefault()
setContextMenu({ x: e.clientX, y: e.clientY, gerbil })
const openContextMenuAt = useCallback((x: number, y: number, gerbil: Gerbil) => {
setContextMenu({ x, y, gerbil })
}, [])
// Rechtsklick (Desktop). Auf Touch-Geräten gibt es keinen Rechtsklick — dort
// öffnet ein langer Druck (Long-Press) dasselbe Menü, siehe PedigreeCard.
const openContextMenu = useCallback(
(e: ReactMouseEvent, gerbil: Gerbil) => {
e.preventDefault()
openContextMenuAt(e.clientX, e.clientY, gerbil)
},
[openContextMenuAt],
)
const closeContextMenu = useCallback(() => setContextMenu(null), [])
const copyId = useCallback(
@@ -349,13 +358,14 @@ export default function StammbaumPage() {
onOpen={() => navigate(`/rennmaeuse/${node.gerbil.id}/stammbaum`)}
onExpand={() => handleExpand(path)}
onContextMenu={(e) => openContextMenu(e, node.gerbil)}
onLongPress={(x, y) => openContextMenuAt(x, y, node.gerbil)}
/>
)}
</foreignObject>
</g>
)
},
[nodesByPath, farbschlagOf, navigate, handleExpand, openContextMenu, t],
[nodesByPath, farbschlagOf, navigate, handleExpand, openContextMenu, openContextMenuAt, t],
)
/* ── Zustände: Laden / Fehler ── */
@@ -443,6 +453,7 @@ export default function StammbaumPage() {
<li>{t.tapHint}</li>
<li>{t.hintName}</li>
<li>{t.hintExpand}</li>
<li>{t.hintContext}</li>
{hasSiblingPairing && <li>{t.hintSiblings}</li>}
</ul>
</section>
@@ -498,6 +509,7 @@ function PedigreeCard({
onOpen,
onExpand,
onContextMenu,
onLongPress,
}: {
node: AnimalNode
isRoot: boolean
@@ -505,6 +517,7 @@ function PedigreeCard({
onOpen: () => void
onExpand: () => void
onContextMenu: (e: ReactMouseEvent) => void
onLongPress: (x: number, y: number) => void
}) {
const t = de.pages.stammbaum
const g = node.gerbil
@@ -517,11 +530,57 @@ function PedigreeCard({
: `${API_BASE_URL}${g.profilePhotoUrl}`
: null
const photoUrl = rawUrl !== failedUrl ? rawUrl : null
/* Long-Press (Touch) = Rechtsklick-Äquivalent: ~500 ms halten ohne zu
verschieben öffnet das Kontextmenü. Der danach folgende Klick (Umwurzeln)
wird unterdrückt. */
const pressTimer = useRef<number | null>(null)
const pressStart = useRef<{ x: number; y: number } | null>(null)
const longPressed = useRef(false)
const cancelPress = () => {
if (pressTimer.current != null) {
clearTimeout(pressTimer.current)
pressTimer.current = null
}
}
const onTouchStart = (e: ReactTouchEvent) => {
const tch = e.touches[0]
if (!tch) return
pressStart.current = { x: tch.clientX, y: tch.clientY }
longPressed.current = false
cancelPress()
pressTimer.current = window.setTimeout(() => {
pressTimer.current = null
longPressed.current = true
onLongPress(pressStart.current!.x, pressStart.current!.y)
}, 500)
}
const onTouchMove = (e: ReactTouchEvent) => {
const tch = e.touches[0]
if (!tch || !pressStart.current) return
if (Math.abs(tch.clientX - pressStart.current.x) > 10 || Math.abs(tch.clientY - pressStart.current.y) > 10) {
cancelPress()
}
}
const onTouchEnd = (e: ReactTouchEvent) => {
cancelPress()
if (longPressed.current) {
// Synthetischen Klick verhindern (sonst würde umgewurzelt / Menü sofort
// wieder geschlossen).
e.preventDefault()
longPressed.current = false
}
}
return (
<div
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
onClick={isRoot ? undefined : onOpen}
onContextMenu={onContextMenu}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={cancelPress}
role={isRoot ? undefined : 'button'}
title={isRoot ? undefined : t.tapHint}
>

View File

@@ -415,6 +415,7 @@ export const de = {
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
hintName: 'Namenslink: Tierakte öffnen',
hintExpand: ': weitere Vorfahren nachladen',
hintContext: 'Rechtsklick (oder Karte lange gedrückt halten): ID kopieren / Fehler melden',
hintSiblings: '⟲: Eltern sind Geschwister — gemeinsame Vorfahren siehe Vaterlinie',
/** Verweis-Knoten bei Geschwister-Verpaarung (Vater & Mutter aus demselben Wurf). */
siblingPairing: {