UNPKG

@hydroperx/tiles

Version:
632 lines (631 loc) 20.8 kB
// third-party imports import assert from "assert"; // local imports import { EMObserver } from "./utils/EMObserver"; import { getWidth, getHeight, } from "./enum/TileSize"; import * as ScaleUtils from "./utils/ScaleUtils"; import { State } from "./State"; import { HorizontalLayout } from "./HorizontalLayout"; import { VerticalLayout } from "./VerticalLayout"; import { GroupFactory } from "./GroupFactory"; import { TileFactory } from "./TileFactory"; import * as Attributes from "./Attributes"; /** * Tiles layout. */ export class Tiles extends EventTarget { constructor(params) { super(); /** @hidden */ Object.defineProperty(this, "_state", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** @hidden */ Object.defineProperty(this, "_container", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** @hidden */ Object.defineProperty(this, "_dir", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** @hidden */ Object.defineProperty(this, "_class_names", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** @hidden */ Object.defineProperty(this, "_small_size", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** @hidden */ Object.defineProperty(this, "_tile_gap", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** @hidden */ Object.defineProperty(this, "_group_gap", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** @hidden */ Object.defineProperty(this, "_group_width", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** @hidden */ Object.defineProperty(this, "_inline_groups", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** @hidden */ Object.defineProperty(this, "_height", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** @hidden */ Object.defineProperty(this, "_label_height", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** @hidden */ Object.defineProperty(this, "_rearrange_timeout", { enumerable: true, configurable: true, writable: true, value: -1 }); /** @hidden */ Object.defineProperty(this, "_state_update_timeout", { enumerable: true, configurable: true, writable: true, value: -1 }); /** @hidden */ Object.defineProperty(this, "_drag_enabled", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** @hidden */ Object.defineProperty(this, "_selection_enabled", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** @hidden */ Object.defineProperty(this, "_em_observer", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** @hidden */ Object.defineProperty(this, "_em", { enumerable: true, configurable: true, writable: true, value: 16 }); /** @hidden */ Object.defineProperty(this, "_layout", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** @hidden */ Object.defineProperty(this, "_buttons", { enumerable: true, configurable: true, writable: true, value: new Map() }); /** @hidden */ Object.defineProperty(this, "_group_draggables", { enumerable: true, configurable: true, writable: true, value: new Map() }); /** @hidden */ Object.defineProperty(this, "_tile_draggables", { enumerable: true, configurable: true, writable: true, value: new Map() }); /** @hidden */ Object.defineProperty(this, "_tile_drag_end_handlers", { enumerable: true, configurable: true, writable: true, value: new WeakMap() }); /** @hidden */ Object.defineProperty(this, "_resize_observer", { enumerable: true, configurable: true, writable: true, value: null }); /** @hidden */ Object.defineProperty(this, "_tile_removal_work", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** @hidden */ Object.defineProperty(this, "_group_removal_work", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** * Tile sizes in the cascading `em` unit. * @hidden */ Object.defineProperty(this, "_tile_em", { enumerable: true, configurable: true, writable: true, value: { small: { w: 0, h: 0 }, medium: { w: 0, h: 0 }, wide: { w: 0, h: 0 }, large: { w: 0, h: 0 }, } }); this._container = params.element; this._dir = params.direction; this._class_names = { group: params.classNames.group, groupLabel: params.classNames.groupLabel, groupLabelText: params.classNames.groupLabelText, groupTiles: params.classNames.groupTiles, tile: params.classNames.tile, tileContent: params.classNames.tileContent, }; this._small_size = params.smallSize; this._tile_gap = params.tileGap; this._group_gap = params.groupGap; this._container.style.position = "relative"; this._tile_em.small.w = this._small_size; this._tile_em.small.h = this._small_size; this._tile_em.medium.w = this._small_size * 2 + this._tile_gap; this._tile_em.medium.h = this._tile_em.medium.w; this._tile_em.wide.w = this._tile_em.medium.w * 2 + this._tile_gap; this._tile_em.wide.h = this._tile_em.medium.w; this._tile_em.large.w = this._tile_em.wide.w; this._tile_em.large.h = this._tile_em.wide.w; // Removal works this._tile_removal_work = params.tileRemovalWork; this._group_removal_work = params.groupRemovalWork; // dragEnabled this._drag_enabled = params.dragEnabled ?? true; // selectionEnabled this._selection_enabled = params.selectionEnabled ?? true; this._group_width = params.groupWidth ?? 6; this._inline_groups = params.inlineGroups ?? 1; this._height = params.height ?? 6; this._label_height = params.labelHeight; // Height >= 4 assertion assert(this._dir == "horizontal" ? this._height >= 4 : true, "Tiles.height must be >= 4."); // Group width >= 4 assertion assert(this._dir == "vertical" ? this._group_width >= 4 : true, "Tiles.groupWidth must be >= 4."); // Inline groups assertion assert(this._dir == "vertical" ? this._inline_groups >= 1 : true, "Tiles.inlineGroups must be >= 1."); // Observe the `em` unit size this._em_observer = new EMObserver(this._container, (val) => { this._em = val; }); // Set state this._state = new State(); // Initial layout this._layout = this._dir == "horizontal" ? new HorizontalLayout(this) : new VerticalLayout(this); // Rearrange this._layout.rearrange(); } /** * The overall tiles state. */ get state() { return this._state; } /** * Clears everything. */ clear() { // Clear state this.state.clear(); // Clear layout measurements this._layout.groups.length = 0; // Discard draggables for (const [, draggable] of this._group_draggables) { draggable.destroy(); } for (const [, draggable] of this._tile_draggables) { draggable.destroy(); } this._group_draggables.clear(); this._tile_draggables.clear(); // Clear button mappings this._buttons.clear(); // Remove children for (const child of Array.from(this._container.children)) { if (child.classList.contains(EMObserver.CLASS)) { continue; } child.remove(); } // Rearrange layout this._deferred_rearrange(); this._deferred_state_update_signal(); } /** * Destroys the `Tiles` instance, disposing * of any observers and removing the container from the DOM. */ destroy(removeFromDOM = true) { this._em_observer.cleanup(); this._resize_observer?.disconnect(); if (removeFromDOM) { this._container.remove(); } // Discard draggables for (const [, draggable] of this._group_draggables) { draggable.destroy(); } for (const [, draggable] of this._tile_draggables) { draggable.destroy(); } this._group_draggables.clear(); this._tile_draggables.clear(); // Discard deferred rearrange if (this._rearrange_timeout != -1) { window.clearTimeout(this._rearrange_timeout); } } /** * Adds a group to the end and returns its `div` element. * @throws If group ID is duplicate. */ addGroup(params) { new GroupFactory(this).add(params); } /** * Removes a group. * @throws If the group does not exist. */ removeGroup(id) { new GroupFactory(this).remove(id); } /** * Attempts to add a tile. * * If both `x` and `y` are null, this method always succeeds, * adding the tile to the best position available. * * @throws If tile ID is duplicate. * @throws If group is specified and does not exist. * @throws If either of `x` and `y` are `null`, but not both. * @returns `true` if successfully added tile; `false` otherwise. * It can fail depending on the `x` and `y` parameters. */ addTile(params) { return new TileFactory(this).add(params); } /** * Removes a tile. * @throws If the tile does not exist. */ removeTile(id) { new TileFactory(this).remove(id); } /** * Renames a group. */ renameGroup(id, label) { // Affect state const group_state = this._state.groups.get(id); assert(!!group_state, "Group '" + id + "' not found."); group_state.label = label; // Affect the DOM const layout_group = this._layout.groups.find(group => group.id == id); assert(!!layout_group, "Group '" + id + "' not found."); if (layout_group.div) { const textElement = layout_group.div .getElementsByClassName(this._class_names.groupLabelText)[0]; textElement.innerText = label; } // State update signal this._deferred_state_update_signal(); } /** * Attempts to resize a tile. */ resizeTile(id, size) { // Fail if currently dragging a tile. const tiles_dragging = Array.from(this._container.getElementsByClassName(this._class_names.tile)) .some(button => button.getAttribute(Attributes.ATTR_DRAGGING) == "true"); if (tiles_dragging) { return false; } // Layout tile const layout_group = this._layout.groups.find(group => group.hasTile(id)); assert(!!layout_group, "Tile '" + id + "' not found."); const layout_tile = layout_group.getTile(id); // Attempt resize if (!layout_tile.resize(getWidth(size), getHeight(size))) { return false; } // Update state const tile_state = this._state.tiles.get(id); assert(!!tile_state, "Tile '" + id + "' not found."); tile_state.size = size; // Update size attribute layout_tile.button?.setAttribute(Attributes.ATTR_SIZE, size); // Rearrange this._deferred_rearrange(); // State update signal this._deferred_state_update_signal(); return true; } /** * Attempts to move a tile. * @param x X coordinate in small tiles unit (1x1). * @param y Y coordinate in small tiles unit (1x1). */ moveTile(id, x, y) { // Fail if currently dragging a tile. const tiles_dragging = Array.from(this._container.getElementsByClassName(this._class_names.tile)) .some(button => button.getAttribute(Attributes.ATTR_DRAGGING) == "true"); if (tiles_dragging) { return false; } // Layout tile const layout_group = this._layout.groups.find(group => group.hasTile(id)); assert(!!layout_group, "Tile '" + id + "' not found."); const layout_tile = layout_group.getTile(id); // Attempt resize if (!layout_tile.move(x, y)) { return false; } // Update state const tile_state = this._state.tiles.get(id); assert(!!tile_state, "Tile '" + id + "' not found."); // Rearrange this._deferred_rearrange(); return true; } /** * Returns which tiles are checked. */ checkedTiles() { const all_buttons = [ ...this._container.getElementsByClassName(this._class_names.tile), ].map(btn => [ btn.getAttribute(Attributes.ATTR_ID), btn.getAttribute(Attributes.ATTR_CHECKED) == "true", ]); return all_buttons.filter(([, y]) => y).map(([id]) => id); } /** * Returns whether a tile is checked or not. */ getChecked(tile) { const button = this._buttons.get(tile); assert(!!button, "Tile '" + tile + "' not found."); return button.getAttribute(Attributes.ATTR_CHECKED) == "true"; } /** * Sets whether a tile is checked or not. */ setChecked(tile, value) { const button = this._buttons.get(tile); assert(!!button, "Tile '" + tile + "' not found."); button.setAttribute(Attributes.ATTR_CHECKED, value.toString()); const all_buttons = [ ...this._container.getElementsByClassName(this._class_names.tile), ].map(btn => [ btn.getAttribute(Attributes.ATTR_ID), btn.getAttribute(Attributes.ATTR_CHECKED) == "true", ]); // Emit selectionchange event this.dispatchEvent(new CustomEvent("selectionchange", { detail: { tiles: all_buttons.filter(([, y]) => y).map(([id]) => id), } })); } /** * Toggles whether a tile is checked or not. */ toggleChecked(tile) { this.setChecked(tile, !this.getChecked(tile)); } on(type, listenerFn, options) { this.addEventListener(type, listenerFn, options); } off(type, listenerFn, options) { this.removeEventListener(type, listenerFn, options); } /** * Returns the number of inline groups available for * the given width (either in `px` or `em`). * *Applies to vertical layouts only.* * * @throws If not in a vertical layout. */ inlineGroupsAvailable(width) { assert(this._dir == "vertical", "Tiles.inlineGroupsAvailable() can only be called on vertical layouts."); const unitMatch = width.match(/(px|em)$/i); assert(!!unitMatch, "Tiles.inlineGroupsAvailable() takes a width with a 'px' or 'em' unit."); const unit = unitMatch[1].toLowerCase(); let w = parseFloat(width); // convert px to em if (unit == "px") { w /= this._em; } let r = 0; for (let acc = 0; acc < w; r++) { if (acc != 0) { acc += this._group_gap; } acc += this._group_width * this._small_size + (this._group_width - 1) * this._group_gap; } return r; } /** * Indicates the number of inline groups in a vertical layout. * * @throws If not in a vertical layout. */ get inlineGroups() { return this._inline_groups; } set inlineGroups(val) { assert(this._dir == "vertical", "Tiles.inlineGroups can only be changed on vertical layouts."); this._inline_groups = val; this._deferred_rearrange(); } /** * Rearranges the layout. * * This call may be necessary if the container is scaled to zero, usable * after the scale is greater than zero. */ rearrange() { this._deferred_rearrange(); } /** * Rearranges the layout when the minimum scale to make it work * is reached. * * This call may be necessary if the container is initially scaled to zero. */ rearrangeOverMinimumScale() { const initialScale = ScaleUtils.getScale(this._container); const abortController = new AbortController(); const check = () => { if (abortController.signal.aborted) { return; } const scale = ScaleUtils.getScale(this._container); if (scale.x >= 0.1 && scale.y >= 0.1) { this.rearrange(); return; } else if (scale.x < initialScale.x || scale.y < initialScale.y) { return; } requestAnimationFrame(check); }; requestAnimationFrame(check); return abortController; } /** @hidden */ _keep_groups_contiguous() { const sorted_groups = Array.from(this._state.groups.entries()).sort((a, b) => a[1].index - b[1].index); let changed = false; for (let i = 0; i < sorted_groups.length; i++) { const group = sorted_groups[i][1]; if (group.index != i) changed = true; group.index = i; } if (changed) this._deferred_state_update_signal(); } /** @hidden */ _deferred_rearrange() { if (this._rearrange_timeout != -1) { window.clearTimeout(this._rearrange_timeout); } this._rearrange_timeout = window.setTimeout(() => { this._layout.rearrange(); }, 0); } /** @hidden */ _deferred_state_update_signal() { if (this._state_update_timeout != -1) { window.clearTimeout(this._state_update_timeout); } this._state_update_timeout = window.setTimeout(() => { this._state_update_signal(); }, 0); } /** @hidden */ _state_update_signal() { this.dispatchEvent(new CustomEvent("stateupdate", { detail: this._state })); } } /** * Attribute name used for identifying a tile's ID. */ Object.defineProperty(Tiles, "ATTR_ID", { enumerable: true, configurable: true, writable: true, value: Attributes.ATTR_ID }); /** * Attribute name used for indicating a tile's size. */ Object.defineProperty(Tiles, "ATTR_SIZE", { enumerable: true, configurable: true, writable: true, value: Attributes.ATTR_SIZE }); /** * Attribute name used for indicating that a tile is actively in drag. */ Object.defineProperty(Tiles, "ATTR_DRAGGING", { enumerable: true, configurable: true, writable: true, value: Attributes.ATTR_DRAGGING }); /** * Attribute name used for indicating that a tile is checked. */ Object.defineProperty(Tiles, "ATTR_CHECKED", { enumerable: true, configurable: true, writable: true, value: Attributes.ATTR_CHECKED });