fix: separate desktop wheel and mobile pinch zoom
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user