UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

712 lines (632 loc) 22.3 kB
/** * Copyright © schukai GmbH and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact schukai GmbH. */ import { instanceSymbol } from "../../constants.mjs"; import { ATTRIBUTE_ROLE } from "../../dom/constants.mjs"; import { CustomElement, getSlottedElements } from "../../dom/customelement.mjs"; import { assembleMethodSymbol, registerCustomElement, } from "../../dom/customelement.mjs"; import { findTargetElementFromEvent } from "../../dom/events.mjs"; import { BoardStyleSheet } from "./stylesheet/board.mjs"; import { Observer } from "../../types/observer.mjs"; export { Board }; /** * @private * @type {symbol} */ export const boardElementSymbol = Symbol("boardElement"); /** * @private * @type {symbol} */ export const gridElementSymbol = Symbol("gridElement"); /** * @private * @type {symbol} */ export const parkingElementSymbol = Symbol("parkingElement"); /** * A Board * * @fragments /fragments/components/layout/board/ * * @example /examples/components/layout/board-simple * * @since 3.116.0 * @copyright schukai GmbH * @summary A beautiful Board that can make your life easier and also looks good. You can use it to create a board, a dashboard, a kanban board, or whatever you want. It is a grid layout with drag and drop support. */ class Board extends CustomElement { /** * This method is called by the `instanceof` operator. * @returns {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/layout/board@@instance"); } /** * * @return {Components.Layout.Board */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initEventHandler.call(this); assignDraggableToAllSlottedElements.call(this); return this; } /** * To set the options via the HTML Tag, the attribute `data-monster-options` must be used. * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} * * The individual configuration values can be found in the table. * * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {Object} dimensions Dimensions of the board * @property {number} dimensions.0.rows Number of rows for the first breakpoint * @property {number} dimensions.0.columns Number of columns for the first breakpoint * @property {number} dimensions.600.rows Number of rows for the second breakpoint * @property {number} dimensions.600.columns Number of columns for the second breakpoint * @property {number} dimensions.1200.rows Number of rows for the third breakpoint * @property {number} dimensions.1200.columns Number of columns for the third breakpoint * @property {number} dimensions.1800.rows Number of rows for the fourth breakpoint * @property {number} dimensions.1800.columns Number of columns for the fourth breakpoint * @property {string} fillMode Fill mode for the board ("top", "bottom", "left", "right", "none") */ get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, dimensions: { 0: { rows: 8, columns: 1, }, 600: { rows: 4, columns: 2, }, 1200: { rows: 4, columns: 3, }, 1800: { rows: 8, columns: 1, }, }, fillMode: "none", // "top", "bottom", "left", "right", "none" }); } /** * @return {string} */ static getTag() { return "monster-board"; } /** * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [BoardStyleSheet]; } } function assignDraggableToAllSlottedElements() { const elements = getSlottedElements.call(this, ""); for (const element of elements) { console.log(element); if (element instanceof HTMLElement) { element.setAttribute("draggable", "true"); } } } /** * @private * @return {initEventHandler} */ function initEventHandler() { const self = this; const element = this[gridElementSymbol]; var dragInfo = { element: undefined, }; this.attachObserver( new Observer(() => { initGrid.call(self); }), ); setTimeout(() => { initGrid.call(self); }); element.addEventListener("drop", function (event) { event.preventDefault(); const gridInfo = getGridInfo(element); const dropCell = getDropTargetCell(element, event, gridInfo); const mode = self.getOption("fillMode") || "none"; const occupant = getElementAtCell.call(self, element, dropCell); if (occupant && occupant !== dragInfo.element) { shiftElement.call(self, occupant, element); } const targetCell = findEmptyCellInMode.call( self, element, dropCell, gridInfo, dragInfo.element, mode, ); moveElementToCell(dragInfo.element, targetCell); if (dragInfo.originalCell) { if ( (mode === "top" || mode === "bottom") && dragInfo.originalCell.col !== targetCell.col ) { // Ursprüngliche Spalte neu ordnen rebalanceColumn.call( self, element, dragInfo.originalCell.col, gridInfo, mode, ); } else if ( (mode === "left" || mode === "right") && dragInfo.originalCell.row !== targetCell.row ) { // Ursprüngliche Zeile neu ordnen rebalanceRow.call( self, element, dragInfo.originalCell.row, gridInfo, mode, ); } } // Aufräumen: Markierung entfernen usw. dragInfo.element.classList.remove("dragging"); dragInfo.element.style.opacity = "1"; dragInfo.element = undefined; dragInfo.originalCell = undefined; }); let clickedHandle = null; const markElementHandle = (event) => { clickedHandle = findTargetElementFromEvent( event, "data-monster-role", "handle", ); if (!clickedHandle) { clickedHandle = null; console.log("no handle"); } else { console.log("handle"); } }; element.addEventListener("mousedown", markElementHandle); element.addEventListener("touchstart", markElementHandle); element.addEventListener("dragstart", (event) => { const target = event.target; const h = target.querySelector("[data-monster-role='handle']"); console.log(h); if (h instanceof HTMLElement) { if (!clickedHandle) { event.preventDefault(); return; } } event.dataTransfer.setData("text/plain", event.target.id); dragInfo.element = event.target; event.target.style.opacity = "0.1"; event.target.classList.add("dragging"); const computedStyle = window.getComputedStyle(event.target); const originalCol = parseInt(computedStyle.getPropertyValue("grid-column-start"), 10) - 1; const originalRow = parseInt(computedStyle.getPropertyValue("grid-row-start"), 10) - 1; dragInfo.originalCell = { row: originalRow, col: originalCol }; }); element.addEventListener("dragend", (event) => { event.target.classList.remove("dragging"); event.target.style.opacity = "1"; dragInfo.element = undefined; }); //let currentDropOverCell = null; element.addEventListener("dragover", function (event) { event.preventDefault(); const gridInfo = getGridInfo(this); const cell = getDropTargetCell(this, event, gridInfo); const occupant = getElementAtCell.call(self, this, cell); if (occupant && occupant !== dragInfo.dragable) { shiftElement.call(self, occupant, this); } }); return this; } /** * Ordnet in der angegebenen Zeile alle Elemente neu, sodass Lücken geschlossen werden. * * @param {HTMLElement} gridContainer - Das Grid-Element. * @param {number} row - Der 0-basierte Index der Zeile. * @param {Object} gridInfo - Informationen zur Grid-Struktur (z.B. Anzahl der Spalten). * @param {string} mode - "left" oder "right". */ function rebalanceRow(gridContainer, row, gridInfo, mode) { const children = Array.from(getSlottedElements.call(this, "div")); let items = []; children.forEach((elem) => { if (elem.classList.contains("dragging")) return; const computedStyle = window.getComputedStyle(elem); const rowStart = parseInt(computedStyle.getPropertyValue("grid-row-start"), 10) - 1; if (rowStart === row) { const colStart = parseInt(computedStyle.getPropertyValue("grid-column-start"), 10) - 1; items.push({ element: elem, col: colStart }); } }); if (mode === "left") { items.sort((a, b) => a.col - b.col); items.forEach((item, index) => { item.element.style.gridColumn = `${index + 1} / span 1`; }); } else if (mode === "right") { items.sort((a, b) => b.col - a.col); const totalCols = gridInfo.columns.length; items.forEach((item, index) => { item.element.style.gridColumn = `${totalCols - index} / span 1`; }); } } /** * Ordnet in der angegebenen Spalte alle Elemente neu, sodass Lücken geschlossen werden. * * @param {HTMLElement} gridContainer - Das Grid-Element. * @param {number} column - Der 0-basierte Index der Spalte. * @param {Object} gridInfo - Informationen zur Grid-Struktur (z.B. Anzahl der Zeilen). * @param {string} mode - "top" oder "bottom". */ function rebalanceColumn(gridContainer, column, gridInfo, mode) { const children = Array.from(getSlottedElements.call(this, "div")); let items = []; children.forEach((elem) => { if (elem.classList.contains("dragging")) return; const computedStyle = window.getComputedStyle(elem); const colStart = parseInt(computedStyle.getPropertyValue("grid-column-start"), 10) - 1; if (colStart === column) { const rowStart = parseInt(computedStyle.getPropertyValue("grid-row-start"), 10) - 1; items.push({ element: elem, row: rowStart }); } }); if (mode === "top") { items.sort((a, b) => a.row - b.row); items.forEach((item, index) => { item.element.style.gridRow = `${index + 1} / span 1`; }); } else if (mode === "bottom") { items.sort((a, b) => b.row - a.row); const totalRows = gridInfo.rows.length; items.forEach((item, index) => { item.element.style.gridRow = `${totalRows - index} / span 1`; }); } } /** * Finds an empty cell in the grid based on the specified mode. * * @param {HTMLElement} gridContainer - The container element of the grid. * @param {Object} cell - The current cell position with row and column properties. * @param {Object} gridInfo - Information about the grid including rows and columns arrays. * @param {HTMLElement} ignoreElement - An element to be ignored during the search for an empty cell. * @param {string} mode - The search mode, determining how to find the empty cell. * Possible values: "top", "bottom", "left", "right", "none". * @return {Object} The position of the empty cell with 'row' and 'col' properties. */ function findEmptyCellInMode( gridContainer, cell, gridInfo, ignoreElement, mode, ) { switch (mode) { case "top": // Suche in der Spalte von oben nach unten for (let row = 0; row < gridInfo.rows.length; row++) { const target = { row, col: cell.col }; const occupant = getElementAtCell.call(this, gridContainer, target); if (!occupant || occupant === ignoreElement) { return target; } } return { row: 0, col: cell.col }; case "bottom": for (let row = gridInfo.rows.length - 1; row >= 0; row--) { const target = { row, col: cell.col }; const occupant = getElementAtCell.call(this, gridContainer, target); if (!occupant || occupant === ignoreElement) { return target; } } return { row: gridInfo.rows.length - 1, col: cell.col }; case "left": for (let col = 0; col < gridInfo.columns.length; col++) { const target = { row: cell.row, col: col }; const occupant = getElementAtCell.call(this, gridContainer, target); if (!occupant || occupant === ignoreElement) { return target; } } return { row: cell.row, col: 0 }; case "right": for (let col = gridInfo.columns.length - 1; col >= 0; col--) { const target = { row: cell.row, col: col }; const occupant = getElementAtCell.call(this, gridContainer, target); if (!occupant || occupant === ignoreElement) { return target; } } return { row: cell.row, col: gridInfo.columns.length - 1 }; case "none": default: return cell; } } /** * Retrieves grid layout information from a grid container element, including the column and row sizes, and the gaps. * * @private * @param {Element} gridContainer The DOM element representing the grid container. * @return {Object} An object containing the grid's columns, rows, column gap, and row gap: * - `columns`: An array of numbers representing the width of each column. * - `rows`: An array of numbers representing the height of each row. * - `columnGap`: A number representing the gap size between columns. * - `rowGap`: A number representing the gap size between rows. */ function getGridInfo(gridContainer) { const style = window.getComputedStyle(gridContainer); const columns = style .getPropertyValue("grid-template-columns") .split(/\s+/) .map((val) => parseFloat(val)); const rows = style .getPropertyValue("grid-template-rows") .split(/\s+/) .map((val) => parseFloat(val)); const columnGap = parseFloat(style.getPropertyValue("column-gap")) || 0; const rowGap = parseFloat(style.getPropertyValue("row-gap")) || 0; return { columns, rows, columnGap, rowGap }; } /** * Determines the drop target cell in a grid container based on the mouse event coordinates. * * @private * @param {HTMLElement} gridContainer - The DOM element representing the grid container. * @param {MouseEvent} event - The mouse event that contains the coordinates of the drop action. * @param {Object} gridInfo - An object containing grid layout information. * @param {number[]} gridInfo.columns - An array of column widths in the grid. * @param {number[]} gridInfo.rows - An array of row heights in the grid. * @param {number} gridInfo.columnGap - The gap size between columns in the grid. * @param {number} gridInfo.rowGap - The gap size between rows in the grid. * @return {Object} An object containing the row and column indices of the drop target cell. * @return {number} return.row - The index of the row in which the drop occurred. Returns -1 if no valid row is found. * @return {number} return.col - The index of the column in which the drop occurred. Returns -1 if no valid column is found. */ function getDropTargetCell(gridContainer, event, gridInfo) { const rect = gridContainer.getBoundingClientRect(); const x = event.clientX - rect.left; // relative X position const y = event.clientY - rect.top; // relative Y position let currentX = 0; let colIndex = -1; for (let i = 0; i < gridInfo.columns.length; i++) { const colWidth = gridInfo.columns[i]; if (x >= currentX && x < currentX + colWidth) { colIndex = i; break; } currentX += colWidth + gridInfo.columnGap; } let currentY = 0; let rowIndex = -1; for (let i = 0; i < gridInfo.rows.length; i++) { const rowHeight = gridInfo.rows[i]; if (y >= currentY && y < currentY + rowHeight) { rowIndex = i; break; } currentY += rowHeight + gridInfo.rowGap; } return { row: rowIndex, col: colIndex }; } function initGrid() { //const element = this[boardElementSymbol]; const dimensions = this.getOption("dimensions"); let stylesheet = ""; for (const [key, value] of Object.entries(dimensions)) { stylesheet += `@container board (min-width: ${key}px) { [data-monster-role="grid"] { grid-template-columns: repeat(${value.columns}, 1fr); grid-template-rows: repeat(${value.rows}, 1fr); } } `; } const styleSheet = new CSSStyleSheet(); styleSheet.replaceSync(stylesheet); this.shadowRoot.adoptedStyleSheets = [ ...Board.getCSSStyleSheet(), styleSheet, ]; return this; } /** * @private * @return {void} */ function initControlReferences() { this[boardElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="control"]`, ); this[gridElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="grid"]`, ); this[parkingElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="parking"]`, ); } /** * Retrieves the element located at the specified cell within a grid container. * * @private * @param {HTMLElement} gridContainer - The container element representing the CSS grid. * @param {{col: number, row: number}} cell - An object containing the zero-based column (col) and row (row) indices of the desired cell. * @return {HTMLElement|null} The element at the specified cell, or null if no element exists at the given cell coordinates. */ function getElementAtCell(gridContainer, cell) { /** @var {Set<HTMLElement>} */ const children = getSlottedElements.call(this, "div").values(); for (const elem of children) { // Überspringe das Element, wenn es gerade gezogen wird if (elem.classList.contains("dragging")) { continue; } // Fetch the computed styles for the current element const computedStyle = window.getComputedStyle(elem); let colValue = computedStyle.getPropertyValue("grid-column-start"); let rowValue = computedStyle.getPropertyValue("grid-row-start"); let gridColumn = computedStyle.getPropertyValue("grid-column"); // z. B. "1 / span 1" let colX = parseInt(gridColumn.split("/")[0], 10) - 1; if (colValue === "auto" || rowValue === "auto") { continue; } const col = parseInt(colValue, 10) - 1; // 0-basierte Indizes const row = parseInt(rowValue, 10) - 1; if (col === cell.col && row === cell.row) { return elem; } } return null; } /** * Adjusts the position of a given cell within a grid based on a default shifting algorithm. * Tries to move the cell downwards first. If not possible, it attempts to move right, * then left. If none of these movements are possible, it returns the original cell position. * * @param {Object} currentCell - The current position of the cell. * @param {number} currentCell.row - The row index of the cell. * @param {number} currentCell.col - The column index of the cell. * @param {Object} gridInfo - Information about the grid structure. * @param {Array} gridInfo.rows - The rows of the grid. * @param {Array} gridInfo.columns - The columns of the grid. * @return {Object} An object containing the new cell position with `row` and `col` properties. */ function defaultShiftAlgorithm(currentCell, gridInfo) { // try first to move down let newRow = currentCell.row + 1; if (newRow < gridInfo.rows.length) { return { row: newRow, col: currentCell.col }; } // if not possible, try to move right let newCol = currentCell.col + 1; if (newCol < gridInfo.columns.length) { return { row: currentCell.row, col: newCol }; } // if not possible, try to move left newCol = currentCell.col - 1; if (newCol >= 0) { return { row: currentCell.row, col: newCol }; } // finally, return the original cell position return currentCell; } /** * Shifts an element within a CSS grid container to a new position based on the specified algorithm. * * @param {HTMLElement} element - The element to be shifted within the grid container. * @param {HTMLElement} gridContainer - The grid container that houses the element. * @param {Function} [shiftAlgorithm=defaultShiftAlgorithm] - A function defining the shifting logic, which takes the current * position and the grid information and returns the new position. Defaults to `defaultShiftAlgorithm` if not provided. * @return {void} No return value. */ function shiftElement( element, gridContainer, shiftAlgorithm = defaultShiftAlgorithm, ) { if (element.classList.contains("dragging")) { return; } const gridInfo = getGridInfo(gridContainer); const currentCol = parseInt(element.style.gridColumn.split("/")[0], 10) - 1; const currentRow = parseInt(element.style.gridRow.split("/")[0], 10) - 1; const currentCell = { row: currentRow, col: currentCol }; if (element.dataset.originalPosition) { const orig = JSON.parse(element.dataset.originalPosition); const occupantAtOrig = getElementAtCell.call(this, gridContainer, orig); if ( (!occupantAtOrig || occupantAtOrig === element) && (currentCell.row !== orig.row || currentCell.col !== orig.col) ) { element.style.gridColumn = `${orig.col + 1} / span 1`; element.style.gridRow = `${orig.row + 1} / span 1`; delete element.dataset.originalPosition; return; } if (currentCell.row === orig.row && currentCell.col === orig.col) { delete element.dataset.originalPosition; } } else { element.dataset.originalPosition = JSON.stringify(currentCell); } const newCell = shiftAlgorithm(currentCell, gridInfo); if (newCell.row === currentCell.row && newCell.col === currentCell.col) { return; } const occupant = getElementAtCell.call(this, gridContainer, newCell); if (occupant) { occupant.style.gridColumn = `${currentCell.col + 1} / span 1`; occupant.style.gridRow = `${currentCell.row + 1} / span 1`; delete occupant.dataset.originalPosition; } element.style.gridColumn = `${newCell.col + 1} / span 1`; element.style.gridRow = `${newCell.row + 1} / span 1`; } /** * Moves an HTML element to a specific cell in a grid layout. * * @param {HTMLElement} element - The HTML element to be positioned within the grid. * @param {{row: number, col: number}} cell - An object specifying the target cell's row and column indices. * @return {void} */ function moveElementToCell(element, cell) { element.style.gridColumn = `${cell.col + 1} / span 1`; element.style.gridRow = `${cell.row + 1} / span 1`; } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <div data-monster-role="parking"></div> <div data-monster-role="control" part="control"> <div data-monster-role="grid" part="grid"> <slot></slot> </div>`; } registerCustomElement(Board);