Improve edge scrolling (#3950)

This PR:
- moves the edge scrolling logic into a manager
- adds a new Editor option for `edgeScrollDelay`
- adds a new Editor option for `edgeScrollEaseDuration`

When in a state that would trigger an edge scroll, a delay is added
before the scrolling starts. When scrolling does start, it is eased in
by a certain duration.

### Change Type

- [x] `sdk` — Changes the tldraw SDK
- [x] `improvement` — Improving existing features

### Test Plan

1. Drag shapes, resize, or drag select to the edge of the screen
2. The screen should move

- [x] Unit Tests

### Release Notes

- Add a delay and easing to edge scrolling.

---------

Co-authored-by: Mitja Bezenšek <mitja.bezensek@gmail.com>
This commit is contained in:
Steve Ruiz 2024-06-17 17:18:49 +03:00 committed by GitHub
parent ccc673b5af
commit 6c7b8febbf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 294 additions and 97 deletions

View file

@ -679,8 +679,10 @@ export const defaultTldrawOptions: {
readonly defaultSvgPadding: 32;
readonly doubleClickDurationMs: 450;
readonly dragDistanceSquared: 16;
readonly edgeScrollDelay: 200;
readonly edgeScrollDistance: 8;
readonly edgeScrollSpeed: 20;
readonly edgeScrollEaseDuration: 200;
readonly edgeScrollSpeed: 25;
readonly flattenImageBoundsExpand: 64;
readonly flattenImageBoundsPadding: 16;
readonly followChaseViewportSnap: 2;
@ -780,6 +782,14 @@ export class Edge2d extends Geometry2d {
ul: number;
}
// @public (undocumented)
export class EdgeScrollManager {
constructor(editor: Editor);
// (undocumented)
editor: Editor;
updateEdgeScrolling(elapsed: number): void;
}
// @public (undocumented)
export class Editor extends EventEmitter<TLEventMap> {
constructor({ store, user, shapeUtils, bindingUtils, tools, getContainer, cameraOptions, assetOptions, initialState, autoFocus, inferDarkMode, options, }: TLEditorOptions);
@ -883,6 +893,7 @@ export class Editor extends EventEmitter<TLEventMap> {
distributeShapes(shapes: TLShape[] | TLShapeId[], operation: 'horizontal' | 'vertical'): this;
duplicatePage(page: TLPage | TLPageId, createId?: TLPageId): this;
duplicateShapes(shapes: TLShape[] | TLShapeId[], offset?: VecLike): this;
edgeScrollManager: EdgeScrollManager;
readonly environment: EnvironmentManager;
// @internal (undocumented)
externalAssetContentHandlers: {
@ -1754,9 +1765,6 @@ export interface MatModel {
f: number;
}
// @public
export function moveCameraWhenCloseToEdge(editor: Editor): void;
// @internal (undocumented)
export function normalizeWheel(event: React.WheelEvent<HTMLElement> | WheelEvent): {
x: number;
@ -2573,8 +2581,12 @@ export interface TldrawOptions {
// (undocumented)
readonly dragDistanceSquared: number;
// (undocumented)
readonly edgeScrollDelay: number;
// (undocumented)
readonly edgeScrollDistance: number;
// (undocumented)
readonly edgeScrollEaseDuration: number;
// (undocumented)
readonly edgeScrollSpeed: number;
// (undocumented)
readonly flattenImageBoundsExpand: number;

View file

@ -136,6 +136,7 @@ export {
type TLBindingUtilConstructor,
} from './lib/editor/bindings/BindingUtil'
export { ClickManager, type TLClickState } from './lib/editor/managers/ClickManager'
export { EdgeScrollManager } from './lib/editor/managers/EdgeScrollManager'
export { EnvironmentManager } from './lib/editor/managers/EnvironmentManager'
export { HistoryManager } from './lib/editor/managers/HistoryManager'
export { ScribbleManager, type ScribbleItem } from './lib/editor/managers/ScribbleManager'
@ -361,7 +362,6 @@ export {
setPointerCapture,
stopEventPropagation,
} from './lib/utils/dom'
export { moveCameraWhenCloseToEdge } from './lib/utils/edgeScrolling'
export { getIncrementedName } from './lib/utils/getIncrementedName'
export { getPointerInfo } from './lib/utils/getPointerInfo'
export { getSvgPathFromPoints } from './lib/utils/getSvgPathFromPoints'

View file

@ -125,6 +125,7 @@ import { parentsToChildren } from './derivations/parentsToChildren'
import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage'
import { getSvgJsx } from './getSvgJsx'
import { ClickManager } from './managers/ClickManager'
import { EdgeScrollManager } from './managers/EdgeScrollManager'
import { EnvironmentManager } from './managers/EnvironmentManager'
import { FocusManager } from './managers/FocusManager'
import { HistoryManager } from './managers/HistoryManager'
@ -692,6 +693,7 @@ export class Editor extends EventEmitter<TLEventMap> {
this.root.enter(undefined, 'initial')
this.edgeScrollManager = new EdgeScrollManager(this)
this.focusManager = new FocusManager(this, autoFocus)
this.disposables.add(this.focusManager.dispose.bind(this.focusManager))
@ -791,6 +793,13 @@ export class Editor extends EventEmitter<TLEventMap> {
*/
readonly sideEffects: StoreSideEffects<TLRecord>
/**
* A manager for moving the camera when the mouse is at the edge of the screen.
*
* @public
*/
edgeScrollManager: EdgeScrollManager
/**
* A manager for ensuring correct focus. See FocusManager for details.
*

View file

@ -0,0 +1,128 @@
import { Vec } from '../../primitives/Vec'
import { EASINGS } from '../../primitives/easings'
import { Editor } from '../Editor'
/** @public */
export class EdgeScrollManager {
constructor(public editor: Editor) {}
private _isEdgeScrolling = false
private _edgeScrollDuration = -1
/**
* Update the camera position when the mouse is close to the edge of the screen.
* Run this on every tick when in a state where edge scrolling is enabled.
*
* @public
*/
updateEdgeScrolling(elapsed: number) {
const { editor } = this
const edgeScrollProximityFactor = this.getEdgeScroll()
if (edgeScrollProximityFactor.x === 0 && edgeScrollProximityFactor.y === 0) {
if (this._isEdgeScrolling) {
this._isEdgeScrolling = false
this._edgeScrollDuration = 0
}
} else {
if (!this._isEdgeScrolling) {
this._isEdgeScrolling = true
this._edgeScrollDuration = 0
}
this._edgeScrollDuration += elapsed
if (this._edgeScrollDuration > editor.options.edgeScrollDelay) {
const eased =
editor.options.edgeScrollEaseDuration > 0
? EASINGS.easeInCubic(
Math.min(
1,
this._edgeScrollDuration /
(editor.options.edgeScrollDelay + editor.options.edgeScrollEaseDuration)
)
)
: 1
this.moveCameraWhenCloseToEdge({
x: edgeScrollProximityFactor.x * eased,
y: edgeScrollProximityFactor.y * eased,
})
}
}
}
/**
* Helper function to get the scroll proximity factor for a given position.
* @param position - The mouse position on the axis.
* @param dimension - The component dimension on the axis.
* @internal
*/
private getEdgeProximityFactors(
position: number,
dimension: number,
isCoarse: boolean,
insetStart: boolean,
insetEnd: boolean
) {
const { editor } = this
const dist = editor.options.edgeScrollDistance
const pw = isCoarse ? editor.options.coarsePointerWidth : 0 // pointer width
const pMin = position - pw
const pMax = position + pw
const min = insetStart ? 0 : dist
const max = insetEnd ? dimension : dimension - dist
if (pMin < min) {
return Math.min(1, (min - pMin) / dist)
} else if (pMax > max) {
return -Math.min(1, (pMax - max) / dist)
}
return 0
}
private getEdgeScroll() {
const { editor } = this
const {
inputs: {
currentScreenPoint: { x, y },
},
} = editor
const screenBounds = editor.getViewportScreenBounds()
const {
isCoarsePointer,
insets: [t, r, b, l],
} = editor.getInstanceState()
const proximityFactorX = this.getEdgeProximityFactors(x, screenBounds.w, isCoarsePointer, l, r)
const proximityFactorY = this.getEdgeProximityFactors(y, screenBounds.h, isCoarsePointer, t, b)
return {
x: proximityFactorX,
y: proximityFactorY,
}
}
/**
* Moves the camera when the mouse is close to the edge of the screen.
* @public
*/
private moveCameraWhenCloseToEdge(proximityFactor: { x: number; y: number }) {
const { editor } = this
if (!editor.inputs.isDragging || editor.inputs.isPanning || editor.getCameraOptions().isLocked)
return
if (proximityFactor.x === 0 && proximityFactor.y === 0) return
const screenBounds = editor.getViewportScreenBounds()
// Determines how much the speed is affected by the screen size
const screenSizeFactorX = screenBounds.w < 1000 ? 0.612 : 1
const screenSizeFactorY = screenBounds.h < 1000 ? 0.612 : 1
// Determines the base speed of the scroll
const zoomLevel = editor.getZoomLevel()
const pxSpeed = editor.user.getEdgeScrollSpeed() * editor.options.edgeScrollSpeed
const scrollDeltaX = (pxSpeed * proximityFactor.x * screenSizeFactorX) / zoomLevel
const scrollDeltaY = (pxSpeed * proximityFactor.y * screenSizeFactorY) / zoomLevel
// update the camera
const { x, y, z } = editor.getCamera()
editor.setCamera(new Vec(x + scrollDeltaX, y + scrollDeltaY, z))
}
}

View file

@ -37,6 +37,8 @@ export interface TldrawOptions {
readonly collaboratorCheckIntervalMs: number
readonly cameraMovingTimeoutMs: number
readonly hitTestMargin: number
readonly edgeScrollDelay: number
readonly edgeScrollEaseDuration: number
readonly edgeScrollSpeed: number
readonly edgeScrollDistance: number
readonly coarsePointerWidth: number
@ -73,7 +75,9 @@ export const defaultTldrawOptions = {
collaboratorCheckIntervalMs: 1200,
cameraMovingTimeoutMs: 64,
hitTestMargin: 8,
edgeScrollSpeed: 20,
edgeScrollDelay: 200,
edgeScrollEaseDuration: 200,
edgeScrollSpeed: 25,
edgeScrollDistance: 8,
coarsePointerWidth: 12,
coarseHandleRadius: 20,

View file

@ -1,69 +0,0 @@
import { Editor } from '../editor/Editor'
import { Vec } from '../primitives/Vec'
/**
* Helper function to get the scroll proximity factor for a given position.
* @param position - The mouse position on the axis.
* @param dimension - The component dimension on the axis.
* @internal
*/
function getEdgeProximityFactor(
editor: Editor,
position: number,
dimension: number,
isCoarse: boolean,
insetStart: boolean,
insetEnd: boolean
) {
const dist = editor.options.edgeScrollDistance
const pw = isCoarse ? editor.options.coarsePointerWidth : 0 // pointer width
const pMin = position - pw
const pMax = position + pw
const min = insetStart ? 0 : dist
const max = insetEnd ? dimension : dimension - dist
if (pMin < min) {
return Math.min(1, (min - pMin) / dist)
} else if (pMax > max) {
return -Math.min(1, (pMax - max) / dist)
}
return 0
}
/**
* Moves the camera when the mouse is close to the edge of the screen.
* @public
*/
export function moveCameraWhenCloseToEdge(editor: Editor) {
if (!editor.inputs.isDragging || editor.inputs.isPanning || editor.getCameraOptions().isLocked)
return
const {
inputs: {
currentScreenPoint: { x, y },
},
} = editor
const zoomLevel = editor.getZoomLevel()
const screenBounds = editor.getViewportScreenBounds()
// Determines how much the speed is affected by the screen size
const screenSizeFactorX = screenBounds.w < 1000 ? 0.612 : 1
const screenSizeFactorY = screenBounds.h < 1000 ? 0.612 : 1
const {
isCoarsePointer,
insets: [t, r, b, l],
} = editor.getInstanceState()
const proximityFactorX = getEdgeProximityFactor(editor, x, screenBounds.w, isCoarsePointer, l, r)
const proximityFactorY = getEdgeProximityFactor(editor, y, screenBounds.h, isCoarsePointer, t, b)
if (proximityFactorX === 0 && proximityFactorY === 0) return
// Determines the base speed of the scroll
const pxSpeed = editor.user.getEdgeScrollSpeed() * editor.options.edgeScrollSpeed
const scrollDeltaX = (pxSpeed * proximityFactorX * screenSizeFactorX) / zoomLevel
const scrollDeltaY = (pxSpeed * proximityFactorY * screenSizeFactorY) / zoomLevel
const camera = editor.getCamera()
editor.setCamera(new Vec(camera.x + scrollDeltaX, camera.y + scrollDeltaY, camera.z))
}

View file

@ -12,8 +12,8 @@ import {
TLPointerEventInfo,
TLShape,
TLShapeId,
TLTickEventInfo,
Vec,
moveCameraWhenCloseToEdge,
pointInPolygon,
polygonsIntersect,
} from '@tldraw/editor'
@ -62,8 +62,9 @@ export class Brushing extends StateNode {
this.editor.updateInstanceState({ brush: null })
}
override onTick = () => {
moveCameraWhenCloseToEdge(this.editor)
override onTick = ({ elapsed }: TLTickEventInfo) => {
const { editor } = this
editor.edgeScrollManager.updateEdgeScrolling(elapsed)
}
override onPointerMove = () => {

View file

@ -14,11 +14,11 @@ import {
TLShapeId,
TLShapePartial,
TLTextShape,
TLTickEventInfo,
Vec,
VecLike,
areAnglesCompatible,
compact,
moveCameraWhenCloseToEdge,
} from '@tldraw/editor'
import { kickoutOccludedShapes } from '../selectHelpers'
@ -72,8 +72,9 @@ export class Resizing extends StateNode {
this.updateShapes()
}
override onTick = () => {
moveCameraWhenCloseToEdge(this.editor)
override onTick = ({ elapsed }: TLTickEventInfo) => {
const { editor } = this
editor.edgeScrollManager.updateEdgeScrolling(elapsed)
}
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {

View file

@ -10,10 +10,10 @@ import {
TLPointerEventInfo,
TLShape,
TLShapePartial,
TLTickEventInfo,
Vec,
compact,
isPageId,
moveCameraWhenCloseToEdge,
} from '@tldraw/editor'
import {
NOTE_ADJACENT_POSITION_SNAP_RADIUS,
@ -101,12 +101,13 @@ export class Translating extends StateNode {
this.dragAndDropManager.clear()
}
override onTick = () => {
override onTick = ({ elapsed }: TLTickEventInfo) => {
const { editor } = this
this.dragAndDropManager.updateDroppingNode(
this.snapshot.movingShapes,
this.updateParentTransforms
)
moveCameraWhenCloseToEdge(this.editor)
editor.edgeScrollManager.updateEdgeScrolling(elapsed)
}
override onPointerMove = () => {

View file

@ -4,7 +4,12 @@ import { TestEditor } from '../TestEditor'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
editor = new TestEditor({
options: {
edgeScrollDelay: 0,
edgeScrollEaseDuration: 0,
},
})
editor.updateViewportScreenBounds(new Box(0, 0, 1600, 900))
})
@ -227,11 +232,13 @@ describe('CameraOptions.panSpeed', () => {
.forceTick()
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1.01 }) // 1 + 1
})
it('Does not effect hand tool panning', () => {
editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, panSpeed: 2 })
editor.setCurrentTool('hand').pointerDown(0, 0).pointerMove(5, 10).forceTick()
expect(editor.getCamera()).toMatchObject({ x: 5, y: 10, z: 1 })
})
it('Effects spacebar panning (2x)', () => {
editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, panSpeed: 2 })
editor
@ -241,6 +248,7 @@ describe('CameraOptions.panSpeed', () => {
.forceTick()
expect(editor.getCamera()).toMatchObject({ x: 10, y: 20, z: 1 })
})
it('Effects spacebar panning (0.5x)', () => {
editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, panSpeed: 0.5 })
editor
@ -250,6 +258,7 @@ describe('CameraOptions.panSpeed', () => {
.forceTick()
expect(editor.getCamera()).toMatchObject({ x: 2.5, y: 5, z: 1 })
})
it('Does not effect edge scroll panning', () => {
const shapeId = createShapeId()
const viewportScreenBounds = editor.getViewportScreenBounds()
@ -261,11 +270,12 @@ describe('CameraOptions.panSpeed', () => {
const shape = editor.getSelectedShapes()[0]
editor.selectNone()
// Move shape far beyond bounds to trigger edge scrolling at maximum speed
editor.pointerDown(shape.x, shape.y).pointerMove(-5000, -5000).forceTick()
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
editor.pointerDown(shape.x, shape.y).pointerMove(-5000, -5000)
// At maximum speed and a zoom level of 1, the camera should move by 40px per tick if the screen
// is wider than 1000 pixels, or by 40 * 0.612px if it is smaller.
const newX = viewportScreenBounds.w < 1000 ? 40 * 0.612 : 40
const newY = viewportScreenBounds.h < 1000 ? 40 * 0.612 : 40
const newX = viewportScreenBounds.w < 1000 ? 25 * 0.612 : 25
const newY = viewportScreenBounds.h < 1000 ? 25 * 0.612 : 25
expect(editor.getCamera()).toMatchObject({ x: newX, y: newY, z: 1 })
})
})

View file

@ -16,7 +16,12 @@ const ids = {
}
beforeEach(() => {
editor = new TestEditor()
editor = new TestEditor({
options: {
edgeScrollDelay: 0,
edgeScrollEaseDuration: 0,
},
})
editor.setScreenBounds({ w: 3000, h: 3000, x: 0, y: 0 })
})
@ -1951,3 +1956,88 @@ it('Ignores locked shapes when hovering', () => {
editor.rightClick()
expect(editor.getSelectedShapeIds()).toEqual([b.id])
})
describe('Edge scrolling', () => {
it('moves the camera correctly when delay and duration are zero', () => {
editor = new TestEditor({
options: {
edgeScrollDelay: 0,
edgeScrollEaseDuration: 0,
},
})
editor.setScreenBounds({ w: 3000, h: 3000, x: 0, y: 0 })
editor.user.updateUserPreferences({ edgeScrollSpeed: 1 })
editor.pointerMove(300, 300)
editor.pointerDown()
editor.pointerMove(0, 0)
expect(editor.getCamera()).toMatchObject({
x: editor.options.edgeScrollSpeed,
y: editor.options.edgeScrollSpeed,
})
editor.forceTick()
expect(editor.getCamera()).toMatchObject({
x: editor.options.edgeScrollSpeed * 2,
y: editor.options.edgeScrollSpeed * 2,
})
})
it('moves the camera correctly when delay is 16 and duration are zero', () => {
editor = new TestEditor({
options: {
edgeScrollDelay: 16,
edgeScrollEaseDuration: 0,
},
})
editor.setScreenBounds({ w: 3000, h: 3000, x: 0, y: 0 })
editor.user.updateUserPreferences({ edgeScrollSpeed: 1 })
editor.pointerMove(300, 300)
editor.pointerDown()
editor.pointerMove(0, 0)
// one tick's length of delay
expect(editor.getCamera()).toMatchObject({
x: 0,
y: 0,
})
editor.forceTick()
expect(editor.getCamera()).toMatchObject({
x: editor.options.edgeScrollSpeed,
y: editor.options.edgeScrollSpeed,
})
})
it('moves the camera correctly when delay is 0 and duration is 32', () => {
editor = new TestEditor({
options: {
edgeScrollDelay: 0,
edgeScrollEaseDuration: 32,
},
})
editor.setScreenBounds({ w: 3000, h: 3000, x: 0, y: 0 })
editor.user.updateUserPreferences({ edgeScrollSpeed: 1 })
editor.pointerMove(300, 300)
editor.pointerDown()
editor.pointerMove(0, 0)
// one tick's length of delay
expect(editor.getCamera()).toMatchObject({
x: editor.options.edgeScrollSpeed * 0.125,
y: editor.options.edgeScrollSpeed * 0.125,
})
editor.forceTick()
expect(editor.getCamera()).toMatchObject({
x: editor.options.edgeScrollSpeed * 1.125,
y: editor.options.edgeScrollSpeed * 1.125,
})
})
})

View file

@ -40,7 +40,12 @@ const ids = {
beforeEach(() => {
console.error = jest.fn()
editor = new TestEditor()
editor = new TestEditor({
options: {
edgeScrollDelay: 0,
edgeScrollEaseDuration: 0,
},
})
})
const getNumSnapPoints = (snap: SnapIndicator): number => {
@ -139,10 +144,10 @@ describe('When translating...', () => {
const before = editor.getShape<TLGeoShape>(ids.box1)!
jest.advanceTimersByTime(100)
editor.forceTick()
editor
// The change is bigger than expected because the camera moves
.expectShapeToMatch({ id: ids.box1, x: -160, y: 10 })
.expectShapeToMatch({ id: ids.box1, x: -65, y: 10 })
// We'll continue moving in the x postion, but now we'll also move in the y position.
// The speed in the y position is smaller since we are further away from the edge.
.pointerMove(0, 25)
@ -161,16 +166,21 @@ describe('When translating...', () => {
editor.user.updateUserPreferences({ edgeScrollSpeed: 1 })
editor.pointerDown(50, 50, ids.box1).pointerMove(1080, 50)
jest.advanceTimersByTime(100)
editor.forceTick()
editor.forceTick()
editor.forceTick()
editor
// The change is bigger than expected because the camera moves
.expectShapeToMatch({ id: ids.box1, x: 1160, y: 10 })
.expectShapeToMatch({ id: ids.box1, x: 1115, y: 10 })
.pointerMove(1080, 800)
jest.advanceTimersByTime(100)
editor.forceTick()
editor.forceTick()
editor.forceTick()
editor
.expectShapeToMatch({ id: ids.box1, x: 1320, y: 845.68 })
.expectShapeToMatch({ id: ids.box1, x: 1215, y: 805.9 })
.pointerUp()
.expectShapeToMatch({ id: ids.box1, x: 1340, y: 857.92 })
.expectShapeToMatch({ id: ids.box1, x: 1240, y: 821.2 })
})
it('translates multiple shapes', () => {