fix: separate desktop wheel and mobile pinch zoom

This commit is contained in:
2026-08-14 15:45:03 +02:00
parent 45c7c6a292
commit a51b508118
6 changed files with 81 additions and 22 deletions
+43 -11
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { BriefConcept, CaseDocument, CaseState, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types'
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, relationPosition, timelinePositionPercent, timelineRange, zoomFromWheel } from './boardDomain'
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, relationPosition, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel } from './boardDomain'
import { documentWidget, exhibitWidget } from './exhibitRegistry'
const BOARD_W = 2400
@@ -351,29 +351,54 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le
function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick, onOpenSource, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
const drag = useRef<{ kind: 'pan' | 'widget' | 'relation'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
const suppressClick = useRef(false)
const touchPoints = useRef(new Map<number, { x: number; y: number }>())
const pinchDistance = useRef<number | null>(null)
const byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence])
const containmentRelations = state.relations.filter(relation => relation.type === 'contains')
useEffect(() => {
const board = boardRef.current
if (!board) return
const handlePinch = (event: WheelEvent) => {
if (!event.ctrlKey && !event.metaKey) return
const handleWheelZoom = (event: WheelEvent) => {
event.preventDefault()
event.stopPropagation()
if (event.ctrlKey || event.metaKey || event.deltaY === 0) return
update(s => ({ ...s, viewport: { ...s.viewport, zoom: zoomFromWheel(s.viewport.zoom, event.deltaY) } }))
}
board.addEventListener('wheel', handlePinch, { passive: false })
return () => board.removeEventListener('wheel', handlePinch)
board.addEventListener('wheel', handleWheelZoom, { passive: false })
return () => board.removeEventListener('wheel', handleWheelZoom)
}, [boardRef, update])
const pointerDown = (event: React.PointerEvent, target?: { kind: 'widget' | 'relation'; id: string }) => {
if ((event.target as HTMLElement).closest('button')) return
if (event.pointerType === 'touch') {
touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
if (touchPoints.current.size >= 2) {
const points = [...touchPoints.current.values()]
pinchDistance.current = Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y)
drag.current = null
event.preventDefault()
return
}
}
const widget = target?.kind === 'widget' ? byId.get(target.id) : undefined
const relation = target?.kind === 'relation' ? state.relations.find(candidate => candidate.id === target.id) : undefined
const position = relation ? relationPosition(state, relation) : undefined
drag.current = { kind: target?.kind || 'pan', id: target?.id, startX: event.clientX, startY: event.clientY, originX: widget?.x ?? position?.x ?? state.viewport.x, originY: widget?.y ?? position?.y ?? state.viewport.y }
event.currentTarget.setPointerCapture(event.pointerId)
try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ }
}
const pointerMove = (event: React.PointerEvent) => {
if (event.pointerType === 'touch' && touchPoints.current.has(event.pointerId)) {
touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
if (touchPoints.current.size >= 2) {
const points = [...touchPoints.current.values()]
const distance = Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y)
const previous = pinchDistance.current
if (previous) update(s => ({ ...s, viewport: { ...s.viewport, zoom: zoomFromPinch(s.viewport.zoom, previous, distance) } }))
pinchDistance.current = distance
suppressClick.current = true
event.preventDefault()
return
}
}
if (!drag.current) return
const dx = event.clientX - drag.current.startX, dy = event.clientY - drag.current.startY
if (Math.abs(dx) + Math.abs(dy) > 4) drag.current.moved = true
@@ -381,11 +406,18 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick,
else if (drag.current.kind === 'relation') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, relations: s.relations.map(relation => relation.id === drag.current!.id ? { ...relation, config: { ...(relation.config || {}), ...next } } : relation) } })
else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }) }))
}
const finishDrag = () => { if (drag.current) suppressClick.current = Boolean(drag.current.moved); drag.current = null }
const finishDrag = (event: React.PointerEvent) => {
if (event.pointerType === 'touch') {
touchPoints.current.delete(event.pointerId)
if (touchPoints.current.size < 2) pinchDistance.current = null
}
if (drag.current) suppressClick.current = Boolean(drag.current.moved)
drag.current = null
}
const toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) }))
return <div className={`board-viewport tool-${tool}`} ref={boardRef}
onPointerDown={e => { if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } }}
onPointerMove={pointerMove} onPointerUp={finishDrag} onAuxClick={e => { if (e.button === 1) e.preventDefault() }}>
onPointerDown={e => { if (e.pointerType === 'touch' || tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } }}
onPointerMove={pointerMove} onPointerUp={finishDrag} onPointerCancel={finishDrag} onAuxClick={e => { if (e.button === 1) e.preventDefault() }}>
<div className="board" style={{ width: BOARD_W, height: BOARD_H, transform: `translate(${state.viewport.x}px, ${state.viewport.y}px) scale(${state.viewport.zoom})` }}>
<div className="board-stamp">AUTHORIZED CITIZEN SCIENTIST WORKSTATION <span>GU-NET / 04</span></div>
<svg className="connections" width={BOARD_W} height={BOARD_H}>
@@ -424,14 +456,14 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick,
</svg>
{state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${selected === ev.id ? 'selected' : ''} ${linkFrom === ev.id ? 'linking' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, rotate: `${(i % 3 - 1) * .45}deg` }}
onPointerDown={e => { e.stopPropagation(); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag() }}
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }}
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}>
<header><span>{definition.heading(ev, containedDocuments)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
<Widget exhibit={ev} documents={containedDocuments} onOpenSource={onOpenSource} onToggleFolder={toggleFolder} onEditFolder={onEditFolder} onEditEvent={onEditEvent} onEditParty={onEditParty}/>
</article>})}
{containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={relation.id} data-temporal-id={`file:${relation.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType}`} style={{ left, top }}
onPointerDown={event => { event.stopPropagation(); if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'relation', id: relation.id }) }}
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag() }} onDoubleClick={() => open && onOpenSource(document.id)}>
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onDoubleClick={() => open && onOpenSource(document.id)}>
<header><span>{definition.label.toUpperCase()}</span><i>{String((relation.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
<div className="source-file-preview"><Preview document={document} source={source}/></div>
<strong>{document.title}</strong><time>{(document.publishedAt || document.date)?.slice(0, 10) || 'UNDATED'}</time>