fix: anchor board zoom to pointer
This commit is contained in:
@@ -83,9 +83,16 @@ test('move, folder expansion, hand pan, desktop wheel zoom, mobile pinch, and re
|
|||||||
|
|
||||||
const transformBeforeWheel = await board.getAttribute('style')
|
const transformBeforeWheel = await board.getAttribute('style')
|
||||||
const browserMetricsBeforeWheel = 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.mouse.move(viewportBox.x + 400, viewportBox.y + 260)
|
const folderAnchorBefore = await folder.boundingBox()
|
||||||
|
if (!folderAnchorBefore) throw new Error('Folder is not visible for cursor-centered zoom')
|
||||||
|
const wheelAnchor = { x: folderAnchorBefore.x + folderAnchorBefore.width / 2, y: folderAnchorBefore.y + folderAnchorBefore.height / 2 }
|
||||||
|
await page.mouse.move(wheelAnchor.x, wheelAnchor.y)
|
||||||
await page.mouse.wheel(0, -80)
|
await page.mouse.wheel(0, -80)
|
||||||
await expect.poll(() => board.getAttribute('style')).not.toEqual(transformBeforeWheel)
|
await expect.poll(() => board.getAttribute('style')).not.toEqual(transformBeforeWheel)
|
||||||
|
const folderAnchorAfter = await folder.boundingBox()
|
||||||
|
if (!folderAnchorAfter) throw new Error('Folder disappeared after zoom')
|
||||||
|
expect(folderAnchorAfter.x + folderAnchorAfter.width / 2).toBeCloseTo(wheelAnchor.x, 0)
|
||||||
|
expect(folderAnchorAfter.y + folderAnchorAfter.height / 2).toBeCloseTo(wheelAnchor.y, 0)
|
||||||
expect(await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))).toEqual(browserMetricsBeforeWheel)
|
expect(await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))).toEqual(browserMetricsBeforeWheel)
|
||||||
|
|
||||||
const transformBeforeDesktopPinch = await board.getAttribute('style')
|
const transformBeforeDesktopPinch = await board.getAttribute('style')
|
||||||
|
|||||||
+9
-3
@@ -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, Connection, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types'
|
import type { BriefConcept, CaseDocument, CaseState, Connection, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types'
|
||||||
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, relationPosition, threadCurve, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel } from './boardDomain'
|
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, relationPosition, threadCurve, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
||||||
import { documentWidget, exhibitWidget } from './exhibitRegistry'
|
import { documentWidget, exhibitWidget } from './exhibitRegistry'
|
||||||
|
|
||||||
const BOARD_W = 2400
|
const BOARD_W = 2400
|
||||||
@@ -440,7 +440,9 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (event.ctrlKey || event.metaKey || event.deltaY === 0) return
|
if (event.ctrlKey || event.metaKey || event.deltaY === 0) return
|
||||||
update(s => ({ ...s, viewport: { ...s.viewport, zoom: zoomFromWheel(s.viewport.zoom, event.deltaY) } }))
|
const bounds = board.getBoundingClientRect()
|
||||||
|
const anchor = { x: event.clientX - bounds.left, y: event.clientY - bounds.top }
|
||||||
|
update(s => ({ ...s, viewport: zoomViewportAt(s.viewport, zoomFromWheel(s.viewport.zoom, event.deltaY), anchor) }))
|
||||||
}
|
}
|
||||||
board.addEventListener('wheel', handleWheelZoom, { passive: false })
|
board.addEventListener('wheel', handleWheelZoom, { passive: false })
|
||||||
return () => board.removeEventListener('wheel', handleWheelZoom)
|
return () => board.removeEventListener('wheel', handleWheelZoom)
|
||||||
@@ -477,7 +479,11 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
|||||||
const points = [...touchPoints.current.values()]
|
const points = [...touchPoints.current.values()]
|
||||||
const distance = Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y)
|
const distance = Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y)
|
||||||
const previous = pinchDistance.current
|
const previous = pinchDistance.current
|
||||||
if (previous) update(s => ({ ...s, viewport: { ...s.viewport, zoom: zoomFromPinch(s.viewport.zoom, previous, distance) } }))
|
if (previous && boardRef.current) {
|
||||||
|
const bounds = boardRef.current.getBoundingClientRect()
|
||||||
|
const anchor = { x: (points[0].x + points[1].x) / 2 - bounds.left, y: (points[0].y + points[1].y) / 2 - bounds.top }
|
||||||
|
update(s => ({ ...s, viewport: zoomViewportAt(s.viewport, zoomFromPinch(s.viewport.zoom, previous, distance), anchor) }))
|
||||||
|
}
|
||||||
pinchDistance.current = distance
|
pinchDistance.current = distance
|
||||||
suppressClick.current = true
|
suppressClick.current = true
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
timelineRange,
|
timelineRange,
|
||||||
zoomFromWheel,
|
zoomFromWheel,
|
||||||
zoomFromPinch,
|
zoomFromPinch,
|
||||||
|
zoomViewportAt,
|
||||||
} from './boardDomain'
|
} from './boardDomain'
|
||||||
|
|
||||||
describe('red thread geometry', () => {
|
describe('red thread geometry', () => {
|
||||||
@@ -80,6 +81,15 @@ describe('board coordinate math', () => {
|
|||||||
expect(zoomFromPinch(1, 0, 120)).toBe(1)
|
expect(zoomFromPinch(1, 0, 120)).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the board point beneath the cursor fixed while zooming', () => {
|
||||||
|
const anchor = { x: 110, y: 120 }
|
||||||
|
const zoomed = zoomViewportAt(state.viewport, 1, anchor)
|
||||||
|
expect(zoomed).toEqual({ x: -90, y: -80, zoom: 1 })
|
||||||
|
expect(zoomed.x + 200 * zoomed.zoom).toBe(anchor.x)
|
||||||
|
expect(zoomed.y + 200 * zoomed.zoom).toBe(anchor.y)
|
||||||
|
expect(zoomViewportAt(zoomed, .5, anchor)).toEqual(state.viewport)
|
||||||
|
})
|
||||||
|
|
||||||
it('places new exhibits in deterministic open slots', () => {
|
it('places new exhibits in deterministic open slots', () => {
|
||||||
const preferred = { x: 500, y: 400 }
|
const preferred = { x: 500, y: 400 }
|
||||||
expect(nextOpenBoardPosition([], preferred, { width: 280 })).toEqual(preferred)
|
expect(nextOpenBoardPosition([], preferred, { width: 280 })).toEqual(preferred)
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ export function zoomFromPinch(currentZoom: number, previousDistance: number, nex
|
|||||||
return clampBoardZoom(currentZoom * nextDistance / previousDistance)
|
return clampBoardZoom(currentZoom * nextDistance / previousDistance)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function zoomViewportAt(viewport: Viewport, nextZoom: number, screenAnchor: BoardPoint): Viewport {
|
||||||
|
if (!Number.isFinite(viewport.zoom) || viewport.zoom <= 0) throw new RangeError('Board zoom must be positive')
|
||||||
|
const zoom = clampBoardZoom(nextZoom)
|
||||||
|
const boardPoint = { x: (screenAnchor.x - viewport.x) / viewport.zoom, y: (screenAnchor.y - viewport.y) / viewport.zoom }
|
||||||
|
return { x: screenAnchor.x - boardPoint.x * zoom, y: screenAnchor.y - boardPoint.y * zoom, zoom }
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
if (!Number.isFinite(zoom) || zoom <= 0) throw new RangeError('Board zoom must be positive')
|
if (!Number.isFinite(zoom) || zoom <= 0) throw new RangeError('Board zoom must be positive')
|
||||||
return { x: origin.x + screenDelta.x / zoom, y: origin.y + screenDelta.y / zoom }
|
return { x: origin.x + screenDelta.x / zoom, y: origin.y + screenDelta.y / zoom }
|
||||||
|
|||||||
Reference in New Issue
Block a user