1. /**
    
  2.  * Copyright (c) Meta Platforms, Inc. and affiliates.
    
  3.  *
    
  4.  * This source code is licensed under the MIT license found in the
    
  5.  * LICENSE file in the root directory of this source tree.
    
  6.  *
    
  7.  * @flow
    
  8.  */
    
  9. 
    
  10. import type {Size, IntrinsicSize, Rect} from './geometry';
    
  11. import type {
    
  12.   Interaction,
    
  13.   MouseDownInteraction,
    
  14.   MouseMoveInteraction,
    
  15.   MouseUpInteraction,
    
  16.   WheelPlainInteraction,
    
  17.   WheelWithShiftInteraction,
    
  18. } from './useCanvasInteraction';
    
  19. import type {ScrollState} from './utils/scrollState';
    
  20. import type {ViewRefs} from './Surface';
    
  21. import type {ViewState} from '../types';
    
  22. 
    
  23. import {Surface} from './Surface';
    
  24. import {View} from './View';
    
  25. import {rectContainsPoint} from './geometry';
    
  26. import {
    
  27.   clampState,
    
  28.   moveStateToRange,
    
  29.   areScrollStatesEqual,
    
  30.   translateState,
    
  31.   zoomState,
    
  32. } from './utils/scrollState';
    
  33. import {
    
  34.   MAX_ZOOM_LEVEL,
    
  35.   MIN_ZOOM_LEVEL,
    
  36.   MOVE_WHEEL_DELTA_THRESHOLD,
    
  37. } from './constants';
    
  38. 
    
  39. export class HorizontalPanAndZoomView extends View {
    
  40.   _contentView: View;
    
  41.   _intrinsicContentWidth: number;
    
  42.   _isPanning: boolean = false;
    
  43.   _viewState: ViewState;
    
  44. 
    
  45.   constructor(
    
  46.     surface: Surface,
    
  47.     frame: Rect,
    
  48.     contentView: View,
    
  49.     intrinsicContentWidth: number,
    
  50.     viewState: ViewState,
    
  51.   ) {
    
  52.     super(surface, frame);
    
  53. 
    
  54.     this._contentView = contentView;
    
  55.     this._intrinsicContentWidth = intrinsicContentWidth;
    
  56.     this._viewState = viewState;
    
  57. 
    
  58.     viewState.onHorizontalScrollStateChange(scrollState => {
    
  59.       this.zoomToRange(scrollState.offset, scrollState.length);
    
  60.     });
    
  61. 
    
  62.     this.addSubview(contentView);
    
  63.   }
    
  64. 
    
  65.   /**
    
  66.    * Just sets scroll state.
    
  67.    * Use `_setStateAndInformCallbacksIfChanged` if this view's callbacks should also be called.
    
  68.    *
    
  69.    * @returns Whether state was changed
    
  70.    * @private
    
  71.    */
    
  72.   setScrollState(proposedState: ScrollState) {
    
  73.     const clampedState = clampState({
    
  74.       state: proposedState,
    
  75.       minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL,
    
  76.       maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL,
    
  77.       containerLength: this.frame.size.width,
    
  78.     });
    
  79.     if (
    
  80.       !areScrollStatesEqual(clampedState, this._viewState.horizontalScrollState)
    
  81.     ) {
    
  82.       this.setNeedsDisplay();
    
  83.     }
    
  84.   }
    
  85. 
    
  86.   /**
    
  87.    * Zoom to a specific range of the content specified as a range of the
    
  88.    * content view's intrinsic content size.
    
  89.    *
    
  90.    * Does not inform callbacks of state change since this is a public API.
    
  91.    */
    
  92.   zoomToRange(rangeStart: number, rangeEnd: number) {
    
  93.     const newState = moveStateToRange({
    
  94.       state: this._viewState.horizontalScrollState,
    
  95.       rangeStart,
    
  96.       rangeEnd,
    
  97.       contentLength: this._intrinsicContentWidth,
    
  98. 
    
  99.       minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL,
    
  100.       maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL,
    
  101.       containerLength: this.frame.size.width,
    
  102.     });
    
  103.     this.setScrollState(newState);
    
  104.   }
    
  105. 
    
  106.   desiredSize(): Size | IntrinsicSize {
    
  107.     return this._contentView.desiredSize();
    
  108.   }
    
  109. 
    
  110.   layoutSubviews() {
    
  111.     const {offset, length} = this._viewState.horizontalScrollState;
    
  112.     const proposedFrame = {
    
  113.       origin: {
    
  114.         x: this.frame.origin.x + offset,
    
  115.         y: this.frame.origin.y,
    
  116.       },
    
  117.       size: {
    
  118.         width: length,
    
  119.         height: this.frame.size.height,
    
  120.       },
    
  121.     };
    
  122.     this._contentView.setFrame(proposedFrame);
    
  123.     super.layoutSubviews();
    
  124.   }
    
  125. 
    
  126.   handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
    
  127.     switch (interaction.type) {
    
  128.       case 'mousedown':
    
  129.         this._handleMouseDown(interaction, viewRefs);
    
  130.         break;
    
  131.       case 'mousemove':
    
  132.         this._handleMouseMove(interaction, viewRefs);
    
  133.         break;
    
  134.       case 'mouseup':
    
  135.         this._handleMouseUp(interaction, viewRefs);
    
  136.         break;
    
  137.       case 'wheel-plain':
    
  138.       case 'wheel-shift':
    
  139.         this._handleWheel(interaction);
    
  140.         break;
    
  141.     }
    
  142.   }
    
  143. 
    
  144.   _handleMouseDown(interaction: MouseDownInteraction, viewRefs: ViewRefs) {
    
  145.     if (rectContainsPoint(interaction.payload.location, this.frame)) {
    
  146.       this._isPanning = true;
    
  147. 
    
  148.       viewRefs.activeView = this;
    
  149. 
    
  150.       this.currentCursor = 'grabbing';
    
  151.     }
    
  152.   }
    
  153. 
    
  154.   _handleMouseMove(interaction: MouseMoveInteraction, viewRefs: ViewRefs) {
    
  155.     const isHovered = rectContainsPoint(
    
  156.       interaction.payload.location,
    
  157.       this.frame,
    
  158.     );
    
  159.     if (isHovered && viewRefs.hoveredView === null) {
    
  160.       viewRefs.hoveredView = this;
    
  161.     }
    
  162. 
    
  163.     if (viewRefs.activeView === this) {
    
  164.       this.currentCursor = 'grabbing';
    
  165.     } else if (isHovered) {
    
  166.       this.currentCursor = 'grab';
    
  167.     }
    
  168. 
    
  169.     if (!this._isPanning) {
    
  170.       return;
    
  171.     }
    
  172. 
    
  173.     // Don't prevent mouse-move events from bubbling if they are vertical drags.
    
  174.     const {movementX, movementY} = interaction.payload.event;
    
  175.     if (Math.abs(movementX) < Math.abs(movementY)) {
    
  176.       return;
    
  177.     }
    
  178. 
    
  179.     const newState = translateState({
    
  180.       state: this._viewState.horizontalScrollState,
    
  181.       delta: movementX,
    
  182.       containerLength: this.frame.size.width,
    
  183.     });
    
  184.     this._viewState.updateHorizontalScrollState(newState);
    
  185.   }
    
  186. 
    
  187.   _handleMouseUp(interaction: MouseUpInteraction, viewRefs: ViewRefs) {
    
  188.     if (this._isPanning) {
    
  189.       this._isPanning = false;
    
  190.     }
    
  191. 
    
  192.     if (viewRefs.activeView === this) {
    
  193.       viewRefs.activeView = null;
    
  194.     }
    
  195.   }
    
  196. 
    
  197.   _handleWheel(interaction: WheelPlainInteraction | WheelWithShiftInteraction) {
    
  198.     const {
    
  199.       location,
    
  200.       delta: {deltaX, deltaY},
    
  201.     } = interaction.payload;
    
  202. 
    
  203.     if (!rectContainsPoint(location, this.frame)) {
    
  204.       return; // Not scrolling on view
    
  205.     }
    
  206. 
    
  207.     const absDeltaX = Math.abs(deltaX);
    
  208.     const absDeltaY = Math.abs(deltaY);
    
  209. 
    
  210.     // Vertical scrolling zooms in and out (unless the SHIFT modifier is used).
    
  211.     // Horizontal scrolling pans.
    
  212.     if (absDeltaY > absDeltaX) {
    
  213.       if (absDeltaY < MOVE_WHEEL_DELTA_THRESHOLD) {
    
  214.         return;
    
  215.       }
    
  216. 
    
  217.       if (interaction.type === 'wheel-shift') {
    
  218.         // Shift modifier is for scrolling, not zooming.
    
  219.         return;
    
  220.       }
    
  221. 
    
  222.       const newState = zoomState({
    
  223.         state: this._viewState.horizontalScrollState,
    
  224.         multiplier: 1 + 0.005 * -deltaY,
    
  225.         fixedPoint: location.x - this._viewState.horizontalScrollState.offset,
    
  226. 
    
  227.         minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL,
    
  228.         maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL,
    
  229.         containerLength: this.frame.size.width,
    
  230.       });
    
  231.       this._viewState.updateHorizontalScrollState(newState);
    
  232.     } else {
    
  233.       if (absDeltaX < MOVE_WHEEL_DELTA_THRESHOLD) {
    
  234.         return;
    
  235.       }
    
  236. 
    
  237.       const newState = translateState({
    
  238.         state: this._viewState.horizontalScrollState,
    
  239.         delta: -deltaX,
    
  240.         containerLength: this.frame.size.width,
    
  241.       });
    
  242.       this._viewState.updateHorizontalScrollState(newState);
    
  243.     }
    
  244.   }
    
  245. }