blob: 55f83d6b967f687d2044cc989fc30d0b3d3f8ded [file] [log] [blame]
/**
* @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 {
--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;
}
.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) {
if (event.buttons !== 1) return;
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) {
if (event.buttons !== 1) return;
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;
}
}