stable-diffusion-webui/javascript/resizeGrid.js

1236 lines
44 KiB
JavaScript

/** resizeGrid.js
*
* This allows generation of a grid of items which are separated by resizable handles.
*
* Generation of this grid is inferred from HTML.
* An example can be found in `javascript/resizeGridExample.html`.
* This example will generate a 2x2 grid with 2 rows and 2 cells per row.
* The top right and bottom left cells should fill the majority of their rows.
* Play around with the buttons to show/hide and drag to resize the grid.
*
* USAGE:
* Current limitations require that every row and column contain AT LEAST ONE
* child with flexGrow=1. CSS shortcuts are allowed (i.e. flex: 1 0 0px).
*
* You CANNOT have rows and columns at the same level. This will throw an error.
*
* flexBasis MUST be set for every item that does not have flexGrow=1. flexBasis can be
* set using `px` or any of the following relative units: [vh, vw, rem, em]. These
* units are limited by `utils.js::cssRelativeUnitToPx()`.
*
* The value set in flexGrow determines an item's size along its respective axis.
* So if you have a `resize-grid--col` item, then `flexBasis` will determine the item's
* height. `resize-grid--cell` items infer their axis from their parent element.
*
* You can also set a `data-min-size` attribute on every row/col/cell. If this attribute
* is not set, then the value set in flexBasis will be the minimum size of the item.
* If you are setting the flexBasis to 0, you MUST specify data-min-size="0px" as well
* otherwise the item's min_size will be set to its rendered size on build.
*
* If you do not set any `resize-grid--row` or `resize-grid--col` elements, but only
* set `resize-grid--cell` elements, then the grid will automatically wrap the cells
* in a single row.
*
* You can have a grid that only contains one row/col as long as that row/col contains
* at least one item.
*/
// Prevent eslint errors on functions defined in other files.
/*global
isNullOrUndefinedThrowError,
isNullOrUndefinedLogError,
isNullOrUndefined,
isString,
isStringThrowError,
isObject,
isFunction,
cssRelativeUnitToPx,
isNumber,
isNumberThrowError,
isElementThrowError,
isElement,
*/
/*eslint no-undef: "error"*/
// Should be between 0 and 15. Any higher and the delay becomes noticable.
// Higher values reduce computational load.
const MOVE_TIME_DELAY_MS = 15;
// Prevents handling element resize events too quickly. Lower values increase
// computational load and may lead to lag when resizing.
const RESIZE_DEBOUNCE_TIME_MS = 50;
// The timeframe in which a second pointerup event must be fired to be treated
// as a double click.
const DBLCLICK_TIME_MS = 500;
// The padding around the draggable resize handle.
// NOTE: Must be an even number.
const PAD_PX = 16;
if (!(PAD_PX > 0 && PAD_PX % 2 === 0)) {
throw new Error('PAD_PX must be an even number > 0');
}
const resize_grids = {};
/* ==== HELPER FUNCTIONS ==== */
const _gen_id_string = () => {
return Math.random().toString(16).slice(2);
};
const _get_unique_id = () => {
/** Generates an ID string that does not exist in the `resize_grids` keys. */
let id = _gen_id_string();
while (id in Object.keys(resize_grids)) {
id = _gen_id_string();
}
return id;
};
const _axis_to_int = (axis) => {
/** Converts an axis to a standardized axis integer.
* Args:
* axis: Integer or string to be parsed.
* Returns:
* "x" or 0: 0
* "y" or 1: 1
* Throws:
* Error if the `axis` input is invalid.
*/
if (axis === 0 || axis === 'x') {
return 0;
} else if (axis === 1 || axis === 'y') {
return 1;
} else {
throw new Error(`"Axis" expected (x (0), y (1)), got: ${axis}`);
}
};
class ResizeGridHandle {
/** The clickable "handle" between two ResizeGridItem instances. */
visible = true;
id = null; // unique identifier for this instance.
pad_px = PAD_PX;
constructor({id, parent, axis} = {}) {
this.id = isNullOrUndefined(id) ? _gen_id_string() : id;
this.parent = parent;
this.elem = document.createElement('div');
this.elem.id = id;
this.axis = _axis_to_int(axis);
isNumberThrowError(this.axis);
this.elem.classList.add('resize-grid--handle');
if (this.axis === 0) {
this.elem.classList.add("resize-grid--row-handle");
this.elem.style.minHeight = this.pad_px + 'px';
this.elem.style.maxHeight = this.pad_px + 'px';
} else if (this.axis === 1) {
this.elem.classList.add("resize-grid--col-handle");
this.elem.style.minWidth = this.pad_px + 'px';
this.elem.style.maxWidth = this.pad_px + 'px';
}
}
destroy() {
this.elem.remove();
}
show() {
this.elem.classList.remove('hidden');
this.visible = true;
}
hide() {
this.elem.classList.add('hidden');
this.visible = false;
}
}
class ResizeGridItem {
/** Grid elements. These can be axes or individual cells.
*
* Attributes:
* id (str):
* A unique identifier for this instance and its element.
* parent (ResizeGridAxis,ResizeGrid):
* The container class for this item. Cannot be a base ResizeGridItem.
* elem (Element):
* The DOM element representing this item.
* callbacks (Object):
* Object specifying callbacks for various operations in this class.
* axis (int):
* The axis along which this item lies. 0: row, 1: col.
* This value is inferred from the passed element's class list.
* is_flex_grow (bool):
* Whether this item should auto expand along its axis.
* The actual elem.style.flexGrow may change independent of this variable
* but this variable is used to determine the default flexGrow.
* is_flex_shrink (bool):
* Whether this item should auto shrink along its axis.
* Same behavior as `is_flex_grow`.
* min_size (int):
* The minimum size of the element.
* base_size (int):
* The default size of the element.
* original_css_text (str):
* The elem.style.cssText string that the element had on initialization.
* Used during destruction of this instance to reset the element.
*/
handle = null;
visible = true;
callbacks = {
/** Allows user to modify the `size_px` value passed to setSize().
* Default callback just returns null. (no op).
* Args:
* item [ResizeGridItem]: This class instance.
* size_px [int]: The size (in px) to be overridden.
* Returns:
* int: The overridden setSize `size_px` paremeter. If null/undefined,
* then `size_px` does not get modified.
*/
set_size_override: (item, size_px) => {
return;
},
};
constructor({id, parent, elem, axis, callbacks} = {}) {
if (elem.id) {
this.id = elem.id;
} else {
this.id = isNullOrUndefined(id) ? _gen_id_string() : id;
}
this.parent = parent;
this.elem = elem;
if (!isNullOrUndefined(axis)) {
this.axis = _axis_to_int(axis);
} else if (elem.classList.contains("resize-grid--row")) {
this.axis = 0;
} else if (elem.classList.contains("resize-grid--col")) {
this.axis = 1;
} else {
throw new Error("Unable to infer axis from element:", elem);
}
// Parse user specified callback overrides.
if (isObject(callbacks)) {
if (isFunction(callbacks.set_size_override)) {
this.callbacks.set_size_override = callbacks.set_size_override;
}
}
this.is_flex_grow = Boolean(parseInt(this.elem.style.flexGrow));
this.is_flex_shrink = Boolean(parseInt(this.elem.style.flexShrink));
let flex_basis = parseInt(cssRelativeUnitToPx(this.elem.style.flexBasis));
if (isNumber(flex_basis) && flex_basis > 0) {
this.base_size = flex_basis;
} else {
const dims = this.elem.getBoundingClientRect();
this.base_size = parseInt(this.axis === 0 ? dims.height : dims.width);
}
this.min_size = this.base_size;
// If data-min-size is set, use that for the min_size instead.
if ("minSize" in this.elem.dataset) {
this.min_size = parseInt(cssRelativeUnitToPx(this.elem.dataset.minSize));
}
this.elem.dataset.id = this.id;
this.original_css_text = this.elem.style.cssText;
}
render({force_flex_grow, force_flex_shrink, reset} = {}) {
/** Sets the element's flex styles.
*
* If no arguments are passed, then flexGrow is reset to default and the
* flexBasis is set to the current calculated size of the element
* (clamped to min_size).
*
* Args:
* force_flex_grow (bool):
* Sets flexGrow=1 and does nothing else.
* force_flex_shrink (bool):
* Sets flexShrink=1 and does nothing else.
* reset (bool):
* Sets flexGrow and flexBasis to the instance defaults.
*/
if (!this.visible) {
return;
}
force_flex_grow = force_flex_grow === true;
force_flex_shrink = force_flex_shrink === true;
reset = reset === true;
const vis_items = this.parent.items.filter(x => x.visible);
if (vis_items.length === 1) {
force_flex_grow = true;
force_flex_shrink = true;
}
if (force_flex_grow || force_flex_shrink) {
this.elem.style.flexGrow = force_flex_grow ? 1 : parseInt(this.elem.style.flexGrow);
this.elem.style.flexShrink = force_flex_shrink ? 1 : parseInt(this.elem.style.flexShrink);
} else if (reset) {
this.elem.style.flexGrow = Number(this.is_flex_grow);
this.elem.style.flexShrink = Number(this.is_flex_shrink);
this.elem.style.flexBasis = this.base_size + 'px';
} else {
this.elem.style.flexGrow = Number(this.is_flex_grow);
this.elem.style.flexShrink = Number(this.is_flex_shrink);
this.elem.style.flexBasis = Math.max(this.min_size, this.getSize()) + 'px';
}
}
destroy() {
if (!isNullOrUndefined(this.handle)) {
this.handle.destroy();
this.handle = null;
}
// Revert changes to the container element.
this.elem.style.cssText = this.original_css_text;
if (!this.elem.style.cssText) {
this.elem.removeAttribute('style');
}
}
shrink(px, {limit_to_base} = {}) {
/** Shrink size along axis by specified pixels. Returns remainder.
*
* Args:
* px (int):
* The number of pixels to shrink by.
* If -1, then the item is shrunk to the `base_size` or `min_size`
* depending on the value of `limit_to_base`.
* limit_to_base (bool):
* Whether to use the `base_size` as the minimum size after shrinking.
* If not specified or false, then `min_size` is used.
*
* Returns:
* int: The number of pixels remaining that could not be shrunk.
*/
if (px <= 0) {
return 0;
}
limit_to_base = limit_to_base === true;
const target_size = limit_to_base ? this.base_size : this.min_size;
const curr_size = this.getSize();
if (px === -1) {
// Shrink to target regardless of the current size.
this.setSize(target_size);
return 0;
} else if (curr_size <= target_size) {
// This can happen if using base_size instead of min_size and the item is
// manually resized smaller than base_size. Cannot shrink, make no changes.
return px;
} else if (curr_size - target_size < px) {
// Can shrink but not to the requested amount. Return remainder.
this.setSize(target_size);
return px - (curr_size - target_size);
} else {
// Can shrink the full requested amount.
this.setSize(curr_size - px);
return 0;
}
}
grow(px, {only_if_flex} = {}) {
/** Grows along axis and returns the amount grown in pixels.
*
* Args:
* px (int):
* The number of pixels to grow by.
* If -1, then the item grows to fill its container.
* only_if_flex (bool):
* If true, then grow is only performed if is_flex_grow=true.
*
* Returns:
* int: The number of pixels that the item grew by.
*/
only_if_flex = only_if_flex === true;
if (only_if_flex && !this.is_flex_grow) {
return 0;
}
let new_size;
const curr_size = this.getSize();
if (px === -1) {
// grow to fill container (only works if visible)
// set flexGrow to 1 to expand to max width so we can calc new width.
this.elem.style.flexGrow = 1;
new_size = this.getSize();
this.elem.style.flexGrow = Number(this.is_flex_grow);
} else {
new_size = curr_size + px;
}
this.setSize(new_size);
return new_size - curr_size;
}
setSize(size_px) {
/** Sets the flexBasis value for the item.
*
* Prior to setting flexBasis, the `set_size_override` callback is called.
* If this callback returns a valid number, then we use that value instead
* of `size_px` for the new size.
*
* Args:
* size_px (int): The new size (in pixels).
*
* Returns:
* int: The size that we just set.
*/
const new_size_px = this.callbacks.set_size_override(this, size_px);
if (isNumber(new_size_px)) {
size_px = new_size_px;
}
this.elem.style.flexBasis = parseInt(size_px) + 'px';
return size_px;
}
getSize() {
/** Returns the current size of this item.
*
* Returns:
* int: If this item is visible in the DOM, then we return the computed
* dimensions of the element. Otherwise we return the element's
* inline style flexBasis value.
*/
if (this.visible) {
const dims = this.elem.getBoundingClientRect();
return this.axis === 0 ? parseInt(dims.height) : parseInt(dims.width);
} else {
return parseInt(this.elem.style.flexBasis);
}
}
genHandle() {
/** Generates a ResizeGridHandle for this item and returns the new handle. */
this.handle = new ResizeGridHandle({
id: `${this.id}_handle`,
parent: this.parent,
axis: this.axis,
});
if (isElement(this.elem.nextElementSibling)) {
this.elem.parentElement.insertBefore(
this.handle.elem,
this.elem.nextSibling
);
} else {
this.elem.parentElement.appendChild(this.handle.elem);
}
return this.handle;
}
show() {
/** Shows this item and its ResizeGridHandle. */
this.elem.classList.remove('hidden');
// Only show the handle if it isnt the last visible item along its axis.
const siblings = this.parent.items;
const sibling = siblings.slice(siblings.indexOf(this) + 1).find(x => x.visible);
if (sibling instanceof ResizeGridItem) {
this.handle.show();
}
this.visible = true;
}
hide() {
/** Hides this item and its ResizeGridHandle. */
this.elem.classList.add('hidden');
this.handle.hide();
this.visible = false;
}
}
class ResizeGridAxis extends ResizeGridItem {
/** Represents a collection of ResizeGridItems along a single axis.
*
* Attributes:
* items (Array[ResizeGridItem]):
* The items contained within this axis.
* item_ids (Object[str, ResizeGridItem]):
* Mapping of item IDs to their ResizeGridItem instance.
*/
constructor(...args) {
super(...args);
this.items = [];
this.item_ids = {};
}
destroy() {
this.items.forEach(item => item.destroy());
this.items = [];
this.item_ids = {};
super.destroy();
}
addCell(id, elem, idx) {
/** Creates a ResizeGridItem along this axis and returns the new item. */
const item = new ResizeGridItem({
id: id,
parent: this,
elem: elem,
axis: this.axis ^ 1, // Children are along the opposite axis of this.
callbacks: this.callbacks,
});
item.genHandle();
item.elem.dataset.index = idx;
this.items.push(item);
this.item_ids[id] = item;
return item;
}
render({force_flex_grow, reset} = {}) {
/** Renders all children items and this item. */
if (!this.visible) {
return;
}
this.items.forEach(item => {
item.render({force_flex_grow: force_flex_grow, reset: reset});
});
// If we only have one visible cell, we need to force it to grow to fill axis.
const visible_cells = this.items.filter(x => x.visible);
if (visible_cells.length === 1) {
visible_cells[0].render({force_flex_grow: true});
}
if (!(this instanceof ResizeGrid)) {
super.render({force_flex_grow: force_flex_grow, reset: reset});
}
this.updateVisibleHandles();
}
getById(id) {
isStringThrowError(id);
if (id in this.item_ids) {
return this.item_ids[id];
}
throw new Error(`No matching Cell ID in ResizeGridAxis: ${id}`);
}
getByElem(elem) {
isElementThrowError(elem);
elem = elem.closest(".resize-grid--cell,.resize-grid--col,.resize-grid--row");
isElementThrowError(elem);
return this.getById(elem.dataset.id);
}
getByIdx(idx) {
isNumberThrowError(idx);
const res = this.items[idx];
if (!isNullOrUndefined(res)) {
return res;
}
throw new Error(`Invalid Cell Index in ResizeGridAxis: ${idx}`);
}
getItem({id, idx, elem} = {}) {
if (isString(id)) {
return this.getById(id);
} else if (isNumber(idx)) {
return this.getByIdx(idx);
} else if (isElement(elem)) {
return this.getByElem(elem);
} else {
// Indicates programmer error.
throw new Error("Invalid arguments. Must specify one of [id, idx, elem].");
}
}
getSiblings(handle_elem) {
/** Returns the nearest visible ResizeGridItems surrounding a ResizeGridHandle.
*
* Args:
* handle_elem (Element): The handle element in the grid to lookup.
*
* Returns:
* Object: Keys=(prev, next). Values are ResizeGridItems.
*/
let prev = this.getItem({elem: handle_elem.previousElementSibling});
if (!prev.visible) {
prev = prev.parent.items.slice(0, this.items.indexOf(prev)).findLast(x => x.visible);
}
let next = this.getItem({elem: handle_elem.nextElementSibling});
if (!next.visible) {
next = next.parent.items.slice(this.items.indexOf(next) + 1).findLast(x => x.visible);
}
return {prev: prev, next: next};
}
updateVisibleHandles() {
/** Sets the visibility of each ResizeGridHandle based on surrounding items. */
for (const item of this.items) {
if (item.visible) {
item.handle.show();
} else {
item.handle.hide();
}
}
const last_vis = this.items[this.items.findLastIndex(x => x.visible)];
if (last_vis instanceof ResizeGridItem) {
last_vis.handle.hide();
}
}
makeRoomForItem(item, siblings, {use_base_size} = {}) {
/** Shrinks items along this axis until the passed item can fit.
*
* Args:
* item (ResizeGridItem):
* The item to be added into this axis.
* siblings (Array[ResizeGridItem]):
* An array of ResizeGridItems within the same container as `item`.
* use_base_size (bool):
* Whether to use the `item`'s base_size or current size when inserting.
*
* Throws:
* Error: If unable to shrink items along this axis to make room for `item`.
*/
isNullOrUndefinedThrowError(item);
const idx = siblings.indexOf(item);
isNumberThrowError(idx);
use_base_size = use_base_size === true;
// If use_base_size=false, then try to get the item's current size and use that.
// Since the item is hidden, this will get the size saved in flexBasis which was
// the previously set size.
const tot = (use_base_size ? item.base_size : item.getSize()) + item.handle.pad_px;
let rem = tot;
// Get first visible sibling after the item we're trying to add.
let sibling = siblings.slice(idx + 1).find(x => x.visible);
const sibling_idx = siblings.indexOf(sibling);
// Shrink from the sibling first.
if (sibling instanceof ResizeGridItem) {
rem = sibling.shrink(rem, {limit_to_base: false});
}
if (rem <= 0) {
return;
}
// Shrink all other items next, starting from the end.
let others = siblings.filter((x, i) => x.visible && i !== idx && i !== sibling_idx);
for (const other of others.slice().reverse()) {
rem = other.shrink(rem, {limit_to_base: false});
if (rem <= 0) {
return;
}
}
// Finally, shrink from the item itself since it is too big to insert.
rem = item.shrink(rem, {limit_to_base: false});
if (rem <= 0) {
return;
}
// This indicates a programmer error.
throw new Error(`No space for item. tot: ${tot}, rem: ${rem}`);
}
growToFill(item, siblings) {
/** Grows an item along an axis to fill its container.
*
* Args:
* item (ResizeGridItem):
* The item to grow.
* siblings (Array[ResizeGridItem]):
* An array of ResizeGridItems within the same container as `item`.
*/
const idx = siblings.indexOf(item);
isNullOrUndefinedThrowError(item); // Indicates programmer error.
let sibling = siblings.slice(idx + 1).find(x => x.visible);
if (isNullOrUndefined(sibling)) {
sibling = siblings.slice(0, idx).findLast(x => x.visible);
isNullOrUndefinedThrowError(sibling); // Indicates programmer error.
}
const sibling_idx = siblings.indexOf(sibling);
// Hide sibling's handle if sibling is the last visible item.
if (siblings.slice(sibling_idx + 1).every(x => !x.visible) && sibling.handle.visible) {
sibling.handle.hide();
}
// If we are growing sibling to fill, then just set flexGrow=1.
if (siblings.length <= 2 || siblings.every(x => !x.visible && x !== sibling && x !== item)) {
sibling.render({force_flex_grow: true});
} else {
sibling.grow(-1);
}
}
show({id, idx, elem, item} = {}) {
/** Shows an item along this axis.
*
* The arguments to this function are used to lookup the item to show.
*/
if (!(item instanceof ResizeGridItem)) {
item = this.getItem({id: id, idx: idx, elem: elem});
}
if (item.visible) {
return;
}
// We are trying to show an item but the container (this) is not visible.
// Show the container (this) first so we can show the item.
if (item !== this && !this.visible) {
// If any parent items are visible, then we need to make room for this item.
if (this.parent.items.some(x => x.visible)) {
this.makeRoomForItem(this, this.parent.items, {use_base_size: false});
}
super.show();
this.parent.render();
}
// No items are visible in this container. Show the item and force flexGrow=1.
if (this.items.every(x => !x.visible)) {
item.show();
item.handle.hide();
item.render({force_flex_grow: true});
return;
}
// Other items are visible in this container, make room for this item.
this.makeRoomForItem(item, this.items, {use_base_size: false});
item.show();
this.updateVisibleHandles();
}
hide({id, idx, elem, item} = {}) {
/** Hides an item along this axis.
*
* The arguments to this function are used to lookup the item to hide.
*/
if (!(item instanceof ResizeGridItem)) {
item = this.getItem({id: id, idx: idx, elem: elem});
}
if (!item.visible) {
return;
}
item.hide();
// If no other items are visible, hide the container.
if (this.items.every(x => !x.visible)) {
super.hide();
if (this.parent.items.every(x => !x.visible)) {
this.parent.render();
return;
}
this.parent.growToFill(this, this.parent.items);
this.parent.render();
return;
}
this.growToFill(item, this.items);
}
toggle({id, idx, elem, item, override} = {}) {
/** Toggles the visibility of an item along this axis.
*
* The arguments to this function are used to lookup the item to show.
*
* Args:
* override (bool): If specified, this value is used to set visibility.
*/
if (!(item instanceof ResizeGridItem)) {
item = this.getItem({id: id, idx: idx, elem: elem});
}
let new_state = !item.visible;
if (override === true || override === false) {
new_state = override;
}
new_state ? item.parent.show({item: item}) : item.parent.hide({item: item});
}
}
class ResizeGrid extends ResizeGridAxis {
/** Class representing a resizable grid.
*
* Attributes (the less obvious ones):
* added_outer_div (bool):
* Whether the outermost ResizeGridAxis was added during setup. This is
* used on destruction to revert the container element to its original state.
* prev_dims (Object):
* Generated by elem.getBoundingClientRect(). This tracks the last known
* dimensions of this container element between resize events.
*/
event_abort_controller = null;
added_outer_div = false;
setup_has_run = false;
prev_dims = null;
resize_observer = null;
resize_observer_timer = null;
constructor(id, elem, {callbacks} = {}) {
const row_elems = Array.from(elem.querySelectorAll(":scope > .resize-grid--row"));
const col_elems = Array.from(elem.querySelectorAll(":scope > .resize-grid--col"));
let axis = 0;
if (row_elems.length && col_elems.length) {
throw new Error("Invalid grid. Cannot have rows and cols at same level.");
} else if (row_elems.length) {
axis = 0;
} else if (col_elems.length) {
axis = 1;
} else {
axis = 0;
}
super({
id: id,
elem: elem,
parent: null,
axis: axis,
callbacks: callbacks,
});
}
destroy() {
this.destroyEvents();
if (this.added_outer_div) {
this.elem.innerHTML = this.elem.children[0].innerHTML;
this.added_outer_div = false;
}
super.destroy();
this.setup_has_run = false;
}
destroyEvents() {
/** Destroys all event listeners and observers. */
// We can simplify removal of event listeners by firing an AbortController
// abort signal. Must pass the signal to any event listeners on creation.
if (this.event_abort_controller) {
this.event_abort_controller.abort();
}
if (!isNullOrUndefined(this.resize_observer)) {
this.resize_observer.disconnect();
}
clearTimeout(this.resize_observer_timer);
this.resize_observer = null;
this.resize_observer_timer = null;
}
setup() {
/** Fully prepares this instance for use. */
// We don't want to run setup a second time without having run `destroy()`.
if (this.setup_has_run) {
// Indicates programmer error.
throw new Error("Setup has already run.");
}
if (!this.elem.querySelector('.resize-grid--row,.resize-grid--col,.resize-grid--cell')) {
throw new Error('Grid has no valid content from which it can build.');
}
this.prev_dims = this.elem.getBoundingClientRect();
this.build();
this.setupEvents();
this.setup_has_run = true;
}
setupEvents() {
/** Sets up all event delegators and observers for this instance. */
this.event_abort_controller = new AbortController();
let prev;
let handle;
let next;
let touch_count = 0;
let dblclick_timer;
let last_move_time;
window.addEventListener(
'pointerdown',
(event) => {
if (event.target.hasPointerCapture(event.pointerId)) {
event.target.releasePointerCapture(event.pointerId);
}
if (event.pointerType === 'mouse' && event.button !== 0) {
return;
}
if (event.pointerType === 'touch') {
touch_count++;
if (touch_count !== 1) {
return;
}
}
const handle_elem = event.target.closest('.resize-grid--handle');
if (!isElement(handle_elem)) {
return;
}
// Clicked handles will always be between two elements. If the user
// somehow clicks an invisible handle then we have bigger problems.
const siblings = this.getSiblings(handle_elem);
if (!(siblings.prev instanceof ResizeGridItem) ||
!(siblings.next instanceof ResizeGridItem)
) {
throw new Error("Failed to find siblings for ResizeGridHandle.");
}
prev = siblings.prev;
handle = prev.handle;
next = siblings.next;
event.preventDefault();
event.stopPropagation();
handle.elem.setPointerCapture(event.pointerId);
// Temporarily set styles for elements. These are cleared on pointerup.
// See `onMove()` comments for more info.
prev.setSize(prev.getSize());
next.setSize(next.getSize());
prev.elem.style.flexGrow = 0;
next.elem.style.flexGrow = 1;
next.elem.style.flexShrink = 1;
document.body.classList.add('resizing');
if (handle.axis === 0) {
document.body.classList.add('resizing-col');
} else {
document.body.classList.add('resizing-row');
}
},
{signal: this.event_abort_controller.signal}
);
window.addEventListener(
'pointermove',
(event) => {
if (
isNullOrUndefined(prev) ||
isNullOrUndefined(handle) ||
isNullOrUndefined(next)
) {
return;
}
event.preventDefault();
event.stopPropagation();
const now = new Date().getTime();
if (!last_move_time || now - last_move_time > MOVE_TIME_DELAY_MS) {
this.onMove(event, prev, handle, next);
last_move_time = now;
}
},
{signal: this.event_abort_controller.signal}
);
window.addEventListener(
'pointerup',
(event) => {
if (
isNullOrUndefined(prev) ||
isNullOrUndefined(handle) ||
isNullOrUndefined(next)
) {
return;
}
if (event.target.hasPointerCapture(event.pointerId)) {
event.target.releasePointerCapture(event.pointerId);
}
if (event.pointerType === 'mouse' && event.button !== 0) {
return;
}
if (event.pointerType === 'touch') {
touch_count--;
}
event.preventDefault();
event.stopPropagation();
handle.elem.releasePointerCapture(event.pointerId);
// Set the new flexBasis value for the `next` element then revert
// the style changes set in the `pointerup` event.
next.elem.style.flexBasis = next.setSize(next.getSize());
prev.render();
next.render();
document.body.classList.remove('resizing');
document.body.classList.remove('resizing-col');
document.body.classList.remove('resizing-row');
if (!dblclick_timer) {
handle.elem.dataset.awaitDblClick = '';
dblclick_timer = setTimeout(
(elem) => {
dblclick_timer = null;
delete elem.dataset.awaitDblClick;
},
DBLCLICK_TIME_MS,
handle.elem
);
} else if ('awaitDblClick' in handle.elem.dataset) {
clearTimeout(dblclick_timer);
dblclick_timer = null;
delete handle.elem.dataset.awaitDblClick;
handle.elem.dispatchEvent(
new CustomEvent('resize_handle_dblclick', {
bubbles: true,
detail: this,
})
);
}
prev = null;
handle = null;
next = null;
},
{signal: this.event_abort_controller.signal}
);
window.addEventListener(
'pointerout',
(event) => {
if (event.pointerType === 'touch') {
touch_count--;
}
},
{signal: this.event_abort_controller.signal}
);
this.resize_observer = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target.id === this.elem.id) {
clearTimeout(this.resize_observer_timer);
this.resize_observer_timer = setTimeout(() => {
this.onResize();
}, RESIZE_DEBOUNCE_TIME_MS);
}
}
});
this.resize_observer.observe(this.elem);
}
addAxis(id, elem, idx) {
/** Creates a ResizeGridAxis instance and returns the new item. */
const item = new ResizeGridAxis({
id: id,
parent: this,
elem: elem,
axis: this.axis,
callbacks: this.callbacks,
});
item.elem.dataset.index = idx;
item.genHandle();
this.items.push(item);
return item;
}
build() {
/** Generates rows/cols based on this instance element's content. */
// Flex direction should be opposite of container axis.
this.elem.style.flexDirection = this.axis === 0 ? "column" : "row";
let axis_elems;
if (this.axis === 0) {
axis_elems = Array.from(this.elem.querySelectorAll(":scope > .resize-grid--row"));
} else {
axis_elems = Array.from(this.elem.querySelectorAll(":scope > .resize-grid--col"));
}
if (!axis_elems.length) {
// If we don't have any rows/cols, then make a new axis.
const elem = document.createElement("div");
elem.classList.add("resize-grid--row");
elem.append(...this.elem.children);
axis_elems = [elem];
// track this change so we can undo it on `destroy()`.
this.added_outer_div = true;
}
// If we only have a single row/col then make it fill the container.
if (axis_elems.length === 1) {
axis_elems[0].style.flexGrow = 1;
}
let id = 0;
axis_elems.forEach((axis_elem, i) => {
const axis = this.addAxis(id++, axis_elem, i);
axis_elem.querySelectorAll(
":scope > .resize-grid--cell"
).forEach((cell_elem, j) => axis.addCell(id++, cell_elem, j));
});
// Set any rows/cols with only one element to be flexGrow=1 and flexShrink=1
// by default. Since they don't have siblings to work around, this makes it so
// the user doesn't have to specify these settings in HTML.
if (this.items.length === 1) {
this.items[0].is_flex_grow = 1;
this.items[0].elem.style.flexGrow = 1;
this.items[0].is_flex_shrink = 1;
this.items[0].elem.style.flexShrink = 1;
}
for (const item of this.items) {
if (item.items.length === 1) {
item.items[0].is_flex_grow = 1;
item.items[0].elem.style.flexGrow = 1;
item.items[0].is_flex_shrink = 1;
item.items[0].elem.style.flexShrink = 1;
}
}
// Render all axes to force them to update flex dims.
this.items[this.items.length - 1].handle.hide();
this.items.forEach(axis => {
axis.items[axis.items.length - 1].handle.hide();
});
this.render({reset: true});
this.buildIdMap(this);
}
buildIdMap(item) {
/** Generates a mapping from ID to ResizeGridItem instances.
*
* Starts at the passed `item` as a root and recursively builds mapping
* from child items.
*/
if (item instanceof ResizeGridAxis) {
this.item_ids[item.id] = item;
item.items.forEach(x => this.buildIdMap(x));
} else if (item instanceof ResizeGridItem) {
this.item_ids[item.id] = item;
}
}
onMove(event, a, handle, b) {
/** Handles pointermove events by calculating and setting new size of elements.
*
* While we are dragging the handle, we only manually set size for the
* item before the handle. During this time, the element after the handle
* should have flexGrow=1 and flexShrink=1 so that it can react to the size
* of the element before the handle. This simplifies calculations since we
* only have to do math for one side of handle.
*/
const a_dims = a.elem.getBoundingClientRect();
const b_dims = b.elem.getBoundingClientRect();
const a_start = Math.ceil(handle.axis === 0 ? a_dims.top : a_dims.left);
const b_end = Math.floor(handle.axis === 0 ? b_dims.bottom : b_dims.right);
const a_lim = a_start + a.min_size;
const b_lim = b_end - b.min_size;
const half_pad = Math.floor(handle.pad_px / 2);
let pos = parseInt(handle.axis === 0 ? event.y : event.x);
pos = Math.min(Math.max(pos, a_lim + half_pad), b_lim - half_pad);
a.setSize((pos - half_pad) - a_start);
}
onResize() {
/** Resizes grid items on resize observer events. */
const curr_dims = this.elem.getBoundingClientRect();
const d_w = curr_dims.width - this.prev_dims.width;
const d_h = curr_dims.height - this.prev_dims.height;
// If no change to size, don't proceed.
if (d_w === 0 && d_h === 0) {
return;
}
if (d_w < 0) {
// Width decrease
for (const item of this.items) {
if (this.axis === 0) {
for (const subitem of item.items) {
if (subitem.getSize() > subitem.min_size) {
subitem.elem.style.flexShrink = 1;
} else {
subitem.elem.style.flexShrink = 0;
subitem.elem.style.flexBasis = subitem.min_size + "px";
}
}
} else {
if (item.getSize() > item.min_size) {
item.elem.style.flexShrink = 1;
} else {
item.elem.style.flexShrink = 0;
item.elem.style.flexBasis = item.min_size + "px";
}
}
}
} else if (d_w > 0) {
// width increase
if (this.axis === 0) {
for (const item of this.items) {
let did_grow = false;
let subitem = item.items.findLast(x => x.visible && x.is_flex_grow);
if (subitem instanceof ResizeGridItem) {
did_grow = subitem.grow(-1) === 0;
}
if (!did_grow) {
subitem = item.items.findLast(x => x.visible && !x.is_flex_grow);
if (subitem instanceof ResizeGridItem) {
subitem.grow(-1);
}
}
}
}
}
if (d_h < 0) {
// height decrease
for (const item of this.items) {
if (this.axis === 1) {
for (const subitem of item.items) {
if (subitem.getSize() > subitem.min_size) {
subitem.elem.style.flexShrink = 1;
} else {
subitem.elem.style.flexShrink = 0;
subitem.elem.style.flexBasis = subitem.min_size + "px";
}
}
} else {
if (item.getSize() > item.min_size) {
item.elem.style.flexShrink = 1;
} else {
item.elem.style.flexShrink = 0;
item.elem.style.flexBasis = item.min_size + "px";
}
}
}
} else if (d_h > 0) {
// height increase
if (this.axis === 1) {
let did_grow = false;
let item = this.items.findLast(x => x.visible && x.is_flex_grow);
if (item instanceof ResizeGridItem) {
did_grow = item.grow(-1) === 0;
}
if (!did_grow) {
item = this.items.findLast(x => x.visible && !x.is_flex_grow);
if (item instanceof ResizeGridItem) {
item.grow(-1);
}
}
}
}
this.prev_dims = curr_dims;
}
}
function resizeGridGetGrid(elem) {
/** Returns the nearest ResizeGrid instance to the passed element. */
// Need to find grid element so we can lookup by its id.
const grid_elem = elem.closest('.resize-grid');
if (!isElement(grid_elem)) {
return null;
}
// Now try to get the actual ResizeGrid instance.
const grid = resize_grids[grid_elem.dataset.id];
if (isNullOrUndefined(grid)) {
return null;
}
return grid;
}
function resizeGridSetup(elem, {id, callbacks} = {}) {
/** Sets up a new ResizeGrid instance for the provided element. */
isElementThrowError(elem);
if (!isString(id)) {
id = _get_unique_id();
}
const grid = new ResizeGrid(id, elem, {callbacks: callbacks});
grid.setup();
resize_grids[id] = grid;
return grid;
}