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

import {assert, assertInstanceof, assertNotReached} 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 {getFallbackTheme, getShaderLayerColorHexes} from './color_utils.js';
import type {OverlayTheme} from './lens.mojom-webui.js';
import {getTemplate} from './overlay_shimmer.html.js';
import type {OverlayShimmerFocusedRegion, OverlayShimmerUnfocusRegion} from './selection_utils.js';
import {ShimmerControlRequester} from './selection_utils.js';
import {toPercent} from './values_converter.js';

interface CircleProperties {
  // The HEX value to color the circle.
  colorHex: string;
  // A number between 0-100 that represents the x position of this circles
  // steady state, relative to the parent rect.
  steadyStateCenterX: number;
  // A number between 0-100 that represents the y position of this circles
  // steady state, relative to the parent rect.
  steadyStateCenterY: number;
}

// INVOCATION CONSTANTS: These are the values that the circles will have on
// the initial invocation, which then transition to the steady state constants.
const INVOCATION_OPACITY_PERCENT = '0%';
const INVOCATION_RADIUS_PERCENT = '0%';
const INVOCATION_CENTER_X_PERCENT = '50%';
const INVOCATION_CENTER_Y_PERCENT = '0%';
// Amplitude is the amount amount of randomness that is applied to each value.
// For example, if the base radius is 10% and radius amplitude is 10%, the
// actual rendered radius can be between 0% and 20%.
const INVOCATION_RADIUS_AMPLITUDE_PERCENT = '0%';
const INVOCATION_CENTER_X_AMPLITUDE_PERCENT = '0%';
const INVOCATION_CENTER_Y_AMPLITUDE_PERCENT = '0%';

// STEADY STATE CONSTANTS: These are the values that the circles will have when
// no other state is being applied.
const STEADY_STATE_OPACITY_PERCENT = '30%';
const STEADY_STATE_RADIUS_PERCENT = '21%';
// The blur value is relative to the radius of the circle. A value of 2, doubles
// the circle radius to visually make the circle more blurred across the screen.
// The blur value should always be greater than 1.
const STEADY_STATE_CIRCLE_BLUR = '2';
// All circles Wiggle around a different position in the steady state. These
// offset control how far from the center the randomized position can be. For
// example, a 10% offset on the X axis means the X value of the randomized
// position can be between 40% and 60%.
const STEADY_STATE_CENTER_X_PERCENT_OFFSET = '50%';
const STEADY_STATE_CENTER_Y_PERCENT_OFFSET = '30%';
// Amplitude is the amount amount of randomness that is applied to each value.
// For example, if the base radius is 10% and radius amplitude is 10%, the
// actual rendered radius can be between 0% and 20%.
const STEADY_STATE_RADIUS_AMPLITUDE_PERCENT = '0%';
const STEADY_STATE_CENTER_X_AMPLITUDE_PERCENT = '21%';
const STEADY_STATE_CENTER_Y_AMPLITUDE_PERCENT = '21%';

// INTERACTION STATE CONSTANTS: These are the values that the circles will have
// the circles are interacting with the interaction the user is making (via
// their cursor, on a post selection bounding box, etc).
const INTERACTION_STATE_OPACITY_PERCENT = '40%';

// CURSOR STATE CONSTANTS: These are the values that are only applied when the
// shimmer is following the cursor.
// The cursor radius is relative to the size of the cursor icon.
const CURSOR_STATE_RADIUS_PERCENT = '80%';
// The blur value is relative to the radius of the circle. A value of 2, doubles
// the circle radius to visually make the circle more blurred across the screen.
// The blur value should always be greater than 1.
const CURSOR_STATE_CIRCLE_BLUR = '2.25';
// Amplitude is the amount amount of randomness that is applied to each value.
// For example, if the base radius is 10% and radius amplitude is 10%, the
// actual rendered radius can be between 0% and 20%.
const CURSOR_STATE_RADIUS_AMPLITUDE_PERCENT = '1.5%';
const CURSOR_STATE_CENTER_X_AMPLITUDE_PERCENT = '1.5%';
const CURSOR_STATE_CENTER_Y_AMPLITUDE_PERCENT = '1.5%';
// The time it takes in MS to transition from the steady state to the cursor
// state.
export const CURSOR_STATE_INITIAL_FOCUS_DURATION = 1000;
// The time it takes in MS to transition from any non steady state to the cursor
// state.
const CURSOR_STATE_FOCUS_DURATION = 750;

// REGION SELECTION STATE CONSTANTS: These are the values that are only applied
// when the shimmer is focusing on a selected region. In the region selection
// state, these values are in relation to the bounding box smallest size, rather
// than the entire viewport.
const REGION_SELECTION_STATE_RADIUS_PERCENT = '45%';
const REGION_SELECTION_STATE_CIRCLE_BLUR = '1.8';
const REGION_SELECTION_STATE_RADIUS_AMPLITUDE_PERCENT = '0%';
const REGION_SELECTION_STATE_CENTER_X_AMPLITUDE_PERCENT = '40%';
const REGION_SELECTION_STATE_CENTER_Y_AMPLITUDE_PERCENT = '40%';
// The time it takes in MS to transition from a different state to the  region
// selection state.
const REGION_SELECTION_STATE_FOCUS_DURATION = 750;

// The time (in MS) to wait between updating the sparkle seed.
const SPARKLE_MOTION_TIMEOUT_MS = 100;

export interface OverlayShimmerElement {
  $: {
    circlesContainer: DomRepeat,
  };
}

/*
 * Controls the shimmer overlaid on the selection elements. The shimmer is
 * composed of multiple circles, each following a randomized pattern. Some
 * properties like the randomized movement, are per circle and controlled by
 * each circle, not this class. This class is responsible for controlling
 * behavior shared by each circle, like their general positioning and opacity.
 */
export class OverlayShimmerElement extends PolymerElement {
  static get is() {
    return 'overlay-shimmer';
  }

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

  static get properties() {
    return {
      circles: Array,
      isSteadyState: Boolean,
      isWiggling: Boolean,
      sparkleSeed: Number,
      enableSparkles: {
        type: Boolean,
        reflectToAttribute: true,
      },
      shaderLayerColorHexes: {
        type: Array,
        computed: 'computeShaderLayerColorHexes_(theme)',
      },
      theme: {
        type: Object,
        value: getFallbackTheme,
      },
    };
  }

  // The properties of circles currently being rendered.
  private circles: CircleProperties[] = [];
  // Whether the circles are in the steady state or not.
  private isSteadyState: boolean = true;
  // Whether the circles should be applying wiggle.
  private isWiggling: boolean = true;
  // The current seed of the sparkling noise.
  private sparkleSeed: number = 0;
  // Whether the sparkles are enabled or not.
  private enableSparkles: boolean =
      loadTimeData.getBoolean('enableShimmerSparkles');
  // The overlay theme.
  private theme: OverlayTheme;
  // Shader hex colors.
  private shaderLayerColorHexes: string[];

  // Event tracker for receiving DOM events.
  private eventTracker_: EventTracker = new EventTracker();
  // Stack the represents the current requesters for control. Once control is
  // relinquished, the next highest priority requester in the stack gains
  // control. If no one is in the stack, returns to steady state.
  private shimmerControllerStack: ShimmerControlRequester[] =
      [ShimmerControlRequester.NONE];
  // The last requester the created an animation. This might be different than
  // who currently has control, if control was relinquished between animations.
  private lastShimmerAnimator: ShimmerControlRequester =
      ShimmerControlRequester.NONE;
  // Used to put the shimmer back on the post selection after shimmer focuses on
  // another object, (like Segmentation).
  private previousPostSelection?: OverlayShimmerFocusedRegion;
  // Last animation that was triggered to play. Undefined if no animation has
  // triggered yet. The animation could be finished playing already.
  private lastAnimation?: Animation;
  // Whether the results are showing or not.
  private areResultsShowing: boolean = false;
  // The time stamp in MS of the last time the sparkles were animated.
  private lastSparkleTime: number = 0;

  // Listener ids for events from the browser side.
  private listenerIds: number[];


  override connectedCallback() {
    super.connectedCallback();
    this.eventTracker_.add(
        document, 'focus-region',
        (e: CustomEvent<OverlayShimmerFocusedRegion>) => {
          this.onFocusRegion(e);
        });
    this.eventTracker_.add(
        document, 'unfocus-region',
        (e: CustomEvent<OverlayShimmerUnfocusRegion>) => {
          this.onUnfocusRegion(e);
        });

    this.listenerIds = [
      BrowserProxyImpl.getInstance()
          .callbackRouter.notifyResultsPanelOpened.addListener(() => {
            this.areResultsShowing = true;
          }),
    ];
  }

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

  private computeShaderLayerColorHexes_() {
    return getShaderLayerColorHexes(this.theme);
  }

  async startAnimation() {
    // Set the invocation values.
    this.style.setProperty(
        '--shimmer-circle-radius', INVOCATION_RADIUS_PERCENT);
    this.style.setProperty(
        '--shimmer-circle-radius-amplitude',
        INVOCATION_RADIUS_AMPLITUDE_PERCENT);
    this.style.setProperty(
        '--shimmer-circle-center-x-amplitude',
        INVOCATION_CENTER_X_AMPLITUDE_PERCENT);
    this.style.setProperty(
        '--shimmer-circle-center-y-amplitude',
        INVOCATION_CENTER_Y_AMPLITUDE_PERCENT);
    this.style.opacity = INVOCATION_OPACITY_PERCENT;

    // Since each circles initial position is different, the
    // ShimmerCircleElement is responsible for animating to the steady state
    // center position. We set the initial value since that is shared between
    // all circles.
    this.style.setProperty(
        '--shimmer-circle-center-x', INVOCATION_CENTER_X_PERCENT);
    this.style.setProperty(
        '--shimmer-circle-center-y', INVOCATION_CENTER_Y_PERCENT);

    // Circle blur does not animate on invocation.
    this.style.setProperty('--shimmer-circle-blur', STEADY_STATE_CIRCLE_BLUR);

    // Start the random noise generation of the seed.
    this.startSparkles();

    // Allow the above styles to take effect.
    requestAnimationFrame(() => {
      const centerXOffsetInt = parseInt(STEADY_STATE_CENTER_X_PERCENT_OFFSET);
      const centerYOffsetInt = parseInt(STEADY_STATE_CENTER_Y_PERCENT_OFFSET);
      // Create a circle for each colorHex defined.
      this.circles = this.shaderLayerColorHexes.map((colorHex: string) => {
        return {
          colorHex,
          steadyStateCenterX: 50 - centerXOffsetInt * (Math.random() * 2 - 1),
          steadyStateCenterY: 50 - centerYOffsetInt * (Math.random() * 2 - 1),
        };
      });

      // Animate to the steady state.
      this.transitionToSteadyState();
    });
  }

  private startSparkles() {
    if (this.enableSparkles) {
      this.updateSparkles(0);
    }
  }

  private updateSparkles(time: number) {
    const delta = time - this.lastSparkleTime;
    if (delta > SPARKLE_MOTION_TIMEOUT_MS) {
      this.lastSparkleTime = time;
      this.sparkleSeed = Math.floor(Math.random() * 10);
    }

    requestAnimationFrame(this.updateSparkles.bind(this));
  }

  private onFocusRegion(e: CustomEvent<OverlayShimmerFocusedRegion>) {
    const centerX = e.detail.left + e.detail.width / 2;
    const centerY = e.detail.top + e.detail.height / 2;

    // Ignore invalid regions.
    if (centerX <= 0 || centerY <= 0) {
      return;
    }

    // Save the post selection in case we need to move the shimmer back to it.
    if (e.detail.requester === ShimmerControlRequester.POST_SELECTION) {
      // Include the post selection into the stack if it is not already present.
      if (!this.shimmerControllerStack.includes(
              ShimmerControlRequester.POST_SELECTION)) {
        this.shimmerControllerStack.push(
            ShimmerControlRequester.POST_SELECTION);
        this.shimmerControllerStack.sort();
      }
      this.previousPostSelection = e.detail;
    }

    this.focusRegion(
        centerX, centerY, e.detail.width, e.detail.height, e.detail.requester);
  }

  private onUnfocusRegion(e: CustomEvent<OverlayShimmerUnfocusRegion>) {
    const index = this.shimmerControllerStack.indexOf(e.detail.requester);
    // Only relinquish control if the requester currently has control.
    if (index === -1) {
      return;
    }
    // Remove the control requester from the stack.
    this.shimmerControllerStack.splice(index, 1);

    const newCurrentController = this.getCurrentShimmerController();
    if (newCurrentController === ShimmerControlRequester.POST_SELECTION &&
        this.previousPostSelection) {
      // Target the shimmer back to the post selection.
      const centerX = this.previousPostSelection.left +
          this.previousPostSelection.width / 2;
      const centerY = this.previousPostSelection.top +
          this.previousPostSelection.height / 2;
      this.focusRegion(
          centerX, centerY, this.previousPostSelection.width,
          this.previousPostSelection.height,
          this.previousPostSelection.requester);
    } else if (newCurrentController === ShimmerControlRequester.NONE) {
      if (!this.areResultsShowing) {
        this.transitionToSteadyState();
      } else {
        // Hide shimmer if user focusing on results.
        this.animate(
            [
              {
                opacity: '0%',
              },
            ],
            {
              duration: 150,
              easing: 'linear',
              fill: 'forwards',
            });
      }
    }
  }

  private async transitionToSteadyState() {
    this.isSteadyState = true;
    this.lastShimmerAnimator = ShimmerControlRequester.NONE;
    // Animate in the opacity
    this.animate(
        [
          {
            opacity: STEADY_STATE_OPACITY_PERCENT,
          },
        ],
        {
          duration: 150,
          easing: 'linear',
          fill: 'forwards',
        });
    this.lastAnimation = this.animate(
        [
          {
            [`--shimmer-circle-radius`]: STEADY_STATE_RADIUS_PERCENT,
            [`--shimmer-circle-radius-amplitude`]:
                STEADY_STATE_RADIUS_AMPLITUDE_PERCENT,
            [`--shimmer-circle-center-x-amplitude`]:
                STEADY_STATE_CENTER_X_AMPLITUDE_PERCENT,
            [`--shimmer-circle-center-y-amplitude`]:
                STEADY_STATE_CENTER_Y_AMPLITUDE_PERCENT,
          },
        ],
        {
          duration: 800,
          easing: 'cubic-bezier(0.05, 0.7, 0.1, 1.0)',
          fill: 'forwards',
        });
  }

  // Focuses the shimmer on a specific region of the screen. The inputted values
  // should be percentage values between 0-1 representing the region to focus.
  private async focusRegion(
      centerX: number, centerY: number, width: number, height: number,
      requester: ShimmerControlRequester) {
    // Ignore if the shimmering hasn't been triggered yet.
    if (!this.lastAnimation) {
      return;
    }

    const currentShimmerController = this.getCurrentShimmerController();
    if (currentShimmerController > requester) {
      // Ignore this request because the current controller has a higher
      // priority than the requester.
      return;
    } else if (currentShimmerController < requester) {
      this.shimmerControllerStack.push(requester);
    }

    const smallestLength = Math.min(width, height);
    const circleCenterX: string = toPercent(centerX);
    const circleCenterY: string = toPercent(centerY);
    let circleRadius: string;
    let circleBlur: string;
    let circleRadiusAmp: string;
    let circleCenterXAmp: string;
    let circleCenterYAmp: string;
    let duration: number;

    switch (requester) {
      case ShimmerControlRequester.SEGMENTATION:
        // Intended fall through since SEGMENTATION AND POST_SELECTION follow
        // the same values.
      case ShimmerControlRequester.POST_SELECTION:
        // Intended fall through since MANUAL_REGION AND POST_SELECTION follow
        // the same values.
      case ShimmerControlRequester.MANUAL_REGION:
        circleRadius = toPercent(
            parseInt(REGION_SELECTION_STATE_RADIUS_PERCENT) / 100 *
            smallestLength);
        circleBlur = REGION_SELECTION_STATE_CIRCLE_BLUR;
        circleRadiusAmp = toPercent(
            parseInt(REGION_SELECTION_STATE_RADIUS_AMPLITUDE_PERCENT) / 100 *
            smallestLength);
        circleCenterXAmp = toPercent(
            parseInt(REGION_SELECTION_STATE_CENTER_X_AMPLITUDE_PERCENT) / 100 *
            width);
        circleCenterYAmp = toPercent(
            parseInt(REGION_SELECTION_STATE_CENTER_Y_AMPLITUDE_PERCENT) / 100 *
            height);
        duration = REGION_SELECTION_STATE_FOCUS_DURATION;
        break;
      case ShimmerControlRequester.CURSOR:
        circleRadius = toPercent(
            parseInt(CURSOR_STATE_RADIUS_PERCENT) / 100 * smallestLength);
        circleBlur = CURSOR_STATE_CIRCLE_BLUR;
        circleRadiusAmp = CURSOR_STATE_RADIUS_AMPLITUDE_PERCENT;
        circleCenterXAmp = CURSOR_STATE_CENTER_X_AMPLITUDE_PERCENT;
        circleCenterYAmp = CURSOR_STATE_CENTER_Y_AMPLITUDE_PERCENT;
        // If we aren't already following the cursor, it should take time to get
        // to the cursor.
        if (this.lastShimmerAnimator !== ShimmerControlRequester.CURSOR) {
          duration = this.isSteadyState ? CURSOR_STATE_INITIAL_FOCUS_DURATION :
                                          CURSOR_STATE_FOCUS_DURATION;
          break;
        }
        // If there is currently a text animation, we should modify the center
        // position to be the new mouse position, and return so the animation
        // automatically catches up to the cursor even if it is moving.
        if (this.lastAnimation.playState === 'running') {
          const keyframeEffect = this.lastAnimation.effect;
          assertInstanceof(keyframeEffect, KeyframeEffect);

          // Replace the old center values in the animation with the new ones.
          const newKeyFrames = keyframeEffect.getKeyframes();
          newKeyFrames[0][`--shimmer-circle-center-x`] = circleCenterX;
          newKeyFrames[0][`--shimmer-circle-center-y`] = circleCenterY;
          keyframeEffect.setKeyframes(newKeyFrames);
          return;
        }
        // There is no active animation, and we aren't transitioning states, so
        // the duration should be 0
        duration = 0;
        break;
      default:
        assertNotReached();
    }

    // We should only stop wiggling in the post selection state.
    if (requester === ShimmerControlRequester.POST_SELECTION) {
      this.isWiggling = false;
    } else {
      this.isWiggling = true;
    }

    if (this.isSteadyState) {
      // On the initial transition from steady state to interaction state,
      // each shimmer circle sets their center coordinates to 'inherit'. We
      // need to specify a value to inherit from, if not the transition will be
      // instant and look bad.
      this.style.setProperty(`--shimmer-circle-center-x`, circleCenterX);
      this.style.setProperty(`--shimmer-circle-center-y`, circleCenterY);
    }

    this.lastShimmerAnimator = requester;
    this.isSteadyState = false;

    // We only need to specify the final values we want. The browser is smart
    // enough to interpret the initial values as the values already set.
    this.lastAnimation = this.animate(
        [
          {
            [`--shimmer-circle-radius`]: circleRadius,
            [`--shimmer-circle-blur`]: circleBlur,
            [`--shimmer-circle-center-x`]: circleCenterX,
            [`--shimmer-circle-center-y`]: circleCenterY,
            [`--shimmer-circle-radius-amplitude`]: circleRadiusAmp,
            [`--shimmer-circle-center-x-amplitude`]: circleCenterXAmp,
            [`--shimmer-circle-center-y-amplitude`]: circleCenterYAmp,
          },
        ],
        {
          duration,
          easing: 'cubic-bezier(0.2, 0.0, 0, 1.0)',
          fill: 'forwards',
        });
    this.animate(
        [
          {
            opacity: INTERACTION_STATE_OPACITY_PERCENT,
          },
        ],
        {
          duration: 150,
          easing: 'linear',
          fill: 'forwards',
        });
  }

  private getCurrentShimmerController(): ShimmerControlRequester {
    return this.shimmerControllerStack[this.shimmerControllerStack.length - 1];
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'overlay-shimmer': OverlayShimmerElement;
  }
}

customElements.define(OverlayShimmerElement.is, OverlayShimmerElement);

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

// Variables controlling the shimmer circles.
CSS.registerProperty({
  name: '--shimmer-circle-color',
  syntax: '<color>',
  inherits: false,
  initialValue: 'transparent',
});

CSS.registerProperty({
  name: '--shimmer-circle-opacity',
  syntax: '<percentage>',
  inherits: true,
  initialValue: '0%',
});

CSS.registerProperty({
  name: '--shimmer-circle-radius',
  syntax: '<percentage>',
  inherits: true,
  initialValue: '0%',
});

CSS.registerProperty({
  name: '--shimmer-circle-blur',
  syntax: '<number>',
  inherits: true,
  initialValue: '0',
});

CSS.registerProperty({
  name: '--shimmer-circle-center-x',
  syntax: '<percentage>',
  inherits: true,
  initialValue: '0%',
});

CSS.registerProperty({
  name: '--shimmer-circle-center-y',
  syntax: '<percentage>',
  inherits: true,
  initialValue: '0%',
});

CSS.registerProperty({
  name: '--shimmer-circle-radius-amplitude',
  syntax: '<percentage>',
  inherits: true,
  initialValue: '0%',
});

CSS.registerProperty({
  name: '--shimmer-circle-center-x-amplitude',
  syntax: '<percentage>',
  inherits: true,
  initialValue: '0%',
});

CSS.registerProperty({
  name: '--shimmer-circle-center-y-amplitude',
  syntax: '<percentage>',
  inherits: true,
  initialValue: '0%',
});

CSS.registerProperty({
  name: '--shimmer-circle-radius-wiggle',
  syntax: '<number>',
  inherits: false,
  initialValue: '0',
});

CSS.registerProperty({
  name: '--shimmer-circle-center-x-wiggle',
  syntax: '<number>',
  inherits: false,
  initialValue: '0',
});

CSS.registerProperty({
  name: '--shimmer-circle-center-y-wiggle',
  syntax: '<number>',
  inherits: false,
  initialValue: '0',
});