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
+3 -1
View File
@@ -52,7 +52,7 @@ Run the isolated Chromium smoke test after installing its browser runtime once w
npm run test:e2e npm run test:e2e
``` ```
The smoke harness builds the application, creates a disposable PostgreSQL database and level, exercises move, hand-pan, board-only pinch zoom, folder expansion, file movement, and reload persistence, then removes the test database. The smoke harness builds the application, creates a disposable PostgreSQL database and level, exercises move, hand-pan, desktop wheel zoom, mobile touch pinch, folder expansion, file movement, and reload persistence, then removes the test database.
The browser suite also imports and solves the bundled Glass Harbor mystery against that disposable database. Mystery manifests are database-authoring content rather than compiled frontend cases; see [`mysteries/README.md`](mysteries/README.md). The browser suite also imports and solves the bundled Glass Harbor mystery against that disposable database. Mystery manifests are database-authoring content rather than compiled frontend cases; see [`mysteries/README.md`](mysteries/README.md).
@@ -107,6 +107,8 @@ The timeline uses an optional board-level start and end date. Click **Timeline**
The typed frontend widget registry maps each exhibit type, and each document type, to its React visualization. Adding a domain type now produces a compile-time requirement to register its renderer. Widgets do not own investigation-domain data. The typed frontend widget registry maps each exhibit type, and each document type, to its React visualization. Adding a domain type now produces a compile-time requirement to register its renderer. Widgets do not own investigation-domain data.
On desktop, an ordinary mouse wheel or two-finger trackpad scroll zooms the board. Desktop pinch gestures are consumed so they cannot zoom both the browser and board. Touch devices use a two-finger pinch on the board; toolbar controls remain available on every device.
## Deploy at osint.glitch.university ## Deploy at osint.glitch.university
The production service depends on `gu_common`: it joins the external `gnommo` Docker network, uses `gnommo-db`, and is routed by the shared nginx container. Deploy `gu_common` after its nginx configuration changes, then deploy this repository: The production service depends on `gu_common`: it joins the external `gnommo` Docker network, uses `gnommo-db`, and is routed by the shared nginx container. Deploy `gu_common` after its nginx configuration changes, then deploy this repository:
+1 -1
View File
@@ -20,7 +20,7 @@ This is the ordered implementation roadmap following the accepted exhibit model.
- [x] Test screen/board coordinate conversion across pan and zoom levels. - [x] Test screen/board coordinate conversion across pan and zoom levels.
- [x] Test timeline date-to-pixel projection and recomputation after viewport resizing. - [x] Test timeline date-to-pixel projection and recomputation after viewport resizing.
- [x] Test the interaction boundary between exhibit dragging, hand-tool panning, and board-only pinch zoom. - [x] Test the interaction boundary between exhibit dragging, hand-tool panning, desktop wheel zoom, and mobile-only pinch zoom.
- [x] Test folder open/close behavior, retained file positions, and containment-band state. - [x] Test folder open/close behavior, retained file positions, and containment-band state.
- [x] Test document upload, metadata persistence, board save/reload, and reset. - [x] Test document upload, metadata persistence, board save/reload, and reset.
- [x] Run migrations and API integration tests against disposable PostgreSQL, not SQLite or mocked persistence. - [x] Run migrations and API integration tests against disposable PostgreSQL, not SQLite or mocked persistence.
+22 -7
View File
@@ -24,7 +24,7 @@ async function waitForSave(page: Page, action: () => Promise<void>) {
await response await response
} }
test('move, folder expansion, hand pan, pinch zoom, and reload persistence', async ({ page }) => { test('move, folder expansion, hand pan, desktop wheel zoom, mobile pinch, and reload persistence', async ({ page }) => {
await page.goto('/?level=e2e-level&edit=1') await page.goto('/?level=e2e-level&edit=1')
await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible()
@@ -78,14 +78,29 @@ test('move, folder expansion, hand pan, pinch zoom, and reload persistence', asy
await expect(folder).toBeVisible() await expect(folder).toBeVisible()
expect(await board.getAttribute('style')).toEqual(transformAfterPan) expect(await board.getAttribute('style')).toEqual(transformAfterPan)
const transformBeforePinch = await board.getAttribute('style') const transformBeforeWheel = await board.getAttribute('style')
const browserMetricsBeforePinch = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio })) const browserMetricsBeforeWheel = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))
await page.keyboard.down('Control')
await page.mouse.move(viewportBox.x + 400, viewportBox.y + 260) await page.mouse.move(viewportBox.x + 400, viewportBox.y + 260)
await page.mouse.wheel(0, -20) await page.mouse.wheel(0, -80)
await expect.poll(() => board.getAttribute('style')).not.toEqual(transformBeforeWheel)
expect(await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))).toEqual(browserMetricsBeforeWheel)
const transformBeforeDesktopPinch = await board.getAttribute('style')
await page.keyboard.down('Control')
await page.mouse.wheel(0, -80)
await page.keyboard.up('Control') await page.keyboard.up('Control')
await expect.poll(() => board.getAttribute('style')).not.toEqual(transformBeforePinch) await page.waitForTimeout(100)
expect(await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))).toEqual(browserMetricsBeforePinch) expect(await board.getAttribute('style')).toEqual(transformBeforeDesktopPinch)
expect(await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))).toEqual(browserMetricsBeforeWheel)
const transformBeforeTouchPinch = await board.getAttribute('style')
const touchOrigin = { x: viewportBox.x + 350, y: viewportBox.y + 240 }
await boardViewport.dispatchEvent('pointerdown', { pointerId: 41, pointerType: 'touch', isPrimary: true, button: 0, clientX: touchOrigin.x, clientY: touchOrigin.y })
await boardViewport.dispatchEvent('pointerdown', { pointerId: 42, pointerType: 'touch', isPrimary: false, button: 0, clientX: touchOrigin.x + 100, clientY: touchOrigin.y })
await boardViewport.dispatchEvent('pointermove', { pointerId: 42, pointerType: 'touch', isPrimary: false, button: 0, clientX: touchOrigin.x + 150, clientY: touchOrigin.y })
await expect.poll(() => board.getAttribute('style')).not.toEqual(transformBeforeTouchPinch)
await boardViewport.dispatchEvent('pointerup', { pointerId: 42, pointerType: 'touch', isPrimary: false, button: 0, clientX: touchOrigin.x + 150, clientY: touchOrigin.y })
await boardViewport.dispatchEvent('pointerup', { pointerId: 41, pointerType: 'touch', isPrimary: true, button: 0, clientX: touchOrigin.x, clientY: touchOrigin.y })
await page.getByRole('button', { name: 'BRIEF', exact: true }).click() await page.getByRole('button', { name: 'BRIEF', exact: true }).click()
await expect(page.locator('.brief-panel')).toContainText('Ada Lovelace') await expect(page.locator('.brief-panel')).toContainText('Ada Lovelace')
+43 -11
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' 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 { 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 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' import { documentWidget, exhibitWidget } from './exhibitRegistry'
const BOARD_W = 2400 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 }) { 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 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 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 byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence])
const containmentRelations = state.relations.filter(relation => relation.type === 'contains') const containmentRelations = state.relations.filter(relation => relation.type === 'contains')
useEffect(() => { useEffect(() => {
const board = boardRef.current const board = boardRef.current
if (!board) return if (!board) return
const handlePinch = (event: WheelEvent) => { const handleWheelZoom = (event: WheelEvent) => {
if (!event.ctrlKey && !event.metaKey) return
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (event.ctrlKey || event.metaKey || event.deltaY === 0) return
update(s => ({ ...s, viewport: { ...s.viewport, zoom: zoomFromWheel(s.viewport.zoom, event.deltaY) } })) update(s => ({ ...s, viewport: { ...s.viewport, zoom: zoomFromWheel(s.viewport.zoom, event.deltaY) } }))
} }
board.addEventListener('wheel', handlePinch, { passive: false }) board.addEventListener('wheel', handleWheelZoom, { passive: false })
return () => board.removeEventListener('wheel', handlePinch) return () => board.removeEventListener('wheel', handleWheelZoom)
}, [boardRef, update]) }, [boardRef, update])
const pointerDown = (event: React.PointerEvent, target?: { kind: 'widget' | 'relation'; id: string }) => { const pointerDown = (event: React.PointerEvent, target?: { kind: 'widget' | 'relation'; id: string }) => {
if ((event.target as HTMLElement).closest('button')) return 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 widget = target?.kind === 'widget' ? byId.get(target.id) : undefined
const relation = target?.kind === 'relation' ? state.relations.find(candidate => candidate.id === target.id) : undefined const relation = target?.kind === 'relation' ? state.relations.find(candidate => candidate.id === target.id) : undefined
const position = relation ? relationPosition(state, relation) : 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 } 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) => { 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 if (!drag.current) return
const dx = event.clientX - drag.current.startX, dy = event.clientY - drag.current.startY 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 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 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 }) })) 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) })) 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} return <div className={`board-viewport tool-${tool}`} ref={boardRef}
onPointerDown={e => { if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } }} onPointerDown={e => { if (e.pointerType === 'touch' || tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } }}
onPointerMove={pointerMove} onPointerUp={finishDrag} onAuxClick={e => { if (e.button === 1) e.preventDefault() }}> 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" 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> <div className="board-stamp">AUTHORIZED CITIZEN SCIENTIST WORKSTATION <span>GU-NET / 04</span></div>
<svg className="connections" width={BOARD_W} height={BOARD_H}> <svg className="connections" width={BOARD_W} height={BOARD_H}>
@@ -424,14 +456,14 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick,
</svg> </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` }} {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 }) }} 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) }}> 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> <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}/> <Widget exhibit={ev} documents={containedDocuments} onOpenSource={onOpenSource} onToggleFolder={toggleFolder} onEditFolder={onEditFolder} onEditEvent={onEditEvent} onEditParty={onEditParty}/>
</article>})} </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 }} {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 }) }} 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> <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> <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> <strong>{document.title}</strong><time>{(document.publishedAt || document.date)?.slice(0, 10) || 'UNDATED'}</time>
+6 -1
View File
@@ -14,6 +14,7 @@ import {
timelinePositionPercent, timelinePositionPercent,
timelineRange, timelineRange,
zoomFromWheel, zoomFromWheel,
zoomFromPinch,
} from './boardDomain' } from './boardDomain'
const folder: Evidence = { 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 }) 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(MIN_BOARD_ZOOM)
expect(clampBoardZoom(10)).toBe(MAX_BOARD_ZOOM) expect(clampBoardZoom(10)).toBe(MAX_BOARD_ZOOM)
expect(zoomFromWheel(1, 10_000)).toBe(MIN_BOARD_ZOOM) expect(zoomFromWheel(1, 10_000)).toBe(MIN_BOARD_ZOOM)
expect(zoomFromWheel(1, -10_000)).toBe(MAX_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', () => { it('places new exhibits in deterministic open slots', () => {
+6 -1
View File
@@ -8,7 +8,12 @@ export function clampBoardZoom(zoom: number) {
} }
export function zoomFromWheel(currentZoom: number, deltaY: 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) { export function moveBoardPoint(origin: { x: number; y: number }, screenDelta: { x: number; y: number }, zoom: number) {