<template> <interact droppable @dropmove="disabled || loading ? undefined : handleDragOver($event)" @drop.prevent="disabled || loading ? undefined : handleDrop($event)" @dragleave="handleDragLeave" class="grid" > <template v-if="loading"> <template v-for="loader in cols * rows"> <div :key="loader"> <slot name="loader"></slot> </div> </template> </template> <template v-else> <div class="highlight-container" ref="highlightContainer"> <slot name="highlight"> <div class="highlight"></div> </slot> </div> <DragContainer v-for="item in items" :key="item.key" :drag-i-d="item.key" :x="getInt('x', item)" :y="getInt('y', item)" :w="getInt('w', item)" :h="getInt('h', item)" :num-siblings="item.numSiblings" :sibling-index="item.siblingIndex" :data="getObject('data', item)" :context="context" :grid-id="gridId" :disabled="disabled || loading || item.disabled" @dragstart="emitContainerDragStart" @dragend="emitContainerDragEnd" > <slot v-bind="transformItem(item)" :raw-item="item" name="item"> <dl> <dt>Key</dt> <dd>{{ item.key }}</dd> <dt>Position</dt> <dd>{{ item.x }}, {{ item.y }}</dd> <dt>Size</dt> <dd>{{ item.w }} × {{ item.h }}</dd> <dt>Data</dt> <dd>{{ item.data }}</dd> </dl> </slot> </DragContainer> <template v-for="disabledField in disabledFields"> <GridItem class="disabledField" :x="disabledField.x" :y="disabledField.y" :key="disabledField.x + '|' + disabledField.y" > <slot name="disabledField" :is-dragged-over="isDraggedOver"></slot> </GridItem> </template> <slot></slot> </template> </interact> </template> <script> import DragContainer from "./DragContainer.vue"; import { v4 as uuidv4 } from "uuid"; import GridItem from "./GridItem.vue"; export default { name: "DragGrid", components: { GridItem, DragContainer, }, emits: ["input", "itemChanged", "containerDragStart", "containerDragEnd"], data() { return { isDraggedOver: false, }; }, props: { rows: { type: Number, required: true, }, cols: { type: Number, required: true, }, posValidation: { type: Function, required: false, default: undefined, }, validateElement: { type: Function, required: false, default: undefined, }, value: { type: Array, required: true, }, context: { type: String, required: false, default: uuidv4, }, gridId: { type: String, required: false, default: uuidv4, }, noHighlight: { type: Boolean, required: false, default: false, }, disabled: { type: Boolean, required: false, default: false, }, disabledFields: { type: Array, required: false, default: () => [], }, loading: { type: Boolean, required: false, default: false, }, multipleItemsY: { type: Boolean, required: false, default: false, }, }, methods: { positionAllowed(x, y, key) { if (x < 0 || y < 0) return false; if (x > this.cols) return false; if (y > this.rows) return false; if ( this.disabledFields.filter((field) => field.x === x && field.y === y) .length > 0 ) { // Field is disabled return false; } if (!this.multipleItemsY) { for (let item of this.value) { if (key === item.key) continue; if ( x >= this.getInt("x", item) && x < this.getInt("x", item) + this.getInt("w", item) ) { if ( y >= this.getInt("y", item) && y < this.getInt("y", item) + this.getInt("h", item) ) { return false; } } } } if (this.posValidation) return this.posValidation(x, y, key); return true; }, handleDragOver(event) { let data = event.relatedTarget.dataset.transfer; if (!data) return; let element = JSON.parse(data); let coords = this.getCoords( event.dragEvent.client.x - element.mouseX, event.dragEvent.client.y - element.mouseY, ); if (element.context !== this.context || this.noHighlight) { this.$refs.highlightContainer.style.display = "none"; return; } this.isDraggedOver = true; let newPositionValid = true; for (let x = coords.x; x < coords.x + element.w; x++) { for (let y = coords.y; y < coords.y + element.h; y++) { newPositionValid = this.positionAllowed(x, y, element.key); if (!newPositionValid) break; } if (!newPositionValid) break; } if (!newPositionValid) { this.$refs.highlightContainer.style.display = "none"; return; } this.$refs.highlightContainer.style.display = "block"; this.$refs.highlightContainer.style.gridColumnStart = coords.x + ""; this.$refs.highlightContainer.style.gridRowStart = coords.y + ""; this.$refs.highlightContainer.style.gridColumnEnd = "span " + element.w; this.$refs.highlightContainer.style.gridRowEnd = "span " + element.h; }, handleDrop(event) { this.isDraggedOver = false; this.$refs.highlightContainer.style.display = "none"; let data = event.relatedTarget.dataset.transfer; if (!data) return; let element = JSON.parse(data); if (this.validateElement) this.validateElement(element); if (element.context !== this.context) { return; } let coords = this.getCoords( event.dragEvent.client.x - element.mouseX, event.dragEvent.client.y - element.mouseY, ); let newPositionValid = true; for (let x = coords.x; x < coords.x + element.w; x++) { for (let y = coords.y; y < coords.y + element.h; y++) { newPositionValid = this.positionAllowed(x, y, element.key); if (!newPositionValid) break; } if (!newPositionValid) break; } if (!newPositionValid) return; element.x = coords.x; element.y = coords.y; try { let valueCopy = structuredClone(this.value); let index = valueCopy.findIndex((i) => { return i.key === element.key; }); if (index >= 0) valueCopy.splice(index, 1); let elementCopy = structuredClone(element); elementCopy.context = undefined; elementCopy.originGridId = undefined; elementCopy.mouseX = undefined; elementCopy.mouseY = undefined; valueCopy.push(elementCopy); this.$emit("input", valueCopy); } catch (e) { if (e.code === DOMException.DATA_CLONE_ERR) { // We use functions for properties → we can't clone; only emit `item-changed` event console.debug( "Grid couldn't be cloned, please listen to the `item-changed` event and handle changes yourself.", ); } else { throw e; } } element.gridId = this.gridId; this.$emit("itemChanged", element); }, handleDragLeave() { this.isDraggedOver = false; this.$refs.highlightContainer.style.display = "none"; }, clamp: (min, num, max) => Math.min(Math.max(num, min), max), getCoords(x, y) { let rect = this.$el.getBoundingClientRect(); return { x: this.clamp( 1, Math.ceil((x - rect.x) / (rect.width / this.cols)), this.cols, ), y: this.clamp( 1, Math.ceil((y - rect.y) / (rect.height / this.rows)), this.rows, ), }; }, getInt(property, item) { let val = item[property] || 1; return val instanceof Function ? val(this.gridData) : parseInt(val); }, getObject(property, item) { let val = item[property] || {}; return val instanceof Function ? val(this.gridData) : val; }, transformItem(item) { let newItem = { key: item.key }; newItem.x = this.getInt("x", item); newItem.y = this.getInt("y", item); newItem.w = this.getInt("w", item); newItem.h = this.getInt("h", item); newItem.data = this.getObject("data", item); return newItem; }, emitContainerDragStart(dataTransfer) { this.$emit("containerDragStart", dataTransfer); }, emitContainerDragEnd(dataTransfer) { this.$emit("containerDragEnd", dataTransfer); }, }, computed: { gridData() { return { gridId: this.gridId, context: this.context, }; }, items() { if (!this.multipleItemsY) return this.value; if (this.value.some((item) => item.w > 1)) { console.warn( "You are using multipleItemsY but some items have a width greater than 1.", "This is not supported and will lead to unexpected behaviour.", ); } // calculate numSiblings for each field // First dimension: the columns let xSiblings = []; this.value.forEach((item) => { for (let i = 0; i < item.h; i++) { if (!xSiblings[item.x]) xSiblings[item.x] = []; if (xSiblings[item.x][item.y + i] === undefined) { xSiblings[item.x][item.y + i] = 0; } xSiblings[item.x][item.y + i]++; } }); let xSiblingsCopy = structuredClone(xSiblings); return this.value.map((item) => { let numSiblings = xSiblings[item.x] .filter((i, index) => { return index >= item.y && index < item.y + item.h; }) .reduce((a, b) => Math.max(a, b), 0); for (let i = item.y; i < item.h + item.y; i++) { xSiblingsCopy[item.x][i]--; } let offset = xSiblingsCopy[item.x] .filter((i, index) => { return index >= item.y && index < item.y + item.h; }) .reduce((a, b) => Math.max(a, b), 0); return { ...item, numSiblings: numSiblings, siblingIndex: offset, }; }); }, }, }; </script> <style scoped> .highlight { background: darkgrey; border: grey dashed 2px; width: 100%; height: 100%; } .highlight-container { display: none; transition: all 2s ease-in-out; pointer-events: none; user-select: none; width: 100%; height: 100%; } .grid { display: grid; grid-template-columns: repeat(v-bind(cols), 1fr); grid-template-rows: repeat(v-bind(rows), 1fr); width: 100%; height: 100%; min-width: 100px; min-height: 100px; gap: 1em; touch-action: none; isolation: isolate; } </style>