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:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user