Improve zoom smoothness, add absolute position cache (#21)
* Improve trackpad pinch-zoom smoothness - Replace Math.pow(0.99, delta) with Math.exp(-delta/100) zoom curve for natural, symmetric scaling that handles both tiny trackpad deltas and large mouse wheel jumps gracefully - Normalize wheel deltaMode (LINE → 40px, PAGE → 800px) so external mice on Firefox produce consistent zoom behavior - Clamp per-flush scale factor to 0.75–1.25 to prevent jarring jumps from discrete mouse wheel ticks * Add per-frame absolute position cache for SceneGraph
This commit is contained in:
parent
a9a1f9ac1f
commit
a32b134eba
|
|
@ -30,6 +30,7 @@
|
||||||
### UI
|
### UI
|
||||||
|
|
||||||
- Replace all native `<select>` dropdowns with reka-ui `AppSelect` component
|
- Replace all native `<select>` dropdowns with reka-ui `AppSelect` component
|
||||||
|
- Smoother trackpad pinch-to-zoom with `Math.exp` curve and deltaMode normalization
|
||||||
- Fix font picker dropdown truncating long font names
|
- Fix font picker dropdown truncating long font names
|
||||||
- Show explanation in font picker when Local Font Access API unavailable (Safari/Firefox)
|
- Show explanation in font picker when Local Font Access API unavailable (Safari/Firefox)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,8 @@ import {
|
||||||
TEXT_CARET_COLOR,
|
TEXT_CARET_COLOR,
|
||||||
TEXT_CARET_WIDTH
|
TEXT_CARET_WIDTH
|
||||||
} from './constants'
|
} from './constants'
|
||||||
import { vectorNetworkToPath } from './vector'
|
|
||||||
import { isFontLoaded } from './fonts'
|
import { isFontLoaded } from './fonts'
|
||||||
|
import { vectorNetworkToPath } from './vector'
|
||||||
|
|
||||||
import type { SceneNode, SceneGraph, Fill, Stroke } from './scene-graph'
|
import type { SceneNode, SceneGraph, Fill, Stroke } from './scene-graph'
|
||||||
import type { SnapGuide } from './snap'
|
import type { SnapGuide } from './snap'
|
||||||
|
|
@ -484,6 +484,8 @@ export class SkiaRenderer {
|
||||||
overlays: RenderOverlays = {},
|
overlays: RenderOverlays = {},
|
||||||
sceneVersion = -1
|
sceneVersion = -1
|
||||||
): void {
|
): void {
|
||||||
|
graph.clearAbsPosCache()
|
||||||
|
|
||||||
const canvas = this.surface.getCanvas()
|
const canvas = this.surface.getCanvas()
|
||||||
canvas.clear(this.ck.Color4f(this.pageColor.r, this.pageColor.g, this.pageColor.b, 1))
|
canvas.clear(this.ck.Color4f(this.pageColor.r, this.pageColor.g, this.pageColor.b, 1))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -409,6 +409,7 @@ export class SceneGraph {
|
||||||
variableCollections = new Map<string, VariableCollection>()
|
variableCollections = new Map<string, VariableCollection>()
|
||||||
activeMode = new Map<string, string>()
|
activeMode = new Map<string, string>()
|
||||||
rootId: string
|
rootId: string
|
||||||
|
private absPosCache = new Map<string, { x: number; y: number }>()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const root = createDefaultNode('FRAME', {
|
const root = createDefaultNode('FRAME', {
|
||||||
|
|
@ -609,7 +610,14 @@ export class SceneGraph {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearAbsPosCache(): void {
|
||||||
|
this.absPosCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
getAbsolutePosition(id: string): { x: number; y: number } {
|
getAbsolutePosition(id: string): { x: number; y: number } {
|
||||||
|
const cached = this.absPosCache.get(id)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
let ax = 0
|
let ax = 0
|
||||||
let ay = 0
|
let ay = 0
|
||||||
let current = this.nodes.get(id)
|
let current = this.nodes.get(id)
|
||||||
|
|
@ -618,7 +626,9 @@ export class SceneGraph {
|
||||||
ay += current.y
|
ay += current.y
|
||||||
current = current.parentId ? this.nodes.get(current.parentId) : undefined
|
current = current.parentId ? this.nodes.get(current.parentId) : undefined
|
||||||
}
|
}
|
||||||
return { x: ax, y: ay }
|
const result = { x: ax, y: ay }
|
||||||
|
this.absPosCache.set(id, result)
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
getAbsoluteBounds(id: string): Rect {
|
getAbsoluteBounds(id: string): Rect {
|
||||||
|
|
@ -648,6 +658,7 @@ export class SceneGraph {
|
||||||
updateNode(id: string, changes: Partial<SceneNode>): void {
|
updateNode(id: string, changes: Partial<SceneNode>): void {
|
||||||
const node = this.nodes.get(id)
|
const node = this.nodes.get(id)
|
||||||
if (!node) return
|
if (!node) return
|
||||||
|
this.absPosCache.clear()
|
||||||
Object.assign(node, changes)
|
Object.assign(node, changes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -661,6 +672,8 @@ export class SceneGraph {
|
||||||
if (!newParent) return
|
if (!newParent) return
|
||||||
if (node.parentId === newParentId) return
|
if (node.parentId === newParentId) return
|
||||||
|
|
||||||
|
this.absPosCache.clear()
|
||||||
|
|
||||||
// Convert absolute position
|
// Convert absolute position
|
||||||
const absPos = this.getAbsolutePosition(nodeId)
|
const absPos = this.getAbsolutePosition(nodeId)
|
||||||
const newParentNode = this.nodes.get(newParentId)
|
const newParentNode = this.nodes.get(newParentId)
|
||||||
|
|
|
||||||
|
|
@ -942,20 +942,35 @@ export function useCanvasInput(
|
||||||
wheelAccum.hasZoom = false
|
wheelAccum.hasZoom = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize wheel deltaY across deltaMode variants (line/page/pixel).
|
||||||
|
// Trackpad pinch is always DOM_DELTA_PIXEL; external mice may use LINE or PAGE.
|
||||||
|
function normalizeWheelDelta(e: WheelEvent): { dx: number; dy: number } {
|
||||||
|
let { deltaX, deltaY } = e
|
||||||
|
if (e.deltaMode === WheelEvent.DOM_DELTA_LINE) {
|
||||||
|
deltaX *= 40
|
||||||
|
deltaY *= 40
|
||||||
|
} else if (e.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
|
||||||
|
deltaX *= 800
|
||||||
|
deltaY *= 800
|
||||||
|
}
|
||||||
|
return { dx: deltaX, dy: deltaY }
|
||||||
|
}
|
||||||
|
|
||||||
function onWheel(e: WheelEvent) {
|
function onWheel(e: WheelEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const canvas = canvasRef.value
|
const canvas = canvasRef.value
|
||||||
if (!canvas) return
|
if (!canvas) return
|
||||||
|
const { dx, dy } = normalizeWheelDelta(e)
|
||||||
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
const rect = canvas.getBoundingClientRect()
|
const rect = canvas.getBoundingClientRect()
|
||||||
wheelAccum.zoomCenterX = e.clientX - rect.left
|
wheelAccum.zoomCenterX = e.clientX - rect.left
|
||||||
wheelAccum.zoomCenterY = e.clientY - rect.top
|
wheelAccum.zoomCenterY = e.clientY - rect.top
|
||||||
wheelAccum.zoomDelta += e.deltaY
|
wheelAccum.zoomDelta += dy
|
||||||
wheelAccum.hasZoom = true
|
wheelAccum.hasZoom = true
|
||||||
} else {
|
} else {
|
||||||
wheelAccum.deltaX -= e.deltaX
|
wheelAccum.deltaX -= dx
|
||||||
wheelAccum.deltaY -= e.deltaY
|
wheelAccum.deltaY -= dy
|
||||||
}
|
}
|
||||||
if (!wheelAccum.rafId) {
|
if (!wheelAccum.rafId) {
|
||||||
wheelAccum.rafId = requestAnimationFrame(flushWheel)
|
wheelAccum.rafId = requestAnimationFrame(flushWheel)
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,14 @@ export const DEFAULT_TEXT_HEIGHT = 24
|
||||||
export const AUTO_LAYOUT_BREAK_THRESHOLD = 8
|
export const AUTO_LAYOUT_BREAK_THRESHOLD = 8
|
||||||
export const HANDLE_HIT_RADIUS = 6
|
export const HANDLE_HIT_RADIUS = 6
|
||||||
export const ROTATION_HIT_RADIUS = 8
|
export const ROTATION_HIT_RADIUS = 8
|
||||||
export const ZOOM_SENSITIVITY = 0.99
|
|
||||||
|
// Pixels of deltaY for one e-fold of zoom (Math.exp(-deltaY / ZOOM_DIVISOR))
|
||||||
|
// Trackpad pinch sends small deltas (~2-5px), mouse wheel sends large (~100px).
|
||||||
|
// Lower = more responsive. 50 matches Figma's trackpad feel.
|
||||||
|
export const ZOOM_DIVISOR = 50
|
||||||
|
// Clamp scale factor per wheel flush to prevent jarring jumps from mouse wheels
|
||||||
|
export const ZOOM_SCALE_MIN = 0.75
|
||||||
|
export const ZOOM_SCALE_MAX = 1.25
|
||||||
|
|
||||||
export const SECTION_DEFAULT_FILL: Fill = {
|
export const SECTION_DEFAULT_FILL: Fill = {
|
||||||
type: 'SOLID',
|
type: 'SOLID',
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ import {
|
||||||
SECTION_DEFAULT_FILL,
|
SECTION_DEFAULT_FILL,
|
||||||
SECTION_DEFAULT_STROKE,
|
SECTION_DEFAULT_STROKE,
|
||||||
CANVAS_BG_COLOR,
|
CANVAS_BG_COLOR,
|
||||||
ZOOM_SENSITIVITY
|
ZOOM_DIVISOR,
|
||||||
|
ZOOM_SCALE_MIN,
|
||||||
|
ZOOM_SCALE_MAX
|
||||||
} from '@/constants'
|
} from '@/constants'
|
||||||
import {
|
import {
|
||||||
parseFigmaClipboard,
|
parseFigmaClipboard,
|
||||||
|
|
@ -1933,7 +1935,10 @@ export function createEditorStore() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyZoom(delta: number, centerX: number, centerY: number) {
|
function applyZoom(delta: number, centerX: number, centerY: number) {
|
||||||
const factor = Math.pow(ZOOM_SENSITIVITY, delta)
|
const factor = Math.min(
|
||||||
|
ZOOM_SCALE_MAX,
|
||||||
|
Math.max(ZOOM_SCALE_MIN, Math.exp(-delta / ZOOM_DIVISOR))
|
||||||
|
)
|
||||||
const newZoom = Math.max(0.02, Math.min(256, state.zoom * factor))
|
const newZoom = Math.max(0.02, Math.min(256, state.zoom * factor))
|
||||||
state.panX = centerX - (centerX - state.panX) * (newZoom / state.zoom)
|
state.panX = centerX - (centerX - state.panX) * (newZoom / state.zoom)
|
||||||
state.panY = centerY - (centerY - state.panY) * (newZoom / state.zoom)
|
state.panY = centerY - (centerY - state.panY) * (newZoom / state.zoom)
|
||||||
|
|
|
||||||
88
tests/engine/abspos-cache.test.ts
Normal file
88
tests/engine/abspos-cache.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { SceneGraph } from '../../packages/core/src/scene-graph'
|
||||||
|
|
||||||
|
function pageId(graph: SceneGraph) {
|
||||||
|
return graph.getPages()[0].id
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('absolute position cache', () => {
|
||||||
|
test('cached result matches uncached computation', () => {
|
||||||
|
const graph = new SceneGraph()
|
||||||
|
const frame = graph.createNode('FRAME', pageId(graph), { name: 'F', x: 100, y: 200 })
|
||||||
|
const child = graph.createNode('RECTANGLE', frame.id, { name: 'R', x: 10, y: 20 })
|
||||||
|
|
||||||
|
const first = graph.getAbsolutePosition(child.id)
|
||||||
|
const second = graph.getAbsolutePosition(child.id)
|
||||||
|
|
||||||
|
expect(first).toEqual({ x: 110, y: 220 })
|
||||||
|
expect(second).toEqual({ x: 110, y: 220 })
|
||||||
|
expect(first).toBe(second)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cache invalidated after node position change', () => {
|
||||||
|
const graph = new SceneGraph()
|
||||||
|
const frame = graph.createNode('FRAME', pageId(graph), { name: 'F', x: 50, y: 50 })
|
||||||
|
const child = graph.createNode('RECTANGLE', frame.id, { name: 'R', x: 10, y: 10 })
|
||||||
|
|
||||||
|
expect(graph.getAbsolutePosition(child.id)).toEqual({ x: 60, y: 60 })
|
||||||
|
|
||||||
|
graph.updateNode(frame.id, { x: 100, y: 100 })
|
||||||
|
|
||||||
|
expect(graph.getAbsolutePosition(child.id)).toEqual({ x: 110, y: 110 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cache invalidated after reparenting', () => {
|
||||||
|
const graph = new SceneGraph()
|
||||||
|
const page = pageId(graph)
|
||||||
|
const frameA = graph.createNode('FRAME', page, { name: 'A', x: 100, y: 100 })
|
||||||
|
const frameB = graph.createNode('FRAME', page, { name: 'B', x: 300, y: 300 })
|
||||||
|
const child = graph.createNode('RECTANGLE', frameA.id, { name: 'R', x: 10, y: 10 })
|
||||||
|
|
||||||
|
expect(graph.getAbsolutePosition(child.id)).toEqual({ x: 110, y: 110 })
|
||||||
|
|
||||||
|
graph.reparentNode(child.id, frameB.id)
|
||||||
|
|
||||||
|
const pos = graph.getAbsolutePosition(child.id)
|
||||||
|
expect(pos).toEqual({ x: 110, y: 110 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('nested nodes (3+ levels) get correct absolute positions', () => {
|
||||||
|
const graph = new SceneGraph()
|
||||||
|
const page = pageId(graph)
|
||||||
|
const level1 = graph.createNode('FRAME', page, { name: 'L1', x: 10, y: 20 })
|
||||||
|
const level2 = graph.createNode('FRAME', level1.id, { name: 'L2', x: 30, y: 40 })
|
||||||
|
const level3 = graph.createNode('FRAME', level2.id, { name: 'L3', x: 50, y: 60 })
|
||||||
|
const leaf = graph.createNode('RECTANGLE', level3.id, { name: 'Leaf', x: 1, y: 2 })
|
||||||
|
|
||||||
|
expect(graph.getAbsolutePosition(level1.id)).toEqual({ x: 10, y: 20 })
|
||||||
|
expect(graph.getAbsolutePosition(level2.id)).toEqual({ x: 40, y: 60 })
|
||||||
|
expect(graph.getAbsolutePosition(level3.id)).toEqual({ x: 90, y: 120 })
|
||||||
|
expect(graph.getAbsolutePosition(leaf.id)).toEqual({ x: 91, y: 122 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clearAbsPosCache forces recomputation', () => {
|
||||||
|
const graph = new SceneGraph()
|
||||||
|
const rect = graph.createNode('RECTANGLE', pageId(graph), { name: 'R', x: 10, y: 20 })
|
||||||
|
|
||||||
|
const first = graph.getAbsolutePosition(rect.id)
|
||||||
|
graph.clearAbsPosCache()
|
||||||
|
const second = graph.getAbsolutePosition(rect.id)
|
||||||
|
|
||||||
|
expect(first).toEqual(second)
|
||||||
|
expect(first).not.toBe(second)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sibling cache entries survive unrelated sibling update', () => {
|
||||||
|
const graph = new SceneGraph()
|
||||||
|
const page = pageId(graph)
|
||||||
|
const a = graph.createNode('RECTANGLE', page, { name: 'A', x: 10, y: 10 })
|
||||||
|
const b = graph.createNode('RECTANGLE', page, { name: 'B', x: 20, y: 20 })
|
||||||
|
|
||||||
|
graph.getAbsolutePosition(a.id)
|
||||||
|
graph.updateNode(b.id, { x: 30 })
|
||||||
|
|
||||||
|
const posA = graph.getAbsolutePosition(a.id)
|
||||||
|
expect(posA).toEqual({ x: 10, y: 10 })
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue