@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
707 lines (628 loc) • 22.2 kB
JavaScript
/**
* Copyright © Volker Schukai 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 Volker Schukai.
*/
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 Volker Schukai
* @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) {
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;
} else {
}
};
element.addEventListener("mousedown", markElementHandle);
element.addEventListener("touchstart", markElementHandle);
element.addEventListener("dragstart", (event) => {
const target = event.target;
const h = target.querySelector("[data-monster-role='handle']");
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);