[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
pull/2431/head^2
Steve Ruiz 2024-01-10 14:29:32 +00:00 zatwierdzone przez GitHub
rodzic e291dda27f
commit 219e2f63dd
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
9 zmienionych plików z 132 dodań i 22 usunięć

Wyświetl plik

@ -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

Wyświetl plik

@ -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.
*

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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', () => {

Wyświetl plik

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

Wyświetl plik

@ -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",

Wyświetl plik

@ -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]

Wyświetl plik

@ -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,
}
},
},
},
})