[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:
parent
e291dda27f
commit
219e2f63dd
9 changed files with 132 additions and 22 deletions
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -1016,6 +1016,8 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
|
|||
// (undocumented)
|
||||
highlightedUserIds: string[];
|
||||
// (undocumented)
|
||||
insets: boolean[];
|
||||
// (undocumented)
|
||||
isChangingStyle: boolean;
|
||||
// (undocumented)
|
||||
isChatting: boolean;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
|
Loading…
Reference in a new issue