| /** |
| * @license |
| * Copyright 2021 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import {css, html, LitElement, PropertyValues} from 'lit'; |
| import {customElement, property, query, state} from 'lit/decorators.js'; |
| import {StyleInfo, styleMap} from 'lit/directives/style-map.js'; |
| import {ImageDiffAction} from '../../../api/diff'; |
| import {fire} from '../../../utils/event-util'; |
| |
| 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}}; |
| |
| @state() protected contentStyle: StyleInfo = {}; |
| |
| @state() protected contentTransformStyle: StyleInfo = {}; |
| |
| @state() protected frameStyle: StyleInfo = {}; |
| |
| @state() 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; |
| |
| protected overlay?: 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 override styles = css` |
| :host { |
| --background-color: var(--overview-image-background-color, #000); |
| --frame-color: var(--overview-image-frame-color, #f00); |
| display: flex; |
| } |
| * { |
| box-sizing: border-box; |
| } |
| ::slotted(*) { |
| display: block; |
| } |
| .content-box { |
| border: 1px solid var(--background-color); |
| background-color: var(--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(--frame-color); |
| position: absolute; |
| will-change: transform; |
| } |
| `; |
| |
| override 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> |
| `; |
| } |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| if (this.isConnected) { |
| this.overlay = document.createElement('div'); |
| // The overlay is added directly to document body to ensure it fills the |
| // entire screen to capture events, without being clipped by any parent |
| // overflow properties. This means it has to be styled manually, since |
| // component styles will not affect it. |
| this.overlay.style.position = 'fixed'; |
| this.overlay.style.top = '0'; |
| this.overlay.style.left = '0'; |
| // We subtract 20 pixels in each dimension to prevent the overlay from |
| // extending offscreen under any existing scrollbar and causing the |
| // scrollbar for the other dimension to show up unnecessarily. |
| this.overlay.style.width = 'calc(100vw - 20px)'; |
| this.overlay.style.height = 'calc(100vh - 20px)'; |
| this.overlay.style.zIndex = '10000'; |
| this.overlay.style.display = 'none'; |
| |
| this.overlay.addEventListener('mousemove', (event: MouseEvent) => |
| this.maybeDragFrame(event) |
| ); |
| this.overlay.addEventListener('mouseleave', (event: MouseEvent) => { |
| // Ignore mouseleave events that are due to closeOverlay() calls. |
| if (this.overlay?.style.display !== 'none') { |
| this.releaseFrame(event); |
| } |
| }); |
| this.overlay.addEventListener('mouseup', (event: MouseEvent) => |
| this.releaseFrame(event) |
| ); |
| |
| document.body.appendChild(this.overlay); |
| } |
| } |
| |
| override disconnectedCallback() { |
| if (this.overlay) { |
| document.body.removeChild(this.overlay); |
| this.overlay = undefined; |
| } |
| super.disconnectedCallback(); |
| } |
| |
| override firstUpdated() { |
| this.resizeObserver.observe(this.contentBox); |
| this.resizeObserver.observe(this.contentTransform); |
| } |
| |
| override updated(changedProperties: PropertyValues) { |
| if (changedProperties.has('frameRect')) { |
| this.updateFrameStyle(); |
| } |
| } |
| |
| clickOverview(event: MouseEvent) { |
| if (event.buttons !== 1) return; |
| event.preventDefault(); |
| |
| this.dragging = true; |
| this.openOverlay(); |
| |
| const rect = this.content.getBoundingClientRect(); |
| this.notifyNewCenter({ |
| x: (event.clientX - rect.left) / this.scale, |
| y: (event.clientY - rect.top) / this.scale, |
| }); |
| } |
| |
| grabFrame(event: MouseEvent) { |
| if (event.buttons !== 1) return; |
| event.preventDefault(); |
| // Do not bubble up into clickOverview(). |
| event.stopPropagation(); |
| |
| this.dragging = true; |
| this.openOverlay(); |
| |
| 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(); |
| |
| const detail: ImageDiffAction = { |
| type: this.dragging ? 'overview-frame-dragged' : 'overview-image-clicked', |
| }; |
| fire(this, 'image-diff-action', detail); |
| |
| this.dragging = false; |
| this.closeOverlay(); |
| this.grabOffset = {x: 0, y: 0}; |
| } |
| |
| private openOverlay() { |
| if (this.overlay) { |
| this.overlay.style.display = 'block'; |
| this.overlay.style.cursor = 'grabbing'; |
| } |
| } |
| |
| private closeOverlay() { |
| if (this.overlay) { |
| this.overlay.style.display = 'none'; |
| this.overlay.style.cursor = ''; |
| } |
| } |
| |
| 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 notifyNewCenter(center: Point) { |
| fire(this, 'center-updated', {...center}); |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-overview-image': GrOverviewImage; |
| } |
| interface HTMLElementEventMap { |
| 'center-updated': CustomEvent<Point>; |
| } |
| } |