|  | /** | 
|  | * @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 '@polymer/paper-button/paper-button'; | 
|  | import '@polymer/paper-card/paper-card'; | 
|  | import '@polymer/paper-checkbox/paper-checkbox'; | 
|  | import '@polymer/paper-dropdown-menu/paper-dropdown-menu'; | 
|  | import '@polymer/paper-fab/paper-fab'; | 
|  | import '@polymer/paper-icon-button/paper-icon-button'; | 
|  | import '@polymer/paper-item/paper-item'; | 
|  | import '@polymer/paper-listbox/paper-listbox'; | 
|  | import './gr-overview-image'; | 
|  | import './gr-zoomed-image'; | 
|  |  | 
|  | import {GrLibLoader} from '../../shared/gr-lib-loader/gr-lib-loader'; | 
|  | import {RESEMBLEJS_LIBRARY_CONFIG} from '../../shared/gr-lib-loader/resemblejs_config'; | 
|  |  | 
|  | import {css, html, LitElement, PropertyValues} from 'lit'; | 
|  | import {customElement, property, query, state} from 'lit/decorators'; | 
|  | import {ifDefined} from 'lit/directives/if-defined'; | 
|  | import {classMap} from 'lit/directives/class-map'; | 
|  | import {StyleInfo, styleMap} from 'lit/directives/style-map'; | 
|  |  | 
|  | import { | 
|  | createEvent, | 
|  | Dimensions, | 
|  | fitToFrame, | 
|  | FrameConstrainer, | 
|  | Point, | 
|  | Rect, | 
|  | } from './util'; | 
|  |  | 
|  | const DRAG_DEAD_ZONE_PIXELS = 5; | 
|  |  | 
|  | const DEFAULT_AUTOMATIC_BLINK_TIME_MS = 1000; | 
|  |  | 
|  | const AUTOMATIC_BLINK_BUTTON_ACTIVE_AREA_PIXELS = 350; | 
|  |  | 
|  | /** | 
|  | * This components allows the user to rapidly switch between two given images | 
|  | * rendered in the same location, to make subtle differences more noticeable. | 
|  | * Images can be magnified to compare details. | 
|  | */ | 
|  | @customElement('gr-image-viewer') | 
|  | export class GrImageViewer extends LitElement { | 
|  | /** URL for the image to use as base. */ | 
|  | @property({type: String}) baseUrl = ''; | 
|  |  | 
|  | /** URL for the image to use as revision. */ | 
|  | @property({type: String}) revisionUrl = ''; | 
|  |  | 
|  | /** | 
|  | * When true, cycle automatically between base and revision image, if both | 
|  | * are available. | 
|  | */ | 
|  | @property({type: Boolean}) automaticBlink = false; | 
|  |  | 
|  | @state() protected baseSelected = false; | 
|  |  | 
|  | @state() protected scaledSelected = true; | 
|  |  | 
|  | @state() protected followMouse = false; | 
|  |  | 
|  | @state() protected scale = 1; | 
|  |  | 
|  | @state() protected checkerboardSelected = true; | 
|  |  | 
|  | @state() protected backgroundColor = ''; | 
|  |  | 
|  | @state() protected automaticBlinkShown = false; | 
|  |  | 
|  | @state() protected zoomedImageStyle: StyleInfo = {}; | 
|  |  | 
|  | @query('.imageArea') protected imageArea!: HTMLDivElement; | 
|  |  | 
|  | @query('gr-zoomed-image') protected zoomedImage!: Element; | 
|  |  | 
|  | @query('#source-image') protected sourceImage!: HTMLImageElement; | 
|  |  | 
|  | @query('#automatic-blink-button') protected automaticBlinkButton?: Element; | 
|  |  | 
|  | private imageSize: Dimensions = {width: 0, height: 0}; | 
|  |  | 
|  | @state() | 
|  | protected magnifierSize: Dimensions = {width: 0, height: 0}; | 
|  |  | 
|  | @state() | 
|  | protected magnifierFrame: Rect = { | 
|  | origin: {x: 0, y: 0}, | 
|  | dimensions: {width: 0, height: 0}, | 
|  | }; | 
|  |  | 
|  | @state() | 
|  | protected overviewFrame: Rect = { | 
|  | origin: {x: 0, y: 0}, | 
|  | dimensions: {width: 0, height: 0}, | 
|  | }; | 
|  |  | 
|  | protected readonly zoomLevels: Array<'fit' | number> = [ | 
|  | 'fit', | 
|  | 1, | 
|  | 1.25, | 
|  | 1.5, | 
|  | 1.75, | 
|  | 2, | 
|  | ]; | 
|  |  | 
|  | @state() protected grabbing = false; | 
|  |  | 
|  | @state() protected canHighlightDiffs = false; | 
|  |  | 
|  | @state() protected diffHighlightSrc?: string; | 
|  |  | 
|  | @state() protected showHighlight = false; | 
|  |  | 
|  | private ownsMouseDown = false; | 
|  |  | 
|  | private centerOnDown: Point = {x: 0, y: 0}; | 
|  |  | 
|  | private pointerOnDown: Point = {x: 0, y: 0}; | 
|  |  | 
|  | private readonly frameConstrainer = new FrameConstrainer(); | 
|  |  | 
|  | private readonly resizeObserver = new ResizeObserver( | 
|  | (entries: ResizeObserverEntry[]) => { | 
|  | for (const entry of entries) { | 
|  | if (entry.target === this.imageArea) { | 
|  | this.magnifierSize = { | 
|  | width: entry.contentRect.width, | 
|  | height: entry.contentRect.height, | 
|  | }; | 
|  | } | 
|  | } | 
|  | } | 
|  | ); | 
|  |  | 
|  | // Ensure constant function references, so that render() does not bind a new | 
|  | // event listener on every call, as it would with lambdas. | 
|  | private createColorPickerCallback(color: string) { | 
|  | return {color, callback: () => this.pickColor(color)}; | 
|  | } | 
|  |  | 
|  | private readonly colorPickerCallbacks = [ | 
|  | this.createColorPickerCallback('#fff'), | 
|  | this.createColorPickerCallback('#000'), | 
|  | this.createColorPickerCallback('#aaa'), | 
|  | ]; | 
|  |  | 
|  | private automaticBlinkTimer?: ReturnType<typeof setInterval>; | 
|  |  | 
|  | // TODO(hermannloose): Make GrLibLoader a singleton. | 
|  | private static readonly libLoader = new GrLibLoader(); | 
|  |  | 
|  | static override styles = css` | 
|  | :host { | 
|  | display: grid; | 
|  | grid-template-rows: 1fr auto; | 
|  | grid-template-columns: 1fr auto; | 
|  | width: 100%; | 
|  | height: 100%; | 
|  | box-sizing: border-box; | 
|  | text-align: initial !important; | 
|  | font-size: var(--font-size-normal); | 
|  | --image-border-width: 2px; | 
|  | } | 
|  | .imageArea { | 
|  | grid-row-start: 1; | 
|  | grid-column-start: 1; | 
|  | box-sizing: border-box; | 
|  | flex-grow: 1; | 
|  | overflow: hidden; | 
|  | display: flex; | 
|  | flex-direction: column; | 
|  | align-items: center; | 
|  | margin: var(--spacing-m); | 
|  | padding: var(--image-border-width); | 
|  | max-height: 100%; | 
|  | position: relative; | 
|  | } | 
|  | #spacer { | 
|  | visibility: hidden; | 
|  | } | 
|  | gr-zoomed-image { | 
|  | border: var(--image-border-width) solid; | 
|  | margin: calc(-1 * var(--image-border-width)); | 
|  | box-sizing: content-box; | 
|  | position: absolute; | 
|  | overflow: hidden; | 
|  | cursor: pointer; | 
|  | } | 
|  | gr-zoomed-image.base { | 
|  | border-color: var(--base-image-border-color, rgb(255, 205, 210)); | 
|  | } | 
|  | gr-zoomed-image.revision { | 
|  | border-color: var(--revision-image-border-color, rgb(170, 242, 170)); | 
|  | } | 
|  | #automatic-blink-button { | 
|  | position: absolute; | 
|  | right: var(--spacing-xl); | 
|  | bottom: var(--spacing-xl); | 
|  | opacity: 0; | 
|  | transition: opacity 200ms ease; | 
|  | --paper-fab-background: var(--primary-button-background-color); | 
|  | --paper-fab-keyboard-focus-background: var( | 
|  | --primary-button-background-color | 
|  | ); | 
|  | } | 
|  | #automatic-blink-button.show, | 
|  | #automatic-blink-button:focus-visible { | 
|  | opacity: 1; | 
|  | } | 
|  | .checkerboard { | 
|  | --square-size: var(--checkerboard-square-size, 10px); | 
|  | --square-color: var(--checkerboard-square-color, #808080); | 
|  | background-color: var(--checkerboard-background-color, #aaaaaa); | 
|  | background-image: linear-gradient( | 
|  | 45deg, | 
|  | var(--square-color) 25%, | 
|  | transparent 25% | 
|  | ), | 
|  | linear-gradient(-45deg, var(--square-color) 25%, transparent 25%), | 
|  | linear-gradient(45deg, transparent 75%, var(--square-color) 75%), | 
|  | linear-gradient(-45deg, transparent 75%, var(--square-color) 75%); | 
|  | background-size: calc(var(--square-size) * 2) calc(var(--square-size) * 2); | 
|  | background-position: 0 0, 0 var(--square-size), | 
|  | var(--square-size) calc(-1 * var(--square-size)), | 
|  | calc(-1 * var(--square-size)) 0; | 
|  | } | 
|  | .dimensions { | 
|  | grid-row-start: 2; | 
|  | justify-self: center; | 
|  | align-self: center; | 
|  | background: var(--primary-button-background-color); | 
|  | color: var(--primary-button-text-color); | 
|  | font-family: var(--font-family); | 
|  | font-size: var(--font-size-small); | 
|  | line-height: var(--line-height-small); | 
|  | border-radius: var(--border-radius, 4px); | 
|  | margin: var(--spacing-s); | 
|  | padding: var(--spacing-xxs) var(--spacing-s); | 
|  | } | 
|  | .controls { | 
|  | grid-column-start: 2; | 
|  | flex-grow: 0; | 
|  | display: flex; | 
|  | flex-direction: column; | 
|  | align-self: flex-start; | 
|  | margin: var(--spacing-m); | 
|  | padding-bottom: var(--spacing-xl); | 
|  | } | 
|  | paper-button { | 
|  | padding: var(--spacing-m); | 
|  | font: var(--image-diff-button-font); | 
|  | text-transform: var(--image-diff-button-text-transform, uppercase); | 
|  | outline: 1px solid transparent; | 
|  | border: 1px solid var(--primary-button-background-color); | 
|  | } | 
|  | paper-button.unelevated { | 
|  | color: var(--primary-button-text-color); | 
|  | background-color: var(--primary-button-background-color); | 
|  | } | 
|  | paper-button.outlined { | 
|  | color: var(--primary-button-background-color); | 
|  | } | 
|  | #version-switcher { | 
|  | display: flex; | 
|  | align-items: center; | 
|  | margin: var(--spacing-xl) var(--spacing-xl) var(--spacing-m); | 
|  | /* Start a stacking context to contain FAB below. */ | 
|  | z-index: 0; | 
|  | } | 
|  | #version-switcher paper-button { | 
|  | flex-grow: 1; | 
|  | margin: 0; | 
|  | /* | 
|  | The floating action button below overlaps part of the version buttons. | 
|  | This min-width ensures the button text still appears somewhat balanced. | 
|  | */ | 
|  | min-width: 7rem; | 
|  | } | 
|  | #version-switcher paper-fab { | 
|  | /* Round button overlaps Base and Revision buttons. */ | 
|  | z-index: 1; | 
|  | margin: 0 -12px; | 
|  | /* Styled as an outlined button. */ | 
|  | color: var(--primary-button-background-color); | 
|  | border: 1px solid var(--primary-button-background-color); | 
|  | --paper-fab-background: var(--primary-background-color); | 
|  | --paper-fab-keyboard-focus-background: var(--primary-background-color); | 
|  | } | 
|  | #version-explanation { | 
|  | color: var(--deemphasized-text-color); | 
|  | text-align: center; | 
|  | margin: var(--spacing-xl) var(--spacing-xl) var(--spacing-m); | 
|  | } | 
|  | #highlight-changes { | 
|  | margin: var(--spacing-m) var(--spacing-xl); | 
|  | } | 
|  | gr-overview-image { | 
|  | min-width: 200px; | 
|  | min-height: 150px; | 
|  | margin-top: var(--spacing-m); | 
|  | } | 
|  | #zoom-control { | 
|  | margin: 0 var(--spacing-xl); | 
|  | } | 
|  | paper-item { | 
|  | cursor: pointer; | 
|  | } | 
|  | paper-item:hover { | 
|  | background-color: var(--hover-background-color); | 
|  | } | 
|  | #follow-mouse { | 
|  | margin: var(--spacing-m) var(--spacing-xl); | 
|  | } | 
|  | .color-picker { | 
|  | margin: var(--spacing-m) var(--spacing-xl) 0; | 
|  | } | 
|  | .color-picker .label { | 
|  | margin-bottom: var(--spacing-s); | 
|  | } | 
|  | .color-picker .options { | 
|  | display: flex; | 
|  | /* Ignore selection border for alignment, for visual balance. */ | 
|  | margin-left: -3px; | 
|  | } | 
|  | .color-picker-button { | 
|  | border-width: 2px; | 
|  | border-style: solid; | 
|  | border-color: transparent; | 
|  | border-radius: 50%; | 
|  | width: 24px; | 
|  | height: 24px; | 
|  | padding: 1px; | 
|  | } | 
|  | .color-picker-button.selected { | 
|  | border-color: var(--primary-button-background-color); | 
|  | } | 
|  | .color-picker-button:focus-within:not(.selected) { | 
|  | /* Not an actual outline, as those do not follow border-radius. */ | 
|  | border-color: var(--outline-color-focus); | 
|  | } | 
|  | .color-picker-button .color { | 
|  | border: 1px solid var(--border-color); | 
|  | border-radius: 50%; | 
|  | width: 100%; | 
|  | height: 100%; | 
|  | box-sizing: border-box; | 
|  | } | 
|  | #source-plus-highlight-container { | 
|  | position: relative; | 
|  | } | 
|  | #source-plus-highlight-container img { | 
|  | position: absolute; | 
|  | top: 0; | 
|  | left: 0; | 
|  | } | 
|  | `; | 
|  |  | 
|  | private renderColorPickerButton(color: string, colorPicked: () => void) { | 
|  | const selected = | 
|  | color === this.backgroundColor && !this.checkerboardSelected; | 
|  | return html` | 
|  | <div | 
|  | class="${classMap({ | 
|  | 'color-picker-button': true, | 
|  | selected, | 
|  | })}" | 
|  | > | 
|  | <paper-icon-button | 
|  | class="color" | 
|  | style="${styleMap({backgroundColor: color})}" | 
|  | @click="${colorPicked}" | 
|  | ></paper-icon-button> | 
|  | </div> | 
|  | `; | 
|  | } | 
|  |  | 
|  | private renderCheckerboardButton() { | 
|  | return html` | 
|  | <div | 
|  | class="${classMap({ | 
|  | 'color-picker-button': true, | 
|  | selected: this.checkerboardSelected, | 
|  | })}" | 
|  | > | 
|  | <paper-icon-button | 
|  | class="color checkerboard" | 
|  | @click="${this.pickCheckerboard}" | 
|  | > | 
|  | </paper-icon-button> | 
|  | </div> | 
|  | `; | 
|  | } | 
|  |  | 
|  | override render() { | 
|  | const src = this.baseSelected ? this.baseUrl : this.revisionUrl; | 
|  |  | 
|  | const sourceImage = html` | 
|  | <img | 
|  | id="source-image" | 
|  | src="${src}" | 
|  | class="${classMap({checkerboard: this.checkerboardSelected})}" | 
|  | style="${styleMap({ | 
|  | backgroundColor: this.checkerboardSelected | 
|  | ? '' | 
|  | : this.backgroundColor, | 
|  | })}" | 
|  | @load="${this.updateSizes}" | 
|  | /> | 
|  | `; | 
|  |  | 
|  | const sourceImageWithHighlight = html` | 
|  | <div id="source-plus-highlight-container"> | 
|  | ${sourceImage} | 
|  | <img | 
|  | id="highlight-image" | 
|  | style="${styleMap({ | 
|  | opacity: this.showHighlight ? '1' : '0', | 
|  | // When the highlight layer is not being shown, saving the image or | 
|  | // opening it in a new tab from the context menu, e.g. for external | 
|  | // comparison, should give back the source image, not the highlight | 
|  | // layer. | 
|  | 'pointer-events': this.showHighlight ? 'auto' : 'none', | 
|  | })}" | 
|  | src="${ifDefined(this.diffHighlightSrc)}" | 
|  | /> | 
|  | </div> | 
|  | `; | 
|  |  | 
|  | const versionExplanation = html` | 
|  | <div id="version-explanation"> | 
|  | This file is being ${this.revisionUrl ? 'added' : 'deleted'}. | 
|  | </div> | 
|  | `; | 
|  |  | 
|  | // This uses the unelevated and outlined attributes from mwc-button with | 
|  | // manual styling, for a more seamless transition later. | 
|  | const leftClasses = { | 
|  | left: true, | 
|  | unelevated: this.baseSelected, | 
|  | outlined: !this.baseSelected, | 
|  | }; | 
|  | const rightClasses = { | 
|  | right: true, | 
|  | unelevated: !this.baseSelected, | 
|  | outlined: this.baseSelected, | 
|  | }; | 
|  | const versionToggle = html` | 
|  | <div id="version-switcher"> | 
|  | <paper-button | 
|  | class="${classMap(leftClasses)}" | 
|  | @click="${this.selectBase}" | 
|  | > | 
|  | Base | 
|  | </paper-button> | 
|  | <paper-fab mini icon="gr-icons:swapHoriz" @click="${this.manualBlink}"> | 
|  | </paper-fab> | 
|  | <paper-button | 
|  | class="${classMap(rightClasses)}" | 
|  | @click="${this.selectRevision}" | 
|  | > | 
|  | Revision | 
|  | </paper-button> | 
|  | </div> | 
|  | `; | 
|  |  | 
|  | const versionSwitcher = html` | 
|  | ${this.baseUrl && this.revisionUrl ? versionToggle : versionExplanation} | 
|  | `; | 
|  |  | 
|  | const highlightSwitcher = this.diffHighlightSrc | 
|  | ? html` | 
|  | <paper-checkbox | 
|  | id="highlight-changes" | 
|  | ?checked="${this.showHighlight}" | 
|  | @change="${this.showHighlightChanged}" | 
|  | > | 
|  | Highlight differences | 
|  | </paper-checkbox> | 
|  | ` | 
|  | : ''; | 
|  |  | 
|  | const overviewImage = html` | 
|  | <gr-overview-image | 
|  | .frameRect="${this.overviewFrame}" | 
|  | @center-updated="${this.onOverviewCenterUpdated}" | 
|  | > | 
|  | <img | 
|  | src="${src}" | 
|  | class="${classMap({checkerboard: this.checkerboardSelected})}" | 
|  | style="${styleMap({ | 
|  | backgroundColor: this.checkerboardSelected | 
|  | ? '' | 
|  | : this.backgroundColor, | 
|  | })}" | 
|  | /> | 
|  | </gr-overview-image> | 
|  | `; | 
|  |  | 
|  | const zoomControl = html` | 
|  | <paper-dropdown-menu id="zoom-control" label="Zoom"> | 
|  | <paper-listbox | 
|  | slot="dropdown-content" | 
|  | selected="fit" | 
|  | .attrForSelected="${'value'}" | 
|  | @selected-changed="${this.zoomControlChanged}" | 
|  | > | 
|  | ${this.zoomLevels.map( | 
|  | zoomLevel => html` | 
|  | <paper-item value="${zoomLevel}"> | 
|  | ${zoomLevel === 'fit' ? 'Fit' : `${zoomLevel * 100}%`} | 
|  | </paper-item> | 
|  | ` | 
|  | )} | 
|  | </paper-listbox> | 
|  | </paper-dropdown-menu> | 
|  | `; | 
|  |  | 
|  | const followMouse = html` | 
|  | <paper-checkbox | 
|  | id="follow-mouse" | 
|  | ?checked="${this.followMouse}" | 
|  | @change="${this.followMouseChanged}" | 
|  | > | 
|  | Magnifier follows mouse | 
|  | </paper-checkbox> | 
|  | `; | 
|  |  | 
|  | const backgroundPicker = html` | 
|  | <div class="color-picker"> | 
|  | <div class="label">Background</div> | 
|  | <div class="options"> | 
|  | ${this.renderCheckerboardButton()} | 
|  | ${this.colorPickerCallbacks.map(({color, callback}) => | 
|  | this.renderColorPickerButton(color, callback) | 
|  | )} | 
|  | </div> | 
|  | </div> | 
|  | `; | 
|  |  | 
|  | /* | 
|  | * We want the content to fill the available space until it can display | 
|  | * without being cropped, the maximum of which will be determined by | 
|  | * (max-)width and (max-)height constraints on the host element; but we | 
|  | * are also limiting the displayed content to the measured dimensions of | 
|  | * the host element without overflow, so we need something else to take up | 
|  | * the requested space unconditionally. | 
|  | */ | 
|  | const spacerScale = Math.max(this.scale, 1); | 
|  | const spacerWidth = this.imageSize.width * spacerScale; | 
|  | const spacerHeight = this.imageSize.height * spacerScale; | 
|  | const spacer = html` | 
|  | <div | 
|  | id="spacer" | 
|  | style="${styleMap({ | 
|  | width: `${spacerWidth}px`, | 
|  | height: `${spacerHeight}px`, | 
|  | })}" | 
|  | ></div> | 
|  | `; | 
|  |  | 
|  | const automaticBlink = html` | 
|  | <paper-fab | 
|  | id="automatic-blink-button" | 
|  | class="${classMap({show: this.automaticBlinkShown})}" | 
|  | title="Automatic blink" | 
|  | icon="gr-icons:${this.automaticBlink ? 'pause' : 'playArrow'}" | 
|  | @click="${this.toggleAutomaticBlink}" | 
|  | > | 
|  | </paper-fab> | 
|  | `; | 
|  |  | 
|  | // To pass CSS mixins for @apply to Polymer components, they need to appear | 
|  | // in <style> inside the template. | 
|  | /* eslint-disable lit/prefer-static-styles */ | 
|  | const customStyle = html` | 
|  | <style> | 
|  | paper-item { | 
|  | --paper-item-min-height: 48; | 
|  | --paper-item: { | 
|  | min-height: 48px; | 
|  | padding: 0 var(--spacing-xl); | 
|  | } | 
|  | --paper-item-focused-before: { | 
|  | background-color: var(--selection-background-color); | 
|  | } | 
|  | --paper-item-focused: { | 
|  | background-color: var(--selection-background-color); | 
|  | } | 
|  | } | 
|  | </style> | 
|  | `; | 
|  |  | 
|  | return html` | 
|  | ${customStyle} | 
|  | <div | 
|  | class="imageArea" | 
|  | @mousemove="${this.mousemoveImageArea}" | 
|  | @mouseleave="${this.mouseleaveImageArea}" | 
|  | > | 
|  | <gr-zoomed-image | 
|  | class="${classMap({ | 
|  | base: this.baseSelected, | 
|  | revision: !this.baseSelected, | 
|  | })}" | 
|  | style="${styleMap({ | 
|  | ...this.zoomedImageStyle, | 
|  | cursor: this.grabbing ? 'grabbing' : 'pointer', | 
|  | })}" | 
|  | .scale="${this.scale}" | 
|  | .frameRect="${this.magnifierFrame}" | 
|  | @mousedown="${this.mousedownMagnifier}" | 
|  | @mouseup="${this.mouseupMagnifier}" | 
|  | @mousemove="${this.mousemoveMagnifier}" | 
|  | @mouseleave="${this.mouseleaveMagnifier}" | 
|  | @dragstart="${this.dragstartMagnifier}" | 
|  | > | 
|  | ${sourceImageWithHighlight} | 
|  | </gr-zoomed-image> | 
|  | ${this.baseUrl && this.revisionUrl ? automaticBlink : ''} ${spacer} | 
|  | </div> | 
|  |  | 
|  | <div class="dimensions"> | 
|  | ${this.imageSize.width} x ${this.imageSize.height} | 
|  | </div> | 
|  |  | 
|  | <paper-card class="controls"> | 
|  | ${versionSwitcher} ${highlightSwitcher} ${overviewImage} ${zoomControl} | 
|  | ${!this.scaledSelected ? followMouse : ''} ${backgroundPicker} | 
|  | </paper-card> | 
|  | `; | 
|  | } | 
|  |  | 
|  | override firstUpdated() { | 
|  | this.resizeObserver.observe(this.imageArea, {box: 'content-box'}); | 
|  | GrImageViewer.libLoader.getLibrary(RESEMBLEJS_LIBRARY_CONFIG).then(() => { | 
|  | this.canHighlightDiffs = true; | 
|  | this.computeDiffImage(); | 
|  | }); | 
|  | } | 
|  |  | 
|  | // We don't want property changes in updateSizes() to trigger infinite update | 
|  | // loops, so we perform this in update() instead of updated(). | 
|  | override update(changedProperties: PropertyValues) { | 
|  | // eslint-disable-next-line lit/no-property-change-update | 
|  | if (!this.baseUrl) this.baseSelected = false; | 
|  | // eslint-disable-next-line lit/no-property-change-update | 
|  | if (!this.revisionUrl) this.baseSelected = true; | 
|  | this.updateSizes(); | 
|  | super.update(changedProperties); | 
|  | } | 
|  |  | 
|  | override updated(changedProperties: PropertyValues) { | 
|  | if ( | 
|  | (changedProperties.has('baseUrl') && this.baseSelected) || | 
|  | (changedProperties.has('revisionUrl') && !this.baseSelected) | 
|  | ) { | 
|  | this.frameConstrainer.requestCenter({x: 0, y: 0}); | 
|  | } | 
|  | if (changedProperties.has('automaticBlink')) { | 
|  | this.updateAutomaticBlink(); | 
|  | } | 
|  | if ( | 
|  | this.canHighlightDiffs && | 
|  | (changedProperties.has('baseUrl') || changedProperties.has('revisionUrl')) | 
|  | ) { | 
|  | this.computeDiffImage(); | 
|  | } | 
|  | } | 
|  |  | 
|  | private computeDiffImage() { | 
|  | if (!(this.baseUrl && this.revisionUrl)) return; | 
|  | window | 
|  | .resemble(this.baseUrl) | 
|  | .compareTo(this.revisionUrl) | 
|  | // By default Resemble.js applies some color / alpha tolerance as well as | 
|  | // min / max brightness beyond which to ignore changes. Until we have | 
|  | // controls to let the user affect these options, always highlight all | 
|  | // changed pixels. | 
|  | .ignoreNothing() | 
|  | .onComplete(result => { | 
|  | this.diffHighlightSrc = result.getImageDataUrl(); | 
|  | }); | 
|  | } | 
|  |  | 
|  | selectBase() { | 
|  | if (!this.baseUrl) return; | 
|  | this.baseSelected = true; | 
|  | this.dispatchEvent( | 
|  | createEvent({type: 'version-switcher-clicked', button: 'base'}) | 
|  | ); | 
|  | } | 
|  |  | 
|  | selectRevision() { | 
|  | if (!this.revisionUrl) return; | 
|  | this.baseSelected = false; | 
|  | this.dispatchEvent( | 
|  | createEvent({type: 'version-switcher-clicked', button: 'revision'}) | 
|  | ); | 
|  | } | 
|  |  | 
|  | manualBlink() { | 
|  | this.toggleImage(); | 
|  | this.dispatchEvent( | 
|  | createEvent({type: 'version-switcher-clicked', button: 'switch'}) | 
|  | ); | 
|  | } | 
|  |  | 
|  | private toggleImage() { | 
|  | if (this.baseUrl && this.revisionUrl) { | 
|  | this.baseSelected = !this.baseSelected; | 
|  | } | 
|  | } | 
|  |  | 
|  | toggleAutomaticBlink() { | 
|  | this.automaticBlink = !this.automaticBlink; | 
|  | this.dispatchEvent( | 
|  | createEvent({type: 'automatic-blink-changed', value: this.automaticBlink}) | 
|  | ); | 
|  | } | 
|  |  | 
|  | private updateAutomaticBlink() { | 
|  | if (this.automaticBlink) { | 
|  | this.toggleImage(); | 
|  | this.setBlinkInterval(); | 
|  | } else { | 
|  | this.clearBlinkInterval(); | 
|  | } | 
|  | } | 
|  |  | 
|  | private setBlinkInterval() { | 
|  | this.clearBlinkInterval(); | 
|  | this.automaticBlinkTimer = setInterval(() => { | 
|  | this.toggleImage(); | 
|  | }, DEFAULT_AUTOMATIC_BLINK_TIME_MS); | 
|  | } | 
|  |  | 
|  | private clearBlinkInterval() { | 
|  | if (this.automaticBlinkTimer) { | 
|  | clearInterval(this.automaticBlinkTimer); | 
|  | this.automaticBlinkTimer = undefined; | 
|  | } | 
|  | } | 
|  |  | 
|  | showHighlightChanged() { | 
|  | this.toggleHighlight('controls'); | 
|  | } | 
|  |  | 
|  | private toggleHighlight(source: 'controls' | 'magnifier') { | 
|  | this.showHighlight = !this.showHighlight; | 
|  | this.dispatchEvent( | 
|  | createEvent({ | 
|  | type: 'highlight-changes-changed', | 
|  | value: this.showHighlight, | 
|  | source, | 
|  | }) | 
|  | ); | 
|  | } | 
|  |  | 
|  | zoomControlChanged(event: CustomEvent) { | 
|  | const value = event.detail.value; | 
|  | if (!value) return; | 
|  | if (value === 'fit') { | 
|  | this.scaledSelected = true; | 
|  | this.dispatchEvent( | 
|  | createEvent({type: 'zoom-level-changed', scale: 'fit'}) | 
|  | ); | 
|  | } | 
|  | if (value > 0) { | 
|  | this.scaledSelected = false; | 
|  | this.scale = value; | 
|  | this.dispatchEvent( | 
|  | createEvent({type: 'zoom-level-changed', scale: value}) | 
|  | ); | 
|  | } | 
|  | this.updateSizes(); | 
|  | } | 
|  |  | 
|  | followMouseChanged() { | 
|  | this.followMouse = !this.followMouse; | 
|  | this.dispatchEvent( | 
|  | createEvent({type: 'follow-mouse-changed', value: this.followMouse}) | 
|  | ); | 
|  | } | 
|  |  | 
|  | pickColor(value: string) { | 
|  | this.checkerboardSelected = false; | 
|  | this.backgroundColor = value; | 
|  | this.dispatchEvent(createEvent({type: 'background-color-changed', value})); | 
|  | } | 
|  |  | 
|  | pickCheckerboard() { | 
|  | this.checkerboardSelected = true; | 
|  | this.dispatchEvent( | 
|  | createEvent({type: 'background-color-changed', value: 'checkerboard'}) | 
|  | ); | 
|  | } | 
|  |  | 
|  | mousemoveImageArea(event: MouseEvent) { | 
|  | if (this.automaticBlinkButton) { | 
|  | this.updateAutomaticBlinkVisibility(event); | 
|  | } | 
|  | this.mousemoveMagnifier(event); | 
|  | } | 
|  |  | 
|  | private updateAutomaticBlinkVisibility(event: MouseEvent) { | 
|  | const rect = this.automaticBlinkButton!.getBoundingClientRect(); | 
|  | const centerX = rect.left + (rect.right - rect.left) / 2; | 
|  | const centerY = rect.top + (rect.bottom - rect.top) / 2; | 
|  | const distX = Math.abs(centerX - event.clientX); | 
|  | const distY = Math.abs(centerY - event.clientY); | 
|  | this.automaticBlinkShown = | 
|  | distX < AUTOMATIC_BLINK_BUTTON_ACTIVE_AREA_PIXELS && | 
|  | distY < AUTOMATIC_BLINK_BUTTON_ACTIVE_AREA_PIXELS; | 
|  | } | 
|  |  | 
|  | mouseleaveImageArea() { | 
|  | this.automaticBlinkShown = false; | 
|  | } | 
|  |  | 
|  | mousedownMagnifier(event: MouseEvent) { | 
|  | if (event.buttons === 1) { | 
|  | this.ownsMouseDown = true; | 
|  | this.centerOnDown = this.frameConstrainer.getCenter(); | 
|  | this.pointerOnDown = { | 
|  | x: event.clientX, | 
|  | y: event.clientY, | 
|  | }; | 
|  | } | 
|  | } | 
|  |  | 
|  | mouseupMagnifier(event: MouseEvent) { | 
|  | if (!this.ownsMouseDown) return; | 
|  | this.grabbing = false; | 
|  | this.ownsMouseDown = false; | 
|  |  | 
|  | if (event.shiftKey && this.diffHighlightSrc) { | 
|  | this.toggleHighlight('magnifier'); | 
|  | return; | 
|  | } | 
|  |  | 
|  | const offsetX = event.clientX - this.pointerOnDown.x; | 
|  | const offsetY = event.clientY - this.pointerOnDown.y; | 
|  | const distance = Math.max(Math.abs(offsetX), Math.abs(offsetY)); | 
|  | // Consider very short drags as clicks. These tend to happen more often on | 
|  | // external mice. | 
|  | if (distance < DRAG_DEAD_ZONE_PIXELS) { | 
|  | this.toggleImage(); | 
|  | this.dispatchEvent(createEvent({type: 'magnifier-clicked'})); | 
|  | } else { | 
|  | this.dispatchEvent(createEvent({type: 'magnifier-dragged'})); | 
|  | } | 
|  | } | 
|  |  | 
|  | mousemoveMagnifier(event: MouseEvent) { | 
|  | if (event.buttons === 1 && this.ownsMouseDown) { | 
|  | this.handleMagnifierDrag(event); | 
|  | return; | 
|  | } | 
|  | if (this.followMouse) { | 
|  | this.handleFollowMouse(event); | 
|  | return; | 
|  | } | 
|  | } | 
|  |  | 
|  | private handleMagnifierDrag(event: MouseEvent) { | 
|  | this.grabbing = true; | 
|  | const offsetX = event.clientX - this.pointerOnDown.x; | 
|  | const offsetY = event.clientY - this.pointerOnDown.y; | 
|  | this.frameConstrainer.requestCenter({ | 
|  | x: this.centerOnDown.x - offsetX / this.scale, | 
|  | y: this.centerOnDown.y - offsetY / this.scale, | 
|  | }); | 
|  | this.updateFrames(); | 
|  | } | 
|  |  | 
|  | private handleFollowMouse(event: MouseEvent) { | 
|  | const rect = this.imageArea!.getBoundingClientRect(); | 
|  | const offsetX = event.clientX - rect.left; | 
|  | const offsetY = event.clientY - rect.top; | 
|  | const fractionX = offsetX / rect.width; | 
|  | const fractionY = offsetY / rect.height; | 
|  | this.frameConstrainer.requestCenter({ | 
|  | x: this.imageSize.width * fractionX, | 
|  | y: this.imageSize.height * fractionY, | 
|  | }); | 
|  | this.updateFrames(); | 
|  | } | 
|  |  | 
|  | mouseleaveMagnifier() { | 
|  | if (!this.ownsMouseDown) return; | 
|  | this.grabbing = false; | 
|  | this.ownsMouseDown = false; | 
|  | this.dispatchEvent(createEvent({type: 'magnifier-dragged'})); | 
|  | } | 
|  |  | 
|  | dragstartMagnifier(event: DragEvent) { | 
|  | event.preventDefault(); | 
|  | } | 
|  |  | 
|  | onOverviewCenterUpdated(event: CustomEvent) { | 
|  | this.frameConstrainer.requestCenter({ | 
|  | x: event.detail.x as number, | 
|  | y: event.detail.y as number, | 
|  | }); | 
|  | this.updateFrames(); | 
|  | } | 
|  |  | 
|  | updateFrames() { | 
|  | this.magnifierFrame = this.frameConstrainer.getUnscaledFrame(); | 
|  | this.overviewFrame = this.frameConstrainer.getScaledFrame(); | 
|  | } | 
|  |  | 
|  | updateSizes() { | 
|  | if (!this.sourceImage || !this.sourceImage.complete) return; | 
|  |  | 
|  | this.imageSize = { | 
|  | width: this.sourceImage.naturalWidth || 0, | 
|  | height: this.sourceImage.naturalHeight || 0, | 
|  | }; | 
|  |  | 
|  | this.frameConstrainer.setBounds(this.imageSize); | 
|  |  | 
|  | if (this.scaledSelected) { | 
|  | const fittedImage = fitToFrame(this.imageSize, this.magnifierSize); | 
|  | this.scale = Math.min(fittedImage.scale, 1); | 
|  | } | 
|  |  | 
|  | this.frameConstrainer.setScale(this.scale); | 
|  |  | 
|  | const scaledImageSize = { | 
|  | width: this.imageSize.width * this.scale, | 
|  | height: this.imageSize.height * this.scale, | 
|  | }; | 
|  |  | 
|  | const width = Math.min(this.magnifierSize.width, scaledImageSize.width); | 
|  | const height = Math.min(this.magnifierSize.height, scaledImageSize.height); | 
|  |  | 
|  | this.frameConstrainer.setFrameSize({width, height}); | 
|  |  | 
|  | this.updateFrames(); | 
|  |  | 
|  | this.zoomedImageStyle = { | 
|  | ...this.zoomedImageStyle, | 
|  | width: `${width}px`, | 
|  | height: `${height}px`, | 
|  | }; | 
|  | } | 
|  | } | 
|  |  | 
|  | declare global { | 
|  | interface HTMLElementTagNameMap { | 
|  | 'gr-image-viewer': GrImageViewer; | 
|  | } | 
|  | } |