blob: f1c7774d991c77cf27968b244d43bceec1ef896a [file] [log] [blame]
/**
* @module modules/plot-summary-sk/h_resizable_box_sk
* @description <h2><code>h-resizable-box-sk</code></h2>
*
* This is created to support UI interactions for summary bar. This component
* itself may be used for drawing and dragging a range horizontally.
* The element will need to cover the entire parent node to work.
*
* @evt selection-changed - Emit when the selectionRange is changed.
* It is not bubbled, and its details contains the selectionRange.
*
*/
import { range } from '../dataframe/index';
import { css, html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
import { ref, createRef } from 'lit/directives/ref.js';
// clamps the value in between min and max. This helps to compute
// rect box not exceeding its boundary.
// if the given max is larger than min. val is clamped to max first.
const clamp = (val: number, min: number, max: number) => {
if (val > max) {
return max;
} else if (val < min) {
return min;
} else {
return val;
}
};
@customElement('h-resizable-box-sk')
export class HResizableBoxSk extends LitElement {
static styles = css`
:host {
display: block;
}
.container {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
}
.surface {
display: none;
position: absolute;
border-radius: 6px;
top: 0px;
bottom: 0px;
left: 0px;
right: 0px;
cursor: move;
--md-elevation-level: 1;
opacity: 0.5;
background-color: var(--md-sys-color-primary-container, #ced0ce);
}
.surface::after {
content: '';
position: absolute;
left: 0px;
width: 4px;
height: 100%;
cursor: ew-resize;
}
.surface::before {
content: '';
position: absolute;
right: 0px;
width: 4px;
height: 100%;
cursor: ew-resize;
}
`;
private selection = createRef<HTMLDivElement>();
// The handle bar width for resizing.
private handleWidth = 4;
// The minimum width that allows dragging and resizing.
private minWidth = 24;
// This tracks the current user intention:
// * drag: the user moves the selection box;
// * left: the user drags the left edge to resize;
// * right: the user drags the right edge to resize;
// * draw: the user starts a new selection.
private action: 'drag' | 'left' | 'right' | 'draw' | null = null;
private lastX = 0;
private startX = 0;
// set and draw the selection box. hidden if null.
// the range is relative to the element itself.
set selectionRange(range: range | null) {
const box = this.selection.value!;
if (!box) {
return;
}
if (!range) {
box.style.display = 'none';
box.style.width = '0px';
return;
} else {
box.style.display = 'block';
}
const parentRt = this.getBoundingClientRect();
box.style.left = clamp(range.begin, 0, parentRt.width) + 'px';
box.style.width =
clamp(range.end - range.begin, this.minWidth, parentRt.width - range.begin) + 'px';
}
// current selection box range.
// the range is relative to the element itself.
get selectionRange() {
const box = this.selection.value;
if (!box || box.style.display === 'none') {
return null;
}
const parentRt = this.getBoundingClientRect();
const rect = box.getBoundingClientRect();
return {
begin: rect.left - parentRt.left,
end: rect.right - parentRt.left,
} as range;
}
private onMouseDown(e: MouseEvent) {
// If the mouseDown is starting from our component, then we takes it over.
// This disable system events like selecting texts.
e.preventDefault();
const box = this.selection.value!;
const rect = box.getBoundingClientRect();
this.lastX = e.x;
this.startX = e.x;
if (e.target !== box) {
this.action = 'draw';
box.style.display = 'block'; // the selection box can be hidden if no selection.
} else if (e.x - rect.left < this.handleWidth) {
this.action = 'left';
} else if (e.x > rect.right - this.handleWidth) {
this.action = 'right';
} else {
this.action = 'drag';
}
}
private onMouseMove(e: MouseEvent) {
if (!this.action) {
return;
}
e.preventDefault();
const box = this.selection.value!;
const parentRt = this.getBoundingClientRect();
const rect = box.getBoundingClientRect();
const delta = this.lastX - e.x;
const localLeft = rect.left - parentRt.left;
const localRight = rect.right - parentRt.left;
// We start to draw a new selection.
if (this.action === 'draw') {
// we drag the mouse to the left, and then we draw a box with a minWidth.
if (e.x < this.startX) {
const left = clamp(e.x - parentRt.left, 0, this.startX - parentRt.left - this.minWidth);
box.style.left = left + 'px';
box.style.width = clamp(this.startX - e.x, this.minWidth, parentRt.width - left) + 'px';
} else {
// We drag the mouse to the right.
const left = clamp(this.startX - parentRt.left, 0, parentRt.width - this.minWidth);
box.style.left = left + 'px';
box.style.width = clamp(e.x - this.startX, this.minWidth, parentRt.width - left) + 'px';
}
}
if (this.action === 'drag') {
// We drag and move the selection box.
box.style.left = clamp(localLeft - delta, 0, parentRt.width - rect.width) + 'px';
box.style.width = rect.width + 'px';
} else if (this.action === 'left') {
// We drag the left edge of the box to resize it.
const left = clamp(localLeft - delta, 0, localRight - this.minWidth);
box.style.left = left + 'px';
box.style.width = localRight - left + 'px';
} else if (this.action === 'right') {
// We drag the right edge of the box to resize it.
const newLeft = rect.left - parentRt.left;
box.style.left = rect.left - parentRt.left + 'px';
box.style.width = clamp(rect.width - delta, this.minWidth, parentRt.width - newLeft) + 'px';
}
this.lastX = e.x;
}
private onMouseUp() {
if (this.action === null) {
return;
}
this.action = null;
this.dispatchEvent(
new CustomEvent('selection-changed', {
detail: this.selectionRange,
})
);
}
protected render() {
return html`<div class="container" @mousedown=${(e: MouseEvent) => this.onMouseDown(e)}>
<div class="surface" ${ref(this.selection)}>
<md-elevation></md-elevation>
</div>
</div>`;
}
connectedCallback(): void {
super.connectedCallback();
// We have mousedown event on the element so we start tracking that's
// originated from ourselves. The default bounding box check helps us
// initiate the tracking.
// We listen to mousemove and moseup on Window so even the mouse is moving
// outside the element, we can still get the callback.
window.addEventListener('mousemove', (e) => {
this.onMouseMove(e);
});
window.addEventListener('mouseup', () => {
this.onMouseUp();
});
}
}
declare global {
interface HTMLElementTagNameMap {
'h-resizable-box-sk': HResizableBoxSk;
}
}