@hydroperx/tiles
Version:
Metro tile layout
439 lines (438 loc) • 17.8 kB
JavaScript
// Third-party imports
import assert from "assert";
import Draggable from "@hydroperx/draggable";
import getOffset from "getoffset";
// Local imports
import { getWidth, getHeight } from "./enum/TileSize";
import * as RandomUtils from "./utils/RandomUtils";
import * as Attributes from "./Attributes";
import { LayoutTile } from "./Layout";
/**
* Drag-n-drop behavior for tiles.
*/
export class TileDraggableBehavior {
// Constructor
constructor($, id) {
Object.defineProperty(this, "$", {
enumerable: true,
configurable: true,
writable: true,
value: $
});
Object.defineProperty(this, "id", {
enumerable: true,
configurable: true,
writable: true,
value: id
});
Object.defineProperty(this, "_startX", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
Object.defineProperty(this, "_startY", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
Object.defineProperty(this, "_startState", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
Object.defineProperty(this, "_gridSnap", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
Object.defineProperty(this, "_ghostTile", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
//
}
// Installs drag-n-drop behavior.
install() {
const { id } = this;
const button = this.$._buttons.get(id);
// Drag end handler
const drag_end = this._dragEnd.bind(this);
this.$._tile_drag_end_handlers.set(button, drag_end);
// Setup Draggable
this.$._tile_draggables.set(button, new Draggable(button, {
threshold: "0.7em",
cascadingUnit: "em",
setPosition: false,
onDragStart: this._dragStart.bind(this),
onDrag: this._drag.bind(this),
onDragEnd: drag_end,
}));
}
// Uninstalls drag-n-drop behavior.
uninstall() {
const { id } = this;
const button = this.$._buttons.get(id);
if (button) {
this.$._tile_draggables.get(button)?.destroy();
this.$._tile_draggables.delete(button);
}
}
// Drag start
_dragStart(element, x, y, event) {
// Basics
const { id } = this;
const button = this.$._buttons.get(id);
// Disable pointer interaction on whole parent
this.$._container.style.pointerEvents = "none";
// ... however allow it in that specific tile.
button.style.pointerEvents = "auto";
// Display tile over everything
button.style.zIndex = "999999999";
// Find the corresponding state, layout group and its index
const state = this.$._state.tiles.get(id);
const layout_group = this.$._layout.groups.find(g => g.hasTile(id));
const layout_tile = layout_group.getTile(id);
// Parent group-tiles-div
const group_tiles_div = button.parentElement;
// Save drag start (since the container will change, save offset too)
const group_tiles_offset = getOffset(group_tiles_div, this.$._container);
const group_tiles_inner_left = group_tiles_offset.left + group_tiles_div.clientLeft;
const group_tiles_inner_top = group_tiles_offset.top + group_tiles_div.clientTop;
const tile_offset = getOffset(button, group_tiles_div);
// this._startX = group_tiles_inner_left + tile_offset.x;
// this._startY = group_tiles_inner_top + tile_offset.y;
this._startX = group_tiles_inner_left;
this._startY = group_tiles_inner_top;
this._startState = this.$._state.clone();
// While the tile is being dragged, it is moved out of the group div temporarily and
// appears a direct child of the Tiles container.
button.remove();
this.$._container.appendChild(button);
// Remove the tile from the layout group.
layout_tile.remove();
// Reset ghost tile caches
this._ghostTile = null;
// Reset grid snap caches
this._gridSnap = null;
// Set the `ATTR_DRAGGING` attribute to `"true"`.
button.setAttribute(Attributes.ATTR_DRAGGING, "true");
// Trigger Tiles#dragstart event
this.$.dispatchEvent(new CustomEvent("dragstart", {
detail: { tile: button },
}));
// Rearrange
this.$._deferred_rearrange();
}
// Drag
_drag(element, x, y, event) {
// Basics
const { id } = this;
const button = this.$._buttons.get(id);
const draggable = this.$._tile_draggables.get(button);
// Exit if the tile is removed while dragging.
if (!button.parentElement) {
return;
}
// Patch the draggable position (due to container move).
const current_pos = draggable.get();
draggable.set(this._startX + current_pos.x, // x
this._startY + current_pos.y // y
);
// Track last grid snap
const oldGridSnap = this._gridSnap;
// Try snapping to grid
this._gridSnap = this.$._layout.snapToGrid(button);
// If grid snap resolves successfully to an existing area
if (!!this._gridSnap && this._gridSnap.group) {
let thresholdMet = true;
// If ghost tile has already been created
if (this._ghostTile) {
// Require a X/Y change threshold (of 1 small tile) to
// continue procedures from here on.
thresholdMet =
Math.abs(oldGridSnap.x - this._gridSnap.x) >= 1 ||
Math.abs(oldGridSnap.y - this._gridSnap.y) >= 1;
// Revert ghost tile
if (thresholdMet) {
this._revertGhostTile();
}
}
// If thresold is met
if (thresholdMet) {
// Insert a ghost tile without button at the layout
const layout_group = this.$._layout.groups.find(g => g.id == this._gridSnap.group);
const size = button.getAttribute(Attributes.ATTR_SIZE);
const { x, y } = this._gridSnap;
const w = getWidth(size);
const h = getHeight(size);
// Limit ghost tile to a certain extent,
// so that dragging the tile doesn't keep infinitely
// growing the same group.
let extentLimit = false;
const layoutSize = layout_group.getLayoutSize();
if (this.$._dir == "horizontal") {
const r = w == 1 ? 2 : w == 2 ? 4 : 6;
if (x + w > layoutSize.width + r) {
extentLimit = true;
}
}
else {
const r = h == 1 ? 2 : h == 2 ? 4 : 6;
if (y + h > layoutSize.height + r) {
extentLimit = true;
}
}
if (extentLimit) {
this._gridSnap = null;
}
else {
this._ghostTile = new LayoutTile("__anonymous$" + RandomUtils.hexLarge(), null);
// If can't add ghost tile, cancel it.
if (!this._ghostTile.addTo(layout_group, x, y, w, h)) {
this._ghostTile = null;
this._gridSnap = null;
}
}
this.$._deferred_rearrange();
}
else {
this.$._deferred_rearrange();
}
// Otherwise if a ghost tile has been created
}
else if (this._ghostTile) {
this._revertGhostTile();
this.$._deferred_rearrange();
// Or just rearrange.
}
else {
this.$._deferred_rearrange();
}
// Trigger Tiles#drag event
this.$.dispatchEvent(new CustomEvent("drag", {
detail: { tile: button },
}));
}
// Drag end
_dragEnd(element, x, y, event) {
// Basics
const { id } = this;
const button = this.$._buttons.get(id);
// Proceed only if still dragging.
if (button.getAttribute(Attributes.ATTR_DRAGGING) != "true") {
return;
}
// Re-enable pointer events on Tiles container
this.$._container.style.pointerEvents = "";
// Reset pointer events setting on the tile's button
button.style.pointerEvents = "";
// Reset Z-index
button.style.zIndex = "";
// Remove dragging indicator
button.removeAttribute(Attributes.ATTR_DRAGGING);
// Basics
const tile_state = this.$._state.tiles.get(id);
const old_layout_group = this.$._layout.groups.find(group => group.id == tile_state.group);
// If the tile is about to be removed
if (button.getAttribute(Attributes.ATTR_REMOVING) == "true") {
// Remove tile from the DOM
if (this.$._tile_removal_work) {
this.$._tile_removal_work(button).then(() => {
button.remove();
});
}
else {
button.remove();
}
// Uninstall draggable behavior
this.uninstall();
// Remove from state
this.$._state.tiles.delete(id);
// Remove from `Tiles#_buttons`
this.$._buttons.delete(id);
// If checked, trigger selection change event.
if (button.getAttribute(Attributes.ATTR_CHECKED)) {
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),
}
}));
}
// If there is a ghost tile, revert it.
if (this._ghostTile)
this._revertGhostTile();
// Rearrange
this.$._deferred_rearrange();
// Trigger Tiles#dragend event
this.$.dispatchEvent(new CustomEvent("dragend", {
detail: { tile: button },
}));
// State update signal
this.$._deferred_state_update_signal();
// Clear start state
this._startState = null;
// Finish
return;
}
// If grid snap resolves successfully to an existing area
if (!!this._gridSnap && !!this._gridSnap.group) {
// Remove the ghost tile from the layout
this._ghostTile.remove();
// New layout group
const layout_group = this.$._layout.groups.find(group => group.id == this._gridSnap.group);
// Create LayoutTile
const size = tile_state.size;
const w = getWidth(size);
const h = getHeight(size);
const layout_tile = new LayoutTile(id, button);
// Put the tile in the snap-match layout group
assert(layout_tile.addTo(layout_group, this._gridSnap.x, this._gridSnap.y, w, h), "Grid snapping failure.");
// Move the tile to the snap-match group's tilesDiv DOM.
button.remove();
if (layout_group.div) {
layout_group.div
.getElementsByClassName(this.$._class_names.groupTiles)[0]
.appendChild(button);
}
// Undo drag position
button.style.inset = "";
// Set the tile state's group field.
tile_state.group = this._gridSnap.group;
// Iterate tiles
for (const tile_2 of layout_group.getTiles()) {
// For any other tile
if (layout_tile != tile_2) {
// Move X/Y reflecting the current state.
const tile_state = this.$._state.tiles.get(tile_2.id);
if (tile_state) {
tile_2.move(tile_state.x, tile_state.y);
}
}
}
// If the previous group is empty, remove it.
if (old_layout_group.isEmpty()) {
this.$.removeGroup(old_layout_group.id);
// Rearrange/state update
}
else {
this.$._deferred_rearrange();
this.$._deferred_state_update_signal();
}
// If the grid snap resolves successfully to a blank area
}
else if (!!this._gridSnap) {
// Let group = anonymous auto-generated ID
const group = "__anonymous$" + RandomUtils.hexLarge();
// Create new group
this.$.addGroup({ id: group });
// Find layout group
const layout_group = this.$._layout.groups.find(groupA => groupA.id == group);
// Create LayoutTile
const size = tile_state.size;
const w = getWidth(size);
const h = getHeight(size);
const layout_tile = new LayoutTile(id, button);
// Put the tile in the new layout group
assert(layout_tile.addTo(layout_group, this._gridSnap.x, this._gridSnap.y, w, h), "Grid-snapping failed.");
// Move the tile to the new group's tilesDiv DOM.
button.remove();
if (layout_group.div) {
layout_group.div
.getElementsByClassName(this.$._class_names.groupTiles)[0]
.appendChild(button);
}
// Undo drag position
button.style.inset = "";
// Set the tile state's group field.
tile_state.group = group;
// If the previous group is empty, remove it.
if (old_layout_group.isEmpty()) {
this.$.removeGroup(old_layout_group.id);
// Rearrange/state update
}
else {
this.$._deferred_rearrange();
this.$._deferred_state_update_signal();
}
// If grid snap failed
}
else {
// Move the tile to the DOM back in the group it was.
button.remove();
if (old_layout_group.div) {
old_layout_group.div
.getElementsByClassName(this.$._class_names.groupTiles)[0]
.appendChild(button);
}
const w = getWidth(tile_state.size);
const h = getHeight(tile_state.size);
const layout_tile = new LayoutTile(id, button);
const old_tile_state = this._startState?.tiles.get(id);
// Put the tile back at the initial respective layout group.
// If it fails, insert it at the last position then.
if (!layout_tile.addTo(old_layout_group, old_tile_state.x, old_tile_state.y, w, h)) {
layout_tile.addTo(old_layout_group, null, null, w, h);
}
// Undo drag position
button.style.inset = "";
// If there is a ghost tile, revert it.
if (this._ghostTile)
this._revertGhostTile();
// Update every tile to reflect the old state.
for (const tile of old_layout_group.getTiles()) {
const oldTileState = this._startState?.tiles.get(tile.id);
if (oldTileState) {
tile.move(oldTileState.x, oldTileState.y);
}
else {
// Keep any new tiles as they are, reflecting the current state.
const s = this.$._state.tiles.get(tile.id);
tile.move(s?.x ?? 0, s?.y ?? 0);
}
}
// Rearrange
this.$._deferred_rearrange();
// State update signal
this.$._deferred_state_update_signal();
}
// Clear start state
this._startState = null;
// Trigger Tiles#dragend event
this.$.dispatchEvent(new CustomEvent("dragend", {
detail: { tile: button },
}));
}
// Reverts the ghost tile created automatically from grid snapping.
_revertGhostTile() {
const layout_group = this._ghostTile.$;
// Remove it from the layout
this._ghostTile.remove();
// Update every tile to reflect the old state.
for (const tile of layout_group.getTiles()) {
const oldTileState = this._startState?.tiles.get(tile.id);
if (oldTileState) {
tile.move(oldTileState.x, oldTileState.y);
}
else {
// Keep any new tiles as they are, reflecting the current state.
const s = this.$._state.tiles.get(tile.id);
tile.move(s?.x ?? 0, s?.y ?? 0);
}
}
// Clear cache
this._ghostTile = null;
}
}