diff --git a/apps/examples/src/examples/camera-options/CameraOptionsExample.tsx b/apps/examples/src/examples/camera-options/CameraOptionsExample.tsx index 204e643b3..f03470e58 100644 --- a/apps/examples/src/examples/camera-options/CameraOptionsExample.tsx +++ b/apps/examples/src/examples/camera-options/CameraOptionsExample.tsx @@ -12,6 +12,7 @@ import { import 'tldraw/tldraw.css' const CAMERA_OPTIONS: TLCameraOptions = { + wheelBehavior: 'pan', isLocked: false, panSpeed: 1, zoomSpeed: 1, diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index c2ba1e98c..2b8c14ac6 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -459,6 +459,9 @@ export const DEFAULT_ANIMATION_OPTIONS: { easing: (t: number) => number; }; +// @internal (undocumented) +export const DEFAULT_CAMERA_OPTIONS: TLCameraOptions; + // @public (undocumented) export function DefaultBackground(): JSX_2.Element; @@ -1093,9 +1096,6 @@ export function getArrowTerminalsInArrowSpace(editor: Editor, shape: TLArrowShap // @public (undocumented) export function getCursor(cursor: TLCursorType, rotation?: number, color?: string): string; -// @internal (undocumented) -export const getDefaultCameraOptions: (cameraOptions?: Partial) => TLCameraOptions; - // @public (undocumented) export function getFreshUserPreferences(): TLUserPreferences; @@ -2019,6 +2019,7 @@ export type TLCameraOptions = { zoomSpeed: number; zoomSteps: number[]; isLocked: boolean; + wheelBehavior: 'none' | 'pan' | 'zoom'; }; // @public (undocumented) diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index d7b6cb4c5..d5958ed22 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -36993,7 +36993,7 @@ }, { "kind": "Content", - "text": ";\n fit: 'max' | 'min' | 'none' | 'x' | 'y';\n };\n panSpeed: number;\n zoomSpeed: number;\n zoomSteps: number[];\n isLocked: boolean;\n}" + "text": ";\n fit: 'max' | 'min' | 'none' | 'x' | 'y';\n };\n panSpeed: number;\n zoomSpeed: number;\n zoomSteps: number[];\n isLocked: boolean;\n wheelBehavior: 'none' | 'pan' | 'zoom';\n}" }, { "kind": "Content", diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index fb001ee65..61322272d 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -111,6 +111,7 @@ export { ANIMATION_SHORT_MS, CAMERA_SLIDE_FRICTION, DEFAULT_ANIMATION_OPTIONS, + DEFAULT_CAMERA_OPTIONS, DOUBLE_CLICK_DURATION, DRAG_DISTANCE, GRID_STEPS, @@ -120,7 +121,6 @@ export { MULTI_CLICK_DURATION, SIDES, SVG_PADDING, - getDefaultCameraOptions, } from './lib/constants' export { Editor, diff --git a/packages/editor/src/lib/constants.ts b/packages/editor/src/lib/constants.ts index a49d0bfe9..a552b3c9e 100644 --- a/packages/editor/src/lib/constants.ts +++ b/packages/editor/src/lib/constants.ts @@ -11,24 +11,15 @@ export const ANIMATION_SHORT_MS = 80 /** @internal */ export const ANIMATION_MEDIUM_MS = 320 -const DEFAULT_COMMON_CAMERA_OPTIONS = { - zoomMax: 8, - zoomMin: 0.1, +/** @internal */ +export const DEFAULT_CAMERA_OPTIONS: TLCameraOptions = { zoomSteps: [0.1, 0.25, 0.5, 1, 2, 4, 8], zoomSpeed: 1, panSpeed: 1, isLocked: false, + wheelBehavior: 'pan', } -/** @internal */ -export const getDefaultCameraOptions = ( - cameraOptions: Partial = {} -): TLCameraOptions => ({ - ...DEFAULT_COMMON_CAMERA_OPTIONS, - ...cameraOptions, -}) - -/** @internal */ export const FOLLOW_CHASE_PROPORTION = 0.5 /** @internal */ export const FOLLOW_CHASE_PAN_SNAP = 0.1 diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 7523a6c8a..b0ed88e76 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -71,6 +71,7 @@ import { COARSE_DRAG_DISTANCE, COLLABORATOR_IDLE_TIMEOUT, DEFAULT_ANIMATION_OPTIONS, + DEFAULT_CAMERA_OPTIONS, DRAG_DISTANCE, FOLLOW_CHASE_PAN_SNAP, FOLLOW_CHASE_PAN_UNSNAP, @@ -82,7 +83,6 @@ import { LONG_PRESS_DURATION, MAX_PAGES, MAX_SHAPES_PER_PAGE, - getDefaultCameraOptions, } from '../constants' import { Box, BoxLike } from '../primitives/Box' import { Mat, MatLike, MatModel } from '../primitives/Mat' @@ -211,7 +211,7 @@ export class Editor extends EventEmitter { this.snaps = new SnapManager(this) - this._cameraOptions.set(getDefaultCameraOptions(cameraOptions)) + this._cameraOptions.set({ ...DEFAULT_CAMERA_OPTIONS, ...cameraOptions }) this.user = new UserPreferencesManager(user ?? createTLUser(), inferDarkMode ?? false) @@ -2105,7 +2105,7 @@ export class Editor extends EventEmitter { } } - private _cameraOptions = atom('camera options', getDefaultCameraOptions({})) + private _cameraOptions = atom('camera options', DEFAULT_CAMERA_OPTIONS) /** * Get the current camera options. @@ -8551,6 +8551,8 @@ export class Editor extends EventEmitter { // Reset velocity on pointer down, or when a pinch starts or ends if (info.name === 'pointer_down' || this.inputs.isPinching) { pointerVelocity.set(0, 0) + this.inputs.originScreenPoint.setTo(currentScreenPoint) + this.inputs.originPagePoint.setTo(currentPagePoint) } // todo: We only have to do this if there are multiple users in the document @@ -8805,15 +8807,20 @@ export class Editor extends EventEmitter { this._ctrlKeyTimeout = setTimeout(this._setCtrlKeyTimeout, 150) } - const { originPagePoint, originScreenPoint, currentPagePoint, currentScreenPoint } = inputs + const { originPagePoint, currentPagePoint } = inputs if (!inputs.isPointing) { inputs.isDragging = false } + const instanceState = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)! + const pageState = this.store.get(this._getCurrentPageStateId())! + const cameraOptions = this._cameraOptions.__unsafe__getWithoutCapture()! + const camera = this.store.unsafeGetWithoutCapture(this.getCameraId())! + switch (type) { case 'pinch': { - if (this.getCameraOptions().isLocked) return + if (cameraOptions.isLocked) return clearTimeout(this._longPressTimeout) this._updateInputsFromEvent(info) @@ -8824,7 +8831,7 @@ export class Editor extends EventEmitter { if (!inputs.isEditing) { this._pinchStart = this.getCamera().z if (!this._selectedShapeIdsAtPointerDown.length) { - this._selectedShapeIdsAtPointerDown = this.getSelectedShapeIds() + this._selectedShapeIdsAtPointerDown = [...pageState.selectedShapeIds] } this._didPinch = true @@ -8844,17 +8851,21 @@ export class Editor extends EventEmitter { delta: { x: dx, y: dy }, } = info - const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)! - const { x, y } = Vec.SubXY(info.point, screenBounds.x, screenBounds.y) + // The center of the pinch in screen space + const { x, y } = Vec.SubXY( + info.point, + instanceState.screenBounds.x, + instanceState.screenBounds.y + ) - const { x: cx, y: cy, z: cz } = this.getCamera() + const { x: cx, y: cy, z: cz } = camera this.stopCameraAnimation() - if (this.getInstanceState().followingUserId) { + if (instanceState.followingUserId) { this.stopFollowingUser() } - const { panSpeed, zoomSpeed } = this.getCameraOptions() + const { panSpeed, zoomSpeed } = cameraOptions this._setCamera( new Vec( cx + (dx * panSpeed) / cz - x / cz + x / (z * zoomSpeed), @@ -8869,18 +8880,24 @@ export class Editor extends EventEmitter { case 'pinch_end': { if (!inputs.isPinching) return this + // Stop pinching inputs.isPinching = false - const { _selectedShapeIdsAtPointerDown } = this - this.setSelectedShapes(this._selectedShapeIdsAtPointerDown, { squashing: true }) + + // Stash and clear the + const { _selectedShapeIdsAtPointerDown: shapesToReselect } = this this._selectedShapeIdsAtPointerDown = [] if (this._didPinch) { this._didPinch = false - this.once('tick', () => { - if (!this._didPinch) { - this.setSelectedShapes(_selectedShapeIdsAtPointerDown, { squashing: true }) - } - }) + if (shapesToReselect.length > 0) { + this.once('tick', () => { + if (!this._didPinch) { + // Unless we've started pinching again... + // Reselect the shapes that were selected when the pinch started + this.setSelectedShapes(shapesToReselect, { squashing: true }) + } + }) + } } return // Stop here! @@ -8888,164 +8905,164 @@ export class Editor extends EventEmitter { } } case 'wheel': { - if (this.getCameraOptions().isLocked) return + if (cameraOptions.isLocked) return this._updateInputsFromEvent(info) if (this.getIsMenuOpen()) { // noop } else { + const { panSpeed, zoomSpeed, wheelBehavior } = cameraOptions + + if (wheelBehavior === 'none') return + + // Stop any camera animation this.stopCameraAnimation() - if (this.getInstanceState().followingUserId) { + // Stop following any following user + if (instanceState.followingUserId) { this.stopFollowingUser() } - if (inputs.ctrlKey) { - // todo: Start or update the zoom end interval - // If the alt or ctrl keys are pressed, - // zoom or pan the camera and then return. + const { x: cx, y: cy, z: cz } = camera + const { x: dx, y: dy, z: dz = 0 } = info.delta - // Subtract the top left offset from the user's point + // If the camera behavior is "zoom" and the ctrl key is presssed, then pan; + // If the camera behavior is "pan" and the ctrl key is not pressed, then zoom + const behavior = + wheelBehavior === 'zoom' ? (inputs.ctrlKey ? 'pan' : 'zoom') : wheelBehavior - const { x, y } = this.inputs.currentScreenPoint - - const { x: cx, y: cy, z: cz } = this.getCamera() - - const { zoomSpeed } = this.getCameraOptions() - const zoom = cz + (info.delta.z ?? 0) * zoomSpeed * cz - - this._setCamera( - new Vec(cx + (x / zoom - x) - (x / cz - x), cy + (y / zoom - y) - (y / cz - y), zoom), - { immediate: true } - ) - - // We want to return here because none of the states in our - // statechart should respond to this event (a camera zoom) - return - } - - // Update the camera here, which will dispatch a pointer move... - // this will also update the pointer position, etc - this.pan(info.delta, { immediate: true }) - - if ( - !inputs.isDragging && - inputs.isPointing && - Vec.Dist2(originPagePoint, currentPagePoint) > - (this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / - this.getZoomLevel() - ) { - clearTimeout(this._longPressTimeout) - inputs.isDragging = true + switch (behavior) { + case 'zoom': { + // Zoom in on current screen point using the wheel delta + const { x, y } = this.inputs.currentScreenPoint + const zoom = cz + (dz ?? 0) * zoomSpeed * cz + this._setCamera( + new Vec( + cx + (x / zoom - x) - (x / cz - x), + cy + (y / zoom - y) - (y / cz - y), + zoom + ), + { immediate: true } + ) + return + } + case 'pan': { + // Pan the camera based on the wheel delta + this._setCamera(new Vec(cx + (dx * panSpeed) / cz, cy + (dy * panSpeed) / cz, cz), { + immediate: true, + }) + return + } } } break } case 'pointer': { - // If we're pinching, return + // Ignore pointer events while we're pinching if (inputs.isPinching) return this._updateInputsFromEvent(info) - const { isPen } = info + const { isPenMode } = instanceState switch (info.name) { case 'pointer_down': { + // If we're in pen mode and the input is not a pen type, then stop here + if (isPenMode && !isPen) return + + // Close any open menus this.clearOpenMenus() + // Start a long press timeout this._longPressTimeout = setTimeout(() => { this.dispatch({ ...info, name: 'long_press' }) }, LONG_PRESS_DURATION) + // Save the selected ids at pointer down this._selectedShapeIdsAtPointerDown = this.getSelectedShapeIds() // Firefox bug fix... // If it's a left-mouse-click, we store the pointer id for later user - if (info.button === 0) { - this.capturedPointerId = info.pointerId - } + if (info.button === 0) this.capturedPointerId = info.pointerId // Add the button from the buttons set inputs.buttons.add(info.button) + // Start pointing and stop dragging inputs.isPointing = true inputs.isDragging = false - if (this.getInstanceState().isPenMode) { - if (!isPen) { - return - } - } else { - if (isPen) { - this.updateInstanceState({ isPenMode: true }) - } - } + // If pen mode is off but we're not already in pen mode, turn that on + if (!isPenMode && isPen) this.updateInstanceState({ isPenMode: true }) + // On devices with erasers (like the Surface Pen or Wacom Pen), button 5 is the eraser if (info.button === 5) { - // Eraser button activates eraser this._restoreToolId = this.getCurrentToolId() this.complete() this.setCurrentTool('eraser') } else if (info.button === 1) { - // Middle mouse pan activates panning + // Middle mouse pan activates panning unless we're already panning (with spacebar) if (!this.inputs.isPanning) { this._prevCursor = this.getInstanceState().cursor.type } - this.inputs.isPanning = true + clearTimeout(this._longPressTimeout) } + // We might be panning because we did a middle mouse click, or because we're holding spacebar and started a regular click + // Also stop here, we don't want the state chart to receive the event if (this.inputs.isPanning) { this.stopCameraAnimation() this.setCursor({ type: 'grabbing', rotation: 0 }) return this } - originScreenPoint.setTo(currentScreenPoint) - originPagePoint.setTo(currentPagePoint) break } case 'pointer_move': { // If the user is in pen mode, but the pointer is not a pen, stop here. - if (!isPen && this.getInstanceState().isPenMode) { - return - } + if (!isPen && isPenMode) return + // If we've started panning, then clear any long press timeout if (this.inputs.isPanning && this.inputs.isPointing) { - clearTimeout(this._longPressTimeout) - // Handle panning + // Handle spacebar / middle mouse button panning const { currentScreenPoint, previousScreenPoint } = this.inputs - this.pan(Vec.Sub(currentScreenPoint, previousScreenPoint)) + const { x: cx, y: cy, z: cz } = camera + const { panSpeed } = cameraOptions + const offset = Vec.Sub(currentScreenPoint, previousScreenPoint) + this.setCamera( + new Vec(cx + (offset.x * panSpeed) / cz, cy + (offset.y * panSpeed) / cz, cz), + { immediate: true } + ) return } if ( - !inputs.isDragging && inputs.isPointing && + !inputs.isDragging && Vec.Dist2(originPagePoint, currentPagePoint) > - (this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / - this.getZoomLevel() + (instanceState.isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / camera.z ) { - clearTimeout(this._longPressTimeout) + // Start dragging inputs.isDragging = true + clearTimeout(this._longPressTimeout) } break } case 'pointer_up': { + // Stop dragging / pointing + inputs.isDragging = false + inputs.isPointing = false + clearTimeout(this._longPressTimeout) + // Remove the button from the buttons set inputs.buttons.delete(info.button) - inputs.isPointing = false - inputs.isDragging = false + // Suppressing pointerup here as doesn't seem to do what we what here. + if (this.getIsMenuOpen()) return - if (this.getIsMenuOpen()) { - // Suppressing pointerup here as doesn't seem to do what we what here. - return - } - - if (!isPen && this.getInstanceState().isPenMode) { - return - } + // If we're in pen mode and we're not using a pen, stop here + if (instanceState.isPenMode && !isPen) return // Firefox bug fix... // If it's the same pointer that we stored earlier... @@ -9056,50 +9073,40 @@ export class Editor extends EventEmitter { } if (inputs.isPanning) { - if (info.button === 1) { - if (!this.inputs.keys.has(' ')) { - inputs.isPanning = false + const slideDirection = this.inputs.pointerVelocity + const slideSpeed = Math.min(2, slideDirection.len()) - this.slideCamera({ - speed: Math.min(2, this.inputs.pointerVelocity.len()), - direction: this.inputs.pointerVelocity, - friction: CAMERA_SLIDE_FRICTION, - }) - this.setCursor({ type: this._prevCursor, rotation: 0 }) - } else { - this.slideCamera({ - speed: Math.min(2, this.inputs.pointerVelocity.len()), - direction: this.inputs.pointerVelocity, - friction: CAMERA_SLIDE_FRICTION, - }) - this.setCursor({ - type: 'grab', - rotation: 0, - }) + switch (info.button) { + case 0: { + this.setCursor({ type: 'grab', rotation: 0 }) + break } - } else if (info.button === 0) { + case 1: { + if (this.inputs.keys.has(' ')) { + this.setCursor({ type: 'grab', rotation: 0 }) + } else { + this.setCursor({ type: this._prevCursor, rotation: 0 }) + } + } + } + + if (slideSpeed > 0) { this.slideCamera({ - speed: Math.min(2, this.inputs.pointerVelocity.len()), - direction: this.inputs.pointerVelocity, + speed: slideSpeed, + direction: slideDirection, friction: CAMERA_SLIDE_FRICTION, }) - this.setCursor({ - type: 'grab', - rotation: 0, - }) } } else { if (info.button === 5) { - // Eraser button activates eraser + // If we were erasing with a stylus button, restore the tool we were using before we started erasing this.complete() this.setCurrentTool(this._restoreToolId) } } - break } } - break } case 'keyboard': { @@ -9114,12 +9121,13 @@ export class Editor extends EventEmitter { inputs.keys.add(info.code) // If the space key is pressed (but meta / control isn't!) activate panning - if (!info.ctrlKey && info.code === 'Space') { + if (info.code === 'Space' && !info.ctrlKey) { if (!this.inputs.isPanning) { - this._prevCursor = this.getInstanceState().cursor.type + this._prevCursor = instanceState.cursor.type } this.inputs.isPanning = true + clearTimeout(this._longPressTimeout) this.setCursor({ type: this.inputs.isPointing ? 'grabbing' : 'grab', rotation: 0 }) } @@ -9129,9 +9137,15 @@ export class Editor extends EventEmitter { // Remove the key from the keys set inputs.keys.delete(info.code) - if (info.code === 'Space' && !this.inputs.buttons.has(1)) { - this.inputs.isPanning = false - this.setCursor({ type: this._prevCursor, rotation: 0 }) + // If we've lifted the space key, + if (info.code === 'Space') { + if (this.inputs.buttons.has(1)) { + // If we're still middle dragging, continue panning + } else { + // otherwise, stop panning + this.inputs.isPanning = false + this.setCursor({ type: this._prevCursor, rotation: 0 }) + } } break @@ -9153,39 +9167,19 @@ export class Editor extends EventEmitter { info.name = 'right_click' } - // If a pointer event, send the event to the click manager. - if (info.isPen === this.getInstanceState().isPenMode) { - switch (info.name) { - case 'pointer_down': { - const otherEvent = this._clickManager.transformPointerDownEvent(info) - if (info.name !== otherEvent.name) { - this.root.handleEvent(info) - this.emit('event', info) - this.root.handleEvent(otherEvent) - this.emit('event', otherEvent) - return - } - - break - } - case 'pointer_up': { - clearTimeout(this._longPressTimeout) - - const otherEvent = this._clickManager.transformPointerUpEvent(info) - if (info.name !== otherEvent.name) { - this.root.handleEvent(info) - this.emit('event', info) - this.root.handleEvent(otherEvent) - this.emit('event', otherEvent) - return - } - - break - } - case 'pointer_move': { - this._clickManager.handleMove() - break - } + // If a left click pointer event, send the event to the click manager. + const { isPenMode } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)! + if (info.isPen === isPenMode) { + // The click manager may return a new event, i.e. a double click event + // depending on the event coming in and its own state. If the event has + // changed then hand both events to the statechart + const clickInfo = this._clickManager.handlePointerEvent(info) + if (info.name !== clickInfo.name) { + this.root.handleEvent(info) + this.emit('event', info) + this.root.handleEvent(clickInfo) + this.emit('event', clickInfo) + return } } } diff --git a/packages/editor/src/lib/editor/managers/ClickManager.ts b/packages/editor/src/lib/editor/managers/ClickManager.ts index ef5e53b10..ba5a1d25b 100644 --- a/packages/editor/src/lib/editor/managers/ClickManager.ts +++ b/packages/editor/src/lib/editor/managers/ClickManager.ts @@ -95,116 +95,121 @@ export class ClickManager { lastPointerInfo = {} as TLPointerEventInfo - /** - * Start the double click timeout. - * - * @param info - The event info. - */ - transformPointerDownEvent = (info: TLPointerEventInfo): TLPointerEventInfo | TLClickEventInfo => { - if (!this._clickState) return info + handlePointerEvent = (info: TLPointerEventInfo): TLPointerEventInfo | TLClickEventInfo => { + switch (info.name) { + case 'pointer_down': { + if (!this._clickState) return info - this._clickScreenPoint = Vec.From(info.point) + this._clickScreenPoint = Vec.From(info.point) - if ( - this._previousScreenPoint && - this._previousScreenPoint.dist(this._clickScreenPoint) > MAX_CLICK_DISTANCE - ) { - this._clickState = 'idle' - } + if ( + this._previousScreenPoint && + this._previousScreenPoint.dist(this._clickScreenPoint) > MAX_CLICK_DISTANCE + ) { + this._clickState = 'idle' + } - this._previousScreenPoint = this._clickScreenPoint + this._previousScreenPoint = this._clickScreenPoint - this.lastPointerInfo = info + this.lastPointerInfo = info - switch (this._clickState) { - case 'idle': { - this._clickState = 'pendingDouble' - this._clickTimeout = this._getClickTimeout(this._clickState) - return info // returns the pointer event - } - case 'pendingDouble': { - this._clickState = 'pendingTriple' - this._clickTimeout = this._getClickTimeout(this._clickState) - return { - ...info, - type: 'click', - name: 'double_click', - phase: 'down', + switch (this._clickState) { + case 'idle': { + this._clickState = 'pendingDouble' + this._clickTimeout = this._getClickTimeout(this._clickState) + return info // returns the pointer event + } + case 'pendingDouble': { + this._clickState = 'pendingTriple' + this._clickTimeout = this._getClickTimeout(this._clickState) + return { + ...info, + type: 'click', + name: 'double_click', + phase: 'down', + } + } + case 'pendingTriple': { + this._clickState = 'pendingQuadruple' + this._clickTimeout = this._getClickTimeout(this._clickState) + return { + ...info, + type: 'click', + name: 'triple_click', + phase: 'down', + } + } + case 'pendingQuadruple': { + this._clickState = 'pendingOverflow' + this._clickTimeout = this._getClickTimeout(this._clickState) + return { + ...info, + type: 'click', + name: 'quadruple_click', + phase: 'down', + } + } + case 'pendingOverflow': { + this._clickState = 'overflow' + this._clickTimeout = this._getClickTimeout(this._clickState) + return info + } + default: { + // overflow + this._clickTimeout = this._getClickTimeout(this._clickState) + return info + } } } - case 'pendingTriple': { - this._clickState = 'pendingQuadruple' - this._clickTimeout = this._getClickTimeout(this._clickState) - return { - ...info, - type: 'click', - name: 'triple_click', - phase: 'down', + case 'pointer_up': { + if (!this._clickState) return info + + this._clickScreenPoint = Vec.From(info.point) + + switch (this._clickState) { + case 'pendingTriple': { + return { + ...this.lastPointerInfo, + type: 'click', + name: 'double_click', + phase: 'up', + } + } + case 'pendingQuadruple': { + return { + ...this.lastPointerInfo, + type: 'click', + name: 'triple_click', + phase: 'up', + } + } + case 'pendingOverflow': { + return { + ...this.lastPointerInfo, + type: 'click', + name: 'quadruple_click', + phase: 'up', + } + } + default: { + // idle, pendingDouble, overflow + return info + } } } - case 'pendingQuadruple': { - this._clickState = 'pendingOverflow' - this._clickTimeout = this._getClickTimeout(this._clickState) - return { - ...info, - type: 'click', - name: 'quadruple_click', - phase: 'down', + case 'pointer_move': { + if ( + this._clickState !== 'idle' && + this._clickScreenPoint && + Vec.Dist2(this._clickScreenPoint, this.editor.inputs.currentScreenPoint) > + (this.editor.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) + ) { + this.cancelDoubleClickTimeout() } - } - case 'pendingOverflow': { - this._clickState = 'overflow' - this._clickTimeout = this._getClickTimeout(this._clickState) - return info - } - default: { - // overflow - this._clickTimeout = this._getClickTimeout(this._clickState) - return info - } - } - } - - /** - * Emit click_up events on pointer up. - * - * @param info - The event info. - */ - transformPointerUpEvent = (info: TLPointerEventInfo): TLPointerEventInfo | TLClickEventInfo => { - if (!this._clickState) return info - - this._clickScreenPoint = Vec.From(info.point) - - switch (this._clickState) { - case 'pendingTriple': { - return { - ...this.lastPointerInfo, - type: 'click', - name: 'double_click', - phase: 'up', - } - } - case 'pendingQuadruple': { - return { - ...this.lastPointerInfo, - type: 'click', - name: 'triple_click', - phase: 'up', - } - } - case 'pendingOverflow': { - return { - ...this.lastPointerInfo, - type: 'click', - name: 'quadruple_click', - phase: 'up', - } - } - default: { - // idle, pendingDouble, overflow return info } } + return info } /** @@ -216,21 +221,4 @@ export class ClickManager { this._clickTimeout = clearTimeout(this._clickTimeout) this._clickState = 'idle' } - - /** - * Handle a move event, possibly cancelling the click timeout. - * - * @internal - */ - handleMove = () => { - // Cancel a double click event if the user has started dragging. - if ( - this._clickState !== 'idle' && - this._clickScreenPoint && - Vec.Dist2(this._clickScreenPoint, this.editor.inputs.currentScreenPoint) > - (this.editor.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) - ) { - this.cancelDoubleClickTimeout() - } - } } diff --git a/packages/editor/src/lib/editor/types/misc-types.ts b/packages/editor/src/lib/editor/types/misc-types.ts index 8f30fe300..bc609e872 100644 --- a/packages/editor/src/lib/editor/types/misc-types.ts +++ b/packages/editor/src/lib/editor/types/misc-types.ts @@ -19,6 +19,7 @@ export type TLSvgOptions = { /** @public */ export type TLCameraOptions = { + wheelBehavior: 'zoom' | 'pan' | 'none' /** The speed of a scroll wheel / trackpad pan */ panSpeed: number /** The speed of a scroll wheel / trackpad zoom */ diff --git a/packages/tldraw/src/test/TestEditor.ts b/packages/tldraw/src/test/TestEditor.ts index 9bb3b527d..715d7ca26 100644 --- a/packages/tldraw/src/test/TestEditor.ts +++ b/packages/tldraw/src/test/TestEditor.ts @@ -200,12 +200,12 @@ export class TestEditor extends Editor { * _transformPointerDownSpy.mockRestore()) */ _transformPointerDownSpy = jest - .spyOn(this._clickManager, 'transformPointerDownEvent') + .spyOn(this._clickManager, 'handlePointerEvent') .mockImplementation((info) => { return info }) _transformPointerUpSpy = jest - .spyOn(this._clickManager, 'transformPointerDownEvent') + .spyOn(this._clickManager, 'handlePointerEvent') .mockImplementation((info) => { return info }) diff --git a/packages/tldraw/src/test/commands/zoomIn.test.ts b/packages/tldraw/src/test/commands/zoomIn.test.ts index 41dae0ab0..21ed190bc 100644 --- a/packages/tldraw/src/test/commands/zoomIn.test.ts +++ b/packages/tldraw/src/test/commands/zoomIn.test.ts @@ -1,4 +1,4 @@ -import { getDefaultCameraOptions } from '@tldraw/editor' +import { DEFAULT_CAMERA_OPTIONS } from '@tldraw/editor' import { TestEditor } from '../TestEditor' let editor: TestEditor @@ -8,7 +8,7 @@ beforeEach(() => { }) it('zooms by increments', () => { - const cameraOptions = getDefaultCameraOptions() + const cameraOptions = DEFAULT_CAMERA_OPTIONS // Starts at 1 expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[3]) @@ -46,7 +46,7 @@ it('preserves the screen center when offset', () => { }) it('zooms to from B to D when B >= (C - A)/2, else zooms from B to C', () => { - const cameraOptions = getDefaultCameraOptions() + const cameraOptions = DEFAULT_CAMERA_OPTIONS editor.setCamera({ x: 0, y: 0, z: (cameraOptions.zoomSteps[2] + cameraOptions.zoomSteps[3]) / 2 }) editor.zoomIn() diff --git a/packages/tldraw/src/test/commands/zoomOut.test.ts b/packages/tldraw/src/test/commands/zoomOut.test.ts index 09bce0367..b9deefcac 100644 --- a/packages/tldraw/src/test/commands/zoomOut.test.ts +++ b/packages/tldraw/src/test/commands/zoomOut.test.ts @@ -1,4 +1,4 @@ -import { getDefaultCameraOptions } from '@tldraw/editor' +import { DEFAULT_CAMERA_OPTIONS } from '@tldraw/editor' import { TestEditor } from '../TestEditor' let editor: TestEditor @@ -8,7 +8,7 @@ beforeEach(() => { }) it('zooms out and in by increments', () => { - const cameraOptions = getDefaultCameraOptions() + const cameraOptions = DEFAULT_CAMERA_OPTIONS // Starts at 1 expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[3])