Merge "Introduce components for new image diff UI"
This commit is contained in:
@@ -247,6 +247,38 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
----
|
||||
|
||||
|
||||
[[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
|
||||
|
||||
|
||||
@@ -3204,6 +3204,38 @@ This software is provided "as is", without any warranty.
|
||||
----
|
||||
|
||||
|
||||
[[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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
236
polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
Normal file
236
polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -253,6 +253,14 @@ const packages: PackageInfo[] = [
|
||||
name: "@polymer/polymer",
|
||||
license: SharedLicenses.Polymer2017
|
||||
},
|
||||
{
|
||||
name: "@types/resize-observer-browser",
|
||||
license: {
|
||||
name: 'DefinitelyTyped',
|
||||
type: LicenseTypes.Mit,
|
||||
packageLicenseFile: "LICENSE"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "@webcomponents/shadycss",
|
||||
license: SharedLicenses.Polymer2017
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user