/*** Copyright (c) Meta Platforms, Inc. and affiliates.** This source code is licensed under the MIT license found in the* LICENSE file in the root directory of this source tree.** @flow*/import type {Size, IntrinsicSize, Rect} from './geometry';
import type {
Interaction,
MouseDownInteraction,
MouseMoveInteraction,
MouseUpInteraction,
WheelPlainInteraction,
WheelWithShiftInteraction,
} from './useCanvasInteraction';
import type {ScrollState} from './utils/scrollState';
import type {ViewRefs} from './Surface';
import type {ViewState} from '../types';
import {Surface} from './Surface';
import {View} from './View';
import {rectContainsPoint} from './geometry';
import {
clampState,
moveStateToRange,
areScrollStatesEqual,
translateState,
zoomState,
} from './utils/scrollState';
import {
MAX_ZOOM_LEVEL,
MIN_ZOOM_LEVEL,
MOVE_WHEEL_DELTA_THRESHOLD,
} from './constants';
export class HorizontalPanAndZoomView extends View {
_contentView: View;
_intrinsicContentWidth: number;
_isPanning: boolean = false;
_viewState: ViewState;
constructor(
surface: Surface,
frame: Rect,
contentView: View,
intrinsicContentWidth: number,
viewState: ViewState,
) {super(surface, frame);
this._contentView = contentView;
this._intrinsicContentWidth = intrinsicContentWidth;
this._viewState = viewState;
viewState.onHorizontalScrollStateChange(scrollState => {
this.zoomToRange(scrollState.offset, scrollState.length);
});this.addSubview(contentView);
}/**
* Just sets scroll state.* Use `_setStateAndInformCallbacksIfChanged` if this view's callbacks should also be called.** @returns Whether state was changed* @private*/setScrollState(proposedState: ScrollState) {
const clampedState = clampState({
state: proposedState,
minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL,
maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL,
containerLength: this.frame.size.width,
});if (
!areScrollStatesEqual(clampedState, this._viewState.horizontalScrollState)
) {this.setNeedsDisplay();
}}/**
* Zoom to a specific range of the content specified as a range of the* content view's intrinsic content size.** Does not inform callbacks of state change since this is a public API.*/zoomToRange(rangeStart: number, rangeEnd: number) {
const newState = moveStateToRange({
state: this._viewState.horizontalScrollState,
rangeStart,
rangeEnd,
contentLength: this._intrinsicContentWidth,
minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL,
maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL,
containerLength: this.frame.size.width,
});this.setScrollState(newState);
}desiredSize(): Size | IntrinsicSize {
return this._contentView.desiredSize();
}layoutSubviews() {
const {offset, length} = this._viewState.horizontalScrollState;
const proposedFrame = {
origin: {x: this.frame.origin.x + offset,
y: this.frame.origin.y,
},size: {width: length,
height: this.frame.size.height,
},};this._contentView.setFrame(proposedFrame);
super.layoutSubviews();
}handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
switch (interaction.type) {
case 'mousedown':
this._handleMouseDown(interaction, viewRefs);
break;
case 'mousemove':
this._handleMouseMove(interaction, viewRefs);
break;
case 'mouseup':
this._handleMouseUp(interaction, viewRefs);
break;
case 'wheel-plain':
case 'wheel-shift':
this._handleWheel(interaction);
break;
}}_handleMouseDown(interaction: MouseDownInteraction, viewRefs: ViewRefs) {
if (rectContainsPoint(interaction.payload.location, this.frame)) {
this._isPanning = true;
viewRefs.activeView = this;
this.currentCursor = 'grabbing';
}}_handleMouseMove(interaction: MouseMoveInteraction, viewRefs: ViewRefs) {
const isHovered = rectContainsPoint(
interaction.payload.location,
this.frame,
);if (isHovered && viewRefs.hoveredView === null) {
viewRefs.hoveredView = this;
}if (viewRefs.activeView === this) {
this.currentCursor = 'grabbing';
} else if (isHovered) {
this.currentCursor = 'grab';
}if (!this._isPanning) {
return;
}// Don't prevent mouse-move events from bubbling if they are vertical drags.
const {movementX, movementY} = interaction.payload.event;
if (Math.abs(movementX) < Math.abs(movementY)) {
return;
}const newState = translateState({
state: this._viewState.horizontalScrollState,
delta: movementX,
containerLength: this.frame.size.width,
});this._viewState.updateHorizontalScrollState(newState);
}_handleMouseUp(interaction: MouseUpInteraction, viewRefs: ViewRefs) {
if (this._isPanning) {
this._isPanning = false;
}if (viewRefs.activeView === this) {
viewRefs.activeView = null;
}}_handleWheel(interaction: WheelPlainInteraction | WheelWithShiftInteraction) {
const {
location,
delta: {deltaX, deltaY},
} = interaction.payload;
if (!rectContainsPoint(location, this.frame)) {
return; // Not scrolling on view
}const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
// Vertical scrolling zooms in and out (unless the SHIFT modifier is used).
// Horizontal scrolling pans.
if (absDeltaY > absDeltaX) {
if (absDeltaY < MOVE_WHEEL_DELTA_THRESHOLD) {
return;
}if (interaction.type === 'wheel-shift') {
// Shift modifier is for scrolling, not zooming.
return;
}const newState = zoomState({
state: this._viewState.horizontalScrollState,
multiplier: 1 + 0.005 * -deltaY,
fixedPoint: location.x - this._viewState.horizontalScrollState.offset,
minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL,
maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL,
containerLength: this.frame.size.width,
});this._viewState.updateHorizontalScrollState(newState);
} else {
if (absDeltaX < MOVE_WHEEL_DELTA_THRESHOLD) {
return;
}const newState = translateState({
state: this._viewState.horizontalScrollState,
delta: -deltaX,
containerLength: this.frame.size.width,
});this._viewState.updateHorizontalScrollState(newState);
}}}