fix: separate desktop wheel and mobile pinch zoom
This commit is contained in:
+43
-11
@@ -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>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
timelinePositionPercent,
|
||||
timelineRange,
|
||||
zoomFromWheel,
|
||||
zoomFromPinch,
|
||||
} from './boardDomain'
|
||||
|
||||
const folder: Evidence = {
|
||||
@@ -53,11 +54,15 @@ describe('board coordinate math', () => {
|
||||
expect(panViewport(state.viewport, { x: 25, y: -10 })).toEqual({ x: 35, y: 10, zoom: 0.5 })
|
||||
})
|
||||
|
||||
it('clamps toolbar and pinch zoom to the same limits', () => {
|
||||
it('clamps toolbar, wheel, and touch pinch zoom to the same limits', () => {
|
||||
expect(clampBoardZoom(-10)).toBe(MIN_BOARD_ZOOM)
|
||||
expect(clampBoardZoom(10)).toBe(MAX_BOARD_ZOOM)
|
||||
expect(zoomFromWheel(1, 10_000)).toBe(MIN_BOARD_ZOOM)
|
||||
expect(zoomFromWheel(1, -10_000)).toBe(MAX_BOARD_ZOOM)
|
||||
expect(zoomFromWheel(1, 100)).toBeCloseTo(0.85)
|
||||
expect(zoomFromPinch(1, 100, 125)).toBeCloseTo(1.25)
|
||||
expect(zoomFromPinch(1, 100, 50)).toBeCloseTo(0.5)
|
||||
expect(zoomFromPinch(1, 0, 120)).toBe(1)
|
||||
})
|
||||
|
||||
it('places new exhibits in deterministic open slots', () => {
|
||||
|
||||
+6
-1
@@ -8,7 +8,12 @@ export function clampBoardZoom(zoom: number) {
|
||||
}
|
||||
|
||||
export function zoomFromWheel(currentZoom: number, deltaY: number) {
|
||||
return clampBoardZoom(currentZoom - deltaY * 0.006)
|
||||
return clampBoardZoom(currentZoom - deltaY * 0.0015)
|
||||
}
|
||||
|
||||
export function zoomFromPinch(currentZoom: number, previousDistance: number, nextDistance: number) {
|
||||
if (!Number.isFinite(previousDistance) || !Number.isFinite(nextDistance) || previousDistance <= 0 || nextDistance <= 0) return currentZoom
|
||||
return clampBoardZoom(currentZoom * nextDistance / previousDistance)
|
||||
}
|
||||
|
||||
export function moveBoardPoint(origin: { x: number; y: number }, screenDelta: { x: number; y: number }, zoom: number) {
|
||||
|
||||
Reference in New Issue
Block a user