Merge "Introduce components for new image diff UI"
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index 48e729f..7ef9473 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -247,6 +247,38 @@
 ----
 
 
+[[DefinitelyTyped]]
+DefinitelyTyped
+
+* @types/resize-observer-browser
+
+[[DefinitelyTyped_license]]
+----
+    MIT License
+
+    Copyright (c) Microsoft Corporation.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in all
+    copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+    SOFTWARE
+
+----
+
+
 [[Polymer-2014]]
 Polymer-2014
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index b7cdf8a..5f0fb65 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -3204,6 +3204,38 @@
 ----
 
 
+[[DefinitelyTyped]]
+DefinitelyTyped
+
+* @types/resize-observer-browser
+
+[[DefinitelyTyped_license]]
+----
+    MIT License
+
+    Copyright (c) Microsoft Corporation.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in all
+    copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+    SOFTWARE
+
+----
+
+
 [[Polymer-2014]]
 Polymer-2014
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
new file mode 100644
index 0000000..42268e9
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
@@ -0,0 +1,304 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+  css,
+  customElement,
+  html,
+  internalProperty,
+  LitElement,
+  property,
+  PropertyValues,
+  query,
+} from 'lit-element';
+import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
+
+import {Dimensions, fitToFrame, Point, Rect} from './util';
+
+/**
+ * Displays a scaled-down version of an image with a draggable frame for
+ * choosing a portion of the image to be magnified by other components.
+ *
+ * Slotted content can be arbitrary elements, but should be limited to images or
+ * stacks of image-like elements (e.g. for overlays) with limited interactivity,
+ * to prevent confusion, as the component only captures a limited set of events.
+ * Slotted content is scaled to fit the bounds of the component, with
+ * letterboxing if aspect ratios differ. For slotted content smaller than the
+ * component, it will cap the scale at 1x and also apply letterboxing.
+ */
+@customElement('gr-overview-image')
+export class GrOverviewImage extends LitElement {
+  @property({type: Object})
+  frameRect: Rect = {origin: {x: 0, y: 0}, dimensions: {width: 0, height: 0}};
+
+  @internalProperty() protected contentStyle: StyleInfo = {};
+
+  @internalProperty() protected contentTransformStyle: StyleInfo = {};
+
+  @internalProperty() protected frameStyle: StyleInfo = {};
+
+  @internalProperty() protected overlayStyle: StyleInfo = {};
+
+  @internalProperty() protected dragging = false;
+
+  @query('.content-box') protected contentBox!: HTMLDivElement;
+
+  @query('.content') protected content!: HTMLDivElement;
+
+  @query('.content-transform') protected contentTransform!: HTMLDivElement;
+
+  @query('.frame') protected frame!: HTMLDivElement;
+
+  private contentBounds: Dimensions = {width: 0, height: 0};
+
+  private imageBounds: Dimensions = {width: 0, height: 0};
+
+  private scale = 1;
+
+  // When grabbing the frame to drag it around, this stores the offset of the
+  // cursor from the center of the frame at the start of the drag.
+  private grabOffset: Point = {x: 0, y: 0};
+
+  private readonly resizeObserver = new ResizeObserver(
+    (entries: ResizeObserverEntry[]) => {
+      for (const entry of entries) {
+        if (entry.target === this.contentBox) {
+          this.contentBounds = {
+            width: entry.contentRect.width,
+            height: entry.contentRect.height,
+          };
+        }
+        if (entry.target === this.contentTransform) {
+          this.imageBounds = {
+            width: entry.contentRect.width,
+            height: entry.contentRect.height,
+          };
+        }
+        this.updateScale();
+      }
+    }
+  );
+
+  static styles = css`
+    :host {
+      --overview-image-background-color: #000;
+      --overview-image-frame-color: #f00;
+      display: flex;
+    }
+    * {
+      box-sizing: border-box;
+    }
+    ::slotted(*) {
+      display: block;
+    }
+    .content-box {
+      border: 1px solid var(--overview-image-background-color);
+      background-color: var(--overview-iamge-background-color);
+      width: 100%;
+      position: relative;
+    }
+    .content {
+      position: absolute;
+      cursor: pointer;
+    }
+    .content-transform {
+      position: absolute;
+      transform-origin: top left;
+      will-change: transform;
+    }
+    .frame {
+      border: 1px solid var(--overview-image-frame-color);
+      position: absolute;
+      will-change: transform;
+    }
+    .overlay {
+      position: absolute;
+      z-index: 10000;
+      cursor: grabbing;
+    }
+  `;
+
+  render() {
+    return html`
+      <div class="content-box">
+        <div
+          class="content"
+          style="${styleMap({
+            ...this.contentStyle,
+          })}"
+          @mousemove="${this.maybeDragFrame}"
+          @mousedown=${this.clickOverview}
+          @mouseup="${this.releaseFrame}"
+        >
+          <div
+            class="content-transform"
+            style="${styleMap(this.contentTransformStyle)}"
+          >
+            <slot></slot>
+          </div>
+          <div
+            class="frame"
+            style="${styleMap({
+              ...this.frameStyle,
+              cursor: this.dragging ? 'grabbing' : 'grab',
+            })}"
+            @mousedown="${this.grabFrame}"
+          ></div>
+        </div>
+        <div
+          class="overlay"
+          style="${styleMap({
+            ...this.overlayStyle,
+            display: this.dragging ? 'block' : 'none',
+          })}"
+          @mousemove="${this.overlayMouseMove}"
+          @mouseleave="${this.releaseFrame}"
+          @mouseup="${this.releaseFrame}"
+        ></div>
+      </div>
+    `;
+  }
+
+  firstUpdated() {
+    this.resizeObserver.observe(this.contentBox);
+    this.resizeObserver.observe(this.contentTransform);
+  }
+
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('frameRect')) {
+      this.updateFrameStyle();
+    }
+  }
+
+  clickOverview(event: MouseEvent) {
+    event.preventDefault();
+
+    this.updateOverlaySize();
+
+    this.dragging = true;
+    const rect = this.content.getBoundingClientRect();
+    this.notifyNewCenter({
+      x: (event.clientX - rect.left) / this.scale,
+      y: (event.clientY - rect.top) / this.scale,
+    });
+  }
+
+  grabFrame(event: MouseEvent) {
+    event.preventDefault();
+    // Do not bubble up into clickOverview().
+    event.stopPropagation();
+
+    this.updateOverlaySize();
+
+    this.dragging = true;
+    const rect = this.frame.getBoundingClientRect();
+    const frameCenterX = rect.x + rect.width / 2;
+    const frameCenterY = rect.y + rect.height / 2;
+    this.grabOffset = {
+      x: event.clientX - frameCenterX,
+      y: event.clientY - frameCenterY,
+    };
+  }
+
+  maybeDragFrame(event: MouseEvent) {
+    event.preventDefault();
+    if (!this.dragging) return;
+    const rect = this.content.getBoundingClientRect();
+    const center = {
+      x: (event.clientX - rect.left - this.grabOffset.x) / this.scale,
+      y: (event.clientY - rect.top - this.grabOffset.y) / this.scale,
+    };
+    this.notifyNewCenter(center);
+  }
+
+  releaseFrame(event: MouseEvent) {
+    event.preventDefault();
+    this.dragging = false;
+    this.grabOffset = {x: 0, y: 0};
+  }
+
+  overlayMouseMove(event: MouseEvent) {
+    event.preventDefault();
+    this.maybeDragFrame(event);
+  }
+
+  private updateScale() {
+    const fitted = fitToFrame(this.imageBounds, this.contentBounds);
+    this.scale = fitted.scale;
+
+    this.contentStyle = {
+      ...this.contentStyle,
+      top: `${fitted.top}px`,
+      left: `${fitted.left}px`,
+      width: `${fitted.width}px`,
+      height: `${fitted.height}px`,
+    };
+
+    this.contentTransformStyle = {
+      transform: `scale(${this.scale})`,
+    };
+
+    this.updateFrameStyle();
+  }
+
+  private updateFrameStyle() {
+    const x = this.frameRect.origin.x * this.scale;
+    const y = this.frameRect.origin.y * this.scale;
+    const width = this.frameRect.dimensions.width * this.scale;
+    const height = this.frameRect.dimensions.height * this.scale;
+    this.frameStyle = {
+      ...this.frameStyle,
+      transform: `translate(${x}px, ${y}px)`,
+      width: `${width}px`,
+      height: `${height}px`,
+    };
+  }
+
+  private updateOverlaySize() {
+    const rect = this.contentBox.getBoundingClientRect();
+    // Create a whole-page overlay to capture mouse events, so that the drag
+    // interaction continues until the user releases the mouse button. Since
+    // innerWidth and innerHeight include scrollbars, we subtract 20 pixels each
+    // to prevent the overlay from extending offscreen under any existing
+    // scrollbar and causing the scrollbar for the other dimension to show up
+    // unnecessarily.
+    const width = window.innerWidth - 20;
+    const height = window.innerHeight - 20;
+    this.overlayStyle = {
+      ...this.overlayStyle,
+      top: `-${rect.top + 1}px`,
+      left: `-${rect.left + 1}px`,
+      width: `${width}px`,
+      height: `${height}px`,
+    };
+  }
+
+  private notifyNewCenter(center: Point) {
+    this.dispatchEvent(
+      new CustomEvent('center-updated', {
+        detail: {...center},
+        bubbles: true,
+        composed: true,
+      })
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-overview-image': GrOverviewImage;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts
new file mode 100644
index 0000000..a14a9cc
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts
@@ -0,0 +1,95 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+  css,
+  customElement,
+  html,
+  internalProperty,
+  LitElement,
+  property,
+  PropertyValues,
+} from 'lit-element';
+import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
+import {Rect} from './util';
+
+/**
+ * Displays its slotted content at a given scale, centered over a given point,
+ * while ensuring the content always fills the container. The content does not
+ * have to be a single image, it can be arbitrary HTML. To prevent user
+ * confusion, it should ideally be image-like, i.e. have limited or no
+ * interactivity, as the component does not prevent events or focus from
+ * reaching the slotted content.
+ */
+@customElement('gr-zoomed-image')
+export class GrZoomedImage extends LitElement {
+  @property({type: Number}) scale = 1;
+
+  @property({type: Object})
+  frameRect: Rect = {origin: {x: 0, y: 0}, dimensions: {width: 0, height: 0}};
+
+  @internalProperty() protected imageStyles: StyleInfo = {};
+
+  static styles = css`
+    :host {
+      display: block;
+    }
+    ::slotted(*) {
+      display: block;
+    }
+    #clip {
+      position: relative;
+      width: 100%;
+      height: 100%;
+      overflow: hidden;
+    }
+    #transform {
+      position: absolute;
+      transform-origin: top left;
+      will-change: transform;
+    }
+  `;
+
+  render() {
+    return html`
+      <div id="clip">
+        <div id="transform" style="${styleMap(this.imageStyles)}">
+          <slot></slot>
+        </div>
+      </div>
+    `;
+  }
+
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('scale') || changedProperties.has('frameRect')) {
+      this.updateImageStyles();
+    }
+  }
+
+  private updateImageStyles() {
+    const {x, y} = this.frameRect.origin;
+    this.imageStyles = {
+      'image-rendering': this.scale >= 1 ? 'pixelated' : 'auto',
+      transform: `translate(${-x}px, ${-y}px) scale(${this.scale})`,
+    };
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-zoomed-image': GrZoomedImage;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
new file mode 100644
index 0000000..b42eea9
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
@@ -0,0 +1,236 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface Point {
+  x: number;
+  y: number;
+}
+
+export interface Dimensions {
+  width: number;
+  height: number;
+}
+
+export interface Rect {
+  origin: Point;
+  dimensions: Dimensions;
+}
+
+export interface FittedContent {
+  top: number;
+  left: number;
+  width: number;
+  height: number;
+  scale: number;
+}
+
+function clamp(value: number, min: number, max: number) {
+  return Math.max(min, Math.min(value, max));
+}
+
+/**
+ * Fits content of the given dimensions into the given frame, maintaining the
+ * aspect ratio of the content and applying letterboxing / pillarboxing as
+ * needed.
+ */
+export function fitToFrame(
+  content: Dimensions,
+  frame: Dimensions
+): FittedContent {
+  const contentAspectRatio = content.width / content.height;
+  const frameAspectRatio = frame.width / frame.height;
+  // If the content is wider than the frame, it will be letterboxed, otherwise
+  // it will be pillarboxed. When letterboxed, content and frame width will
+  // match exactly, when pillarboxed, content and frame height will match
+  // exactly.
+  const isLetterboxed = contentAspectRatio > frameAspectRatio;
+  let width: number;
+  let height: number;
+  if (isLetterboxed) {
+    width = Math.min(frame.width, content.width);
+    height = content.height * (width / content.width);
+  } else {
+    height = Math.min(frame.height, content.height);
+    width = content.width * (height / content.height);
+  }
+  const top = (frame.height - height) / 2;
+  const left = (frame.width - width) / 2;
+  const scale = width / content.width;
+  return {top, left, width, height, scale};
+}
+
+function ensureInBounds(part: Rect, bounds: Dimensions): Rect {
+  const x =
+    part.dimensions.width <= bounds.width
+      ? clamp(part.origin.x, 0, bounds.width - part.dimensions.width)
+      : (bounds.width - part.dimensions.width) / 2;
+  const y =
+    part.dimensions.height <= bounds.height
+      ? clamp(part.origin.y, 0, bounds.height - part.dimensions.height)
+      : (bounds.height - part.dimensions.height) / 2;
+  return {origin: {x, y}, dimensions: part.dimensions};
+}
+
+/**
+ * Maintains a given frame inside given bounds, adjusting requested positions
+ * for the frame as needed. This supports the non-destructive application of a
+ * scaling factor, so that e.g. the magnification of an image can be changed
+ * easily while keeping the frame centered over the same spot. Changing bounds
+ * or frame size also keeps the frame position when possible.
+ */
+export class FrameConstrainer {
+  private center: Point = {x: 0, y: 0};
+
+  private frameSize: Dimensions = {width: 0, height: 0};
+
+  private bounds: Dimensions = {width: 0, height: 0};
+
+  private scale = 1;
+
+  private unscaledFrame: Rect = {
+    origin: {x: 0, y: 0},
+    dimensions: {width: 0, height: 0},
+  };
+
+  private scaledFrame: Rect = {
+    origin: {x: 0, y: 0},
+    dimensions: {width: 0, height: 0},
+  };
+
+  getCenter(): Point {
+    return {...this.center};
+  }
+
+  /**
+   * Returns the frame at its original size, positioned within the given bounds
+   * at the given scale; its origin will be in scaled bounds coordinates.
+   *
+   * Ex: for given bounds 100x50 and frame size 30x20, centered over (50, 25),
+   * all at 1x scale, when setting scale to 2, this will return a frame of size
+   * 30x20, centered over (100, 50), within bounds 200x100.
+   *
+   * Useful for positioning a viewport of fixed size over a magnified image.
+   */
+  getUnscaledFrame(): Rect {
+    return {
+      origin: {...this.unscaledFrame.origin},
+      dimensions: {...this.unscaledFrame.dimensions},
+    };
+  }
+
+  /**
+   * Returns the scaled down frame–a scale of 2 will result in frame dimensions
+   * being halved—position within the given bounds at 1x scale; its origin will
+   * be in unscaled bounds coordinates.
+   *
+   * Ex: for given bounds 100x50 and frame size 30x20, centered over (50, 25),
+   * all at 1x scale, when setting scale to 2, this will return a frame of size
+   * 15x10, centered over (50, 25), within bounds 100x50.
+   *
+   * Useful for highlighting the magnified portion of an image as determined by
+   * getUnscaledFrame() in an overview image of fixed size.
+   */
+  getScaledFrame(): Rect {
+    return {
+      origin: {...this.scaledFrame.origin},
+      dimensions: {...this.scaledFrame.dimensions},
+    };
+  }
+
+  /**
+   * Requests the frame to be centered over the given point, in unscaled bounds
+   * coordinates. This will keep the frame within the given bounds, also when
+   * requesting a center point fully outside the given bounds.
+   */
+  requestCenter(center: Point) {
+    this.center = {...center};
+
+    this.ensureFrameInBounds();
+  }
+
+  /**
+   * Sets the frame size, while keeping the frame within the given bounds, and
+   * maintaining the current center if possible.
+   */
+  setFrameSize(frameSize: Dimensions) {
+    if (frameSize.width <= 0 || frameSize.height <= 0) return;
+    this.frameSize = {...frameSize};
+
+    this.ensureFrameInBounds();
+  }
+
+  /**
+   * Sets the bounds, while keeping the frame within them, and maintaining the
+   * current center if possible.
+   */
+  setBounds(bounds: Dimensions) {
+    if (bounds.width <= 0 || bounds.height <= 0) return;
+    this.bounds = {...bounds};
+
+    this.ensureFrameInBounds();
+  }
+
+  /**
+   * Sets the applied scale, while keeping the frame within the given bounds,
+   * and maintaining the current center if possible (both relevant moving from
+   * a larger scale to a smaller scale).
+   */
+  setScale(scale: number) {
+    if (!scale || scale <= 0) return;
+    this.scale = scale;
+
+    this.ensureFrameInBounds();
+  }
+
+  private ensureFrameInBounds() {
+    const scaledCenter = {
+      x: this.center.x * this.scale,
+      y: this.center.y * this.scale,
+    };
+    const scaledBounds = {
+      width: this.bounds.width * this.scale,
+      height: this.bounds.height * this.scale,
+    };
+    const scaledFrameSize = {
+      width: this.frameSize.width / this.scale,
+      height: this.frameSize.height / this.scale,
+    };
+
+    const requestedUnscaledFrame = {
+      origin: {
+        x: scaledCenter.x - this.frameSize.width / 2,
+        y: scaledCenter.y - this.frameSize.height / 2,
+      },
+      dimensions: this.frameSize,
+    };
+    const requestedScaledFrame = {
+      origin: {
+        x: this.center.x - scaledFrameSize.width / 2,
+        y: this.center.y - scaledFrameSize.height / 2,
+      },
+      dimensions: scaledFrameSize,
+    };
+
+    this.unscaledFrame = ensureInBounds(requestedUnscaledFrame, scaledBounds);
+    this.scaledFrame = ensureInBounds(requestedScaledFrame, this.bounds);
+
+    this.center = {
+      x: this.scaledFrame.origin.x + this.scaledFrame.dimensions.width / 2,
+      y: this.scaledFrame.origin.y + this.scaledFrame.dimensions.height / 2,
+    };
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js
new file mode 100644
index 0000000..80cfa36
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js
@@ -0,0 +1,171 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../test/common-test-setup-karma.js';
+import {FrameConstrainer} from './util.js';
+
+suite('FrameConstrainer tests', () => {
+  let constrainer;
+
+  setup(() => {
+    constrainer = new FrameConstrainer();
+    constrainer.setBounds({width: 100, height: 100});
+    constrainer.setFrameSize({width: 50, height: 50});
+    constrainer.requestCenter({x: 50, y: 50});
+  });
+
+  suite('changing center', () => {
+    test('moves frame to requested position', () => {
+      constrainer.requestCenter({x: 30, y: 30});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 5, y: 5}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('keeps frame in bounds for top left corner', () => {
+      constrainer.requestCenter({x: 5, y: 5});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 0, y: 0}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('keeps frame in bounds for bottom right corner', () => {
+      constrainer.requestCenter({x: 95, y: 95});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center left', () => {
+      constrainer.requestCenter({x: -5, y: 50});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 0, y: 25}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center right', () => {
+      constrainer.requestCenter({x: 105, y: 50});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 50, y: 25}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center top', () => {
+      constrainer.requestCenter({x: 50, y: -5});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 25, y: 0}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center bottom', () => {
+      constrainer.requestCenter({x: 50, y: 105});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 25, y: 50}, dimensions: {width: 50, height: 50}});
+    });
+  });
+
+  suite('changing frame size', () => {
+    test('maintains center when decreased', () => {
+      constrainer.setFrameSize({width: 10, height: 10});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 45, y: 45}, dimensions: {width: 10, height: 10}});
+    });
+
+    test('maintains center when increased', () => {
+      constrainer.setFrameSize({width: 80, height: 80});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 10, y: 10}, dimensions: {width: 80, height: 80}});
+    });
+
+    test('updates center to remain in bounds when increased', () => {
+      constrainer.setFrameSize({width: 10, height: 10});
+      constrainer.requestCenter({x: 95, y: 95});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 90, y: 90}, dimensions: {width: 10, height: 10}});
+
+      constrainer.setFrameSize({width: 20, height: 20});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 80, y: 80}, dimensions: {width: 20, height: 20}});
+    });
+  });
+
+  suite('changing scale', () => {
+    suite('for unscaled frame', () => {
+      test('adjusts origin to maintain center when zooming in', () => {
+        constrainer.setScale(2);
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 75, y: 75}, dimensions: {width: 50, height: 50}});
+      });
+
+      test('adjusts origin to maintain center when zooming out', () => {
+        constrainer.setFrameSize({width: 20, height: 20});
+        constrainer.setScale(0.5);
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 15, y: 15}, dimensions: {width: 20, height: 20}});
+      });
+
+      test('keeps frame in bounds when zooming out', () => {
+        constrainer.setScale(5);
+        constrainer.requestCenter({x: 100, y: 100});
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 450, y: 450}, dimensions: {width: 50, height: 50}});
+
+        constrainer.setScale(1);
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
+      });
+    });
+
+    suite('for scaled frame', () => {
+      test('decreases frame size and maintains center when zooming in', () => {
+        constrainer.setScale(2);
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 37.5, y: 37.5}, dimensions: {width: 25, height: 25}});
+      });
+
+      test('increases frame size and maintains center when zooming out', () => {
+        constrainer.setFrameSize({width: 20, height: 20});
+        constrainer.setScale(0.5);
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 30, y: 30}, dimensions: {width: 40, height: 40}});
+      });
+
+      test('keeps frame in bounds when zooming out', () => {
+        constrainer.setScale(5);
+        constrainer.requestCenter({x: 100, y: 100});
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 90, y: 90}, dimensions: {width: 10, height: 10}});
+
+        constrainer.setScale(1);
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
+      });
+    });
+  });
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 7a6253b..f03c7e6 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -254,6 +254,14 @@
     license: SharedLicenses.Polymer2017
   },
   {
+    name: "@types/resize-observer-browser",
+    license: {
+      name: 'DefinitelyTyped',
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE"
+    }
+  },
+  {
     name: "@webcomponents/shadycss",
     license: SharedLicenses.Polymer2017
   },
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 3351386..5e15990 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -25,6 +25,7 @@
     "@polymer/paper-tabs": "^3.1.0",
     "@polymer/paper-toggle-button": "^3.0.1",
     "@polymer/polymer": "^3.4.1",
+    "@types/resize-observer-browser": "^0.1.5",
     "@webcomponents/shadycss": "^1.9.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
     "ba-linkify": "file:../../lib/ba-linkify/src/",
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index e544dbc..ec3b7a0 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -324,6 +324,11 @@
   dependencies:
     "@webcomponents/shadycss" "^1.9.1"
 
+"@types/resize-observer-browser@^0.1.5":
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.5.tgz#36d897708172ac2380cd486da7a3daf1161c1e23"
+  integrity sha512-8k/67Z95Goa6Lznuykxkfhq9YU3l1Qe6LNZmwde1u7802a3x8v44oq0j91DICclxatTr0rNnhXx7+VTIetSrSQ==
+
 "@webcomponents/shadycss@^1.9.1", "@webcomponents/shadycss@^1.9.2":
   version "1.9.4"
   resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.9.4.tgz#4f9d8ea1526bab084c60b53d4854dc39fdb2bb48"