UNPKG

@schukai/monster

Version:

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

1,706 lines (1,550 loc) 67.7 kB
/** * 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. * * SPDX-License-Identifier: AGPL-3.0 */ import { instanceSymbol } from "../../constants.mjs"; import { addAttributeToken } from "../../dom/attributes.mjs"; import { ATTRIBUTE_ERRORMESSAGE, ATTRIBUTE_ROLE, } from "../../dom/constants.mjs"; import { CustomControl } from "../../dom/customcontrol.mjs"; import { assembleMethodSymbol, registerCustomElement, } from "../../dom/customelement.mjs"; import { fireCustomEvent } from "../../dom/events.mjs"; import { getLocaleOfDocument } from "../../dom/locale.mjs"; import { Observer } from "../../types/observer.mjs"; import { isArray, isObject, isString } from "../../types/is.mjs"; import { ID } from "../../types/id.mjs"; import { clone } from "../../util/clone.mjs"; import { SheetStyleSheet } from "./stylesheet/sheet.mjs"; export { Sheet }; const gridElementSymbol = Symbol("sheetGrid"); const gridWrapperSymbol = Symbol("sheetGridWrapper"); const spacerElementSymbol = Symbol("sheetGridSpacer"); const addRowButtonSymbol = Symbol("sheetAddRowButton"); const addColumnButtonSymbol = Symbol("sheetAddColumnButton"); const lastSnapshotSymbol = Symbol("sheetLastSnapshot"); const resizeStateSymbol = Symbol("sheetResizeState"); const skipRenderSymbol = Symbol("sheetSkipRender"); const lastViewportSymbol = Symbol("sheetLastViewport"); const scrollFrameSymbol = Symbol("sheetScrollFrame"); const forceRenderSymbol = Symbol("sheetForceRender"); const resizeFrameSymbol = Symbol("sheetResizeFrame"); const selectionSymbol = Symbol("sheetSelection"); const selectionBoxSymbol = Symbol("sheetSelectionBox"); const fillHandleSymbol = Symbol("sheetFillHandle"); const statusSymbol = Symbol("sheetStatus"); const statusTimeoutSymbol = Symbol("sheetStatusTimeout"); const contextMenuSymbol = Symbol("sheetContextMenu"); const menuStateSymbol = Symbol("sheetMenuState"); const dragFillSymbol = Symbol("sheetDragFill"); const lastCopySymbol = Symbol("sheetLastCopy"); const dragSelectSymbol = Symbol("sheetDragSelect"); class Sheet extends CustomControl { static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/form/sheet@@instance"); } [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initEventHandler.call(this); initOptionObserver.call(this); updateControl.call(this); return this; } get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate() }, labels: getTranslations(), classes: { button: "monster-button-outline-primary", }, features: { addRows: false, addColumns: false, editable: true, resizeRows: true, resizeColumns: true, virtualize: false, }, virtualization: { rowBuffer: 4, columnBuffer: 2, }, columns: defaultColumns(3), rows: defaultRows(3), sizes: { columns: {}, rows: {}, rowHeaderWidth: 56, headerHeight: 32, }, constraints: { minColumnWidth: 64, maxColumnWidth: 360, minRowHeight: 28, maxRowHeight: 120, }, value: { cells: {}, formulas: {} }, disabled: false, cell: { placeholder: "", }, }); } static getTag() { return "monster-sheet"; } static getCSSStyleSheet() { return [SheetStyleSheet]; } get value() { return this.getOption("value"); } set value(value) { this.setOption("value", value); this[forceRenderSymbol] = true; setFormValueSafe.call(this); updateControl.call(this); } } function initControlReferences() { const root = this.shadowRoot; this[gridElementSymbol] = root.querySelector(`[${ATTRIBUTE_ROLE}=grid]`); this[gridWrapperSymbol] = root.querySelector( `[${ATTRIBUTE_ROLE}=grid-wrapper]`, ); if (this[gridWrapperSymbol]) { let spacer = this[gridWrapperSymbol].querySelector( `[${ATTRIBUTE_ROLE}=spacer]`, ); if (!spacer) { spacer = document.createElement("div"); spacer.setAttribute(ATTRIBUTE_ROLE, "spacer"); spacer.setAttribute("part", "spacer"); this[gridWrapperSymbol].prepend(spacer); } this[spacerElementSymbol] = spacer; } this[addRowButtonSymbol] = root.querySelector(`[${ATTRIBUTE_ROLE}=add-row]`); this[addColumnButtonSymbol] = root.querySelector( `[${ATTRIBUTE_ROLE}=add-column]`, ); this[selectionBoxSymbol] = root.querySelector( `[${ATTRIBUTE_ROLE}=selection]`, ); this[fillHandleSymbol] = root.querySelector( `[${ATTRIBUTE_ROLE}=fill-handle]`, ); this[statusSymbol] = root.querySelector(`[${ATTRIBUTE_ROLE}=status]`); this[contextMenuSymbol] = root.querySelector( `[${ATTRIBUTE_ROLE}=context-menu]`, ); } function initEventHandler() { this[addRowButtonSymbol].addEventListener("click", () => { if (!canAddRows.call(this)) return; addRow.call(this); }); this[addColumnButtonSymbol].addEventListener("click", () => { if (!canAddColumns.call(this)) return; addColumn.call(this); }); this[gridElementSymbol].addEventListener("input", (event) => { const input = event.target; if (!(input instanceof HTMLInputElement)) return; const cell = input.closest(`[${ATTRIBUTE_ROLE}=cell]`); if (!cell) return; const rowId = cell.dataset.rowId; const colId = cell.dataset.colId; if (!rowId || !colId) return; setCellValue.call(this, rowId, colId, input.value); }); this[gridElementSymbol].addEventListener("focusin", (event) => { const input = event.target; if (!(input instanceof HTMLInputElement)) return; const cell = input.closest(`[${ATTRIBUTE_ROLE}=cell]`); if (!cell) return; const rowId = cell.dataset.rowId; const colId = cell.dataset.colId; if (!rowId || !colId) return; const data = normalizeValue.call(this); const formula = data.formulas?.[rowId]?.[colId]; if (isString(formula)) { input.value = formula; } const selection = this[selectionSymbol]; const isMulti = selection?.anchor && selection?.focus && (selection.anchor.rowId !== selection.focus.rowId || selection.anchor.colId !== selection.focus.colId); if (!event.shiftKey && isMulti) return; setSelectionFromCell.call(this, rowId, colId, event.shiftKey); }); this[gridElementSymbol].addEventListener("focusout", (event) => { const input = event.target; if (!(input instanceof HTMLInputElement)) return; const cell = input.closest(`[${ATTRIBUTE_ROLE}=cell]`); if (!cell) return; const rowId = cell.dataset.rowId; const colId = cell.dataset.colId; if (!rowId || !colId) return; refreshDisplayValues.call(this); }); this[gridElementSymbol].addEventListener("pointerdown", (event) => { if (event.button !== 0) return; const columnHandle = event.target.closest( `[${ATTRIBUTE_ROLE}=column-resize]`, ); if (columnHandle) { if (!this.getOption("features.resizeColumns", true)) return; startColumnResize.call(this, event, columnHandle); return; } const rowHandle = event.target.closest(`[${ATTRIBUTE_ROLE}=row-resize]`); if (rowHandle) { if (!this.getOption("features.resizeRows", true)) return; startRowResize.call(this, event, rowHandle); return; } const cell = event.target.closest(`[${ATTRIBUTE_ROLE}=cell]`); if (!cell) return; const rowId = cell.dataset.rowId; const colId = cell.dataset.colId; if (!rowId || !colId) return; setSelectionFromCell.call(this, rowId, colId, event.shiftKey); startSelectionDrag.call(this, event); }); this[gridElementSymbol].addEventListener("keydown", (event) => { if (!(event.ctrlKey || event.metaKey)) return; if (event.key === "c" || event.key === "C") { event.preventDefault(); void copySelectionToClipboard.call(this); return; } if (event.key === "x" || event.key === "X") { event.preventDefault(); void cutSelectionToClipboard.call(this); return; } if (event.key === "v" || event.key === "V") { event.preventDefault(); void pasteFromClipboard.call(this); } }); this[gridElementSymbol].addEventListener("contextmenu", (event) => { const cell = event.target.closest(`[${ATTRIBUTE_ROLE}=cell]`); if (!cell) return; event.preventDefault(); const rowId = cell.dataset.rowId; const colId = cell.dataset.colId; if (rowId && colId) { setSelectionFromCell.call(this, rowId, colId, false); } showContextMenu.call(this, event.clientX, event.clientY); }); this.shadowRoot.addEventListener("pointerdown", (event) => { const menu = this[contextMenuSymbol]; if (!menu || menu.hidden) return; if (menu.contains(event.target)) return; hideContextMenu.call(this); }); if (this[gridWrapperSymbol]) { this[gridWrapperSymbol].addEventListener("scroll", () => { if (!this.getOption("features.virtualize", false)) return; if (this[scrollFrameSymbol]) return; this[scrollFrameSymbol] = requestAnimationFrame(() => { this[scrollFrameSymbol] = null; updateControl.call(this); }); }); } if (this[gridWrapperSymbol]) { this[gridWrapperSymbol].addEventListener("scroll", () => { hideContextMenu.call(this); }); } if (this[fillHandleSymbol]) { this[fillHandleSymbol].addEventListener("pointerdown", (event) => { startFillDrag.call(this, event); }); } wireContextMenuActions.call(this); } function initOptionObserver() { this[lastSnapshotSymbol] = ""; this.attachObserver( new Observer(() => { const snapshot = JSON.stringify({ value: this.getOption("value"), columns: this.getOption("columns"), rows: this.getOption("rows"), sizes: this.getOption("sizes"), features: this.getOption("features"), disabled: this.getOption("disabled"), classes: this.getOption("classes"), labels: this.getOption("labels"), cell: this.getOption("cell"), }); if (this[skipRenderSymbol]) { this[lastSnapshotSymbol] = snapshot; this[skipRenderSymbol] = false; return; } if (snapshot === this[lastSnapshotSymbol]) return; this[lastSnapshotSymbol] = snapshot; updateControl.call(this); }), ); } function updateControl() { const normalized = normalizeValue.call(this); const current = this.getOption("value"); if (!isSameValue(current, normalized)) { this.setOption("value", normalized); setFormValueSafe.call(this); } const virtualize = this.getOption("features.virtualize", false) === true; if (virtualize) { this.setAttribute("data-virtualized", ""); if (this[forceRenderSymbol]) { this[lastViewportSymbol] = null; this[forceRenderSymbol] = false; } renderVirtual.call(this, normalized); } else { if (this.hasAttribute("data-virtualized")) { this.removeAttribute("data-virtualized"); } if (this[spacerElementSymbol]) { this[spacerElementSymbol].style.width = "0px"; this[spacerElementSymbol].style.height = "0px"; } this[lastViewportSymbol] = null; const displayCells = buildDisplayCells.call(this, normalized); renderGrid.call(this, normalized, displayCells); } syncToolbarState.call(this); applyDisabledState.call(this); updateSelectionDisplay.call(this); } function syncToolbarState() { const addRow = canAddRows.call(this); const addCol = canAddColumns.call(this); const labels = this.getOption("labels", {}); const buttonClass = this.getOption("classes.button", ""); this[addRowButtonSymbol].hidden = !addRow; this[addColumnButtonSymbol].hidden = !addCol; this[addRowButtonSymbol].disabled = this.getOption("disabled", false) || !addRow; this[addColumnButtonSymbol].disabled = this.getOption("disabled", false) || !addCol; this[addRowButtonSymbol].className = buttonClass; this[addColumnButtonSymbol].className = buttonClass; if (isString(labels.addRow)) { this[addRowButtonSymbol].textContent = labels.addRow; } if (isString(labels.addColumn)) { this[addColumnButtonSymbol].textContent = labels.addColumn; } syncContextMenuLabels.call(this); } function applyDisabledState() { const disabled = this.getOption("disabled", false); const editable = this.getOption("features.editable", true); const inputs = this.shadowRoot.querySelectorAll( `[${ATTRIBUTE_ROLE}=cell-input]`, ); inputs.forEach((input) => { input.disabled = disabled || !editable; }); } function renderGrid(value, displayCells) { const grid = this[gridElementSymbol]; if (!grid) return; grid.style.position = ""; grid.style.left = ""; grid.style.top = ""; grid.style.transform = ""; grid.textContent = ""; const fragment = document.createDocumentFragment(); fragment.appendChild(buildCornerCell.call(this)); const lastColumnId = value.columns.length > 0 ? value.columns[value.columns.length - 1].id : null; for (const col of value.columns) { fragment.appendChild( buildColumnHeader.call(this, col, col.id === lastColumnId), ); } for (const row of value.rows) { fragment.appendChild(buildRowHeader.call(this, row)); for (const col of value.columns) { fragment.appendChild( buildCell.call(this, row, col, displayCells, col.id === lastColumnId), ); } } grid.appendChild(fragment); applyGridTemplate.call(this, value); } function renderVirtual(value) { const grid = this[gridElementSymbol]; const wrapper = this[gridWrapperSymbol]; const spacer = this[spacerElementSymbol]; if (!grid || !wrapper || !spacer) return; const sizes = getVirtualSizes.call(this, value); const viewportWidth = wrapper.clientWidth; const viewportHeight = wrapper.clientHeight; if (viewportWidth === 0 || viewportHeight === 0) { if (!this[scrollFrameSymbol]) { this[scrollFrameSymbol] = requestAnimationFrame(() => { this[scrollFrameSymbol] = null; updateControl.call(this); }); } return; } const scrollLeft = wrapper.scrollLeft; const scrollTop = wrapper.scrollTop; const bufferCols = getSizeNumber( this.getOption("virtualization.columnBuffer"), 2, ); const bufferRows = getSizeNumber( this.getOption("virtualization.rowBuffer"), 4, ); const visible = getVisibleRange( sizes.columnOffsets, sizes.rowOffsets, scrollLeft - sizes.rowHeaderWidth, scrollTop - sizes.headerHeight, viewportWidth, viewportHeight, ); const colStart = Math.max(0, visible.colStart - bufferCols); const colEnd = Math.min( value.columns.length - 1, visible.colEnd + bufferCols, ); const rowStart = Math.max(0, visible.rowStart - bufferRows); const rowEnd = Math.min(value.rows.length - 1, visible.rowEnd + bufferRows); const viewportKey = `${colStart}-${colEnd}-${rowStart}-${rowEnd}-${sizes.totalWidth}-${sizes.totalHeight}`; if (this[lastViewportSymbol] === viewportKey) return; this[lastViewportSymbol] = viewportKey; spacer.style.width = `${sizes.totalWidth}px`; spacer.style.height = `${sizes.totalHeight}px`; const offsetX = sizes.rowHeaderWidth + sizes.columnOffsets[colStart]; const offsetY = sizes.headerHeight + sizes.rowOffsets[rowStart]; grid.style.position = "absolute"; grid.style.left = "0"; grid.style.top = "0"; grid.style.transform = `translate(${offsetX}px, ${offsetY}px)`; const visibleColumns = value.columns.slice(colStart, colEnd + 1); const visibleRows = value.rows.slice(rowStart, rowEnd + 1); const visibleWidths = visibleColumns.map( (col, index) => `${sizes.columnWidths[colStart + index]}px`, ); const visibleHeights = visibleRows.map( (row, index) => `${sizes.rowHeights[rowStart + index]}px`, ); grid.style.gridTemplateColumns = `${sizes.rowHeaderWidth}px ${visibleWidths.join(" ")}`; grid.style.gridTemplateRows = `${sizes.headerHeight}px ${visibleHeights.join(" ")}`; grid.textContent = ""; const fragment = document.createDocumentFragment(); fragment.appendChild(buildCornerCell.call(this)); const lastVisibleColumnId = visibleColumns.length > 0 ? visibleColumns[visibleColumns.length - 1].id : null; for (const col of visibleColumns) { fragment.appendChild( buildColumnHeader.call(this, col, col.id === lastVisibleColumnId), ); } const errors = []; const getDisplayValue = (rowId, colId) => { const formula = value.formulas?.[rowId]?.[colId]; if (isString(formula)) { const evaluated = evaluateFormula.call(this, value, formula); if (Number.isFinite(evaluated)) return evaluated; errors.push({ rowId, colId, formula }); return "#ERR"; } const raw = value.cells?.[rowId]?.[colId]; return raw === undefined || raw === null ? "" : raw; }; for (const row of visibleRows) { fragment.appendChild(buildRowHeader.call(this, row)); for (const col of visibleColumns) { fragment.appendChild( buildCell.call( this, row, col, getDisplayValue, col.id === lastVisibleColumnId, ), ); } } if (errors.length > 0) { fireCustomEvent(this, "monster-sheet-formula-error", { errors }); } grid.appendChild(fragment); } function buildCornerCell() { const labels = this.getOption("labels", {}); const cell = document.createElement("div"); cell.setAttribute(ATTRIBUTE_ROLE, "corner"); cell.setAttribute("part", "corner"); cell.textContent = isString(labels.corner) ? labels.corner : ""; return cell; } function buildColumnHeader(column, isLastColumn) { const cell = document.createElement("div"); cell.setAttribute(ATTRIBUTE_ROLE, "column-header"); cell.setAttribute("part", "column-header"); cell.dataset.colId = column.id; if (isLastColumn) { cell.dataset.lastColumn = "true"; } cell.textContent = column.label ?? column.id; if (this.getOption("features.resizeColumns", true)) { const handle = document.createElement("span"); handle.setAttribute(ATTRIBUTE_ROLE, "column-resize"); handle.setAttribute("part", "column-resize"); handle.dataset.colId = column.id; cell.appendChild(handle); } return cell; } function buildRowHeader(row) { const cell = document.createElement("div"); cell.setAttribute(ATTRIBUTE_ROLE, "row-header"); cell.setAttribute("part", "row-header"); cell.dataset.rowId = row.id; cell.textContent = row.label ?? row.id; if (this.getOption("features.resizeRows", true)) { const handle = document.createElement("span"); handle.setAttribute(ATTRIBUTE_ROLE, "row-resize"); handle.setAttribute("part", "row-resize"); handle.dataset.rowId = row.id; cell.appendChild(handle); } return cell; } function buildCell(row, column, cells, isLastColumn) { const wrapper = document.createElement("div"); wrapper.setAttribute(ATTRIBUTE_ROLE, "cell"); wrapper.setAttribute("part", "cell"); wrapper.dataset.rowId = row.id; wrapper.dataset.colId = column.id; if (isLastColumn) { wrapper.dataset.lastColumn = "true"; } const input = document.createElement("input"); input.setAttribute(ATTRIBUTE_ROLE, "cell-input"); input.setAttribute("part", "cell-input"); input.type = "text"; input.autocomplete = "off"; input.inputMode = "text"; input.placeholder = this.getOption("cell.placeholder", ""); const value = typeof cells === "function" ? cells(row.id, column.id) : cells?.[row.id]?.[column.id]; input.value = value === undefined || value === null ? "" : String(value); input.disabled = this.getOption("disabled", false) || !this.getOption("features.editable", true); wrapper.appendChild(input); return wrapper; } function applyGridTemplate(value) { const sizes = this.getOption("sizes", {}); const rowHeaderWidth = getRowHeaderWidthNumber.call( this, sizes.rowHeaderWidth, value.rows, ); const headerHeight = getSizeNumber(sizes.headerHeight, 32); const columnSizes = value.columns.map((col) => getColumnWidth.call(this, col.id), ); const rowSizes = value.rows.map((row) => getRowHeight.call(this, row.id)); this[gridElementSymbol].style.gridTemplateColumns = `${rowHeaderWidth}px ${columnSizes.join(" ")}`; this[gridElementSymbol].style.gridTemplateRows = `${headerHeight}px ${rowSizes.join(" ")}`; } function getVirtualSizes(value) { const sizes = this.getOption("sizes", {}); const rowHeaderWidth = getRowHeaderWidthNumber.call( this, sizes.rowHeaderWidth, value.rows, ); const headerHeight = getSizeNumber(sizes.headerHeight, 32); const columnWidths = value.columns.map((col) => getColumnWidthNumber.call(this, col.id), ); const rowHeights = value.rows.map((row) => getRowHeightNumber.call(this, row.id), ); const columnOffsets = buildOffsets(columnWidths); const rowOffsets = buildOffsets(rowHeights); const totalWidth = rowHeaderWidth + (columnWidths.length > 0 ? columnOffsets[columnOffsets.length - 1] + columnWidths[columnWidths.length - 1] : 0); const totalHeight = headerHeight + (rowHeights.length > 0 ? rowOffsets[rowOffsets.length - 1] + rowHeights[rowHeights.length - 1] : 0); return { rowHeaderWidth, headerHeight, columnWidths, rowHeights, columnOffsets, rowOffsets, totalWidth, totalHeight, }; } function buildOffsets(values) { const offsets = []; let current = 0; for (const value of values) { offsets.push(current); current += value; } return offsets; } function getVisibleRange( columnOffsets, rowOffsets, scrollLeft, scrollTop, viewportWidth, viewportHeight, ) { const colStart = findStartIndex(columnOffsets, Math.max(0, scrollLeft)); const colEnd = findEndIndex( columnOffsets, Math.max(0, scrollLeft) + viewportWidth, ); const rowStart = findStartIndex(rowOffsets, Math.max(0, scrollTop)); const rowEnd = findEndIndex( rowOffsets, Math.max(0, scrollTop) + viewportHeight, ); return { colStart, colEnd, rowStart, rowEnd }; } function findStartIndex(offsets, value) { let low = 0; let high = offsets.length - 1; let result = 0; while (low <= high) { const mid = Math.floor((low + high) / 2); if (offsets[mid] <= value) { result = mid; low = mid + 1; } else { high = mid - 1; } } return result; } function findEndIndex(offsets, value) { let low = 0; let high = offsets.length - 1; let result = offsets.length - 1; while (low <= high) { const mid = Math.floor((low + high) / 2); if (offsets[mid] < value) { low = mid + 1; } else { result = mid; high = mid - 1; } } return Math.max(0, result); } function normalizeValue() { const optionsValue = this.getOption("value"); const columns = normalizeColumns( optionsValue?.columns ?? this.getOption("columns"), ); const rows = normalizeRows(optionsValue?.rows ?? this.getOption("rows")); const cells = isObject(optionsValue?.cells) ? clone(optionsValue.cells) : {}; const formulas = isObject(optionsValue?.formulas) ? clone(optionsValue.formulas) : {}; return { columns, rows, cells, formulas }; } function normalizeColumns(columns) { const list = isArray(columns) ? columns : []; return list.map((col, index) => { if (isString(col)) { return { id: col, label: col }; } if (isObject(col)) { const id = isString(col.id) ? col.id : nextColumnLabel(index); return { id, label: col.label ?? id }; } const id = nextColumnLabel(index); return { id, label: id }; }); } function normalizeRows(rows) { const list = isArray(rows) ? rows : []; return list.map((row, index) => { if (isString(row)) { return { id: row, label: row }; } if (isObject(row)) { const id = isString(row.id) ? row.id : nextRowLabel(index); return { id, label: row.label ?? id }; } const id = nextRowLabel(index); return { id, label: id }; }); } function setCellValue(rowId, colId, value) { const data = normalizeValue.call(this); if (!data.cells[rowId]) data.cells[rowId] = {}; if (!data.formulas[rowId]) data.formulas[rowId] = {}; const next = isString(value) ? value : String(value); if (next.trim().startsWith("=")) { data.formulas[rowId][colId] = next.trim(); delete data.cells[rowId][colId]; } else { delete data.formulas[rowId][colId]; data.cells[rowId][colId] = value; } this.setOption("value", data); this[skipRenderSymbol] = true; this[forceRenderSymbol] = true; setFormValueSafe.call(this); fireCustomEvent(this, "monster-sheet-change", { value: data, cell: { rowId, colId, value }, }); } function addRow() { const data = normalizeValue.call(this); const newRow = createRow(data.rows.length, data.rows); data.rows.push(newRow); this.setOption("value", data); this[skipRenderSymbol] = true; setFormValueSafe.call(this); fireCustomEvent(this, "monster-sheet-add-row", { row: newRow, value: data }); updateControl.call(this); } function addColumn() { const data = normalizeValue.call(this); const newColumn = createColumn(data.columns.length, data.columns); data.columns.push(newColumn); this.setOption("value", data); this[skipRenderSymbol] = true; setFormValueSafe.call(this); fireCustomEvent(this, "monster-sheet-add-column", { column: newColumn, value: data, }); updateControl.call(this); } function canAddRows() { return this.getOption("features.addRows", false) === true; } function canAddColumns() { return this.getOption("features.addColumns", false) === true; } function createColumn(index, columns) { const label = nextColumnLabel(index); const existing = new Set(columns.map((col) => col.id)); const id = existing.has(label) ? new ID("column-").toString() : label; return { id, label }; } function createRow(index, rows) { const label = nextRowLabel(index); const existing = new Set(rows.map((row) => row.id)); const id = existing.has(label) ? new ID("row-").toString() : label; return { id, label }; } function nextColumnLabel(index) { let value = index + 1; let label = ""; while (value > 0) { const mod = (value - 1) % 26; label = String.fromCharCode(65 + mod) + label; value = Math.floor((value - 1) / 26); } return label; } function nextRowLabel(index) { return String(index + 1); } function defaultColumns(count) { return Array.from({ length: count }, (_, i) => { const label = nextColumnLabel(i); return { id: label, label }; }); } function defaultRows(count) { return Array.from({ length: count }, (_, i) => { const label = nextRowLabel(i); return { id: label, label }; }); } function getSizeNumber(value, fallback) { const n = Number(value); return Number.isFinite(n) ? n : fallback; } function getColumnWidth(columnId) { const sizes = this.getOption("sizes.columns", {}); const width = getSizeNumber(sizes?.[columnId], 120); const min = getSizeNumber(this.getOption("constraints.minColumnWidth"), 64); const max = getSizeNumber(this.getOption("constraints.maxColumnWidth"), 360); return `${clamp(width, min, max)}px`; } function getColumnWidthNumber(columnId) { const sizes = this.getOption("sizes.columns", {}); const width = getSizeNumber(sizes?.[columnId], 120); const min = getSizeNumber(this.getOption("constraints.minColumnWidth"), 64); const max = getSizeNumber(this.getOption("constraints.maxColumnWidth"), 360); return clamp(width, min, max); } function getRowHeight(rowId) { const sizes = this.getOption("sizes.rows", {}); const height = getSizeNumber(sizes?.[rowId], 32); const min = getSizeNumber(this.getOption("constraints.minRowHeight"), 28); const max = getSizeNumber(this.getOption("constraints.maxRowHeight"), 120); return `${clamp(height, min, max)}px`; } function getRowHeightNumber(rowId) { const sizes = this.getOption("sizes.rows", {}); const height = getSizeNumber(sizes?.[rowId], 32); const min = getSizeNumber(this.getOption("constraints.minRowHeight"), 28); const max = getSizeNumber(this.getOption("constraints.maxRowHeight"), 120); return clamp(height, min, max); } function getRowHeaderWidthNumber(value, rows) { if (value === "auto" || value === null || value === undefined) { const maxLen = getMaxRowLabelLength(rows); const base = 16; const charWidth = 8; return Math.max(56, base + maxLen * charWidth); } return getSizeNumber(value, 56); } function getMaxRowLabelLength(rows) { if (!isArray(rows) || rows.length === 0) return 1; const last = rows[rows.length - 1]; const label = isString(last?.label) ? last.label : last?.id; const text = label === undefined || label === null ? "" : String(label); if (/^\d+$/.test(text)) { return String(rows.length).length; } return text.length; } function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function startColumnResize(event, handle) { const colId = handle.dataset.colId; if (!colId) return; event.preventDefault(); event.stopPropagation(); const grid = this[gridElementSymbol]; const headerCell = handle.parentElement; if (!headerCell) return; const startWidth = headerCell.getBoundingClientRect().width; this.setAttribute("data-resizing", ""); this[resizeStateSymbol] = { kind: "column", id: colId, start: event.clientX, startSize: startWidth, }; grid.setPointerCapture(event.pointerId); grid.addEventListener("pointermove", handleResizeMove); grid.addEventListener("pointerup", handleResizeEnd); grid.addEventListener("pointercancel", handleResizeEnd); } function startRowResize(event, handle) { const rowId = handle.dataset.rowId; if (!rowId) return; event.preventDefault(); event.stopPropagation(); const headerCell = handle.parentElement; if (!headerCell) return; const startHeight = headerCell.getBoundingClientRect().height; const grid = this[gridElementSymbol]; this.setAttribute("data-resizing", ""); this[resizeStateSymbol] = { kind: "row", id: rowId, start: event.clientY, startSize: startHeight, }; grid.setPointerCapture(event.pointerId); grid.addEventListener("pointermove", handleResizeMove); grid.addEventListener("pointerup", handleResizeEnd); grid.addEventListener("pointercancel", handleResizeEnd); } function handleResizeMove(event) { const grid = event.currentTarget; const sheet = grid.getRootNode().host; if (!sheet || !sheet[resizeStateSymbol]) return; const state = sheet[resizeStateSymbol]; if (state.kind === "column") { const delta = event.clientX - state.start; const size = state.startSize + delta; setColumnSize.call(sheet, state.id, size); } else if (state.kind === "row") { const delta = event.clientY - state.start; const size = state.startSize + delta; setRowSize.call(sheet, state.id, size); } } function handleResizeEnd(event) { const grid = event.currentTarget; const sheet = grid.getRootNode().host; if (!sheet) return; if (sheet.hasAttribute("data-resizing")) { sheet.removeAttribute("data-resizing"); } grid.releasePointerCapture(event.pointerId); grid.removeEventListener("pointermove", handleResizeMove); grid.removeEventListener("pointerup", handleResizeEnd); grid.removeEventListener("pointercancel", handleResizeEnd); sheet[resizeStateSymbol] = null; } function setColumnSize(columnId, size) { const min = getSizeNumber(this.getOption("constraints.minColumnWidth"), 64); const max = getSizeNumber(this.getOption("constraints.maxColumnWidth"), 360); const next = clamp(size, min, max); this.setOption(`sizes.columns.${columnId}`, next); this[skipRenderSymbol] = true; if (this.getOption("features.virtualize", false) === true) { scheduleVirtualResizeRender.call(this); } else { applyGridTemplate.call(this, normalizeValue.call(this)); } fireCustomEvent(this, "monster-sheet-resize-column", { columnId, width: next, }); } function setRowSize(rowId, size) { const min = getSizeNumber(this.getOption("constraints.minRowHeight"), 28); const max = getSizeNumber(this.getOption("constraints.maxRowHeight"), 120); const next = clamp(size, min, max); this.setOption(`sizes.rows.${rowId}`, next); this[skipRenderSymbol] = true; if (this.getOption("features.virtualize", false) === true) { scheduleVirtualResizeRender.call(this); } else { applyGridTemplate.call(this, normalizeValue.call(this)); } fireCustomEvent(this, "monster-sheet-resize-row", { rowId, height: next, }); } function setFormValueSafe() { try { this.setFormValue(this.value); } catch (e) { addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e.message); } } function isSameValue(a, b) { try { return JSON.stringify(a) === JSON.stringify(b); } catch (e) { return false; } } function refreshDisplayValues() { const data = normalizeValue.call(this); const virtualize = this.getOption("features.virtualize", false) === true; const displayCells = virtualize ? null : buildDisplayCells.call(this, data); const active = this.shadowRoot?.activeElement; const inputs = this.shadowRoot.querySelectorAll( `[${ATTRIBUTE_ROLE}=cell-input]`, ); inputs.forEach((input) => { if (!(input instanceof HTMLInputElement)) return; if (input === active) return; const cell = input.closest(`[${ATTRIBUTE_ROLE}=cell]`); if (!cell) return; const rowId = cell.dataset.rowId; const colId = cell.dataset.colId; if (!rowId || !colId) return; const value = virtualize ? getCellDisplayValue.call(this, data, rowId, colId) : displayCells?.[rowId]?.[colId]; input.value = value === undefined || value === null ? "" : String(value); }); } function setSelectionFromCell(rowId, colId, expand) { hideContextMenu.call(this); const selection = this[selectionSymbol] || {}; if (expand && selection.anchor) { selection.focus = { rowId, colId }; } else { selection.anchor = { rowId, colId }; selection.focus = { rowId, colId }; } this[selectionSymbol] = selection; updateSelectionDisplay.call(this); } function setSelectionFocus(rowId, colId) { const selection = this[selectionSymbol] || {}; if (!selection.anchor) { selection.anchor = { rowId, colId }; } selection.focus = { rowId, colId }; this[selectionSymbol] = selection; updateSelectionDisplay.call(this); } function startSelectionDrag(event) { if (event.button !== 0) return; const cell = event.target.closest(`[${ATTRIBUTE_ROLE}=cell]`); if (!cell) return; const state = { active: true, startX: event.clientX, startY: event.clientY, moved: false, }; this[dragSelectSymbol] = state; this.setAttribute("data-selecting", ""); const move = (moveEvent) => { handleSelectionDragMove.call(this, moveEvent); }; const end = () => { handleSelectionDragEnd.call(this); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", end, { once: true }); window.addEventListener("pointercancel", end, { once: true }); state.cleanup = () => { window.removeEventListener("pointermove", move); }; } function handleSelectionDragMove(event) { const state = this[dragSelectSymbol]; if (!state?.active) return; if (!state.moved) { const dx = event.clientX - state.startX; const dy = event.clientY - state.startY; if (Math.abs(dx) < 3 && Math.abs(dy) < 3) return; state.moved = true; } const cell = getCellFromPoint.call(this, event.clientX, event.clientY); if (!cell) return; const rowId = cell.dataset.rowId; const colId = cell.dataset.colId; if (!rowId || !colId) return; setSelectionFocus.call(this, rowId, colId); } function handleSelectionDragEnd() { const state = this[dragSelectSymbol]; if (!state) return; if (state.cleanup) state.cleanup(); this[dragSelectSymbol] = null; this.removeAttribute("data-selecting"); } function updateSelectionDisplay() { const grid = this[gridElementSymbol]; if (!grid) return; const data = normalizeValue.call(this); const selection = this[selectionSymbol]; const range = selection ? getSelectionRange(selection, data) : null; const virtualize = this.getOption("features.virtualize", false) === true; const cells = grid.querySelectorAll(`[${ATTRIBUTE_ROLE}=cell]`); let overlay = null; const wrapper = this[gridWrapperSymbol]; const wrapperRect = wrapper?.getBoundingClientRect() ?? null; let minLeft = Infinity; let minTop = Infinity; let maxRight = -Infinity; let maxBottom = -Infinity; cells.forEach((cell) => { const rowId = cell.dataset.rowId; const colId = cell.dataset.colId; let selected = false; if (range && rowId && colId) { const { rowIndexById, colIndexById } = range; const rowIndex = rowIndexById.get(rowId); const colIndex = colIndexById.get(colId); if ( rowIndex !== undefined && colIndex !== undefined && rowIndex >= range.rowStart && rowIndex <= range.rowEnd && colIndex >= range.colStart && colIndex <= range.colEnd ) { selected = true; } } if (selected) { cell.dataset.selected = "true"; if (wrapperRect) { const rect = cell.getBoundingClientRect(); minLeft = Math.min(minLeft, rect.left); minTop = Math.min(minTop, rect.top); maxRight = Math.max(maxRight, rect.right); maxBottom = Math.max(maxBottom, rect.bottom); } } else { delete cell.dataset.selected; } if ( selection?.focus?.rowId === rowId && selection?.focus?.colId === colId ) { cell.dataset.active = "true"; } else { delete cell.dataset.active; } }); if (Number.isFinite(minLeft) && Number.isFinite(maxRight) && wrapperRect) { overlay = { left: minLeft - wrapperRect.left + wrapper.scrollLeft, top: minTop - wrapperRect.top + wrapper.scrollTop, width: Math.max(0, maxRight - minLeft), height: Math.max(0, maxBottom - minTop), }; } else if (range && virtualize) { const sizes = getVirtualSizes.call(this, data); const colStart = range.colStart; const colEnd = range.colEnd; const rowStart = range.rowStart; const rowEnd = range.rowEnd; const left = sizes.rowHeaderWidth + sizes.columnOffsets[colStart]; const top = sizes.headerHeight + sizes.rowOffsets[rowStart]; const width = sizes.columnOffsets[colEnd] + sizes.columnWidths[colEnd] - sizes.columnOffsets[colStart]; const height = sizes.rowOffsets[rowEnd] + sizes.rowHeights[rowEnd] - sizes.rowOffsets[rowStart]; overlay = { left, top, width, height }; } updateSelectionOverlay.call(this, overlay); } function updateSelectionOverlay(bounds) { const box = this[selectionBoxSymbol]; const wrapper = this[gridWrapperSymbol]; const handle = this[fillHandleSymbol]; if (!box || !wrapper || !handle) return; if (!bounds) { box.style.display = "none"; handle.style.display = "none"; return; } box.style.display = "block"; box.style.left = `${bounds.left}px`; box.style.top = `${bounds.top}px`; box.style.width = `${bounds.width}px`; box.style.height = `${bounds.height}px`; handle.style.display = "block"; handle.style.left = `${bounds.left + bounds.width - 4}px`; handle.style.top = `${bounds.top + bounds.height - 4}px`; } function getSelectionRange(selection, data) { if (!selection?.anchor || !selection?.focus) return null; const { rowIndexById, colIndexById } = getIndexMaps(data); const startRow = rowIndexById.get(selection.anchor.rowId); const endRow = rowIndexById.get(selection.focus.rowId); const startCol = colIndexById.get(selection.anchor.colId); const endCol = colIndexById.get(selection.focus.colId); if ( startRow === undefined || endRow === undefined || startCol === undefined || endCol === undefined ) { return null; } return { rowStart: Math.min(startRow, endRow), rowEnd: Math.max(startRow, endRow), colStart: Math.min(startCol, endCol), colEnd: Math.max(startCol, endCol), rowIndexById, colIndexById, }; } function getIndexMaps(data) { const rowIndexById = new Map(); const colIndexById = new Map(); data.rows.forEach((row, index) => { if (row?.id) rowIndexById.set(row.id, index); }); data.columns.forEach((col, index) => { if (col?.id) colIndexById.set(col.id, index); }); return { rowIndexById, colIndexById }; } function getActiveCell() { const active = this.shadowRoot?.activeElement; if (!(active instanceof HTMLInputElement)) return null; const cell = active.closest(`[${ATTRIBUTE_ROLE}=cell]`); if (!cell) return null; const rowId = cell.dataset.rowId; const colId = cell.dataset.colId; if (!rowId || !colId) return null; return { rowId, colId }; } async function copySelectionToClipboard() { const data = normalizeValue.call(this); const range = getSelectionRange.call(this, this[selectionSymbol], data); const resolvedRange = range ?? getRangeFromActiveCell.call(this, data); if (!resolvedRange) return; const text = buildClipboardText(data, resolvedRange); this[lastCopySymbol] = { text, range: { rowStart: resolvedRange.rowStart, rowEnd: resolvedRange.rowEnd, colStart: resolvedRange.colStart, colEnd: resolvedRange.colEnd, }, values: buildClipboardMatrix(data, resolvedRange), }; const success = await writeClipboardText(text); if (success) { showStatus.call(this, this.getOption("labels.copied") || "Copied"); } } async function cutSelectionToClipboard() { const data = normalizeValue.call(this); const range = getSelectionRange.call(this, this[selectionSymbol], data); const resolvedRange = range ?? getRangeFromActiveCell.call(this, data); if (!resolvedRange) return; const text = buildClipboardText(data, resolvedRange); const success = await writeClipboardText(text); if (!success) return; const next = clearRange(data, resolvedRange); this.value = next; showStatus.call(this, this.getOption("labels.cutDone") || "Cut"); } async function pasteFromClipboard() { const text = await readClipboardText(); if (text === null) return; const data = normalizeValue.call(this); const range = getSelectionRange.call(this, this[selectionSymbol], data); const startRange = range ?? getRangeFromActiveCell.call(this, data); if (!startRange) return; const { startRow, startCol } = { startRow: startRange.rowStart, startCol: startRange.colStart, }; const cached = this[lastCopySymbol]; const next = cached && cached.text === text ? applyPasteCached(data, cached, startRow, startCol) : applyPasteText(data, text, startRow, startCol); this.value = next.value; setSelectionByRange.call(this, next.range, next.value); showStatus.call(this, this.getOption("labels.pasted") || "Pasted"); } function buildClipboardText(data, range) { const rows = []; for (let r = range.rowStart; r <= range.rowEnd; r += 1) { const rowId = data.rows[r]?.id; if (!rowId) continue; const cols = []; for (let c = range.colStart; c <= range.colEnd; c += 1) { const colId = data.columns[c]?.id; if (!colId) continue; const value = getCellRawValue(data, rowId, colId); cols.push(value); } rows.push(cols.join("\t")); } return rows.join("\n"); } function buildClipboardMatrix(data, range) { const rows = []; for (let r = range.rowStart; r <= range.rowEnd; r += 1) { const rowId = data.rows[r]?.id; if (!rowId) continue; const cols = []; for (let c = range.colStart; c <= range.colEnd; c += 1) { const colId = data.columns[c]?.id; if (!colId) continue; cols.push(getCellRawValue(data, rowId, colId)); } rows.push(cols); } return rows; } function getCellRawValue(data, rowId, colId) { const formula = data.formulas?.[rowId]?.[colId]; if (isString(formula)) return formula; const raw = data.cells?.[rowId]?.[colId]; return raw === undefined || raw === null ? "" : String(raw); } function clearRange(data, range) { const next = { ...data, cells: data.cells ? { ...data.cells } : {}, formulas: data.formulas ? { ...data.formulas } : {}, }; for (let r = range.rowStart; r <= range.rowEnd; r += 1) { const rowId = data.rows[r]?.id; if (!rowId) continue; for (let c = range.colStart; c <= range.colEnd; c += 1) { const colId = data.columns[c]?.id; if (!colId) continue; if (next.cells[rowId]) delete next.cells[rowId][colId]; if (next.formulas[rowId]) delete next.formulas[rowId][colId]; } } return next; } function getRangeFromActiveCell(data) { const active = getActiveCell.call(this); if (!active) return null; const { rowIndexById, colIndexById } = getIndexMaps(data); const rowIndex = rowIndexById.get(active.rowId); const colIndex = colIndexById.get(active.colId); if (rowIndex === undefined || colIndex === undefined) return null; return { rowStart: rowIndex, rowEnd: rowIndex, colStart: colIndex, colEnd: colIndex, rowIndexById, colIndexById, }; } function applyPasteText(data, text, startRowIndex, startColIndex) { const rows = normalizeClipboardRows(text); const next = { ...data, cells: data.cells ? { ...data.cells } : {}, formulas: data.formulas ? { ...data.formulas } : {}, }; let rowEnd = startRowIndex; let colEnd = startColIndex; rows.forEach((rowText, rowOffset) => { const rowIndex = startRowIndex + rowOffset; if (rowIndex >= data.rows.length) return; const rowId = data.rows[rowIndex]?.id; if (!rowId) return; const cols = rowText.split("\t"); cols.forEach((cellText, colOffset) => { const colIndex = startColIndex + colOffset; if (colIndex >= data.columns.length) return; const colId = data.columns[colIndex]?.id; if (!colId) return; setCellData(next, rowId, colId, cellText); rowEnd = Math.max(rowEnd, rowIndex); colEnd = Math.max(colEnd, colIndex); }); }); return { value: next, range: { rowStart: startRowIndex, rowEnd, colStart: startColIndex, colEnd, }, }; } function applyPasteCached(data, cached, startRowIndex, startColIndex) { const next = { ...data, cells: data.cells ? { ...data.cells } : {}, formulas: data.formulas ? { ...data.formulas } : {}, }; const deltaRow = startRowIndex - cached.range.rowStart; const deltaCol = startColIndex - cached.range.colStart; let rowEnd = startRowIndex; let colEnd = startColIndex; cached.values.forEach((rowValues, rowOffset) => { const rowIndex = startRowIndex + rowOffset; if (rowIndex >= data.rows.length) return; const rowId = data.rows[rowIndex]?.id; if (!rowId) return; rowValues.forEach((cellValue, colOffset) => { const colIndex = startColIndex + colOffset; if (colIndex >= data.columns.length) return; const colId = data.columns[colIndex]?.id; if (!colId) return; let nextValue = cellValue; if (isFormulaString(cellValue)) { nextValue = adjustFormulaReferences(cellValue, deltaRow, deltaCol); } setCellData(next, rowId, colId, nextValue); rowEnd = Math.max(rowEnd, rowIndex); colEnd = Math.max(colEnd, colIndex); }); }); return { value: next, range: { rowStart: startRowIndex, rowEnd, colStart: startColIndex, colEnd, }, }; } function normalizeClipboardRows(text) { const normalized = String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const rows = normalized.split("\n"); while (rows.length > 1 && rows[rows.length - 1] === "") { rows.pop(); } return rows; } function setCellData(data, rowId, colId, value) { if (!data.cells) data.cells = {}; if (!data.formulas) data.formulas = {}; if (!data.cells[rowId]) data.cells[rowId] = {}; if (!data.formulas[rowId]) data.formulas[rowId] = {}; const next = String(value ?? ""); if (next.trim().startsWith("=")) { data.formulas[rowId][colId] = next.trim(); delete data.cells[rowId][colId]; } else { delete data.formulas[rowId][colId]; data.cells[rowId][colId] = next; } } function isFormulaString(value) { return isString(value) && value.trim().startsWith("="); } function adjustFormulaReferences(formula, deltaRow, deltaCol) { const expr = formula.trim(); if (!expr.startsWith("=")) return formula; const body = expr.slice(1); const adjusted = body.replace(/([A-Za-z]+)([0-9]+)/g, (match, col, row) => { const colIndex = columnLabelToIndex(col); if (colIndex === null) return match; const rowIndex = Number(row) - 1; if (!Number.isFinite(rowIndex)) return match; const nextCol = colIndex + deltaCol; const nextRow = rowIndex + deltaRow; if (nextCol < 0 || nextRow < 0) return match; return `${indexToColumnLabel(nextCol)}${nextRow + 1}`; }); return `=${adjusted}`; } function columnLabelToIndex(label) { const text = String(label || "").toUpperCase(); if (!/^[A-Z]+$/.test(text)) return null; let index = 0; for (let i = 0; i < text.length; i += 1) { index = index * 26 + (text.charCodeAt(i) - 64); } return index - 1; } function indexToColumnLabel(index) { if (!Number.isFinite(index) || index < 0) return "A"; return nextColumnLabel(index); } function setSelectionByRange(range, data) { if (!range) return; const rows = data.rows; const cols = data.columns; if ( range.rowStart < 0 || range.colStart < 0 || range.rowEnd >= rows.length || range.colEnd >= cols.length ) { return; } this[selectionSymbol] = { anchor: { rowId: rows[range.rowStart].id, colId: cols[range.colStart].id, }, focus: { rowId: rows[range.rowEnd].id, colId: cols[range.colEnd].id, }, }; updateSelectionDisplay.call(this); } async function writeClipboardText(text) { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; } } catch (e) { return false; } return false; } async function readClipboardText() { try { if (navigator.clipboard?.readText) { return await navigator.clipboard.readText(); } } catch (e) { return null; } return null; } function showStatus(message) { const status = this[statusSymbol]; if (!status) return; status.textContent = message; status.dataset.show = "true"; if (this[statusTimeoutSymbol]) { clearTimeout(this[statusTimeoutSymbol]); } this[statusTimeoutSymbol] = setTimeout(() => { status.dataset.show = "false"; }, 1200); } function syncContextMenuLabels() { const menu = this[contextMenuSymbol]; if (!menu) return; const labels = this.getOption("labels", {}); const copy = menu.querySelector(`[${ATTRIBUTE_ROLE}=menu-copy]`); const paste = menu.querySelector(`[${ATTRIBUTE_ROLE}=menu-paste]`); const cut = menu.querySelector(`[${ATTRIBUTE_ROLE}=menu-cut]`); if (copy && isString(labels.copy)) copy.textContent = labels.copy; if (paste && isString(labels.paste)) paste.textContent = labels.paste; if (cut && isString(labels.cut)) cut.textContent = labels.cut; } function wireContextMenuActions() { const menu = this[contextMenuSymbol]; if (!menu) return; const copy = menu.querySelector(`[${ATT