Start scrolling if we are dragging close to the window edges. (#2299)

Start scrolling when we get close to the edges of the window. This works
for brush selecting, translating, and resizing.


https://github.com/tldraw/tldraw/assets/2523721/4a5effc8-5445-411b-b317-36097233d36c


### Change Type

- [ ] `patch` — Bug fix
- [x] `minor` — New feature
- [ ] `major` — Breaking change
- [ ] `dependencies` — Changes to package dependencies[^1]
- [ ] `documentation` — Changes to the documentation only[^2]
- [ ] `tests` — Changes to any test code only[^2]
- [ ] `internal` — Any other changes that don't affect the published
package[^2]
- [ ] I don't know

[^1]: publishes a `patch` release, for devDependencies use `internal`
[^2]: will not publish a new version

### Test Plan

1. Select a shape.
2. Move it towards the edge of the window. The camera position should
change.
3. Also try resizing, brush selecting.

- [x] Unit Tests
- [ ] End to end tests

### Release Notes

- Adds the logic to change the camera position when you get close to the
edges of the window. This allows you to drag, resize, brush select past
the edges of the current viewport.

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Mitja Bezenšek 2023-12-16 00:37:03 +01:00 committed by GitHub
parent 0171d1498d
commit 4e50c9c162
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 428 additions and 15 deletions

View file

@ -77,6 +77,8 @@
"action.toggle-dark-mode": "Toggle dark mode", "action.toggle-dark-mode": "Toggle dark mode",
"action.toggle-reduce-motion.menu": "Reduce motion", "action.toggle-reduce-motion.menu": "Reduce motion",
"action.toggle-reduce-motion": "Toggle reduce motion", "action.toggle-reduce-motion": "Toggle reduce motion",
"action.toggle-edge-scrolling.menu": "Edge scrolling",
"action.toggle-edge-scrolling": "Toggle edge scrolling",
"action.toggle-debug-mode.menu": "Debug mode", "action.toggle-debug-mode.menu": "Debug mode",
"action.toggle-debug-mode": "Toggle debug mode", "action.toggle-debug-mode": "Toggle debug mode",
"action.toggle-focus-mode.menu": "Focus mode", "action.toggle-focus-mode.menu": "Focus mode",

View file

@ -1,7 +1,5 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"packages": [ "packages": ["packages/*"],
"packages/*"
],
"version": "2.0.0-alpha.19" "version": "2.0.0-alpha.19"
} }

View file

@ -488,6 +488,7 @@ export const defaultUserPreferences: Readonly<{
locale: "ar" | "ca" | "da" | "de" | "en" | "es" | "fa" | "fi" | "fr" | "gl" | "he" | "hi-in" | "hu" | "it" | "ja" | "ko-kr" | "ku" | "my" | "ne" | "no" | "pl" | "pt-br" | "pt-pt" | "ro" | "ru" | "sv" | "te" | "th" | "tr" | "uk" | "vi" | "zh-cn" | "zh-tw"; locale: "ar" | "ca" | "da" | "de" | "en" | "es" | "fa" | "fi" | "fr" | "gl" | "he" | "hi-in" | "hu" | "it" | "ja" | "ko-kr" | "ku" | "my" | "ne" | "no" | "pl" | "pt-br" | "pt-pt" | "ro" | "ru" | "sv" | "te" | "th" | "tr" | "uk" | "vi" | "zh-cn" | "zh-tw";
color: "#02B1CC" | "#11B3A3" | "#39B178" | "#55B467" | "#7B66DC" | "#9D5BD2" | "#BD54C6" | "#E34BA9" | "#EC5E41" | "#F04F88" | "#F2555A" | "#FF802B"; color: "#02B1CC" | "#11B3A3" | "#39B178" | "#55B467" | "#7B66DC" | "#9D5BD2" | "#BD54C6" | "#E34BA9" | "#EC5E41" | "#F04F88" | "#F2555A" | "#FF802B";
isDarkMode: false; isDarkMode: false;
edgeScrollSpeed: 1;
animationSpeed: 0 | 1; animationSpeed: 0 | 1;
isSnapMode: false; isSnapMode: false;
}>; }>;
@ -1459,6 +1460,9 @@ export const MAX_ZOOM = 8;
// @internal (undocumented) // @internal (undocumented)
export const MIN_ZOOM = 0.1; export const MIN_ZOOM = 0.1;
// @public
export function moveCameraWhenCloseToEdge(editor: Editor): void;
// @internal (undocumented) // @internal (undocumented)
export const MULTI_CLICK_DURATION = 200; export const MULTI_CLICK_DURATION = 200;
@ -1963,6 +1967,8 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
// (undocumented) // (undocumented)
onRightClick?: TLEventHandlers['onRightClick']; onRightClick?: TLEventHandlers['onRightClick'];
// (undocumented) // (undocumented)
onTick?: TLTickEventHandler;
// (undocumented)
onTripleClick?: TLEventHandlers['onTripleClick']; onTripleClick?: TLEventHandlers['onTripleClick'];
// (undocumented) // (undocumented)
onWheel?: TLEventHandlers['onWheel']; onWheel?: TLEventHandlers['onWheel'];
@ -2702,6 +2708,9 @@ export type TLSvgOptions = {
// @public (undocumented) // @public (undocumented)
export type TLTickEvent = (elapsed: number) => void; export type TLTickEvent = (elapsed: number) => void;
// @public (undocumented)
export type TLTickEventHandler = () => void;
// @public // @public
export interface TLUserPreferences { export interface TLUserPreferences {
// (undocumented) // (undocumented)
@ -2709,6 +2718,8 @@ export interface TLUserPreferences {
// (undocumented) // (undocumented)
color?: null | string; color?: null | string;
// (undocumented) // (undocumented)
edgeScrollSpeed?: null | number;
// (undocumented)
id: string; id: string;
// (undocumented) // (undocumented)
isDarkMode?: boolean | null; isDarkMode?: boolean | null;

View file

@ -6126,7 +6126,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<{\n name: \"New User\";\n locale: \"ar\" | \"ca\" | \"da\" | \"de\" | \"en\" | \"es\" | \"fa\" | \"fi\" | \"fr\" | \"gl\" | \"he\" | \"hi-in\" | \"hu\" | \"it\" | \"ja\" | \"ko-kr\" | \"ku\" | \"my\" | \"ne\" | \"no\" | \"pl\" | \"pt-br\" | \"pt-pt\" | \"ro\" | \"ru\" | \"sv\" | \"te\" | \"th\" | \"tr\" | \"uk\" | \"vi\" | \"zh-cn\" | \"zh-tw\";\n color: \"#02B1CC\" | \"#11B3A3\" | \"#39B178\" | \"#55B467\" | \"#7B66DC\" | \"#9D5BD2\" | \"#BD54C6\" | \"#E34BA9\" | \"#EC5E41\" | \"#F04F88\" | \"#F2555A\" | \"#FF802B\";\n isDarkMode: false;\n animationSpeed: 0 | 1;\n isSnapMode: false;\n}>" "text": "<{\n name: \"New User\";\n locale: \"ar\" | \"ca\" | \"da\" | \"de\" | \"en\" | \"es\" | \"fa\" | \"fi\" | \"fr\" | \"gl\" | \"he\" | \"hi-in\" | \"hu\" | \"it\" | \"ja\" | \"ko-kr\" | \"ku\" | \"my\" | \"ne\" | \"no\" | \"pl\" | \"pt-br\" | \"pt-pt\" | \"ro\" | \"ru\" | \"sv\" | \"te\" | \"th\" | \"tr\" | \"uk\" | \"vi\" | \"zh-cn\" | \"zh-tw\";\n color: \"#02B1CC\" | \"#11B3A3\" | \"#39B178\" | \"#55B467\" | \"#7B66DC\" | \"#9D5BD2\" | \"#BD54C6\" | \"#E34BA9\" | \"#EC5E41\" | \"#F04F88\" | \"#F2555A\" | \"#FF802B\";\n isDarkMode: false;\n edgeScrollSpeed: 1;\n animationSpeed: 0 | 1;\n isSnapMode: false;\n}>"
} }
], ],
"fileUrlPath": "packages/editor/src/lib/config/TLUserPreferences.ts", "fileUrlPath": "packages/editor/src/lib/config/TLUserPreferences.ts",
@ -28314,6 +28314,52 @@
], ],
"extendsTokenRanges": [] "extendsTokenRanges": []
}, },
{
"kind": "Function",
"canonicalReference": "@tldraw/editor!moveCameraWhenCloseToEdge:function(1)",
"docComment": "/**\n * Moves the camera when the mouse is close to the edge of the screen.\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export declare function moveCameraWhenCloseToEdge(editor: "
},
{
"kind": "Reference",
"text": "Editor",
"canonicalReference": "@tldraw/editor!Editor:class"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "void"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/editor/src/lib/utils/edgeScrolling.ts",
"returnTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"releaseTag": "Public",
"overloadIndex": 1,
"parameters": [
{
"parameterName": "editor",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
}
],
"name": "moveCameraWhenCloseToEdge"
},
{ {
"kind": "Function", "kind": "Function",
"canonicalReference": "@tldraw/editor!openWindow:function(1)", "canonicalReference": "@tldraw/editor!openWindow:function(1)",
@ -36443,6 +36489,37 @@
"isProtected": false, "isProtected": false,
"isAbstract": false "isAbstract": false
}, },
{
"kind": "Property",
"canonicalReference": "@tldraw/editor!StateNode#onTick:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "onTick?: "
},
{
"kind": "Reference",
"text": "TLTickEventHandler",
"canonicalReference": "@tldraw/editor!TLTickEventHandler:type"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": true,
"releaseTag": "Public",
"name": "onTick",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{ {
"kind": "Property", "kind": "Property",
"canonicalReference": "@tldraw/editor!StateNode#onTripleClick:member", "canonicalReference": "@tldraw/editor!StateNode#onTripleClick:member",
@ -43331,6 +43408,32 @@
"endIndex": 2 "endIndex": 2
} }
}, },
{
"kind": "TypeAlias",
"canonicalReference": "@tldraw/editor!TLTickEventHandler:type",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export type TLTickEventHandler = "
},
{
"kind": "Content",
"text": "() => void"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/editor/src/lib/editor/types/event-types.ts",
"releaseTag": "Public",
"name": "TLTickEventHandler",
"typeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{ {
"kind": "Interface", "kind": "Interface",
"canonicalReference": "@tldraw/editor!TLUserPreferences:interface", "canonicalReference": "@tldraw/editor!TLUserPreferences:interface",
@ -43400,6 +43503,33 @@
"endIndex": 2 "endIndex": 2
} }
}, },
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/editor!TLUserPreferences#edgeScrollSpeed:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "edgeScrollSpeed?: "
},
{
"kind": "Content",
"text": "null | number"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": true,
"releaseTag": "Public",
"name": "edgeScrollSpeed",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{ {
"kind": "PropertySignature", "kind": "PropertySignature",
"canonicalReference": "@tldraw/editor!TLUserPreferences#id:member", "canonicalReference": "@tldraw/editor!TLUserPreferences#id:member",

View file

@ -225,6 +225,7 @@ export {
type TLPointerEventName, type TLPointerEventName,
type TLPointerEventTarget, type TLPointerEventTarget,
type TLTickEvent, type TLTickEvent,
type TLTickEventHandler,
type TLWheelEvent, type TLWheelEvent,
type TLWheelEventInfo, type TLWheelEventInfo,
type UiEvent, type UiEvent,
@ -346,6 +347,7 @@ export {
setPointerCapture, setPointerCapture,
stopEventPropagation, stopEventPropagation,
} from './lib/utils/dom' } from './lib/utils/dom'
export { moveCameraWhenCloseToEdge } from './lib/utils/edgeScrolling'
export { getIncrementedName } from './lib/utils/getIncrementedName' export { getIncrementedName } from './lib/utils/getIncrementedName'
export { getPointerInfo } from './lib/utils/getPointerInfo' export { getPointerInfo } from './lib/utils/getPointerInfo'
export { getSvgPathFromPoints } from './lib/utils/getSvgPathFromPoints' export { getSvgPathFromPoints } from './lib/utils/getSvgPathFromPoints'

View file

@ -18,6 +18,7 @@ export interface TLUserPreferences {
color?: string | null color?: string | null
isDarkMode?: boolean | null isDarkMode?: boolean | null
animationSpeed?: number | null animationSpeed?: number | null
edgeScrollSpeed?: number | null
isSnapMode?: boolean | null isSnapMode?: boolean | null
} }
@ -39,6 +40,7 @@ const userTypeValidator: T.Validator<TLUserPreferences> = T.object<TLUserPrefere
color: T.string.nullable().optional(), color: T.string.nullable().optional(),
isDarkMode: T.boolean.nullable().optional(), isDarkMode: T.boolean.nullable().optional(),
animationSpeed: T.number.nullable().optional(), animationSpeed: T.number.nullable().optional(),
edgeScrollSpeed: T.number.nullable().optional(),
isSnapMode: T.boolean.nullable().optional(), isSnapMode: T.boolean.nullable().optional(),
}) })
@ -46,10 +48,11 @@ const Versions = {
AddAnimationSpeed: 1, AddAnimationSpeed: 1,
AddIsSnapMode: 2, AddIsSnapMode: 2,
MakeFieldsNullable: 3, MakeFieldsNullable: 3,
AddEdgeScrollSpeed: 4,
} as const } as const
const userMigrations = defineMigrations({ const userMigrations = defineMigrations({
currentVersion: Versions.MakeFieldsNullable, currentVersion: Versions.AddEdgeScrollSpeed,
migrators: { migrators: {
[Versions.AddAnimationSpeed]: { [Versions.AddAnimationSpeed]: {
up: (user) => { up: (user) => {
@ -86,6 +89,17 @@ const userMigrations = defineMigrations({
} }
}, },
}, },
[Versions.AddEdgeScrollSpeed]: {
up: (user: TLUserPreferences) => {
return {
...user,
edgeScrollSpeed: 1,
}
},
down: ({ edgeScrollSpeed: _, ...user }: TLUserPreferences) => {
return user
},
},
}, },
}) })
@ -131,6 +145,7 @@ export const defaultUserPreferences = Object.freeze({
locale: getDefaultTranslationLocale(), locale: getDefaultTranslationLocale(),
color: getRandomColor(), color: getRandomColor(),
isDarkMode: false, isDarkMode: false,
edgeScrollSpeed: 1,
animationSpeed: userPrefersReducedMotion() ? 0 : 1, animationSpeed: userPrefersReducedMotion() ? 0 : 1,
isSnapMode: false, isSnapMode: false,
}) satisfies Readonly<Omit<TLUserPreferences, 'id'>> }) satisfies Readonly<Omit<TLUserPreferences, 'id'>>

View file

@ -92,3 +92,6 @@ export const CAMERA_MAX_RENDERING_INTERVAL = 620
/** @public */ /** @public */
export const HIT_TEST_MARGIN = 8 export const HIT_TEST_MARGIN = 8
/** @internal */
export const EDGE_SCROLL_SPEED = 20

View file

@ -52,6 +52,13 @@ export class UserPreferencesManager {
return this.getIsDarkMode() return this.getIsDarkMode()
} }
/**
* The speed at which the user can scroll by dragging toward the edge of the screen.
*/
@computed getEdgeScrollSpeed() {
return this.user.userPreferences.get().edgeScrollSpeed ?? defaultUserPreferences.edgeScrollSpeed
}
@computed getAnimationSpeed() { @computed getAnimationSpeed() {
return this.user.userPreferences.get().animationSpeed ?? defaultUserPreferences.animationSpeed return this.user.userPreferences.get().animationSpeed ?? defaultUserPreferences.animationSpeed
} }

View file

@ -8,6 +8,7 @@ import {
TLEventInfo, TLEventInfo,
TLExitEventHandler, TLExitEventHandler,
TLPinchEventInfo, TLPinchEventInfo,
TLTickEventHandler,
} from '../types/event-types' } from '../types/event-types'
type TLStateNodeType = 'branch' | 'leaf' | 'root' type TLStateNodeType = 'branch' | 'leaf' | 'root'
@ -155,6 +156,7 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
enter = (info: any, from: string) => { enter = (info: any, from: string) => {
this._isActive.set(true) this._isActive.set(true)
this.onEnter?.(info, from) this.onEnter?.(info, from)
if (this.onTick) this.editor.on('tick', this.onTick)
if (this.children && this.initial && this.getIsActive()) { if (this.children && this.initial && this.getIsActive()) {
const initial = this.children[this.initial] const initial = this.children[this.initial]
this._current.set(initial) this._current.set(initial)
@ -165,6 +167,7 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
// todo: move this logic into transition // todo: move this logic into transition
exit = (info: any, from: string) => { exit = (info: any, from: string) => {
this._isActive.set(false) this._isActive.set(false)
if (this.onTick) this.editor.off('tick', this.onTick)
this.onExit?.(info, from) this.onExit?.(info, from)
if (!this.getIsActive()) { if (!this.getIsActive()) {
this.getCurrent()?.exit(info, from) this.getCurrent()?.exit(info, from)
@ -223,4 +226,5 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
onEnter?: TLEnterEventHandler onEnter?: TLEnterEventHandler
onExit?: TLExitEventHandler onExit?: TLExitEventHandler
onTick?: TLTickEventHandler
} }

View file

@ -136,6 +136,8 @@ export type UiEvent =
| TLCancelEvent | TLCancelEvent
| TLCompleteEvent | TLCompleteEvent
/** @public */
export type TLTickEventHandler = () => void
/** @public */ /** @public */
export type TLEnterEventHandler = (info: any, from: string) => void export type TLEnterEventHandler = (info: any, from: string) => void
/** @public */ /** @public */

View file

@ -0,0 +1,66 @@
import { EDGE_SCROLL_SPEED } from '../constants'
import { Editor } from '../editor/Editor'
/**
* Helper function to get the scroll offset for a given position.
* The closer the mouse is to the edge of the screen the faster we scroll.
* We also adjust the speed and the start offset based on the screen size and zoom level.
*
* @param editor - The mouse position on the screen in pixels
* @returns How much we should scroll in pixels
* @internal
*/
export function getEdgeProximityFactor(position: number, scrollOffset: number, extreme: number) {
if (position < 0) {
return 1
} else if (position > extreme) {
return -1
} else if (position < scrollOffset) {
return (scrollOffset - position) / scrollOffset
} else if (position > extreme - scrollOffset) {
return -(scrollOffset - extreme + position) / scrollOffset
}
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) return
const {
inputs: {
currentScreenPoint: { x, y },
},
} = editor
const zoomLevel = editor.getZoomLevel()
const screenBounds = editor.getViewportScreenBounds()
// Determines how far from the edges we start the scroll behaviour
const insetX = screenBounds.w < 1000 ? 40 : 32
const insetY = screenBounds.h < 1000 ? 40 : 32
// 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 pxSpeed = editor.user.getEdgeScrollSpeed() * EDGE_SCROLL_SPEED
const proximityFactorX = getEdgeProximityFactor(x, insetX, screenBounds.w)
const proximityFactorY = getEdgeProximityFactor(y, insetY, screenBounds.h)
if (proximityFactorX === 0 && proximityFactorY === 0) return
const scrollDeltaX = (pxSpeed * proximityFactorX * screenSizeFactorX) / zoomLevel
const scrollDeltaY = (pxSpeed * proximityFactorY * screenSizeFactorY) / zoomLevel
const camera = editor.getCamera()
editor.setCamera({
x: camera.x + scrollDeltaX,
y: camera.y + scrollDeltaY,
})
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -13,7 +13,9 @@ import {
TLPointerEventInfo, TLPointerEventInfo,
TLShape, TLShape,
TLShapeId, TLShapeId,
TLTickEventHandler,
Vec2d, Vec2d,
moveCameraWhenCloseToEdge,
pointInPolygon, pointInPolygon,
polygonsIntersect, polygonsIntersect,
} from '@tldraw/editor' } from '@tldraw/editor'
@ -60,6 +62,10 @@ export class Brushing extends StateNode {
this.editor.updateInstanceState({ brush: null }) this.editor.updateInstanceState({ brush: null })
} }
override onTick: TLTickEventHandler = () => {
moveCameraWhenCloseToEdge(this.editor)
}
override onPointerMove = () => { override onPointerMove = () => {
this.hitTestShapes() this.hitTestShapes()
} }

View file

@ -13,10 +13,12 @@ import {
TLShape, TLShape,
TLShapeId, TLShapeId,
TLShapePartial, TLShapePartial,
TLTickEventHandler,
Vec2d, Vec2d,
VecLike, VecLike,
areAnglesCompatible, areAnglesCompatible,
compact, compact,
moveCameraWhenCloseToEdge,
} from '@tldraw/editor' } from '@tldraw/editor'
type ResizingInfo = TLPointerEventInfo & { type ResizingInfo = TLPointerEventInfo & {
@ -72,6 +74,10 @@ export class Resizing extends StateNode {
this.updateShapes() this.updateShapes()
} }
override onTick: TLTickEventHandler = () => {
moveCameraWhenCloseToEdge(this.editor)
}
override onPointerMove: TLEventHandlers['onPointerMove'] = () => { override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
this.updateShapes() this.updateShapes()
} }

View file

@ -10,9 +10,11 @@ import {
TLPointerEventInfo, TLPointerEventInfo,
TLShape, TLShape,
TLShapePartial, TLShapePartial,
TLTickEventHandler,
Vec2d, Vec2d,
compact, compact,
isPageId, isPageId,
moveCameraWhenCloseToEdge,
} from '@tldraw/editor' } from '@tldraw/editor'
import { DragAndDropManager } from '../DragAndDropManager' import { DragAndDropManager } from '../DragAndDropManager'
@ -77,12 +79,10 @@ export class Translating extends StateNode {
this.snapshot = this.selectionSnapshot this.snapshot = this.selectionSnapshot
this.handleStart() this.handleStart()
this.updateShapes() this.updateShapes()
this.editor.on('tick', this.updateParent)
} }
override onExit = () => { override onExit = () => {
this.parent.setCurrentToolIdMask(undefined) this.parent.setCurrentToolIdMask(undefined)
this.editor.off('tick', this.updateParent)
this.selectionSnapshot = {} as any this.selectionSnapshot = {} as any
this.snapshot = {} as any this.snapshot = {} as any
this.editor.snaps.clear() this.editor.snaps.clear()
@ -93,6 +93,14 @@ export class Translating extends StateNode {
this.dragAndDropManager.clear() this.dragAndDropManager.clear()
} }
override onTick: TLTickEventHandler = () => {
this.dragAndDropManager.updateDroppingNode(
this.snapshot.movingShapes,
this.updateParentTransforms
)
moveCameraWhenCloseToEdge(this.editor)
}
override onPointerMove = () => { override onPointerMove = () => {
this.updateShapes() this.updateShapes()
} }
@ -153,11 +161,6 @@ export class Translating extends StateNode {
this.updateShapes() this.updateShapes()
} }
updateParent = () => {
const { snapshot } = this
this.dragAndDropManager.updateDroppingNode(snapshot.movingShapes, this.updateParentTransforms)
}
reset() { reset() {
this.editor.bailToMark(this.markId) this.editor.bailToMark(this.markId)
} }

View file

@ -994,6 +994,19 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
}, },
checkbox: true, checkbox: true,
}, },
{
id: 'toggle-edge-scrolling',
label: 'action.toggle-edge-scrolling',
menuLabel: 'action.toggle-edge-scrolling.menu',
readonlyOk: true,
onSelect(source) {
trackEvent('toggle-edge-scrolling', { source })
editor.user.updateUserPreferences({
edgeScrollSpeed: editor.user.getEdgeScrollSpeed() === 0 ? 1 : 0,
})
},
checkbox: true,
},
{ {
id: 'toggle-transparent', id: 'toggle-transparent',
label: 'action.toggle-transparent', label: 'action.toggle-transparent',

View file

@ -81,6 +81,7 @@ export interface TLUiEventMap {
'toggle-debug-mode': null 'toggle-debug-mode': null
'toggle-lock': null 'toggle-lock': null
'toggle-reduce-motion': null 'toggle-reduce-motion': null
'toggle-edge-scrolling': null
'exit-pen-mode': null 'exit-pen-mode': null
'stop-following': null 'stop-following': null
'open-cursor-chat': null 'open-cursor-chat': null

View file

@ -51,6 +51,9 @@ export function TLUiMenuSchemaProvider({ overrides, children }: TLUiMenuSchemaPr
const isDarkMode = useValue('isDarkMode', () => editor.user.getIsDarkMode(), [editor]) const isDarkMode = useValue('isDarkMode', () => editor.user.getIsDarkMode(), [editor])
const animationSpeed = useValue('animationSpeed', () => editor.user.getAnimationSpeed(), [editor]) const animationSpeed = useValue('animationSpeed', () => editor.user.getAnimationSpeed(), [editor])
const edgeScrollSpeed = useValue('edgeScrollSpeed', () => editor.user.getEdgeScrollSpeed(), [
editor,
])
const isGridMode = useValue('isGridMode', () => editor.getInstanceState().isGridMode, [editor]) const isGridMode = useValue('isGridMode', () => editor.getInstanceState().isGridMode, [editor])
const isSnapMode = useValue('isSnapMode', () => editor.user.getIsSnapMode(), [editor]) const isSnapMode = useValue('isSnapMode', () => editor.user.getIsSnapMode(), [editor])
const isToolLock = useValue('isToolLock', () => editor.getInstanceState().isToolLocked, [editor]) const isToolLock = useValue('isToolLock', () => editor.getInstanceState().isToolLocked, [editor])
@ -214,6 +217,7 @@ export function TLUiMenuSchemaProvider({ overrides, children }: TLUiMenuSchemaPr
menuItem(actions['toggle-grid'], { checked: isGridMode }), menuItem(actions['toggle-grid'], { checked: isGridMode }),
menuItem(actions['toggle-dark-mode'], { checked: isDarkMode }), menuItem(actions['toggle-dark-mode'], { checked: isDarkMode }),
menuItem(actions['toggle-focus-mode'], { checked: isFocusMode }), menuItem(actions['toggle-focus-mode'], { checked: isFocusMode }),
menuItem(actions['toggle-edge-scrolling'], { checked: edgeScrollSpeed === 1 }),
menuItem(actions['toggle-reduce-motion'], { checked: animationSpeed === 0 }), menuItem(actions['toggle-reduce-motion'], { checked: animationSpeed === 0 }),
menuItem(actions['toggle-debug-mode'], { checked: isDebugMode }) menuItem(actions['toggle-debug-mode'], { checked: isDebugMode })
) )
@ -258,6 +262,7 @@ export function TLUiMenuSchemaProvider({ overrides, children }: TLUiMenuSchemaPr
isFocusMode, isFocusMode,
exportBackground, exportBackground,
isDebugMode, isDebugMode,
edgeScrollSpeed,
isZoomedTo100, isZoomedTo100,
oneEmbeddableBookmarkSelected, oneEmbeddableBookmarkSelected,
oneEmbedSelected, oneEmbedSelected,

View file

@ -81,6 +81,8 @@ export type TLUiTranslationKey =
| 'action.toggle-dark-mode' | 'action.toggle-dark-mode'
| 'action.toggle-reduce-motion.menu' | 'action.toggle-reduce-motion.menu'
| 'action.toggle-reduce-motion' | 'action.toggle-reduce-motion'
| 'action.toggle-edge-scrolling.menu'
| 'action.toggle-edge-scrolling'
| 'action.toggle-debug-mode.menu' | 'action.toggle-debug-mode.menu'
| 'action.toggle-debug-mode' | 'action.toggle-debug-mode'
| 'action.toggle-focus-mode.menu' | 'action.toggle-focus-mode.menu'

View file

@ -81,6 +81,8 @@ export const DEFAULT_TRANSLATION = {
'action.toggle-dark-mode': 'Toggle dark mode', 'action.toggle-dark-mode': 'Toggle dark mode',
'action.toggle-reduce-motion.menu': 'Reduce motion', 'action.toggle-reduce-motion.menu': 'Reduce motion',
'action.toggle-reduce-motion': 'Toggle reduce motion', 'action.toggle-reduce-motion': 'Toggle reduce motion',
'action.toggle-edge-scrolling.menu': 'Edge scrolling',
'action.toggle-edge-scrolling': 'Toggle edge scrolling',
'action.toggle-debug-mode.menu': 'Debug mode', 'action.toggle-debug-mode.menu': 'Debug mode',
'action.toggle-debug-mode': 'Toggle debug mode', 'action.toggle-debug-mode': 'Toggle debug mode',
'action.toggle-focus-mode.menu': 'Focus mode', 'action.toggle-focus-mode.menu': 'Focus mode',

View file

@ -109,15 +109,18 @@ describe('TLUserPreferences', () => {
userPreferences, userPreferences,
}), }),
}) })
// called once in the constructor of testeditor to set edge scroll speed to 0
expect(setUserPreferences).toHaveBeenCalledTimes(1)
expect(editor.user.getName()).toBe('blah') expect(editor.user.getName()).toBe('blah')
editor.user.updateUserPreferences({ name: null }) editor.user.updateUserPreferences({ name: null })
expect(editor.user.getName()).toBe('New User') expect(editor.user.getName()).toBe('New User')
expect(setUserPreferences).toHaveBeenCalledTimes(1) expect(setUserPreferences).toHaveBeenCalledTimes(2)
expect(setUserPreferences).toHaveBeenLastCalledWith({ expect(setUserPreferences).toHaveBeenLastCalledWith({
id: '123', id: '123',
name: null, name: null,
edgeScrollSpeed: 0,
}) })
}) })
}) })

View file

@ -109,6 +109,9 @@ export class TestEditor extends Editor {
}) })
return [{ box, text: textToMeasure }] return [{ box, text: textToMeasure }]
} }
// Turn off edge scrolling for tests. Tests that require this can turn it back on.
this.user.updateUserPreferences({ edgeScrollSpeed: 0 })
} }
elm: HTMLDivElement elm: HTMLDivElement

View file

@ -3899,3 +3899,25 @@ describe('Resizing text from the right edge', () => {
}) })
}) })
}) })
describe('When resizing near the edges of the screen', () => {
it('resizes past the edge of the screen', () => {
editor.user.updateUserPreferences({ edgeScrollSpeed: 1 })
editor
.select(ids.boxA)
.pointerDown(10, 10, {
type: 'pointer',
target: 'selection',
handle: 'top_left',
})
.expectShapeToMatch({ id: ids.boxA, x: 10, y: 10, props: { w: 100, h: 100 } })
.pointerMove(10, 25)
jest.advanceTimersByTime(1000)
editor.expectShapeToMatch({
id: ids.boxA,
x: -842.5,
y: -259.58,
props: { w: 952.5, h: 369.58 },
})
})
})

View file

@ -1708,3 +1708,45 @@ describe('right clicking', () => {
expect(editor.getSelectedShapeIds()).toEqual([]) expect(editor.getSelectedShapeIds()).toEqual([])
}) })
}) })
describe('When brushing close to the edges of the screen', () => {
it('selects shapes that are outside of the viewport', () => {
editor.user.updateUserPreferences({ edgeScrollSpeed: 1 })
editor.createShapes([{ id: ids.box1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } }])
editor.createShapes([
{ id: ids.box2, type: 'geo', x: -150, y: -150, props: { w: 100, h: 100 } },
])
editor.pointerMove(300, 300)
editor.pointerDown()
editor.pointerMove(50, 50)
editor.expectToBeIn('select.brushing')
expect(editor.getSelectedShapeIds()).toEqual([ids.box1])
editor.pointerMove(0, 0)
// still only box 1...
expect(editor.getSelectedShapeIds()).toEqual([ids.box1])
jest.advanceTimersByTime(100)
// ...but now viewport will have moved to select box2 as well
expect(editor.getSelectedShapeIds()).toEqual([ids.box1, ids.box2])
editor.pointerUp()
})
it('doesnt edge scroll to the other shape', () => {
editor.user.updateUserPreferences({ edgeScrollSpeed: 0 }) // <-- no edge scrolling
editor.createShapes([{ id: ids.box1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } }])
editor.createShapes([
{ id: ids.box2, type: 'geo', x: -150, y: -150, props: { w: 100, h: 100 } },
])
editor.pointerMove(300, 300)
editor.pointerDown()
editor.pointerMove(50, 50)
editor.expectToBeIn('select.brushing')
expect(editor.getSelectedShapeIds()).toEqual([ids.box1])
editor.pointerMove(0, 0)
expect(editor.getSelectedShapeIds()).toEqual([ids.box1])
jest.advanceTimersByTime(100)
expect(editor.getSelectedShapeIds()).toEqual([ids.box1])
editor.pointerUp()
})
})

View file

@ -127,6 +127,40 @@ describe('When translating...', () => {
.expectShapeToMatch({ id: ids.box1, x: 60, y: 60 }) .expectShapeToMatch({ id: ids.box1, x: 60, y: 60 })
}) })
it('translates a single shape near the top left edge', () => {
editor.user.updateUserPreferences({ edgeScrollSpeed: 1 })
editor.pointerDown(50, 50, ids.box1).pointerMove(0, 50) // [-50, 0]
jest.advanceTimersByTime(100)
editor
// The change is bigger than expected because the camera moves
.expectShapeToMatch({ id: ids.box1, x: -160, 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)
jest.advanceTimersByTime(100)
editor
.expectShapeToMatch({ id: ids.box1, x: -280, y: -42.54 })
.pointerUp()
.expectShapeToMatch({ id: ids.box1, x: -280, y: -42.54 })
})
it('translates a single shape near the bottom right edge', () => {
editor.user.updateUserPreferences({ edgeScrollSpeed: 1 })
editor.pointerDown(50, 50, ids.box1).pointerMove(1080, 50)
jest.advanceTimersByTime(100)
editor
// The change is bigger than expected because the camera moves
.expectShapeToMatch({ id: ids.box1, x: 1140, y: 10 })
.pointerMove(1080, 800)
jest.advanceTimersByTime(100)
editor
.expectShapeToMatch({ id: ids.box1, x: 1280, y: 845.68 })
.pointerUp()
.expectShapeToMatch({ id: ids.box1, x: 1280, y: 845.68 })
})
it('translates multiple shapes', () => { it('translates multiple shapes', () => {
editor editor
.select(ids.box1, ids.box2) .select(ids.box1, ids.box2)
@ -173,6 +207,7 @@ describe('When cloning...', () => {
}) })
it('clones a single shape and restores when stopping cloning', () => { it('clones a single shape and restores when stopping cloning', () => {
// Move the camera so that we are not at the edges, which causes the camera to move when we translate
expect(editor.getCurrentPageShapeIds().size).toBe(3) expect(editor.getCurrentPageShapeIds().size).toBe(3)
expect(editor.getCurrentPageShapeIds().size).toBe(3) expect(editor.getCurrentPageShapeIds().size).toBe(3)
editor.select(ids.box1).pointerDown(50, 50, ids.box1).pointerMove(50, 40) // [0, -10] editor.select(ids.box1).pointerDown(50, 50, ids.box1).pointerMove(50, 40) // [0, -10]