blob: a05b5e2c1498b93a28371ab93217d62aead60837 [file] [log] [blame]
/**
* @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 get styles() {
return [
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>;
}
}