<template> <div @dragover.prevent="disabled ? undefined : handleDragOver($event)" @drop.prevent="disabled ? undefined : handleDrop($event)" class="grid" > <div class="highlight-container" ref="highlightContainer"> <slot name="highlight"> <div class="highlight"></div> </slot> </div> <DragContainer v-for="item in value" :key="item.key" :drag-i-d="item.key" :x="getInt('x', item)" :y="getInt('y', item)" :w="getInt('w', item)" :h="getInt('h', item)" :data="getObject('data', item)" :context="context" :grid-id="gridId" :disabled="disabled || item.disabled" > <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"></slot> </GridItem> </template> <slot></slot> </div> </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"], 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: () => [], }, }, 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; } 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.dataTransfer.getData("vueDrag/gridItem"); if (!data) return; let element = JSON.parse(data); let coords = this.getCoords(event.layerX, event.layerY); if (element.context !== this.context || this.noHighlight) { this.$refs.highlightContainer.style.display = "none"; return; } 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.$refs.highlightContainer.style.display = "none"; let data = event.dataTransfer.getData("vueDrag/gridItem"); 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.layerX, event.layerY); 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; 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); }, getCoords(x, y) { return { x: Math.ceil(x / (this.$el.offsetWidth / this.cols)), y: Math.ceil(y / (this.$el.offsetHeight / 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; }, }, computed: { gridData() { return { gridId: this.gridId, context: this.context, }; }, }, }; </script> <style scoped> .highlight { background: darkgrey; border: grey dashed 2px; width: 100%; height: 100%; } .highlight-container { display: none; transition: all 2s ease-in-out; z-index: -1; 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; } </style>