chromium/chrome/browser/resources/lens/overlay/object_layer.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 './strings.m.js';

import {assert, assertInstanceof} from '//resources/js/assert.js';
import {EventTracker} from '//resources/js/event_tracker.js';
import {loadTimeData} from '//resources/js/load_time_data.js';
import {PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import type {DomRepeat} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {BrowserProxyImpl} from './browser_proxy.js';
import type {BrowserProxy} from './browser_proxy.js';
import {getFallbackTheme, skColorToRgbaWithCustomAlpha} from './color_utils.js';
import {type CursorTooltipData, CursorTooltipType} from './cursor_tooltip.js';
import {CenterRotatedBox_CoordinateType} from './geometry.mojom-webui.js';
import type {CenterRotatedBox} from './geometry.mojom-webui.js';
import type {LensPageCallbackRouter, OverlayTheme} from './lens.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 './object_layer.html.js';
import type {OverlayObject} from './overlay_object.mojom-webui.js';
import {Polygon_CoordinateType} from './polygon.mojom-webui.js';
import type {Vertex} from './polygon.mojom-webui.js';
import type {PostSelectionBoundingBox} from './post_selection_renderer.js';
import {ScreenshotBitmapBrowserProxyImpl} from './screenshot_bitmap_browser_proxy.js';
import {renderScreenshot} from './screenshot_utils.js';
import type {CursorData} from './selection_overlay.js';
import {CursorType, focusShimmerOnRegion, type GestureEvent, ShimmerControlRequester, unfocusShimmer} from './selection_utils.js';
import {toPercent} from './values_converter.js';

// The percent of the selection layer width and height the object needs to take
// up to be considered full page.
const FULLSCREEN_OBJECT_THRESHOLD_PERCENT = 0.95;
// The transition duration for the fade out animation into the cursor state.
const CURSOR_FADE_OUT_TRANSITION_DURATION = 150;

// Returns true if the object has a valid bounding box and is renderable by the
// ObjectLayer.
function isObjectRenderable(object: OverlayObject): boolean {
  // For an object to be renderable, it must have a bounding box with normalized
  // coordinates.
  // TODO(b/330183480): Add rendering for IMAGE CoordinateType
  const objectBoundingBox = object.geometry?.boundingBox;
  if (!objectBoundingBox) {
    return false;
  }

  // Filter out objects covering the entire screen.
  if (objectBoundingBox.box.width >= FULLSCREEN_OBJECT_THRESHOLD_PERCENT &&
      objectBoundingBox.box.height >= FULLSCREEN_OBJECT_THRESHOLD_PERCENT) {
    return false;
  }

  // TODO(b/334940363): CoordinateType is being incorrectly set to
  // kUnspecified instead of kNormalized. Once this is fixed, change this
  // check back to objectBoundingBox.coordinateType ===
  // CenterRotatedBox_CoordinateType.kNormalized.
  return objectBoundingBox.coordinateType !==
      CenterRotatedBox_CoordinateType.kImage;
}

// Returns true if the object has a segmentation mask.
function hasSegmentationMask(object: OverlayObject): boolean {
  assert(object.geometry);
  return object.geometry.segmentationPolygon.length > 0;
}

// Comparator to order objects with larger areas before objects with smaller
// areas.
function compareArea(object1: OverlayObject, object2: OverlayObject): number {
  assert(object1.geometry);
  assert(object2.geometry);
  return object2.geometry.boundingBox.box.width *
      object2.geometry.boundingBox.box.height -
      object1.geometry.boundingBox.box.width *
      object1.geometry.boundingBox.box.height;
}

// Comparator to order objects with segmentation masks with larger areas before
// objects with segmentation masks with smaller areas.
function compareSegmentationMaskArea(
    object1: OverlayObject, object2: OverlayObject): number {
  assert(object1.geometry);
  assert(object2.geometry);
  return getSegmentationMaskArea(object2) - getSegmentationMaskArea(object1);
}

// Calculates the area of the segmentation mask of the object using the
// shoelace formula. Uses signed area so that counter-clockwise polygons
// (holes) are subtracted.
function getSegmentationMaskArea(object: OverlayObject): number {
  let area = 0;
  for (const polygon of object.geometry.segmentationPolygon) {
    const vertices = polygon.vertex;
    for (let i = 0; i < vertices.length; i++) {
      if (i < vertices.length - 1) {
        area += vertices[i].x * vertices[i + 1].y -
            vertices[i + 1].x * vertices[i].y;
      } else {
        area += vertices[i].x * vertices[0].y - vertices[0].x * vertices[i].y;
      }
    }
  }
  return 0.5 * area;
}

// Returns a clip path value for the object corresponding to its segmentation
// mask. If there is no segmentation mask, returns the value 'none'.
function toCssClipPath(object: OverlayObject): string {
  const polygons = object.geometry.segmentationPolygon;
  if (!polygons) {
    return 'none';
  }

  const points: string[] = [];
  for (const polygon of polygons) {
    // TODO(b/330183480): Currently, we are assuming that polygon
    // coordinates are normalized. We should still implement
    // rendering in case this assumption is ever violated.
    if (polygon.coordinateType !== Polygon_CoordinateType.kNormalized) {
      continue;
    }

    for (const vertex of polygon.vertex) {
      points.push(toCssPolygonVertex(object, vertex));
    }
    // Add first vertex again to close the path.
    points.push(toCssPolygonVertex(object, polygon.vertex[0]));
  }
  if (points.length === 0) {
    return 'none';
  }
  return 'polygon(evenodd, ' + points.join(', ') + ')';
}

// Converts the vertex to a string containing a pair of length-percentage values
// relative to the object bounding box, to be used in the CSS polygon()
// function.
function toCssPolygonVertex(object: OverlayObject, vertex: Vertex): string {
  const objectBoundingBox = object.geometry!.boundingBox;
  return toPercent(
             0.5 +
             (vertex.x - objectBoundingBox.box.x) /
                 objectBoundingBox.box.width) +
      ' ' +
      toPercent(
             0.5 +
             (vertex.y - objectBoundingBox.box.y) /
                 objectBoundingBox.box.height);
}

export interface ObjectLayerElement {
  $: {
    highlightImgCanvas: HTMLCanvasElement,
    objectsContainer: DomRepeat,
    objectSelectionCanvas: HTMLCanvasElement,
  };
}

/*
 * Element responsible for highlighting and selection text.
 */
export class ObjectLayerElement extends PolymerElement {
  static get is() {
    return 'lens-object-layer';
  }

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

  static get properties() {
    return {
      canvasHeight: Number,
      canvasWidth: Number,
      canvasPhysicalHeight: Number,
      canvasPhysicalWidth: Number,
      renderedObjects: {
        type: Array,
        value: () => [],
      },
      debugMode: {
        type: Boolean,
        value: () => loadTimeData.getBoolean('enableDebuggingMode'),
        reflectToAttribute: true,
      },
      theme: {
        type: Object,
        value: getFallbackTheme,
      },
    };
  }

  private eventTracker_: EventTracker = new EventTracker();
  private canvasHeight: number;
  private canvasWidth: number;
  private canvasPhysicalHeight: number;
  private canvasPhysicalWidth: number;
  private context: CanvasRenderingContext2D;
  // The objects rendered in this layer.
  private renderedObjects: OverlayObject[];
  // The last post selection made. Updated by events from the post selection
  // layer.
  private lastPostSelection: PostSelectionBoundingBox|null = null;
  // The overlay theme.
  private theme: OverlayTheme;
  private fadeOutAnimations: Animation[] = [];
  private fadeOutTimeoutIds: number[] = [];
  private postSelectionComparisonThreshold: number =
      loadTimeData.getValue('postSelectionComparisonThreshold');

  private readonly router: LensPageCallbackRouter =
      BrowserProxyImpl.getInstance().callbackRouter;
  private objectsReceivedListenerId: number|null = null;
  private browserProxy: BrowserProxy = BrowserProxyImpl.getInstance();

  override ready() {
    super.ready();

    this.context = this.$.objectSelectionCanvas.getContext('2d')!;
  }

  override connectedCallback() {
    super.connectedCallback();
    this.eventTracker_.add(
        document, 'post-selection-updated',
        (e: CustomEvent<PostSelectionBoundingBox>) => {
          this.lastPostSelection = e.detail;
        });
    // Set up listener to receive objects from C++.
    this.objectsReceivedListenerId = this.router.objectsReceived.addListener(
        this.onObjectsReceived.bind(this));

    ScreenshotBitmapBrowserProxyImpl.getInstance().fetchScreenshot(
        (screenshot: ImageBitmap) => {
          renderScreenshot(this.$.highlightImgCanvas, screenshot);
        });
  }

  override disconnectedCallback() {
    super.disconnectedCallback();

    // Remove listener to receive objects from C++.
    assert(this.objectsReceivedListenerId);
    this.router.removeListener(this.objectsReceivedListenerId);
    this.objectsReceivedListenerId = null;
  }

  handleUpGesture(event: GestureEvent): boolean {
    const objectIndex = this.objectIndexFromPoint(event.clientX, event.clientY);
    // Ignore if the click is not on an object.
    if (objectIndex === null) {
      return false;
    }

    const object = this.renderedObjects[objectIndex];
    const selectionRegion = object.geometry!.boundingBox;

    // Issue the query.
    this.browserProxy.handler.issueLensObjectRequest(
        selectionRegion, hasSegmentationMask(object));

    // Send the region to be rendered on the page.
    this.dispatchEvent(new CustomEvent('render-post-selection', {
      bubbles: true,
      composed: true,
      detail: this.getPostSelectionRegion(selectionRegion),
    }));

    // Since the selection is made and rendering is being done by the post
    // selection layer, act as the cursor left so the segmentation is no longer
    // highlighted.
    this.handlePointerLeave();

    recordLensOverlayInteraction(INVOCATION_SOURCE, UserAction.kObjectClick);

    return true;
  }

  private handlePointerEnter(event: PointerEvent) {
    assertInstanceof(event.target, HTMLElement);

    // Only continue if we have an object that has a segmentation mask and is
    // not already selected.
    const object = this.$.objectsContainer.itemForElement(event.target);
    if (object === null || !hasSegmentationMask(object) ||
        this.isRegionAlreadySelected(
            this.getPostSelectionRegion(object.geometry!.boundingBox))) {
      return;
    }

    this.clearAndCancelAnimation();
    this.drawObject(this.context, object);
    this.focusShimmer(object);
    this.dispatchEvent(new CustomEvent<CursorData>('set-cursor', {
      bubbles: true,
      composed: true,
      detail: {cursor: CursorType.POINTER},
    }));
    this.dispatchEvent(
        new CustomEvent<CursorTooltipData>('set-cursor-tooltip', {
          bubbles: true,
          composed: true,
          detail: {tooltipType: CursorTooltipType.CLICK_SEARCH},
        }));
    this.dispatchEvent(new CustomEvent('darken-extra-scrim-opacity', {
      bubbles: true,
      composed: true,
    }));
    this.style.cursor = 'pointer';
  }

  private isRegionAlreadySelected(boundingBox: PostSelectionBoundingBox):
      boolean {
    if (this.lastPostSelection === null) {
      return false;
    }
    return Math.abs(boundingBox.top - this.lastPostSelection.top) <=
        this.postSelectionComparisonThreshold &&
        Math.abs(boundingBox.left - this.lastPostSelection.left) <=
        this.postSelectionComparisonThreshold &&
        Math.abs(boundingBox.width - this.lastPostSelection.width) <=
        this.postSelectionComparisonThreshold &&
        Math.abs(boundingBox.height - this.lastPostSelection.height) <=
        this.postSelectionComparisonThreshold;
  }

  private handlePointerLeave() {
    this.fadeOutAnimations.push(
        this.$.objectSelectionCanvas.animate({opacity: 0}, {
          duration: CURSOR_FADE_OUT_TRANSITION_DURATION,
          fill: 'forwards',
        }));
    this.fadeOutTimeoutIds.push(setTimeout(() => {
      this.clearCanvas(this.context);
    }, CURSOR_FADE_OUT_TRANSITION_DURATION));
    unfocusShimmer(this, ShimmerControlRequester.SEGMENTATION);
    this.dispatchEvent(new CustomEvent<CursorData>('set-cursor', {
      bubbles: true,
      composed: true,
      detail: {cursor: CursorType.DEFAULT},
    }));
    this.dispatchEvent(
        new CustomEvent<CursorTooltipData>('set-cursor-tooltip', {
          bubbles: true,
          composed: true,
          detail: {tooltipType: CursorTooltipType.REGION_SEARCH},
        }));
    this.dispatchEvent(new CustomEvent('lighten-extra-scrim-opacity', {
      bubbles: true,
      composed: true,
    }));
    this.style.cursor = 'unset';
  }

  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;
    this.context.setTransform(
        window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0);
  }

  private drawObject(context: CanvasRenderingContext2D, object: OverlayObject) {
    const polygons = object.geometry.segmentationPolygon;
    if (!polygons) {
      return;
    }

    context.beginPath();
    const cornerRadius =
        loadTimeData.getInteger('segmentationMaskCornerRadius');
    for (const polygon of polygons) {
      // TODO(b/330183480): Currently, we are assuming that polygon
      // coordinates are normalized. We should still implement
      // rendering in case this assumption is ever violated.
      if (polygon.coordinateType !== Polygon_CoordinateType.kNormalized) {
        continue;
      }

      const firstVertex = polygon.vertex[0];
      context.moveTo(
          firstVertex.x * this.canvasWidth, firstVertex.y * this.canvasHeight);
      // Draw the segmentation mask, rounding each corner by the configured
      // radius.
      for (let i = 1; i < polygon.vertex.length; i++) {
        const currentVertex = polygon.vertex[i];
        const previousVertex = polygon.vertex[i - 1];

        // Calculate the distance between the current and previous vertices.
        const dx = currentVertex.x - previousVertex.x;
        const dy = currentVertex.y - previousVertex.y;
        const distance = Math.sqrt(dx * dx + dy * dy);

        // The control point distance should be the desired relative corner
        // radius or the radius of the arc between the two points. Whichever is
        // smaller.
        const controlPointDistance =
            Math.min(distance / 2, cornerRadius / this.canvasWidth);

        // Use linear interpolation to find the control points.
        const controlPoint1x =
            previousVertex.x + (dx * controlPointDistance) / distance;
        const controlPoint1y =
            previousVertex.y + (dy * controlPointDistance) / distance;
        const controlPoint2x =
            currentVertex.x - (dx * controlPointDistance) / distance;
        const controlPoint2y =
            currentVertex.y - (dy * controlPointDistance) / distance;

        context.lineTo(
            controlPoint1x * this.canvasWidth,
            controlPoint1y * this.canvasHeight);
        context.arcTo(
            controlPoint1x * this.canvasWidth,
            controlPoint1y * this.canvasHeight,
            controlPoint2x * this.canvasWidth,
            controlPoint2y * this.canvasHeight, cornerRadius);
      }
    }
    context.closePath();

    // Draw the highlight image clipped to the path.
    context.save();
    context.filter = 'none';
    context.clip();
    context.drawImage(
        this.$.highlightImgCanvas, 0, 0, this.canvasWidth, this.canvasHeight);
    context.restore();

    // Stroke the path on top of the image.
    context.lineCap = 'round';
    context.lineJoin = 'round';
    context.lineWidth = 6;
    context.filter = 'blur(8px)';
    // Fit a square around the bounding box to use for gradient coordinates.
    const objectBoundingBox = object.geometry.boundingBox;
    const longestEdge =
        Math.max(objectBoundingBox.box.width, objectBoundingBox.box.height);
    const left = (objectBoundingBox.box.x - longestEdge / 2) * this.canvasWidth;
    const top = (objectBoundingBox.box.y - longestEdge / 2) * this.canvasHeight;
    const right =
        (objectBoundingBox.box.x + longestEdge / 2) * this.canvasWidth;
    const bottom =
        (objectBoundingBox.box.y + longestEdge / 2) * this.canvasHeight;
    const gradient = context.createLinearGradient(
        left,
        top,
        right,
        bottom,
    );
    const segmentationColor =
        skColorToRgbaWithCustomAlpha(this.theme.selectionElement, 0.65);
    gradient.addColorStop(0, segmentationColor);
    gradient.addColorStop(1, segmentationColor);
    context.strokeStyle = gradient;
    context.stroke();
  }

  private clearCanvas(context: CanvasRenderingContext2D) {
    context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);

    // Create a new blank path so isPointInPath returns false.
    context.beginPath();
    context.closePath();
  }

  private focusShimmer(object: OverlayObject) {
    const polygons = object.geometry.segmentationPolygon;
    if (!polygons) {
      return;
    }

    const firstVertex = polygons[0].vertex[0];
    let topMostPoint = firstVertex.y;
    let bottomMostPoint = firstVertex.y;
    let leftMostPoint = firstVertex.x;
    let rightMostPoint = firstVertex.x;

    for (const polygon of polygons) {
      // TODO(b/330183480): Currently, we are assuming that polygon
      // coordinates are normalized. We should still implement
      // rendering in case this assumption is ever violated.
      if (polygon.coordinateType !== Polygon_CoordinateType.kNormalized) {
        continue;
      }

      for (const vertex of polygon.vertex.slice(1)) {
        topMostPoint = Math.min(topMostPoint, vertex.y);
        bottomMostPoint = Math.max(bottomMostPoint, vertex.y);
        leftMostPoint = Math.min(leftMostPoint, vertex.x);
        rightMostPoint = Math.max(rightMostPoint, vertex.x);
      }
    }

    // Focus the shimmer on the segmentation object.
    focusShimmerOnRegion(
        this, topMostPoint, leftMostPoint, rightMostPoint - leftMostPoint,
        bottomMostPoint - topMostPoint, ShimmerControlRequester.SEGMENTATION);
  }

  private onObjectsReceived(objects: OverlayObject[]) {
    // Sort objects with segmentation masks after objects without
    // segmentation masks. Then sort by descending segmentation mask or
    // bounding box area so that smaller objects are rendered over, and take
    // priority over, larger objects.
    const renderableObjects = objects.filter(o => isObjectRenderable(o));
    const objectsWithMask: OverlayObject[] = [];
    const objectsWithoutMask: OverlayObject[] = [];
    for (const object of renderableObjects) {
      if (hasSegmentationMask(object)) {
        objectsWithMask.push(object);
      } else {
        objectsWithoutMask.push(object);
      }
    }
    objectsWithMask.sort(compareSegmentationMaskArea);
    objectsWithoutMask.sort(compareArea);
    this.renderedObjects = objectsWithoutMask.concat(objectsWithMask);
  }

  /** @return The CSS styles string for the given object. */
  private getObjectStyle(object: OverlayObject): string {
    // Objects without bounding boxes are filtered out, so guaranteed that
    // geometry is not null.
    const objectBoundingBox = object.geometry!.boundingBox;

    // TODO(b/330183480): Currently, we are assuming that object
    // coordinates are normalized. We should still implement
    // rendering in case this assumption is ever violated.
    // TODO(b/334940363): CoordinateType is being incorrectly set to
    // kUnspecified instead of kNormalized. Once this is fixed, change this
    // check back to objectBoundingBox.coordinateType !==
    // CenterRotatedBox_CoordinateType.kNormalized.
    if (objectBoundingBox.coordinateType ===
        CenterRotatedBox_CoordinateType.kImage) {
      return '';
    }

    // Put into an array instead of a long string to keep this code readable.
    const styles: string[] = [
      `width: ${toPercent(objectBoundingBox.box.width)}`,
      `height: ${toPercent(objectBoundingBox.box.height)}`,
      `top: ${
          toPercent(
              objectBoundingBox.box.y - (objectBoundingBox.box.height / 2))}`,
      `left: ${
          toPercent(
              objectBoundingBox.box.x - (objectBoundingBox.box.width / 2))}`,
      `transform: rotate(${objectBoundingBox.rotation}rad)`,
      `clip-path: ${toCssClipPath(object)}`,
    ];
    return styles.join(';');
  }

  private getPostSelectionRegion(box: CenterRotatedBox):
      PostSelectionBoundingBox {
    const boundingBox = box.box;
    const top = boundingBox.y - (boundingBox.height / 2);
    const left = boundingBox.x - (boundingBox.width / 2);
    return {
      top,
      left,
      width: boundingBox.width,
      height: boundingBox.height,
    };
  }

  /**
   * @return Returns the index in renderedObjects of the object at the given
   *     point. Returns null if no object is at the given point.
   */
  private objectIndexFromPoint(x: number, y: number): number|null {
    // Find the top-most element at the clicked point that is an object.
    // elementFromPoint() may select non-object elements that have a higher
    // z-index.
    const elementsAtPoint = this.shadowRoot!.elementsFromPoint(x, y);
    for (const element of elementsAtPoint) {
      if (!(element instanceof HTMLElement)) {
        continue;
      }
      const index = this.$.objectsContainer.indexForElement(element);
      if (index !== null) {
        return index;
      }
    }
    return null;
  }

  // Clears any animation state currently present on the canvas.
  private clearAndCancelAnimation() {
    // Clear and cancel any animations.
    for (let i = 0; i < this.fadeOutAnimations.length; i++) {
      this.fadeOutAnimations[i].cancel();
    }
    this.fadeOutAnimations = [];

    this.clearCanvas(this.context);
    for (let i = 0; i < this.fadeOutTimeoutIds.length; i++) {
      clearTimeout(this.fadeOutTimeoutIds[i]);
    }
    this.fadeOutTimeoutIds = [];
  }

  // Testing method to get the objects on the page.
  getObjectNodesForTesting() {
    return this.shadowRoot!.querySelectorAll<HTMLElement>('.object');
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'lens-object-layer': ObjectLayerElement;
  }
}

customElements.define(ObjectLayerElement.is, ObjectLayerElement);