diff --git a/gerbil-manager-web/e2e/feedback.spec.ts b/gerbil-manager-web/e2e/feedback.spec.ts index f6167ef..a69b9ce 100644 --- a/gerbil-manager-web/e2e/feedback.spec.ts +++ b/gerbil-manager-web/e2e/feedback.spec.ts @@ -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() +}) diff --git a/gerbil-manager-web/src/pages/StammbaumPage.tsx b/gerbil-manager-web/src/pages/StammbaumPage.tsx index 2c3b4bd..a5e7609 100644 --- a/gerbil-manager-web/src/pages/StammbaumPage.tsx +++ b/gerbil-manager-web/src/pages/StammbaumPage.tsx @@ -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(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)} /> )} ) }, - [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() {
  • {t.tapHint}
  • {t.hintName}
  • {t.hintExpand}
  • +
  • {t.hintContext}
  • {hasSiblingPairing &&
  • {t.hintSiblings}
  • } @@ -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(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 (
    diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index 5a79b0d..5e8f10e 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -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: {