feature(CIS Dashboard gridsystem): allows to pin widgets in the grid and changes how widgets are positioned in different viewports

This commit is contained in:
SimonGschnell
2025-04-09 10:50:19 +02:00
parent 97e34948e5
commit 9b9b8b1532
5 changed files with 233 additions and 94 deletions
+5 -7
View File
@@ -77,12 +77,10 @@
z-index: 2;
}
.dragged-widget-icon{
position: absolute;
height: 250px;
width: 200px;
top: -999px;
left: -999px;
z-index: 3;
.pin:hover{
cursor: pointer;
}
.pin[pinned]:hover{
color:rgb(153, 116, 116);
}
+35 -4
View File
@@ -24,7 +24,9 @@ export default {
"dragstart",
"resizestart",
"configOpened",
"configClosed"
"configClosed",
"pinItem",
"unPinItem"
],
props: [
"id",
@@ -36,8 +38,13 @@ export default {
"hidden",
"editMode",
"loading",
"item_data",
"place",
],
computed: {
isPinned(){
return this.place?.pinned ? true : false;
},
isResizeable() {
if (!this.widget) return false;
return this.widget.setup.width.max || this.widget.setup.height.max;
@@ -47,6 +54,17 @@ export default {
},
},
methods: {
unpin(){
// Unpinning is only possible in edit mode
if(!this.editMode)
return;
let result = { item: this.item_data, x: this.item_data.x, y: this.item_data.y };
this.$emit('unPinItem', [result]);
},
pinItem(){
let result = { item: this.item_data, x: this.item_data.x, y: this.item_data.y};
this.$emit('pinItem',[result]);
},
getWidgetC4Link(widget) {
return (FHC_JS_DATA_STORAGE_OBJECT.app_root +
FHC_JS_DATA_STORAGE_OBJECT.ci_router + widget.setup.cis4link)
@@ -120,12 +138,25 @@ export default {
<i class="fa-solid fa-spinner fa-pulse fa-3x"></i>
</div>
</div>
<div v-else-if="!hidden || editMode" :id="widgetID" class="dashboard-item card overflow-hidden h-100 position-relative" :class="arguments && arguments.className ? arguments.className : ''">
<div v-else-if="!hidden || editMode" class="dashboard-item card overflow-hidden h-100 position-relative" :class="arguments && arguments.className ? arguments.className : ''">
<div v-if="widget" class="card-header d-flex ps-0 pe-2 align-items-center">
<Transition>
<span v-if="editMode" drag-action="move" class="col-auto mx-2 px-2 cursor-move"><i class="fa-solid fa-grip-vertical"></i></span>
<span v-if="editMode && !isPinned" drag-action="move" class="col-auto mx-2 px-2 cursor-move"><i class="fa-solid fa-grip-vertical"></i></span>
</Transition>
<span class="col mx-2 px-2">{{ widget.setup.name }}</span>
<template v-if="isPinned">
<div v-if="editMode" pinned="true" @click="unpin" class="pin cursor-pointer col-auto me-2">
<i class="fa-solid fa-thumbtack "></i>
</div>
<div v-else class="col-auto me-2">
<i class="fa-solid fa-thumbtack "></i>
</div>
</template>
<template v-else>
<div v-if="editMode" class="col-auto me-2 pin" @click="pinItem">
<i class="fa-solid fa-thumbtack" style="color:lightgray;"></i>
</div>
</template>
<a v-if="widget.setup.cis4link" :href="getWidgetC4Link(widget)" class="col-auto ms-auto ">
<i class="fa fa-arrow-up-right-from-square me-1"></i>
</a>
@@ -157,7 +188,7 @@ export default {
</template>
</bs-modal>
<height-transition>
<div v-if="editMode && isResizeable" class="card-footer d-flex justify-content-end p-0">
<div v-if="editMode && isResizeable && !isPinned" class="card-footer d-flex justify-content-end p-0">
<span drag-action="resize" class="col-auto px-1 cursor-nw-resize"><i class="fa-solid fa-up-right-and-down-left-from-center mirror-x"></i></span>
</div>
</height-transition>
+22 -63
View File
@@ -57,71 +57,28 @@ export default {
return 'margin-bottom: 8px;';
},
items() {
const computeNearestPlace = (item, gridWidth) =>{
// reuses the nearest placement of the widget from another viewport
/* const computeNearestPlace = (item, gridWidth) =>{
let place;
if (Object.keys(item.place).length > 0) {
const nearestIndex = Object.keys(item.place)
.sort((a, b) => Math.abs(a - gridWidth) - Math.abs(b - gridWidth))
.pop();
.shift();
place = item.place[nearestIndex];
}
else{
place = { x: 0, y: 0, w: 1, h: 1 };
}
return place;
}
} */
return this.widgets.map(item => {
return { ...item, ...(item.place[this.gridWidth] || computeNearestPlace(item, this.gridWidth))};
let placedItems = this.widgets.map(item => {
return { ...item, reorder: false, ...(item.place[this.gridWidth] || { reorder: true, ...{ x: 0, y: 0, w: 1, h: 1 } })};
});
return placedItems;
},
items_hashmap() {
let items = {};
this.items.forEach(item => {
items[`x${item.x}y${item.y}`] = item;
});
return items
},
items_placeholders(){
let placeholders = [];
let col_max = this.gridWidth;
let rows_max = this.gridHeight;
// 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;
},
},
methods: {
handleConfigOpened() {
@@ -140,7 +97,7 @@ export default {
else
minmaxW = {min:minmaxW,max:minmaxW};
if (w < minmaxW.min)
w = minmaxW.min;
w = minmaxW.min;
if (w > minmaxW.max)
w = minmaxW.max;
@@ -170,7 +127,7 @@ export default {
payload[item.id] = { config };
this.updatePreset(payload);
},
updatePositions(updated) {
updatePositions(updated, pinned=false) {
let result = {};
updated.forEach(update => {
@@ -182,6 +139,7 @@ export default {
delete item.y;
delete item.w;
delete item.h;
delete item.place[this.gridWidth].pinned;
if (update.x !== undefined)
item.place[this.gridWidth].x = update.x;
if (update.y !== undefined)
@@ -190,11 +148,13 @@ export default {
item.place[this.gridWidth].w = update.w;
if (update.h !== undefined)
item.place[this.gridWidth].h = update.h;
if (pinned){
item.place[this.gridWidth].pinned = true;
}
result[item.id] = item;
}
});
this.updatePreset(result);
},
updatePreset(update) {
@@ -213,13 +173,8 @@ export default {
});
},
template: `
<div class="dashboard-section position-relative" ref="container" :style="getSectionStyle">
<template v-for="setup in widgetsSetup">
<div class="dragged-widget-icon" :id="'widget-'+name+'-'+setup.widget_id" >
<widget-icon v-if="widgetsSetup" :widget="setup"></widget-icon>
</div>
</template>
<drop-grid v-model:cols="gridWidth" :items="items" :placeholders="items_placeholders" :active="editModeIsActive" :resize-limit="checkResizeLimit" :margin-for-extra-row=".01" @rearrange-items="updatePositions" @gridHeight="gridHeight=$event" >
<div class="dashboard-section position-relative pb-3 border-bottom" ref="container" :style="getSectionStyle">
<drop-grid v-model:cols="gridWidth" :items="items" :active="editModeIsActive" :resize-limit="checkResizeLimit" :margin-for-extra-row=".01" @rearrange-items="updatePositions" @gridHeight="gridHeight=$event" >
<template #default="item">
<dashboard-item
@@ -228,15 +183,19 @@ export default {
:widgetID="item.id"
:width="item.w"
:height="item.h"
:item_data="{config:item.config, custom:item.custom, h:item.h, w:item.w,id:item.id,reorder:item.reorder,place:item.place,widget:item.widget,widgetid:item.widgetid,x:item.x,y:item.y}"
:loading="item.loading"
:config="item.config"
:custom="item.custom"
:hidden="item.hidden"
:editMode="editMode"
:place="item.place[gridWidth]"
@change="saveConfig($event, item)"
@remove="removeWidget(item, $event)"
@config-opened="handleConfigOpened"
@config-closed="handleConfigClosed">
@config-closed="handleConfigClosed"
@pinItem="updatePositions($event,true)"
@unPinItem="updatePositions">
</dashboard-item>
<div v-else class="empty-tile-hover" @click="$emit('widgetAdd', name, { widget: 1, config: {}, place: {[gridWidth]: {x:item.x,y:item.y,w:1,h:1}}, custom: 1 })"></div>
+110 -16
View File
@@ -24,10 +24,6 @@ export default {
type: Number,
default: 0
},
placeholders: {
type: Array,
default: () => []
}
},
emits: [
"rearrangeItems",
@@ -48,8 +44,11 @@ export default {
fixedPositionUpdates: null,
draggedOffset: [0,0],
draggedItem: null,
draggedNode: null,
draggedItemIcon: null,
additionalRow: null,
reorderedItems:[],
clonedWidget:null,
}
},
inject:{
@@ -59,6 +58,64 @@ export default {
},
},
computed: {
items_hashmap() {
let items = {};
this.items.forEach(item => {
if (this.reorderedItems.length > 0){
if(item.reorder){
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.mode == MODE_MOVE || this.mode == MODE_RESIZE) && this.dragGrid)
return this.dragGrid.h;
@@ -83,6 +140,7 @@ export default {
y: item.y,
w: item.w,
h: item.h,
reorder: item.reorder,
weight: item.weight || 0,
data: item
}
@@ -110,7 +168,7 @@ export default {
if (!this.positionUpdates)
return this.prePlacedItems;
return this.prePlacedItems.map(item => {
if (!this.positionUpdates[item.index])
if (!this.positionUpdates[item.index] )
return item;
return {
index: item.index,
@@ -123,9 +181,6 @@ export default {
};
});
},
placedItems_withPlaceholders(){
return [...this.placedItems,...this.placeholders];
},
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;
@@ -169,27 +224,52 @@ export default {
}
},
methods: {
toggleDraggedItemOverlay(condition){
if(!this.draggedItem)
if(!this.draggedNode)
return;
let draggedItemNode = document.getElementById(this.draggedItem.data.id);
if(condition){
draggedItemNode.classList.add("dashboard-item-overlay");
this.draggedNode.firstElementChild.classList.add("dashboard-item-overlay");
}else{
draggedItemNode.classList.remove("dashboard-item-overlay");
this.draggedNode.firstElementChild.classList.remove("dashboard-item-overlay");
}
},
dragging(event){
if(this.mode == MODE_MOVE){
this.toggleDraggedItemOverlay(true);
this.draggedItemIcon.style.top = `${this.clientY}px`;
this.draggedItemIcon.style.left = `${this.clientX}px`;
this.clonedWidget.style.top = `${this.clientY-20}px`;
this.clonedWidget.style.left = `${this.clientX-15}px`;
}
},
createNewGrid(items) {
this.grid = new GridLogic(this.cols);
const result = [];
[...items].sort((a, b) => a.weight > b.weight).forEach(item => {
let sortedItems = [...items].sort((a, b) => {
if (a.reorder){
return 999;
}
if (b.reorder){
return -999;
}
return a.weight > b.weight;
});
let reorderedItems = [];
sortedItems.forEach(item => {
let freeSlots = this.grid.getFreeSlots();
if(item.reorder){
item.reorder=true;
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;
@@ -214,10 +294,12 @@ export default {
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)
@@ -296,7 +378,14 @@ export default {
this.mode = MODE_MOVE;
this.draggedItem = item;
this.draggedItemIcon = document.getElementById(`widget-${this.sectionName}-${this.draggedItem?.data.widget}`);
this.draggedNode = evt.target;
//clones the widget for the drag Image
let clone = evt.target.cloneNode(true);
clone.style.zIndex = 20;
clone.classList.add("widgetClone");
this.$refs.container.appendChild(clone);
this.clonedWidget = clone;
this.draggedOffset = [item.x - this.x, item.y - this.y];
this._dragStart(evt, item);
},
@@ -353,6 +442,7 @@ export default {
this.positionUpdates = null;
this.draggedOffset = [0,0],
this.draggedItem = null;
this.draggedNode = null;
if(this.draggedItemIcon){
this.draggedItemIcon.style.top = '-999px';
@@ -362,6 +452,10 @@ export default {
},
dragEnd() {
let widgetClones = document.getElementsByClassName("widgetClone");
for(let widget of widgetClones){
this.$refs.container.removeChild(widget);
}
if (this.mode == MODE_IDLE)
return;
if (!this.active || this.x < 0 || this.y < 0 || this.x >= this.cols)
+61 -4
View File
@@ -23,9 +23,34 @@ class GridLogic {
const i = y*this.w + x;
return !this.grid[i] && this.grid[i] !== 0;
}
getMaxY(){
return this.data.reduce((acc, item) => {
if (item?.y > acc) {
acc = item.y;
}
return acc;
}, 0);
}
getFreeSlots() {
const freeSlots = [];
let biggestY = this.getMaxY();
let totalSpaces = this.w * (biggestY+1);
for(let i=0; i < totalSpaces; i++){
if (!this.grid[i] && this.grid[i] !== 0){
this.grid[i] = undefined;
}
}
for(let i =0; i < this.grid.length; i++){
if (!this.grid[i] && this.grid[i] !== 0){
let x = i % this.w;
let y = Math.floor(i / this.w);
freeSlots.push({x, y});
}
}
return freeSlots;
}
add(item, prefer) {
let occupiers = this.getItemsInFrame(item.frame);
if (!occupiers.length) {
item.frame.forEach(f => this.grid[f] = item.index);
this.data[item.index] = item;
@@ -63,6 +88,7 @@ class GridLogic {
});
item.frame.forEach(f => this.grid[f] = item.index);
this.data[item.index] = item;
return result;
} else {
console.error('FATAL', "can't arrange item on grid");
@@ -70,8 +96,11 @@ class GridLogic {
}
}
move(item, x, y) {
if (item.data.place[this.w].pinned)
return [];
if (item.x == x && item.y == y)
return [];
this.remove(item);
let prefer = undefined;
@@ -91,6 +120,32 @@ class GridLogic {
currItem.x = x;
currItem.y = y;
currItem.frame = this.getItemFrame(currItem);
let occupiers = this.getItemsInFrame(currItem.frame);
// does not update if the target conatins pinned widgets
if (occupiers.some(frame => this.data[frame]?.data.place[this.w].pinned)) {
return [];
}
// checks if target contains widget with the same high and width
let replace = occupiers
.map(occupier => this.data[occupier])
.filter( occupier => {
return occupier.data.w == item.w && occupier.data.h == item.h
});
// replaces positions of widget and target widget if they have same height and width
if(replace.length > 0){
let replaceUpdate =[];
replaceUpdate[replace[0].index] = { index: replace[0].index, x:item.x, y:item.y };
replaceUpdate[item.index] = { index: item.index, x: replace[0].x, y: replace[0].y};
//update Grid and dataGrid
replace[0].frame.forEach(f => this.grid[f] = item.index)
item.frame.forEach(f => this.grid[f] = replace[0].index);
this.data[replace[0].index] = item;
this.data[item.index] = replace[0];
return replaceUpdate;
}
const updates = this.add(currItem, prefer);
updates[item.index] = {index: item.index, x, y};
@@ -145,25 +200,27 @@ class GridLogic {
let targetframe;
switch(dir) {
case DIR_UP:
if (this.data[index].y - amount < 0)
if (this.data[index].data?.place[this.w]?.pinned || this.data[index].y - amount < 0)
return false;
targetframe = this.data[index].frame.map(i => i-this.w*amount);
move.y = -amount;
break;
case DIR_DOWN:
if (this.data[index].data?.place[this.w]?.pinned)
return false;
if (this.data[index].y + this.data[index].h + amount > this.h)
cost += .4;
targetframe = this.data[index].frame.map(i => i+this.w*amount);
move.y = amount;
break;
case DIR_LEFT:
if (this.data[index].x - amount < 0)
if (this.data[index].data?.place[this.w]?.pinned || this.data[index].x - amount < 0)
return false;
targetframe = this.data[index].frame.map(i => i-amount);
move.x = -amount;
break;
case DIR_RIGHT:
if (this.data[index].x + this.data[index].w + amount > this.w)
if (this.data[index].data?.place[this.w]?.pinned || this.data[index].x + this.data[index].w + amount > this.w)
return false;
targetframe = this.data[index].frame.map(i => i+amount);
move.x = amount;