Files

694 lines
20 KiB
JavaScript

// TODO(chris): Comments
import GridItem from './Grid/Item.js';
import GridLogic from '../../composables/GridLogic.js';
const MODE_IDLE = 0;
const MODE_MOVE = 1;
const MODE_RESIZE = 2;
const MODE_MOUSE_DOWN = 3;
export default {
name: 'Grid',
components: {
GridItem,
},
props: {
cols: Number,
items: Array,
itemsSetup: Object,
resizeLimit: Function,
active: {
type: Boolean,
default: true
},
marginForExtraRow: {
type: Number,
default: 0
},
additionalRow:{
type: Boolean,
default: false,
}
},
emits: [
"rearrangeItems",
"newItem",
"gridHeight",
"draggedItem",
"update:additionalRow"
],
data() {
return {
x: -1,
y: -1,
clientX:0,
clientY: 0,
mode: MODE_IDLE,
grid: null,
dragGrid: null,
permUpdates: [],
positionUpdates: null,
fixedPositionUpdates: null,
draggedOffset: [0,0],
draggedItem: null,
draggedNode: null,
reorderedItems:[],
clonedWidget:null,
}
},
inject:{
sectionName: {
type: String,
default: '',
},
},
computed: {
additionalRowComputed: {
get() {
return this.additionalRow;
},
set(value) {
this.$emit('update:additionalRow', value);
}
},
items_hashmap() {
let items = {};
this.items.forEach(item => {
if (this.reorderedItems.length > 0 && this.needsReordering(item)){
let rearrangedPosition = this.reorderedItems.filter(widget => widget.data.widgetid == item.widgetid)?.pop();
if (rearrangedPosition) {
item.x = rearrangedPosition.x;
item.y = rearrangedPosition.y;
}
}
items[`x${item.x}y${item.y}`] = item;
});
return items
},
items_placeholders(){
let placeholders = [];
let col_max = this.cols;
let rows_max = this.rows;
// occupied hashmap to keep track of the occupied cells
let occupied = {};
for (let y = 0; y < rows_max; y++) {
for (let x = 0; x < col_max; x++) {
// skip current position if it was registered as occupied
if (Object.keys(occupied).length && occupied[`x${x}y${y}`]) {
continue;
}
let current_item = this.items_hashmap[`x${x}y${y}`];
if (current_item) {
//calculate the occupied cells from the width and the height from the items
let width = current_item.w;
let height = current_item.h;
let max_x = x + width - 1;
let max_y = y + height - 1;
if(x != max_x || y != max_y){
for (let occupied_y = y; occupied_y <= max_y; occupied_y++) {
for (let occupied_x = x; occupied_x <= max_x; occupied_x++) {
if (occupied_x != x || occupied_y != y) {
occupied[`x${occupied_x}y${occupied_y}`]=true;
}
}
}
}
}
else {
placeholders.push({ x: x, y: y, w: 1, h: 1, placeholder: true,
data: { id: 'placeholder_' + String(placeholders.length).padStart(4, "0") } });
}
}
}
return placeholders;
},
placedItems_withPlaceholders() {
return [...this.placedItems, ...this.items_placeholders];
},
rows() {
if (this.additionalRowComputed) {
return this.grid ? (this.grid.h+1) : 1;
}
return this.grid ? this.grid.h : 1;
},
gridStyle() {
const addH = this.active ? this.marginForExtraRow : 0;
return {
'--fhc-dg-row-height': 100/(this.rows + addH) + '%',
'--fhc-dg-col-width': 100/this.cols + '%',
'--fhc-dg-item-padding-horizontal': '0.25%',
'--fhc-dg-item-padding-top': '0.5%',
'padding-bottom': 100 * (this.rows + addH)/this.cols + '%'
}
},
indexedItems() {
return this.items.map(
(item, index) => {
return {
index: index,
x: item.x,
y: item.y,
w: item.w,
h: item.h,
weight: item.weight || 0,
data: item
}
}
);
},
prePlacedItems() {
if (!this.fixedPositionUpdates)
return this.indexedItems;
return this.indexedItems.map(item => {
if (!this.fixedPositionUpdates[item.index])
return item;
return {
index: item.index,
weight: item.weight,
data: item.data,
x: this.fixedPositionUpdates[item.index].x === undefined ? item.x : this.fixedPositionUpdates[item.index].x,
y: this.fixedPositionUpdates[item.index].y === undefined ? item.y : this.fixedPositionUpdates[item.index].y,
w: this.fixedPositionUpdates[item.index].w === undefined ? item.w : this.fixedPositionUpdates[item.index].w,
h: this.fixedPositionUpdates[item.index].h === undefined ? item.h : this.fixedPositionUpdates[item.index].h
};
});
},
placedItems() {
if (!this.positionUpdates)
return this.prePlacedItems;
let mappedPlacedItems= this.prePlacedItems.map(item => {
if (!this.positionUpdates[item.index] )
return item;
let height_diff = this.positionUpdates[item.index]?.h - item.h;
let width_diff = this.positionUpdates[item.index]?.w - item.w;
return {
resize: this.positionUpdates[item.index]?.resize,
index: item.index,
weight: item.weight,
data: item.data,
x: this.positionUpdates[item.index].x === undefined ? item.x : this.positionUpdates[item.index].x,
y: this.positionUpdates[item.index].y === undefined ? item.y : this.positionUpdates[item.index].y,
w: width_diff>0?item.w:this.positionUpdates[item.index].w === undefined ? item.w : this.positionUpdates[item.index].w,
h: height_diff > 0 ?item.h:this.positionUpdates[item.index].h === undefined ? item.h : this.positionUpdates[item.index].h
};
});
let temporaryResizeItems = [];
mappedPlacedItems.forEach(item=>{
if(item.resize){
let newItem = {
...item,
w:this.positionUpdates[item.index].w === undefined ? item.w : this.positionUpdates[item.index].w,
h:this.positionUpdates[item.index].h === undefined ? item.h : this.positionUpdates[item.index].h,
resizeOverlay:true,
blank:true,
};
temporaryResizeItems.push(newItem)
}
})
return [...mappedPlacedItems, ...temporaryResizeItems];
},
showEmptyTileHover() {
if (!this.active || !this.grid || this.mode != MODE_IDLE || this.x < 0 || this.y < 0 || this.x >= this.cols || this.y >= this.rows)
return false;
return this.grid.isFreeSlot(this.x, this.y);
},
widgetSetup(){
if (!this.widgetsSetup)
return;
return this.widgetsSetup.reduce((acc, ele) => {
acc[ele.widget_id] =ele;
return acc;
} ,{});
},
},
watch: {
active(active) {
if (!active)
this.dragCancel();
},
cols() {
this.dragCancel();
},
rows: {
handler(value) {
this.$emit('gridHeight', value);
},
immediate: true
},
indexedItems: {
handler(value) {
this.dragCancel();
const updated = this.createNewGrid(value);
this.fixedPositionUpdates = updated;
if (updated.length)
this.$emit('rearrangeItems', updated.filter(v => v));
},
immediate: true,
deep: true
}
},
methods: {
needsReordering(item){
if (!item?.data?.place[this.cols]){
return true;
}
return false;
},
toggleDraggedItemOverlay(condition){
if(!this.draggedNode)
return;
if(condition){
this.draggedNode.firstElementChild.classList.add("dashboard-item-overlay");
}else{
this.draggedNode.firstElementChild.classList.remove("dashboard-item-overlay");
}
},
dragging(event){
if(this.mode == MODE_MOVE){
this.toggleDraggedItemOverlay(true);
const containerRect = this.$refs.container.getBoundingClientRect();
const clonedWidgetRect = this.clonedWidget.getBoundingClientRect();
let desiredTop = this.clientY - 20;
let desiredLeft = this.clientX - 15;
const minTop = 0;
const maxTop = containerRect.height - clonedWidgetRect.height;
const minLeft = 0;
const maxLeft = containerRect.width - clonedWidgetRect.width;
const constrainedTop = Math.max(minTop, Math.min(maxTop, desiredTop));
const constrainedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft));
this.clonedWidget.style.top = `${constrainedTop}px`;
this.clonedWidget.style.left = `${constrainedLeft}px`;
}
},
createNewGrid(items) {
this.grid = new GridLogic(this.cols);
const result = [];
let sortedItems = [...items].sort((a, b) => {
if(this.needsReordering(a) && this.needsReordering(b)){
return 0;
}
else if(this.needsReordering(a)){
return 999;
}
else if(this.needsReordering(b)){
return -999;
}
return a.weight > b.weight;
});
let reorderedItems = [];
sortedItems.forEach(item => {
let freeSlots = this.grid.getFreeSlots();
if(this.needsReordering(item)){
let firstFreeSlot = freeSlots.shift();
if (!firstFreeSlot) {
item.x = 0;
item.y = this.grid.h;
}else{
item.x = firstFreeSlot.x;
item.y = firstFreeSlot.y;
}
reorderedItems.push(item);
}
if (item.x + item.w > this.cols) {
let targetW = this.cols-item.x,
targetX = undefined;
if (this.resizeLimit) {
[targetW] = this.resizeLimit(item.data, targetW, item.h);
}
if (targetW < 1)
targetW = 1;
if (targetW > this.cols)
targetW = this.cols;
if (item.x + targetW > this.cols) {
targetX = this.cols - targetW;
}
if (targetW == item.w)
targetW = undefined;
result[item.index] = {
item: item.data,
x: targetX,
w: targetW
};
}
item.frame = this.grid.getItemFrame(item);
this.convertGridResultToUpdate(this.grid.add(item), result, items);
});
this.reorderedItems = reorderedItems;
this.grid.clearWeights();
return result;
},
convertGridResultToUpdate(input, output, baseArray) {
if (!input)
return;
if (!baseArray)
baseArray = this.indexedItems;
input.forEach(item => {
let result = {
item: baseArray[item.index].data
};
if (item.x !== undefined)
result.x = item.x;
if (item.y !== undefined)
result.y = item.y;
if (item.w !== undefined)
result.w = item.w;
if (item.h !== undefined)
result.h = item.h;
output[item.index] = result;
});
},
mouseLeave() {
/* if (this.mode == MODE_IDLE) {
this.x = -1;
this.y = -1;
} */
},
updateCursor(evt) {
if (!this.active) {
this.x = this.y = -1;
return false;
}
const addH = this.active ? this.marginForExtraRow : 0;
const rect = this.$refs.container.getBoundingClientRect();
if (!evt.clientX && !evt.clientY && evt.touches){
evt.clientX = evt.touches[0].clientX;
evt.clientY = evt.touches[0].clientY;
}
this.clientX = (evt.clientX - rect.left);
this.clientY = (evt.clientY - rect.top);
const gridX = Math.floor(this.cols * (evt.clientX - rect.left) / this.$refs.container.clientWidth);
const gridY = Math.floor((this.rows + addH) * (evt.clientY - rect.top) / this.$refs.container.clientHeight);
if (this.x == gridX && this.y == gridY)
return false;
this.x = gridX;
this.y = gridY;
return true;
},
_dragStart(evt, item) {
if (evt.dataTransfer) {
evt.dataTransfer.setDragImage(evt.target, -99999, -99999);
evt.dataTransfer.dropEffect = 'move';
evt.dataTransfer.effectAllowed = 'move';
}
},
startMove(evt, item) {
if (!this.active)
return;
this.mode = MODE_MOVE;
this.draggedItem = item;
this.$emit('draggedItem', item);
// workaround for chrome fireing event dragend when styles are manipulated during dragging
setTimeout(() => {
this.draggedNode = evt.target.closest(".drop-grid-item");
//clones the widget for the drag Image
let clone = evt.target.closest(".drop-grid-item")?.cloneNode(true);
clone.style.zIndex = 5;
clone.classList.add("widgetClone");
this.$refs.container.appendChild(clone);
const hiddenWidget = clone.querySelector("[style='display: none;']");
hiddenWidget.style.removeProperty("display");
this.clonedWidget = clone;
}, 0);
this.draggedOffset = [item.x - this.x, item.y - this.y];
this._dragStart(evt, item);
},
startResize(evt, item) {
if (!this.active)
return;
this.mode = MODE_RESIZE;
this.draggedItem = item;
this.$emit('draggedItem', item);
this._dragStart(evt);
},
dragOver(evt) {
if ((this.y + 1) > this.rows && (this.mode == MODE_MOVE || this.mode == MODE_RESIZE)) {
this.dragCancel();
}
if (!this.active)
return this.dragCancel();
this.checkPinnedWidgetAnimation();
if(this.mode == MODE_RESIZE){
this.checkWidgetSizeLimitAnimation();
}
if (this.updateCursor(evt)) {
switch(this.mode) {
case MODE_MOVE: {
evt.preventDefault();
this.dragGrid = new GridLogic(this.grid);
let x = this.x + this.draggedOffset[0];
let y = this.y + this.draggedOffset[1];
if (x < 0) {
this.draggedOffset[0] += x;
x = 0;
} else if (x + this.draggedItem.w > this.cols) {
this.draggedOffset[0] += this.cols - this.draggedItem.w - x;
x = this.cols - this.draggedItem.w;
}
if (y < 0) {
this.draggedOffset[1] += y;
y = 0;
}
this.positionUpdates= this.dragGrid.move(this.draggedItem, x, y);
break;
}
case MODE_RESIZE: {
evt.preventDefault();
this.dragGrid = new GridLogic(this.grid);
let w = Math.min(this.cols - this.draggedItem.x, Math.max(1, this.x - this.draggedItem.x + 1));
let h = Math.max(1, this.y - this.draggedItem.y + 1);
if (this.resizeLimit)
[w, h] = this.resizeLimit(this.draggedItem.data, w, h);
this.positionUpdates = this.dragGrid.resize(this.draggedItem, w, h);
break;
}
}
}
},
dragCancel() {
this.removeWidgetClones();
this.additionalRowComputed = false;
this.toggleDraggedItemOverlay(false);
this.mode = MODE_IDLE;
this.positionUpdates = null;
this.draggedOffset = [0,0],
this.draggedItem = null;
this.$emit('draggedItem',null);
this.draggedNode = null;
},
dragEnd() {
this.removeWidgetClones();
this.toggleDraggedItemOverlay(false);
if (this.mode == MODE_IDLE){
return;
}
// clean up unused classes
let draggedItemNode = document.getElementById(this.draggedItem.data.widgetid);
draggedItemNode.classList.remove("border-danger");
Array.from(document.getElementsByClassName("denied-dragging-animation"))?.forEach(ele => {
ele.classList.remove("denied-dragging-animation");
})
//if (!this.active || this.x < 0 || this.y < 0 || this.x >= this.cols)
//return this.dragCancel();
this.mode = MODE_IDLE;
let updated = [];
this.convertGridResultToUpdate(this.positionUpdates, updated);
updated = this._updateFixedPositions(updated);
if (updated.length)
this.$emit('rearrangeItems', updated.filter(v => v));
this.draggedItem = null;
this.draggedNode = null;
this.$emit('draggedItem', null);
},
_updateFixedPositions(updated) {
updated.forEach((item, index) => {
if (!this.fixedPositionUpdates[index])
this.fixedPositionUpdates[index] = item;
else
this.fixedPositionUpdates[index] = {...this.fixedPositionUpdates[index], ...item};
});
let additionalUpdates = this.createNewGrid(this.prePlacedItems);
if (additionalUpdates.length) {
// NOTE(chris): this should never happen but it's here for safety
additionalUpdates.forEach((item, index) => updated[index] = item);
return this._updateFixedPositions(updated);
}
return updated;
},
emptyTileClicked() {
this.additionalRowComputed = false;
this.$emit('newItem', this.x, this.y);
},
updateCursorOnMouseMove(evt){
if(this.mode == MODE_IDLE){
this.updateCursor(evt);
}
},
checkPinnedWidgetAnimation(){
let itemAtPosition=[];
switch(this.mode){
case MODE_RESIZE:
for (let x = this.draggedItem.x; x <= this.x; x++) {
for (let y = this.draggedItem.y; y <= this.y; y++) {
this.items.forEach(item => {
if (item.x == x && item.y == y) {
itemAtPosition.push(item);
}
});
}
}
break;
case MODE_MOVE:
itemAtPosition = this.items.filter(item=>item.x == this.x && item.y == this.y);
break;
}
Array.from(document.getElementsByClassName("denied-dragging-animation"))?.forEach(ele => {
ele.classList.remove("denied-dragging-animation");
})
itemAtPosition.forEach(item=>{
if (item.place[this.cols] && item.place[this.cols].pinned) {
let pinnedWidget = document.getElementById(item.widgetid);
let pinNode = pinnedWidget.querySelector("[pinned='true']");
if (!pinNode.classList.contains("denied-dragging-animation")) {
pinNode.classList.add("denied-dragging-animation");
}
}
})
},
checkWidgetSizeLimitAnimation() {
let draggedItemSetup = this.itemsSetup[this.draggedItem.data.widget];
let draggedItemMaxWidth = draggedItemSetup.width.max ?? draggedItemSetup.width;
let draggedItemMinWidth = draggedItemSetup.width.min ?? draggedItemSetup.width;
let draggedItemMaxHeight = draggedItemSetup.height.max ?? draggedItemSetup.height;
let draggedItemMinHeight = draggedItemSetup.height.min ?? draggedItemSetup.height;
let draggedItemNode = document.getElementById(this.draggedItem.data.widgetid);
let width_after_resize = this.x - this.draggedItem.x + 1;
let height_after_resize = this.y - this.draggedItem.y + 1;
if(
(width_after_resize > 0 && (width_after_resize > draggedItemMaxWidth
|| width_after_resize < draggedItemMinWidth)
)
||
(height_after_resize > 0 && (height_after_resize > draggedItemMaxHeight
|| height_after_resize < draggedItemMinHeight)
)
){
draggedItemNode.classList.add("border-danger");
}else{
draggedItemNode.classList.remove("border-danger");
}
},
removeWidgetClones(){
let widgetClones = Array.from(document.getElementsByClassName("widgetClone"));
for (let i = 0; i < widgetClones.length; i++) {
this.$refs.container.removeChild(widgetClones[i]);
}
},
mouseDown(){
this.mode = MODE_MOUSE_DOWN;
},
mouseUp() {
this.mode = MODE_IDLE;
},
},
template: `
<div
ref="container"
class="drop-grid position-relative h-0"
:style="gridStyle"
@touchmove="dragOver"
@touchend="dragCancel"
@dragover.prevent="dragOver"
@drop="dragEnd($event)"
@mousemove="updateCursorOnMouseMove"
@mouseleave="mouseLeave">
<TransitionGroup tag="div">
<grid-item
ref="gridItems"
v-for="(item,index) in ((mode != 1 && mode != 2) && active ? placedItems_withPlaceholders : placedItems)"
:key="item.data.id"
:item="item"
@start-move="startMove"
@mouse-down="mouseDown"
@mouse-up="mouseUp"
@start-resize="startResize"
@dragging="dragging"
@end-drag="dragEnd"
@touch-end="dragEnd();mouseUp();"
@touch-start="updateCursorOnMouseMove($event); mouseDown();"
class="position-absolute"
:active="active"
:style="{
zIndex: item.resizeOverlay ? 1 : 'auto',
top: 'calc(' + item.y + ' * var(--fhc-dg-row-height))',
left: 'calc(' + item.x + ' * var(--fhc-dg-col-width))',
width: 'calc(' + item.w + ' * var(--fhc-dg-col-width))',
height: 'calc(' + item.h + ' * var(--fhc-dg-row-height))',
paddingTop: 'var(--fhc-dg-item-padding-top)',
paddingLeft: 'var(--fhc-dg-item-padding-horizontal)',
paddingRight: 'var(--fhc-dg-item-padding-horizontal)'
}">
<template v-slot="item">
<slot v-bind="{...item, ...item.data, index:index}" :x="item.x" :y="item.y" ></slot>
</template>
</grid-item>
</TransitionGroup>
</div>`
}
/*
OLD VERSION - ON HOVER
<div
v-if="showEmptyTileHover"
class="position-absolute d-flex justify-content-center align-items-center"
:style="{
cursor: 'pointer',
top: 'calc(' + y + ' * var(--fhc-dg-row-height))',
left: 'calc(' + x + ' * var(--fhc-dg-col-width))',
width: 'var(--fhc-dg-col-width)',
height: 'var(--fhc-dg-row-height)'
}"
@click="emptyTileClicked">
<slot :x="x" :y="y" name="empty-tile-hover"></slot>
</div>
*/