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.length).toBe(1)
|
||||||
expect(reports[0]).toMatchObject({ context: 'stammbaum', gerbilId: 'kruemel' })
|
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
|
* - Druck/PDF: separate CSS-Grid-Ahnentafel (.stammbaum-print), per
|
||||||
* @media print sichtbar; Browser „Als PDF speichern“ ist der Export.
|
* @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 { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import Tree from 'react-d3-tree'
|
import Tree from 'react-d3-tree'
|
||||||
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
|
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
|
||||||
@@ -102,11 +102,20 @@ export default function StammbaumPage() {
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [reportContext, setReportContext] = useState<ReportErrorContext | null>(null)
|
const [reportContext, setReportContext] = useState<ReportErrorContext | null>(null)
|
||||||
|
|
||||||
const openContextMenu = useCallback((e: ReactMouseEvent, gerbil: Gerbil) => {
|
const openContextMenuAt = useCallback((x: number, y: number, gerbil: Gerbil) => {
|
||||||
e.preventDefault()
|
setContextMenu({ x, y, gerbil })
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, 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 closeContextMenu = useCallback(() => setContextMenu(null), [])
|
||||||
|
|
||||||
const copyId = useCallback(
|
const copyId = useCallback(
|
||||||
@@ -349,13 +358,14 @@ export default function StammbaumPage() {
|
|||||||
onOpen={() => navigate(`/rennmaeuse/${node.gerbil.id}/stammbaum`)}
|
onOpen={() => navigate(`/rennmaeuse/${node.gerbil.id}/stammbaum`)}
|
||||||
onExpand={() => handleExpand(path)}
|
onExpand={() => handleExpand(path)}
|
||||||
onContextMenu={(e) => openContextMenu(e, node.gerbil)}
|
onContextMenu={(e) => openContextMenu(e, node.gerbil)}
|
||||||
|
onLongPress={(x, y) => openContextMenuAt(x, y, node.gerbil)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</foreignObject>
|
</foreignObject>
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[nodesByPath, farbschlagOf, navigate, handleExpand, openContextMenu, t],
|
[nodesByPath, farbschlagOf, navigate, handleExpand, openContextMenu, openContextMenuAt, t],
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ── Zustände: Laden / Fehler ── */
|
/* ── Zustände: Laden / Fehler ── */
|
||||||
@@ -443,6 +453,7 @@ export default function StammbaumPage() {
|
|||||||
<li>{t.tapHint}</li>
|
<li>{t.tapHint}</li>
|
||||||
<li>{t.hintName}</li>
|
<li>{t.hintName}</li>
|
||||||
<li>{t.hintExpand}</li>
|
<li>{t.hintExpand}</li>
|
||||||
|
<li>{t.hintContext}</li>
|
||||||
{hasSiblingPairing && <li>{t.hintSiblings}</li>}
|
{hasSiblingPairing && <li>{t.hintSiblings}</li>}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
@@ -498,6 +509,7 @@ function PedigreeCard({
|
|||||||
onOpen,
|
onOpen,
|
||||||
onExpand,
|
onExpand,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
|
onLongPress,
|
||||||
}: {
|
}: {
|
||||||
node: AnimalNode
|
node: AnimalNode
|
||||||
isRoot: boolean
|
isRoot: boolean
|
||||||
@@ -505,6 +517,7 @@ function PedigreeCard({
|
|||||||
onOpen: () => void
|
onOpen: () => void
|
||||||
onExpand: () => void
|
onExpand: () => void
|
||||||
onContextMenu: (e: ReactMouseEvent) => void
|
onContextMenu: (e: ReactMouseEvent) => void
|
||||||
|
onLongPress: (x: number, y: number) => void
|
||||||
}) {
|
}) {
|
||||||
const t = de.pages.stammbaum
|
const t = de.pages.stammbaum
|
||||||
const g = node.gerbil
|
const g = node.gerbil
|
||||||
@@ -517,11 +530,57 @@ function PedigreeCard({
|
|||||||
: `${API_BASE_URL}${g.profilePhotoUrl}`
|
: `${API_BASE_URL}${g.profilePhotoUrl}`
|
||||||
: null
|
: null
|
||||||
const photoUrl = rawUrl !== failedUrl ? rawUrl : 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
|
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
|
||||||
onClick={isRoot ? undefined : onOpen}
|
onClick={isRoot ? undefined : onOpen}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
onTouchCancel={cancelPress}
|
||||||
role={isRoot ? undefined : 'button'}
|
role={isRoot ? undefined : 'button'}
|
||||||
title={isRoot ? undefined : t.tapHint}
|
title={isRoot ? undefined : t.tapHint}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -415,6 +415,7 @@ export const de = {
|
|||||||
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
|
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
|
||||||
hintName: 'Namenslink: Tierakte öffnen',
|
hintName: 'Namenslink: Tierakte öffnen',
|
||||||
hintExpand: '+: weitere Vorfahren nachladen',
|
hintExpand: '+: weitere Vorfahren nachladen',
|
||||||
|
hintContext: 'Rechtsklick (oder Karte lange gedrückt halten): ID kopieren / Fehler melden',
|
||||||
hintSiblings: '⟲: Eltern sind Geschwister — gemeinsame Vorfahren siehe Vaterlinie',
|
hintSiblings: '⟲: Eltern sind Geschwister — gemeinsame Vorfahren siehe Vaterlinie',
|
||||||
/** Verweis-Knoten bei Geschwister-Verpaarung (Vater & Mutter aus demselben Wurf). */
|
/** Verweis-Knoten bei Geschwister-Verpaarung (Vater & Mutter aus demselben Wurf). */
|
||||||
siblingPairing: {
|
siblingPairing: {
|
||||||
|
|||||||
Reference in New Issue
Block a user