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.   WheelWithShiftInteraction,
    
  17. } from './useCanvasInteraction';
    
  18. import type {ScrollState} from './utils/scrollState';
    
  19. import type {ViewRefs} from './Surface';
    
  20. import type {ViewState} from '../types';
    
  21. 
    
  22. import {Surface} from './Surface';
    
  23. import {View} from './View';
    
  24. import {rectContainsPoint} from './geometry';
    
  25. import {
    
  26.   clampState,
    
  27.   areScrollStatesEqual,
    
  28.   translateState,
    
  29. } from './utils/scrollState';
    
  30. import {MOVE_WHEEL_DELTA_THRESHOLD} from './constants';
    
  31. import {COLORS} from '../content-views/constants';
    
  32. 
    
  33. const CARET_MARGIN = 3;
    
  34. const CARET_WIDTH = 5;
    
  35. const CARET_HEIGHT = 3;
    
  36. 
    
  37. type OnChangeCallback = (
    
  38.   scrollState: ScrollState,
    
  39.   containerLength: number,
    
  40. ) => void;
    
  41. 
    
  42. export class VerticalScrollView extends View {
    
  43.   _contentView: View;
    
  44.   _isPanning: boolean;
    
  45.   _mutableViewStateKey: string;
    
  46.   _onChangeCallback: OnChangeCallback | null;
    
  47.   _scrollState: ScrollState;
    
  48.   _viewState: ViewState;
    
  49. 
    
  50.   constructor(
    
  51.     surface: Surface,
    
  52.     frame: Rect,
    
  53.     contentView: View,
    
  54.     viewState: ViewState,
    
  55.     label: string,
    
  56.   ) {
    
  57.     super(surface, frame);
    
  58. 
    
  59.     this._contentView = contentView;
    
  60.     this._isPanning = false;
    
  61.     this._mutableViewStateKey = label + ':VerticalScrollView';
    
  62.     this._onChangeCallback = null;
    
  63.     this._scrollState = {
    
  64.       offset: 0,
    
  65.       length: 0,
    
  66.     };
    
  67.     this._viewState = viewState;
    
  68. 
    
  69.     this.addSubview(contentView);
    
  70. 
    
  71.     this._restoreMutableViewState();
    
  72.   }
    
  73. 
    
  74.   setFrame(newFrame: Rect) {
    
  75.     super.setFrame(newFrame);
    
  76. 
    
  77.     // Revalidate scrollState
    
  78.     this._setScrollState(this._scrollState);
    
  79.   }
    
  80. 
    
  81.   desiredSize(): Size | IntrinsicSize {
    
  82.     return this._contentView.desiredSize();
    
  83.   }
    
  84. 
    
  85.   draw(context: CanvasRenderingContext2D, viewRefs: ViewRefs) {
    
  86.     super.draw(context, viewRefs);
    
  87. 
    
  88.     // Show carets if there's scroll overflow above or below the viewable area.
    
  89.     if (this.frame.size.height > CARET_HEIGHT * 2 + CARET_MARGIN * 3) {
    
  90.       const offset = this._scrollState.offset;
    
  91.       const desiredSize = this._contentView.desiredSize();
    
  92. 
    
  93.       const above = offset;
    
  94.       const below = this.frame.size.height - desiredSize.height - offset;
    
  95. 
    
  96.       if (above < 0 || below < 0) {
    
  97.         const {visibleArea} = this;
    
  98.         const {x, y} = visibleArea.origin;
    
  99.         const {width, height} = visibleArea.size;
    
  100.         const horizontalCenter = x + width / 2;
    
  101. 
    
  102.         const halfWidth = CARET_WIDTH;
    
  103.         const left = horizontalCenter + halfWidth;
    
  104.         const right = horizontalCenter - halfWidth;
    
  105. 
    
  106.         if (above < 0) {
    
  107.           const topY = y + CARET_MARGIN;
    
  108. 
    
  109.           context.beginPath();
    
  110.           context.moveTo(horizontalCenter, topY);
    
  111.           context.lineTo(left, topY + CARET_HEIGHT);
    
  112.           context.lineTo(right, topY + CARET_HEIGHT);
    
  113.           context.closePath();
    
  114.           context.fillStyle = COLORS.SCROLL_CARET;
    
  115.           context.fill();
    
  116.         }
    
  117. 
    
  118.         if (below < 0) {
    
  119.           const bottomY = y + height - CARET_MARGIN;
    
  120. 
    
  121.           context.beginPath();
    
  122.           context.moveTo(horizontalCenter, bottomY);
    
  123.           context.lineTo(left, bottomY - CARET_HEIGHT);
    
  124.           context.lineTo(right, bottomY - CARET_HEIGHT);
    
  125.           context.closePath();
    
  126.           context.fillStyle = COLORS.SCROLL_CARET;
    
  127.           context.fill();
    
  128.         }
    
  129.       }
    
  130.     }
    
  131.   }
    
  132. 
    
  133.   layoutSubviews() {
    
  134.     const {offset} = this._scrollState;
    
  135.     const desiredSize = this._contentView.desiredSize();
    
  136. 
    
  137.     const minimumHeight = this.frame.size.height;
    
  138.     const desiredHeight = desiredSize ? desiredSize.height : 0;
    
  139.     // Force view to take up at least all remaining vertical space.
    
  140.     const height = Math.max(desiredHeight, minimumHeight);
    
  141. 
    
  142.     const proposedFrame = {
    
  143.       origin: {
    
  144.         x: this.frame.origin.x,
    
  145.         y: this.frame.origin.y + offset,
    
  146.       },
    
  147.       size: {
    
  148.         width: this.frame.size.width,
    
  149.         height,
    
  150.       },
    
  151.     };
    
  152.     this._contentView.setFrame(proposedFrame);
    
  153.     super.layoutSubviews();
    
  154.   }
    
  155. 
    
  156.   handleInteraction(interaction: Interaction): ?boolean {
    
  157.     switch (interaction.type) {
    
  158.       case 'mousedown':
    
  159.         return this._handleMouseDown(interaction);
    
  160.       case 'mousemove':
    
  161.         return this._handleMouseMove(interaction);
    
  162.       case 'mouseup':
    
  163.         return this._handleMouseUp(interaction);
    
  164.       case 'wheel-shift':
    
  165.         return this._handleWheelShift(interaction);
    
  166.     }
    
  167.   }
    
  168. 
    
  169.   onChange(callback: OnChangeCallback) {
    
  170.     this._onChangeCallback = callback;
    
  171.   }
    
  172. 
    
  173.   scrollBy(deltaY: number): boolean {
    
  174.     const newState = translateState({
    
  175.       state: this._scrollState,
    
  176.       delta: -deltaY,
    
  177.       containerLength: this.frame.size.height,
    
  178.     });
    
  179. 
    
  180.     // If the state is updated by this wheel scroll,
    
  181.     // return true to prevent the interaction from bubbling.
    
  182.     // For instance, this prevents the outermost container from also scrolling.
    
  183.     return this._setScrollState(newState);
    
  184.   }
    
  185. 
    
  186.   _handleMouseDown(interaction: MouseDownInteraction) {
    
  187.     if (rectContainsPoint(interaction.payload.location, this.frame)) {
    
  188.       const frameHeight = this.frame.size.height;
    
  189.       const contentHeight = this._contentView.desiredSize().height;
    
  190.       // Don't claim drag operations if the content is not tall enough to be scrollable.
    
  191.       // This would block any outer scroll views from working.
    
  192.       if (frameHeight < contentHeight) {
    
  193.         this._isPanning = true;
    
  194.       }
    
  195.     }
    
  196.   }
    
  197. 
    
  198.   _handleMouseMove(interaction: MouseMoveInteraction): void | boolean {
    
  199.     if (!this._isPanning) {
    
  200.       return;
    
  201.     }
    
  202. 
    
  203.     // Don't prevent mouse-move events from bubbling if they are horizontal drags.
    
  204.     const {movementX, movementY} = interaction.payload.event;
    
  205.     if (Math.abs(movementX) > Math.abs(movementY)) {
    
  206.       return;
    
  207.     }
    
  208. 
    
  209.     const newState = translateState({
    
  210.       state: this._scrollState,
    
  211.       delta: interaction.payload.event.movementY,
    
  212.       containerLength: this.frame.size.height,
    
  213.     });
    
  214.     this._setScrollState(newState);
    
  215. 
    
  216.     return true;
    
  217.   }
    
  218. 
    
  219.   _handleMouseUp(interaction: MouseUpInteraction) {
    
  220.     if (this._isPanning) {
    
  221.       this._isPanning = false;
    
  222.     }
    
  223.   }
    
  224. 
    
  225.   _handleWheelShift(interaction: WheelWithShiftInteraction): boolean {
    
  226.     const {
    
  227.       location,
    
  228.       delta: {deltaX, deltaY},
    
  229.     } = interaction.payload;
    
  230. 
    
  231.     if (!rectContainsPoint(location, this.frame)) {
    
  232.       return false; // Not scrolling on view
    
  233.     }
    
  234. 
    
  235.     const absDeltaX = Math.abs(deltaX);
    
  236.     const absDeltaY = Math.abs(deltaY);
    
  237.     if (absDeltaX > absDeltaY) {
    
  238.       return false; // Scrolling horizontally
    
  239.     }
    
  240. 
    
  241.     if (absDeltaY < MOVE_WHEEL_DELTA_THRESHOLD) {
    
  242.       return false; // Movement was too small and should be ignored.
    
  243.     }
    
  244. 
    
  245.     return this.scrollBy(deltaY);
    
  246.   }
    
  247. 
    
  248.   _restoreMutableViewState() {
    
  249.     if (
    
  250.       this._viewState.viewToMutableViewStateMap.has(this._mutableViewStateKey)
    
  251.     ) {
    
  252.       this._scrollState = ((this._viewState.viewToMutableViewStateMap.get(
    
  253.         this._mutableViewStateKey,
    
  254.       ): any): ScrollState);
    
  255.     } else {
    
  256.       this._viewState.viewToMutableViewStateMap.set(
    
  257.         this._mutableViewStateKey,
    
  258.         this._scrollState,
    
  259.       );
    
  260.     }
    
  261. 
    
  262.     this.setNeedsDisplay();
    
  263.   }
    
  264. 
    
  265.   _setScrollState(proposedState: ScrollState): boolean {
    
  266.     const contentHeight = this._contentView.frame.size.height;
    
  267.     const containerHeight = this.frame.size.height;
    
  268. 
    
  269.     const clampedState = clampState({
    
  270.       state: proposedState,
    
  271.       minContentLength: contentHeight,
    
  272.       maxContentLength: contentHeight,
    
  273.       containerLength: containerHeight,
    
  274.     });
    
  275.     if (!areScrollStatesEqual(clampedState, this._scrollState)) {
    
  276.       this._scrollState.offset = clampedState.offset;
    
  277.       this._scrollState.length = clampedState.length;
    
  278. 
    
  279.       this.setNeedsDisplay();
    
  280. 
    
  281.       if (this._onChangeCallback !== null) {
    
  282.         this._onChangeCallback(clampedState, this.frame.size.height);
    
  283.       }
    
  284. 
    
  285.       return true;
    
  286.     }
    
  287. 
    
  288.     // Don't allow wheel events to bubble past this view even if we've scrolled to the edge.
    
  289.     // It just feels bad to have the scrolling jump unexpectedly from in a container to the outer page.
    
  290.     // The only exception is when the container fits the content (no scrolling).
    
  291.     if (contentHeight === containerHeight) {
    
  292.       return false;
    
  293.     }
    
  294. 
    
  295.     return true;
    
  296.   }
    
  297. }