Files
FHC-Core/public/js/components/Drop/Grid.js
T
2026-04-27 09:37:59 +02:00

619 lines
17 KiB
JavaScript

import GridItem from './Grid/Item.js';
import GridLogic from '../../composables/GridLogic.js';
const MODE_IDLE = 0;
const MODE_MOVE = 1;
const MODE_RESIZE = 2;
export default {
name: 'Grid',
components: {
GridItem,
},
props: {
cols: Number,
items: Array,
itemsSetup: Object,
active: {
type: Boolean,
default: true
},
additionalRow: {
type: Boolean,
default: false
}
},
emits: [
"rearrangeItems",
"gridHeight"
],
data() {
return {
// gridlogic
grid: null,
tempPositionUpdates: null,
correctedPositionUpdates: null,
// dragging
mode: MODE_IDLE,
draggedOffset: [0, 0],
draggedItem: null,
overwriteRows: null,
clonedWidget: null, // ghost image
// tile coordinates while dragging
x: -1,
y: -1,
// mouse coordinates while dragging
clientX: 0,
clientY: 0
};
},
computed: {
// gridlogic
rows() {
const gridH = this.grid?.h || 1;
if (this.overwriteRows !== null && this.overwriteRows > gridH)
return this.overwriteRows;
if (this.additionalRow)
return this.grid ? gridH + 1 : 1;
return gridH;
},
gridStyle() {
return {
'--fhc-dg-row-height': 100/this.rows + '%',
'--fhc-dg-col-width': 100/this.cols + '%',
'--fhc-dg-item-padding':
'var(--fhc-dg-item-py, var(--fhc-dg-item-p, .25%))' +
' ' +
'var(--fhc-dg-item-px, var(--fhc-dg-item-p, .25%))',
'padding-bottom': 100 * this.rows/this.cols + '%'
};
},
// dragging
sizeLimits() {
return Object.fromEntries(Object.entries(this.itemsSetup).map(([type, { setup }]) => {
const result = {}; // work on a copy
if (setup.height === undefined)
result.height = { min: 1, max: undefined };
else if (Number.isInteger(setup.height))
result.height = { min: setup.height, max: setup.height };
else
result.height = {
min: setup.height.min ?? 1,
max: setup.height.max
};
if (setup.width === undefined)
result.width = { min: 1, max: undefined };
else if (Number.isInteger(setup.width))
result.width = { min: setup.width, max: setup.width };
else
result.width = {
min: setup.width.min ?? 1,
max: setup.width.max
};
return [type, result];
}));
},
// item pipeline
placeholders() { // empty tiles
let placeholders = this.grid.getFreeSlots().map((item, index) => {
return {
x: item.x,
y: item.y,
h: 1,
w: 1,
placeholder: true,
data: {
id: 'placeholder_' + index
}
};
});
if (this.additionalRow) {
for (var x = 0; x < this.cols; x++)
placeholders.push({
x,
y: this.grid.h,
h: 1,
w: 1,
placeholder: true,
data: {
id: 'placeholder_' + placeholders.length
}
});
}
return placeholders;
},
indexedItems() { // indexed
return this.items.map(
(item, index) => {
return {
index: index,
x: item.x,
y: item.y,
w: item.w,
h: item.h,
pinned: item.pinned,
weight: item.weight || 0,
data: item
}
}
);
},
prePlacedItems() { // indexed & corrected
if (!this.correctedPositionUpdates)
return this.indexedItems;
return this.indexedItems.map(item => {
if (!this.correctedPositionUpdates[item.index])
return item;
return {
index: item.index,
weight: item.weight,
data: item.data,
x: this.correctedPositionUpdates[item.index].x === undefined ? item.x : this.correctedPositionUpdates[item.index].x,
y: this.correctedPositionUpdates[item.index].y === undefined ? item.y : this.correctedPositionUpdates[item.index].y,
w: this.correctedPositionUpdates[item.index].w === undefined ? item.w : this.correctedPositionUpdates[item.index].w,
h: this.correctedPositionUpdates[item.index].h === undefined ? item.h : this.correctedPositionUpdates[item.index].h,
pinned: item.pinned
};
});
},
placedItems() { // indexed & corrected & dragging
if (!this.tempPositionUpdates)
return this.prePlacedItems;
return this.prePlacedItems.map(item => {
if (!this.tempPositionUpdates[item.index])
return item;
return {
index: item.index,
weight: item.weight,
data: item.data,
x: this.tempPositionUpdates[item.index].x === undefined ? item.x : this.tempPositionUpdates[item.index].x,
y: this.tempPositionUpdates[item.index].y === undefined ? item.y : this.tempPositionUpdates[item.index].y,
w: this.tempPositionUpdates[item.index].w === undefined ? item.w : this.tempPositionUpdates[item.index].w,
h: this.tempPositionUpdates[item.index].h === undefined ? item.h : this.tempPositionUpdates[item.index].h,
pinned: item.pinned
};
});
},
currentItems() { // final items with classes
if (this.mode == MODE_IDLE && this.active)
return [ ...this.placedItems, ...this.placeholders ];
if (this.mode != MODE_IDLE && this.draggedItem) {
// add classes to dragged item
const draggedItemIndex = this.placedItems.findIndex(item => item.index == this.draggedItem.index);
const modifiedDraggedItem = {
...this.placedItems[draggedItemIndex],
classes: []
};
if (this.mode == MODE_MOVE) {
modifiedDraggedItem.classes.push('drop-grid-item-move');
}
if (this.mode == MODE_RESIZE) {
modifiedDraggedItem.classes.push('drop-grid-item-resize');
if (this.draggedItem.oversized)
modifiedDraggedItem.classes.push('drop-grid-item-oversized')
else if (this.tempPositionUpdates?.length)
modifiedDraggedItem.classes.push('drop-grid-item-sizechanged')
}
let currentItems = this.placedItems.toSpliced(draggedItemIndex, 1, modifiedDraggedItem);
if (!this.tempPositionUpdates?.length && this.draggedItem.blockers) {
this.draggedItem.blockers.forEach(index => {
const blockerIndex = this.placedItems.findIndex(item => item.index == index);
const modifiedBlocker = {
...this.placedItems[blockerIndex],
classes: ['drop-grid-item-blocker']
};
currentItems.splice(blockerIndex, 1, modifiedBlocker);
});
}
return currentItems;
}
return this.placedItems;
}
},
watch: {
active(active) {
if (!active && this.mode != MODE_IDLE)
this.dragCancel();
},
cols(value, oldValue) {
if (value == oldValue)
return;
this.reinitGrid();
},
rows: {
handler(value, oldValue) {
if (value == oldValue)
return;
this.$emit('gridHeight', value);
},
immediate: true
},
indexedItems: {
handler(value) {
this.reinitGrid();
},
immediate: true,
deep: true
}
},
methods: {
// helpers
reinitGrid() {
if (this.mode != MODE_IDLE)
this.dragCancel();
let updated = this.createNewGrid(this.indexedItems);
this.correctedPositionUpdates = updated;
if (updated.length)
updated = updated.filter(v => v);
if (updated.length)
this.$emit('rearrangeItems', updated);
},
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;
});
},
// gridlogic
createNewGrid(items) {
this.grid = new GridLogic(this.cols);
const result = [];
const sortedItems = [...items].sort((a, b) => a.weight > b.weight);
sortedItems.forEach(item => {
const target = { ...item };
if (target.x === undefined && target.y == undefined) {
target.x = 0;
target.y = 0;
const setup = this.sizeLimits[target.data.widget];
target.w = setup.width.min;
target.h = setup.height.min;
}
if (target.x + target.w > this.cols) {
let targetW = this.cols - target.x,
targetX = undefined;
[ targetW ] = this.cropSizeToAllowed(target.data.widget, targetW, target.h);
if (targetW > this.cols)
targetW = this.cols;
if (target.x + targetW > this.cols) {
targetX = this.cols - targetW;
}
if (targetW == target.w)
targetW = undefined;
if (targetX !== undefined)
target.x = targetX;
if (targetW !== undefined)
target.w = targetW;
}
target.frame = this.grid.getItemFrame(target);
this.convertGridResultToUpdate(this.grid.add(target), result, items);
['x', 'y', 'h', 'w'].forEach(prop => {
if (target[prop] === item[prop])
return;
if (!result[target.index])
result[target.index] = { item: target.data };
if (result[target.index][prop] === undefined)
result[target.index][prop] = target[prop];
});
});
this.grid.clearWeights();
return result;
},
_updateCorrectedPositions(updated) {
updated.forEach((item, index) => {
if (!this.correctedPositionUpdates[index])
this.correctedPositionUpdates[index] = item;
else
this.correctedPositionUpdates[index] = {...this.correctedPositionUpdates[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._updateCorrectedPositions(updated);
}
return updated;
},
// dragging
_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;
// workaround for chrome fireing event dragend when styles are manipulated during dragging
setTimeout(() => {
this.mode = MODE_MOVE;
this.updateCursor(evt);
this.draggedItem = item;
//clones the widget for the drag Image
// NOTE(chris): this is the element that follows the mouse while dragging
// equivalent to the ghost 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;']");
if (hiddenWidget)
hiddenWidget.style.removeProperty("display");
this.clonedWidget = clone;
this.draggedOffset = [item.x - this.x, item.y - this.y];
}, 0);
this._dragStart(evt, item);
},
startResize(evt, item) {
if (!this.active)
return;
// workaround for chrome fireing event dragend when styles are manipulated during dragging
setTimeout(() => {
this.mode = MODE_RESIZE;
this.draggedItem = item;
}, 0);
this._dragStart(evt);
},
updateCursor(evt) {
if (!this.active) {
this.x = this.y = -1;
return false;
}
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 * (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;
},
dragOver(evt) {
if ((this.y + 1) > this.rows && (this.mode == MODE_MOVE || this.mode == MODE_RESIZE)) {
this.dragCancel();
}
if (!this.active)
return this.dragCancel();
if (this.updateCursor(evt)) {
const targetCoordinates = {};
const dragGrid = new GridLogic(this.grid);
switch(this.mode) {
case MODE_MOVE: {
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;
}
targetCoordinates.x = x;
targetCoordinates.y = y;
targetCoordinates.w = this.draggedItem.w;
targetCoordinates.h = this.draggedItem.h;
this.tempPositionUpdates = dragGrid.move(this.draggedItem, x, y);
this.overwriteRows = dragGrid.h;
break;
}
case MODE_RESIZE: {
const targetW = this.x - this.draggedItem.x + 1;
const targetH = this.y - this.draggedItem.y + 1;
let w = Math.min(this.cols - this.draggedItem.x, targetW);
let h = targetH;
[ w, h ] = this.cropSizeToAllowed(this.draggedItem.data.widget, w, h);
// check resize limits
this.draggedItem.oversized = (w !== targetW || h !== targetH);
if (this.draggedItem.oversized)
[ w, h ] = [ this.draggedItem.w, this.draggedItem.h ];
targetCoordinates.x = this.draggedItem.x;
targetCoordinates.y = this.draggedItem.y;
targetCoordinates.w = w;
targetCoordinates.h = h;
this.tempPositionUpdates = dragGrid.resize(this.draggedItem, w, h);
this.overwriteRows = dragGrid.h;
break;
}
}
// check for blocking pinned widgets
if (!this.tempPositionUpdates?.length) {
const frame = this.grid.getItemFrame(targetCoordinates);
const itemsAtPosition = this.grid.getItemsInFrame(frame);
this.draggedItem.blockers = itemsAtPosition.filter(index => this.indexedItems[index].pinned);
}
}
if (this.tempPositionUpdates?.length)
evt.preventDefault();
},
_cleanupDragging() {
this.mode = MODE_IDLE;
this.overwriteRows = null;
if (this.draggedItem) {
const draggedItem = this.indexedItems.find(item => item.index == this.draggedItem.index);
delete draggedItem.classes;
this.draggedItem = null;
}
// removeWidgetClones
let widgetClones = Array.from(document.getElementsByClassName("widgetClone"));
for (let i = 0; i < widgetClones.length; i++) {
this.$refs.container.removeChild(widgetClones[i]);
}
},
dragCancel() {
if (this.mode == MODE_IDLE) {
return;
}
this.tempPositionUpdates = null;
this.draggedOffset = [0,0];
this._cleanupDragging();
},
dragEnd() {
if (this.mode == MODE_IDLE) {
return;
}
let updated = [];
this.convertGridResultToUpdate(this.tempPositionUpdates, updated);
updated = this._updateCorrectedPositions(updated);
if (updated.length)
this.$emit('rearrangeItems', updated.filter(v => v));
this._cleanupDragging();
},
moveGhostImage(event) {
if (this.mode == MODE_MOVE) {
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`;
}
},
cropSizeToAllowed(type, w, h) {
if (w < 1)
w = 1;
if (h < 1)
h = 1;
const setup = this.sizeLimits[type];
if (!setup)
return [w, h];
if (w < setup.width.min)
w = setup.width.min;
if (h < setup.height.min)
h = setup.height.min;
if (setup.width.max && w > setup.width.max)
w = setup.width.max;
if (setup.height.max && h > setup.height.max)
h = setup.height.max;
return [w, h];
}
},
template: /* html */`
<ul
ref="container"
class="drop-grid position-relative h-0 list-unstyled"
:style="gridStyle"
@dragover="dragOver"
@drop="dragEnd"
>
<TransitionGroup>
<grid-item
ref="gridItems"
v-for="(item, index) in currentItems"
:key="item.data.id"
class="position-absolute"
:class="item.classes"
:item="item"
:style="{
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))',
padding: 'var(--fhc-dg-item-padding)'
}"
@start-move="startMove"
@start-resize="startResize"
@drag="moveGhostImage"
@dragend="dragCancel"
>
<template v-slot="item">
<slot
v-bind="{ ...item, ...item.data, index: index }"
:x="item.x"
:y="item.y"
></slot>
</template>
</grid-item>
</TransitionGroup>
</ul>`
}