mirror of
https://github.com/AUTOMATIC1111/stable-diffusion-webui.git
synced 2024-06-07 21:20:49 +00:00
1236 lines
44 KiB
JavaScript
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;
|
|
}
|