chromium/chrome/browser/resources/lens/overlay/post_selection_renderer.ts

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {assert, assertNotReached} from '//resources/js/assert.js';
import {EventTracker} from '//resources/js/event_tracker.js';
import {PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {BrowserProxyImpl} from './browser_proxy.js';
import type {BrowserProxy} from './browser_proxy.js';
import {CenterRotatedBox_CoordinateType} from './geometry.mojom-webui.js';
import type {CenterRotatedBox} from './geometry.mojom-webui.js';
import {UserAction} from './lens.mojom-webui.js';
import {INVOCATION_SOURCE} from './lens_overlay_app.js';
import {recordLensOverlayInteraction} from './metrics_utils.js';
import {getTemplate} from './post_selection_renderer.html.js';
import {ScreenshotBitmapBrowserProxyImpl} from './screenshot_bitmap_browser_proxy.js';
import {renderScreenshot} from './screenshot_utils.js';
import {focusShimmerOnRegion, ShimmerControlRequester, unfocusShimmer} from './selection_utils.js';
import type {GestureEvent} from './selection_utils.js';
import {toPercent, toPixels} from './values_converter.js';

// Bounding box send to PostSelectionRendererElement to render a bounding box.
// The numbers should be normalized to the image dimensions, between 0 and 1
export interface PostSelectionBoundingBox {
  top: number;
  left: number;
  width: number;
  height: number;
}

// The target currently being dragged on by the user.
enum DragTarget {
  NONE,
  TOP_LEFT,
  TOP_RIGHT,
  BOTTOM_RIGHT,
  BOTTOM_LEFT,
}

// The amount of pixels around the edge leave as a buffer so user can't drag too
// far. Exported for testing.
export const PERIMETER_SELECTION_PADDING_PX = 4;
// The maximum length of a corner. Exported for testing.
export const MAX_CORNER_LENGTH_PX = 22;
// The maximum radius of a corner. Exported for testing.
export const MAX_CORNER_RADIUS_PX = 14;
// Cutout radius used with larger corner radii. Exported for testing.
export const CUTOUT_RADIUS_PX = 5;
// A cutout radius will only be used when the corner radius is above this
// threshold.
const CUTOUT_RADIUS_THRESHOLD_PX = 12;
// Minimum box size allowed. Exported for testing.
export const MIN_BOX_SIZE_PX = 12;

function clamp(value: number, min: number, max: number): number {
  return Math.min(Math.max(value, min), max);
}

export interface PostSelectionRendererElement {
  $: {
    backgroundImageCanvas: HTMLCanvasElement,
    postSelection: HTMLElement,
  };
}

interface CornerDimensions {
  length: number;
  radius: number;
  cutoutRadius: number;
}

/*
 * Renders the users visual selection after one is made. This element is also
 * responsible for allowing the user to adjust their region to issue a new
 * Lens request.
 */
export class PostSelectionRendererElement extends PolymerElement {
  static get is() {
    return 'post-selection-renderer';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      top: Number,
      left: Number,
      height: Number,
      width: Number,
      currentDragTarget: Number,
      cornerIds: Array,
      canvasHeight: Number,
      canvasWidth: Number,
      canvasPhysicalHeight: Number,
      canvasPhysicalWidth: Number,
      selectionOverlayRect: Object,
    };
  }

  private eventTracker_: EventTracker = new EventTracker();
  // The bounds of the current selection
  private top: number = 0;
  private left: number = 0;
  private height: number = 0;
  private width: number = 0;
  // What is currently being dragged by the user.
  private currentDragTarget: DragTarget = DragTarget.NONE;
  // IDs used to generate the corner hitbox divs.
  private cornerIds: string[] =
      ['topLeft', 'topRight', 'bottomRight', 'bottomLeft'];
  private canvasHeight: number;
  private canvasWidth: number;
  private canvasPhysicalHeight: number;
  private canvasPhysicalWidth: number;
  // The bounds of the parent element. This is updated by the parent to avoid
  // this class needing to call getBoundingClientRect().
  private selectionOverlayRect: DOMRect;

  private context: CanvasRenderingContext2D;
  // Listener IDs for events tracked from the browser.
  private listenerIds: number[];
  // The original bounds from the start of a drag.
  private originalBounds:
      PostSelectionBoundingBox = {left: 0, top: 0, width: 0, height: 0};
  private browserProxy: BrowserProxy = BrowserProxyImpl.getInstance();
  private resizeObserver: ResizeObserver = new ResizeObserver(() => {
    this.handleResize();
  });
  private newBoxAnimation: Animation|null = null;
  private animateOnResize = false;

  override connectedCallback() {
    super.connectedCallback();
    ScreenshotBitmapBrowserProxyImpl.getInstance().fetchScreenshot(
        (screenshot: ImageBitmap) => {
          renderScreenshot(this.$.backgroundImageCanvas, screenshot);
        });
    this.eventTracker_.add(
        document, 'render-post-selection',
        (e: CustomEvent<PostSelectionBoundingBox>) => {
          this.onRenderPostSelection(e);
        });
    this.eventTracker_.add(document, 'finished-receiving-text', () => {
      if (this.hasSelection()) {
        // Check for selectable text
        this.dispatchEvent(new CustomEvent('detect-text-in-region', {
          bubbles: true,
          composed: true,
          detail: this.getNormalizedCenterRotatedBox(),
        }));
      }
    });
    this.resizeObserver.observe(this);
    // Set up listener to listen to events from C++.
    this.listenerIds = [
      this.browserProxy.callbackRouter.clearAllSelections.addListener(
          this.clearSelection.bind(this)),
      this.browserProxy.callbackRouter.clearRegionSelection.addListener(
          this.clearSelection.bind(this)),
      this.browserProxy.callbackRouter.setPostRegionSelection.addListener(
          this.setSelection.bind(this)),
    ];
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    this.eventTracker_.removeAll();
    this.resizeObserver.unobserve(this);
    this.listenerIds.forEach(
        id => assert(this.browserProxy.callbackRouter.removeListener(id)));
    this.listenerIds = [];
  }

  setCanvasSizeTo(width: number, height: number) {
    // Resetting the canvas width and height also clears the canvas.
    this.canvasWidth = width;
    this.canvasHeight = height;
    this.canvasPhysicalWidth = width * window.devicePixelRatio;
    this.canvasPhysicalHeight = height * window.devicePixelRatio;
  }

  clearSelection() {
    unfocusShimmer(this, ShimmerControlRequester.POST_SELECTION);
    this.height = 0;
    this.width = 0;
    this.dispatchEvent(new CustomEvent(
        'hide-detected-text-context-menu', {bubbles: true, composed: true}));
    this.notifyPostSelectionUpdated();
  }

  handleDownGesture(event: GestureEvent): boolean {
    this.currentDragTarget =
        this.dragTargetFromPoint(event.clientX, event.clientY);

    if (this.shouldHandleDownGesture()) {
      // User is dragging the post selection (if enabled) or resizing.
      this.originalBounds = {
        left: this.left,
        top: this.top,
        width: this.width,
        height: this.height,
      };
      return true;
    }
    return false;
  }

  handleDragGesture(event: GestureEvent) {
    const imageBounds = this.selectionOverlayRect;
    const normalizedX = (event.clientX - imageBounds.left) / imageBounds.width;
    const normalizedY = (event.clientY - imageBounds.top) / imageBounds.height;
    const normalizedMinBoxWidth = MIN_BOX_SIZE_PX / imageBounds.width;
    const normalizedMinBoxHeight = MIN_BOX_SIZE_PX / imageBounds.height;

    const currentLeft = this.left;
    const currentTop = this.top;
    const currentRight = this.left + this.width;
    const currentBottom = this.top + this.height;
    let newLeft;
    let newTop;
    let newRight;
    let newBottom;

    switch (this.currentDragTarget) {
      case DragTarget.TOP_LEFT:
        newLeft = Math.min(normalizedX, currentRight - normalizedMinBoxWidth);
        newTop = Math.min(normalizedY, currentBottom - normalizedMinBoxHeight);
        newRight = currentRight;
        newBottom = currentBottom;
        break;
      case DragTarget.TOP_RIGHT:
        newLeft = currentLeft;
        newTop = Math.min(normalizedY, currentBottom - normalizedMinBoxHeight);
        newRight = Math.max(normalizedX, currentLeft + normalizedMinBoxWidth);
        newBottom = currentBottom;
        break;
      case DragTarget.BOTTOM_RIGHT:
        newLeft = currentLeft;
        newTop = currentTop;
        newRight = Math.max(normalizedX, currentLeft + normalizedMinBoxWidth);
        newBottom = Math.max(normalizedY, currentTop + normalizedMinBoxHeight);
        break;
      case DragTarget.BOTTOM_LEFT:
        newLeft = Math.min(normalizedX, currentRight - normalizedMinBoxWidth);
        newTop = currentTop;
        newRight = currentRight;
        newBottom = Math.max(normalizedY, currentTop + normalizedMinBoxHeight);
        break;
      default:
        assertNotReached();
    }
    assert(newLeft !== undefined);
    assert(newTop !== undefined);
    assert(newRight !== undefined);
    assert(newBottom !== undefined);

    // Ensure the new region is within the image bounds.
    const clampedBounds = this.getClampedBounds({
      left: newLeft,
      top: newTop,
      width: newRight - newLeft,
      height: newBottom - newTop,
    });

    // Set the new dimensions.
    this.left = clampedBounds.left;
    this.top = clampedBounds.top;
    this.width = clampedBounds.width;
    this.height = clampedBounds.height;

    this.rerender();
  }

  handleUpGesture() {
    if (this.areBoundsChanging()) {
      // Issue Lens request for new bounds
      BrowserProxyImpl.getInstance().handler.issueLensRegionRequest(
          this.getNormalizedCenterRotatedBox(), /*is_click=*/ false);

      // Check for selectable text
      this.dispatchEvent(new CustomEvent('detect-text-in-region', {
        bubbles: true,
        composed: true,
        detail: this.getNormalizedCenterRotatedBox(),
      }));

      recordLensOverlayInteraction(
          INVOCATION_SOURCE, UserAction.kRegionSelectionChange);
    }

    this.originalBounds = {left: 0, top: 0, width: 0, height: 0};
    this.currentDragTarget = DragTarget.NONE;
  }

  cancelGesture() {
    this.originalBounds = {left: 0, top: 0, width: 0, height: 0};
    this.currentDragTarget = DragTarget.NONE;
  }

  private setSelection(region: CenterRotatedBox) {
    const normalizedTop = region.box.y - (region.box.height / 2);
    const normalizedLeft = region.box.x - (region.box.width / 2);

    this.top = normalizedTop;
    this.left = normalizedLeft;
    this.height = region.box.height;
    this.width = region.box.width;
    this.originalBounds = {left: 0, top: 0, width: 0, height: 0};

    this.rerender();
    this.triggerNewBoxAnimation();
  }

  private onRenderPostSelection(e: CustomEvent<PostSelectionBoundingBox>) {
    this.top = e.detail.top;
    this.left = e.detail.left;
    this.height = e.detail.height;
    this.width = e.detail.width;

    this.rerender();
    this.triggerNewBoxAnimation();
  }

  // Returns the bounds of the post selection clamped to the edges of the image,
  // including the post selection corners. If no bounds are given, uses those
  // currently being rendered.
  private getClampedBounds(bounds?: PostSelectionBoundingBox):
      PostSelectionBoundingBox {
    const imageBounds = this.selectionOverlayRect;
    const left = bounds ? bounds.left : this.left;
    const top = bounds ? bounds.top : this.top;
    const right = bounds ? bounds.left + bounds.width : this.left + this.width;
    const bottom = bounds ? bounds.top + bounds.height : this.top + this.height;

    // Helper values to clamp to within the bounds.
    const normalizedMinBoxWidth = MIN_BOX_SIZE_PX / imageBounds.width;
    const normalizedMinBoxHeight = MIN_BOX_SIZE_PX / imageBounds.height;
    const normalizedPerimeterPaddingWidth =
        PERIMETER_SELECTION_PADDING_PX / imageBounds.width;
    const normalizedPerimeterPaddingHeight =
        PERIMETER_SELECTION_PADDING_PX / imageBounds.height;
    const minXValue = normalizedPerimeterPaddingWidth;
    const minYValue = normalizedPerimeterPaddingHeight;
    const maxXValue = 1 - normalizedPerimeterPaddingWidth;
    const maxYValue = 1 - normalizedPerimeterPaddingHeight;

    // Clamp the values to within the selection overlay bounds.
    const clampedLeft =
        clamp(left, minXValue, maxXValue - normalizedMinBoxWidth);
    const clampedTop =
        clamp(top, minYValue, maxYValue - normalizedMinBoxHeight);
    const clampedRight =
        clamp(right, minXValue + normalizedMinBoxWidth, maxXValue);
    const clampedBottom =
        clamp(bottom, minYValue + normalizedMinBoxHeight, maxYValue);

    return {
      left: clampedLeft,
      top: clampedTop,
      width: clampedRight - clampedLeft,
      height: clampedBottom - clampedTop,
    };
  }

  private handleResize() {
    // Only update properties defined absolutely, i.e. corner dimensions.
    // Properties that are defined relatively do not need to be updated.
    this.updateCornerDimensions();
    if (this.newBoxAnimation) {
      (this.newBoxAnimation.effect as KeyframeEffect)
          .setKeyframes(this.getNewBoxAnimationKeyframes());
    } else if (this.animateOnResize) {
      this.animateOnResize = false;
      this.triggerNewBoxAnimation();
    }
    this.rerender();
  }

  private rerender() {
    // rerender() can be called when there is not a selection present. This
    // should be a no-op otherwise the shimmer will be set to focus on the post
    // selection region without a selection.
    if (!this.hasSelection()) {
      return;
    }

    const clampedBounds = this.getClampedBounds();
    // Set the CSS properties to reflect current bounds and force rerender.
    this.style.setProperty('--selection-width', toPercent(clampedBounds.width));
    this.style.setProperty(
        '--selection-height', toPercent(clampedBounds.height));
    this.style.setProperty('--selection-top', toPercent(clampedBounds.top));
    this.style.setProperty('--selection-left', toPercent(clampedBounds.left));

    this.updateCornerDimensions();

    // Focus the shimmer on the new post selection region.
    focusShimmerOnRegion(
        this, clampedBounds.top, clampedBounds.left, clampedBounds.width,
        clampedBounds.height, ShimmerControlRequester.POST_SELECTION);

    this.notifyPostSelectionUpdated();
  }

  private updateCornerDimensions() {
    const cornerDimensions = this.getCornerDimensions();
    this.style.setProperty(
        '--post-selection-corner-horizontal-length',
        toPixels(cornerDimensions.length));
    this.style.setProperty(
        '--post-selection-corner-vertical-length',
        toPixels(cornerDimensions.length));
    this.style.setProperty(
        '--post-selection-corner-radius', toPixels(cornerDimensions.radius));
    this.style.setProperty(
        '--post-selection-cutout-corner-radius',
        toPixels(cornerDimensions.cutoutRadius));
  }

  private triggerNewBoxAnimation() {
    const parentBoundingRect = this.selectionOverlayRect;
    if (parentBoundingRect.width === 0 || parentBoundingRect.height === 0) {
      // Renderer has probably not been sized yet. Defer until resize.
      this.animateOnResize = true;
      return;
    }

    this.newBoxAnimation = this.animate(this.getNewBoxAnimationKeyframes(), {
      duration: 450,
      easing: 'cubic-bezier(0.2, 0.0, 0, 1.0)',
    });
    this.newBoxAnimation.onfinish = () => {
      this.newBoxAnimation = null;
    };
  }

  private getNewBoxAnimationKeyframes() {
    const parentBoundingRect = this.selectionOverlayRect;
    const cornerDimensions = this.getCornerDimensions();
    return [
      {
        [`--post-selection-corner-horizontal-length`]:
            toPixels(parentBoundingRect.width * this.width / 2),
        [`--post-selection-corner-vertical-length`]:
            toPixels(parentBoundingRect.height * this.height / 2),
      },
      {
        [`--post-selection-corner-horizontal-length`]:
            toPixels(cornerDimensions.length),
        [`--post-selection-corner-vertical-length`]:
            toPixels(cornerDimensions.length),
      },
    ];
  }

  private getCornerDimensions(): CornerDimensions {
    const imageBounds = this.selectionOverlayRect;
    if (imageBounds.width === 0 || imageBounds.height === 0) {
      // Renderer has probably not been sized yet. Return default values.
      return {
        length: MAX_CORNER_LENGTH_PX,
        radius: MAX_CORNER_RADIUS_PX,
        cutoutRadius: CUTOUT_RADIUS_PX,
      };
    }

    const shortestSide = Math.min(
        this.width * imageBounds.width, this.height * imageBounds.height);
    const length = Math.min(shortestSide / 2, MAX_CORNER_LENGTH_PX);
    const radius = Math.min(shortestSide / 3, MAX_CORNER_RADIUS_PX);
    // Do not use a cutout radius at small radii to prevent gaps.
    const cutoutRadius =
        radius > CUTOUT_RADIUS_THRESHOLD_PX ? CUTOUT_RADIUS_PX : 0;

    return {length, radius, cutoutRadius};
  }

  private notifyPostSelectionUpdated() {
    this.dispatchEvent(new CustomEvent('post-selection-updated', {
      bubbles: true,
      composed: true,
      detail: {
        top: this.top,
        left: this.left,
        width: this.width,
        height: this.height,
      },
    }));
  }

  // Returns if the current bounds are being updated.
  private areBoundsChanging() {
    return this.originalBounds.top !== this.top ||
        this.originalBounds.left !== this.left ||
        this.originalBounds.height !== this.height ||
        this.originalBounds.width !== this.width;
  }

  /**
   * @return Returns the drag target at the given point.
   */
  private dragTargetFromPoint(x: number, y: number): DragTarget {
    const topMostElements = this.shadowRoot!.elementsFromPoint(x, y);
    const topMostDraggableElement = topMostElements.find(el => {
      return (el instanceof HTMLElement) &&
          el.classList.contains('corner-hit-box');
    });
    if (!topMostDraggableElement) {
      return DragTarget.NONE;
    }
    switch (topMostDraggableElement.id) {
      case 'topLeft':
        return DragTarget.TOP_LEFT;
      case 'topRight':
        return DragTarget.TOP_RIGHT;
      case 'bottomRight':
        return DragTarget.BOTTOM_RIGHT;
      case 'bottomLeft':
        return DragTarget.BOTTOM_LEFT;
      default:
        // Did not click on a target we care about.
        break;
    }
    return DragTarget.NONE;
  }

  private shouldHandleDownGesture(): boolean {
    return this.currentDragTarget !== DragTarget.NONE;
  }

  // Converts the current region to a CenterRotatedBox
  private getNormalizedCenterRotatedBox(): CenterRotatedBox {
    return {
      box: {
        x: this.left + (this.width / 2),
        y: this.top + (this.height / 2),
        width: this.width,
        height: this.height,
      },
      rotation: 0,
      coordinateType: CenterRotatedBox_CoordinateType.kNormalized,
    };
  }

  private getScrimStyleProperties() {
    // If there is no selection, set opacity to zero to trigger fade out
    // CSS transition.
    return !this.hasSelection() ? 'opacity: 0;' : '';
  }

  // Used in HTML template to know if there is currently a selection to render.
  private hasSelection(): boolean {
    return this.width > 0 && this.height > 0;
  }

  setSelectionOverlayRectForTesting(rect: DOMRect) {
    this.selectionOverlayRect = rect;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'post-selection-renderer': PostSelectionRendererElement;
  }
}

customElements.define(
    PostSelectionRendererElement.is, PostSelectionRendererElement);

// Setup CSS Houdini API
CSS.paintWorklet.addModule('post_selection_paint_worklet.js');

// Variables controlling the rendered post selection
CSS.registerProperty({
  name: '--post-selection-corner-horizontal-length',
  syntax: '<length>',
  inherits: true,
  initialValue: '22px',
});
CSS.registerProperty({
  name: '--post-selection-corner-vertical-length',
  syntax: '<length>',
  inherits: true,
  initialValue: '22px',
});
CSS.registerProperty({
  name: '--post-selection-corner-width',
  syntax: '<length>',
  inherits: true,
  initialValue: '4px',
});
CSS.registerProperty({
  name: '--post-selection-corner-radius',
  syntax: '<length>',
  inherits: true,
  initialValue: '14px',
});