diff --git a/README.md b/README.md index c40eaf1..9644d00 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Run the isolated Chromium smoke test after installing its browser runtime once w 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). @@ -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. +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 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: diff --git a/docs/TODO.md b/docs/TODO.md index 7f3b05c..db52cb8 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 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 document upload, metadata persistence, board save/reload, and reset. - [x] Run migrations and API integration tests against disposable PostgreSQL, not SQLite or mocked persistence. diff --git a/e2e/board.smoke.spec.ts b/e2e/board.smoke.spec.ts index 6e448c5..d18c58a 100644 --- a/e2e/board.smoke.spec.ts +++ b/e2e/board.smoke.spec.ts @@ -24,7 +24,7 @@ async function waitForSave(page: Page, action: () => Promise) { 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 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() expect(await board.getAttribute('style')).toEqual(transformAfterPan) - const transformBeforePinch = await board.getAttribute('style') - const browserMetricsBeforePinch = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio })) - await page.keyboard.down('Control') + const transformBeforeWheel = await board.getAttribute('style') + const browserMetricsBeforeWheel = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio })) 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 expect.poll(() => board.getAttribute('style')).not.toEqual(transformBeforePinch) - expect(await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))).toEqual(browserMetricsBeforePinch) + await page.waitForTimeout(100) + 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 expect(page.locator('.brief-panel')).toContainText('Ada Lovelace') diff --git a/src/App.tsx b/src/App.tsx index 16f917d..7b1eefd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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; 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()) + const pinchDistance = useRef(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
{ 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() }}>
AUTHORIZED CITIZEN SCIENTIST WORKSTATION GU-NET / 04
@@ -424,14 +456,14 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick, {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
{ 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) }}>
{definition.heading(ev, containedDocuments)}{String(i + 1).padStart(3, '0')}
})} {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
{ 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)}>
{definition.label.toUpperCase()}{String((relation.sortOrder || 0) + 1).padStart(2, '0')}
{document.title} diff --git a/src/boardDomain.test.ts b/src/boardDomain.test.ts index 8ac585e..0338a31 100644 --- a/src/boardDomain.test.ts +++ b/src/boardDomain.test.ts @@ -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', () => { diff --git a/src/boardDomain.ts b/src/boardDomain.ts index ac5f47d..57eb6c2 100644 --- a/src/boardDomain.ts +++ b/src/boardDomain.ts @@ -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) {