[improvement] account for coarse pointers / insets in edge scrolling (#2401)

This PR:
- shrinks the distance for edge scrolling and insets the distance for
coarse pointers
- adds edge inset tracking

## Scroll distances

Rather than increasing the distance, we move the "zero" in from the
edges, so that the middle of a honkin' fat finger would be at "zero"
when the edge of the finger is touching the edge of the screen. This is
a bit more reliable than looking at just the component size.

## Inset tracking

We now track whether a shape's edges are identical to the edges of the
document body. When an edge is inset, we extend the edge scrolling
distance outside of the component, so that dragging PAST the edge of the
component will scroll. When an edge is NOT inset, we bring that distance
into the component's bounds, so that dragging NEAR TO the edge will
begin to scroll.


![image](https://github.com/tldraw/tldraw/assets/23072548/bb216c98-3dd0-4e2e-a635-4c4f339d5117)


![image](https://github.com/tldraw/tldraw/assets/23072548/75e83c81-1ca9-40a9-8edc-72851d3b1411)


![image](https://github.com/tldraw/tldraw/assets/23072548/6cda7bda-2935-4ded-821c-e7bf78833a1c)

### Change Type

- [x] `minor` — New feature

### Test Plan

1. Use edge scrolling on mobile
2. Use edge scrolling on desktop
3. Use edge scrolling in the "scrolling example"

- [x] Unit Tests

### Release Notes

- Add `instanceState.insets` to track which edges of the component are
inset from the edges of the document body.
- Improve behavior around edge scrolling
This commit is contained in:
Steve Ruiz 2024-01-10 14:29:32 +00:00 committed by GitHub
parent e291dda27f
commit 219e2f63dd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 132 additions and 22 deletions

View file

@ -97,4 +97,7 @@ export const HIT_TEST_MARGIN = 8
export const EDGE_SCROLL_SPEED = 20
/** @internal */
export const EDGE_SCROLL_DISTANCE = 32
export const EDGE_SCROLL_DISTANCE = 8
/** @internal */
export const COARSE_POINTER_WIDTH = 12

View file

@ -2689,6 +2689,7 @@ export class Editor extends EventEmitter<TLEventMap> {
/** @internal */
private _willSetInitialBounds = true
private _wasInset = false
/**
* Update the viewport. The viewport will measure the size and screen position of its container
@ -2706,8 +2707,8 @@ export class Editor extends EventEmitter<TLEventMap> {
*/
updateViewportScreenBounds(center = false): this {
const container = this.getContainer()
if (!container) return this
const rect = container.getBoundingClientRect()
const screenBounds = new Box(
rect.left || rect.x,
@ -2715,6 +2716,18 @@ export class Editor extends EventEmitter<TLEventMap> {
Math.max(rect.width, 1),
Math.max(rect.height, 1)
)
const insets = [
// top
screenBounds.minY !== 0,
// right
document.body.scrollWidth !== screenBounds.maxX,
// bottom
document.body.scrollHeight !== screenBounds.maxY,
// left
screenBounds.minX !== 0,
]
const boundsAreEqual = screenBounds.equals(this.getViewportScreenBounds())
const { _willSetInitialBounds } = this
@ -2726,7 +2739,7 @@ export class Editor extends EventEmitter<TLEventMap> {
// If we have just received the initial bounds, don't center the camera.
this._willSetInitialBounds = false
this.updateInstanceState(
{ screenBounds: screenBounds.toJson() },
{ screenBounds: screenBounds.toJson(), insets },
{ squashing: true, ephemeral: true }
)
} else {
@ -2734,14 +2747,14 @@ export class Editor extends EventEmitter<TLEventMap> {
// Get the page center before the change, make the change, and restore it
const before = this.getViewportPageCenter()
this.updateInstanceState(
{ screenBounds: screenBounds.toJson() },
{ screenBounds: screenBounds.toJson(), insets },
{ squashing: true, ephemeral: true }
)
this.centerOnPoint(before)
} else {
// Otherwise,
this.updateInstanceState(
{ screenBounds: screenBounds.toJson() },
{ screenBounds: screenBounds.toJson(), insets },
{ squashing: true, ephemeral: true }
)
}
@ -2772,6 +2785,7 @@ export class Editor extends EventEmitter<TLEventMap> {
@computed getViewportScreenCenter() {
return this.getViewportScreenBounds().center
}
/**
* The current viewport in the current page space.
*

View file

@ -1,4 +1,4 @@
import { EDGE_SCROLL_DISTANCE, EDGE_SCROLL_SPEED } from '../constants'
import { COARSE_POINTER_WIDTH, EDGE_SCROLL_DISTANCE, EDGE_SCROLL_SPEED } from '../constants'
import { Editor } from '../editor/Editor'
/**
@ -7,15 +7,23 @@ import { Editor } from '../editor/Editor'
* @param dimension - The component dimension on the axis.
* @internal
*/
function getEdgeProximityFactor(position: number, dimension: number) {
if (position < 0) {
return 1
} else if (position > dimension) {
return -1
} else if (position < EDGE_SCROLL_DISTANCE) {
return (EDGE_SCROLL_DISTANCE - position) / EDGE_SCROLL_DISTANCE
} else if (position > dimension - EDGE_SCROLL_DISTANCE) {
return -(EDGE_SCROLL_DISTANCE - dimension + position) / EDGE_SCROLL_DISTANCE
function getEdgeProximityFactor(
position: number,
dimension: number,
isCoarse: boolean,
insetStart: boolean,
insetEnd: boolean
) {
const dist = EDGE_SCROLL_DISTANCE
const pw = isCoarse ? COARSE_POINTER_WIDTH : 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
}
@ -39,8 +47,24 @@ export function moveCameraWhenCloseToEdge(editor: Editor) {
const screenSizeFactorX = screenBounds.w < 1000 ? 0.612 : 1
const screenSizeFactorY = screenBounds.h < 1000 ? 0.612 : 1
const proximityFactorX = getEdgeProximityFactor(x - screenBounds.x, screenBounds.w)
const proximityFactorY = getEdgeProximityFactor(y - screenBounds.y, screenBounds.h)
const {
isCoarsePointer,
insets: [t, r, b, l],
} = editor.getInstanceState()
const proximityFactorX = getEdgeProximityFactor(
x - screenBounds.x,
screenBounds.w,
isCoarsePointer,
l,
r
)
const proximityFactorY = getEdgeProximityFactor(
y - screenBounds.y,
screenBounds.h,
isCoarsePointer,
t,
b
)
if (proximityFactorX === 0 && proximityFactorY === 0) return

View file

@ -3906,7 +3906,7 @@ describe('When resizing near the edges of the screen', () => {
target: 'selection',
handle: 'top_left',
})
.pointerMove(10, 25)
.pointerMove(-1, -1) // into the edge scrolling distance
jest.advanceTimersByTime(1000)
const after = editor.getShape<TLGeoShape>(ids.boxA)!
expect(after.x).toBeLessThan(before.x)

View file

@ -1717,6 +1717,7 @@ describe('When brushing close to the edges of the screen', () => {
editor.pointerDown()
editor.pointerMove(0, 0)
jest.advanceTimersByTime(100)
editor.pointerUp()
const camera2 = editor.getCamera()
expect(camera2.x).toBeGreaterThan(camera1.x) // for some reason > is left
expect(camera2.y).toBeGreaterThan(camera1.y) // for some reason > is up
@ -1729,6 +1730,7 @@ describe('When brushing close to the edges of the screen', () => {
editor.pointerDown()
editor.pointerMove(100, 100)
jest.advanceTimersByTime(100)
editor.pointerUp()
const camera2 = editor.getCamera()
// should NOT have moved the camera by edge scrolling
expect(camera2.x).toEqual(camera1.x)
@ -1742,10 +1744,19 @@ describe('When brushing close to the edges of the screen', () => {
editor.pointerDown()
editor.pointerMove(100, 100)
jest.advanceTimersByTime(100)
editor.pointerUp()
const camera4 = editor.getCamera()
// should have moved the camera by edge scrolling
expect(camera4.x).toBeGreaterThan(camera3.x)
expect(camera4.y).toBeGreaterThan(camera3.y)
// should NOT have moved the camera by edge scrolling because the edge is now "inset"
expect(camera4.x).toEqual(camera3.x)
expect(camera4.y).toEqual(camera3.y)
editor.pointerDown()
editor.pointerMove(90, 90) // off the edge of the component
jest.advanceTimersByTime(100)
const camera5 = editor.getCamera()
// should have moved the camera by edge scrolling off the component edge
expect(camera5.x).toBeGreaterThan(camera4.x)
expect(camera5.y).toBeGreaterThan(camera4.y)
})
it('selects shapes that are outside of the viewport', () => {

View file

@ -1016,6 +1016,8 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
// (undocumented)
highlightedUserIds: string[];
// (undocumented)
insets: boolean[];
// (undocumented)
isChangingStyle: boolean;
// (undocumented)
isChatting: boolean;

View file

@ -6699,6 +6699,33 @@
"endIndex": 2
}
},
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/tlschema!TLInstance#insets:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "insets: "
},
{
"kind": "Content",
"text": "boolean[]"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "insets",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/tlschema!TLInstance#isChangingStyle:member",

View file

@ -1608,6 +1608,18 @@ describe('add isHoveringCanvas to TLInstance', () => {
})
})
describe('add isInset to TLInstance', () => {
const { up, down } = instanceMigrations.migrators[instanceVersions.AddInset]
test('up works as expected', () => {
expect(up({})).toEqual({ insets: [false, false, false, false] })
})
test('down works as expected', () => {
expect(down({ insets: [false, false, false, false] })).toEqual({})
})
})
describe('add scribbles to TLInstance', () => {
const { up, down } = instanceMigrations.migrators[instanceVersions.AddScribbles]

View file

@ -31,6 +31,7 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
isToolLocked: boolean
exportBackground: boolean
screenBounds: BoxModel
insets: boolean[]
zoomBrush: BoxModel | null
chatMessage: string
isChatting: boolean
@ -80,6 +81,7 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
isToolLocked: T.boolean,
exportBackground: T.boolean,
screenBounds: boxModelValidator,
insets: T.arrayOf(T.boolean),
zoomBrush: boxModelValidator.nullable(),
isPenMode: T.boolean,
isGridMode: T.boolean,
@ -118,6 +120,7 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
isDebugMode: process.env.NODE_ENV === 'development',
isToolLocked: false,
screenBounds: { x: 0, y: 0, w: 1080, h: 720 },
insets: [false, false, false, false],
zoomBrush: null,
isGridMode: false,
isPenMode: false,
@ -161,11 +164,12 @@ export const instanceVersions = {
ReadOnlyReadonly: 20,
AddHoveringCanvas: 21,
AddScribbles: 22,
AddInset: 23,
} as const
/** @public */
export const instanceMigrations = defineMigrations({
currentVersion: instanceVersions.AddScribbles,
currentVersion: instanceVersions.AddInset,
migrators: {
[instanceVersions.AddTransparentExportBgs]: {
up: (instance: TLInstance) => {
@ -486,6 +490,19 @@ export const instanceMigrations = defineMigrations({
return { ...record, scribble: null }
},
},
[instanceVersions.AddInset]: {
up: (record) => {
return {
...record,
insets: [false, false, false, false],
}
},
down: ({ insets: _, ...record }) => {
return {
...record,
}
},
},
},
})