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 */`