chore: release v1.0.5 purifying architecture, eliminating __unsafeRefreshState, optimizing component rendering By: AICoder

This commit is contained in:
AI Engineer 2026-06-11 19:48:40 +08:00
parent 016a7e7a42
commit 814c23a085
17 changed files with 4282 additions and 1892 deletions

629
dist/datatable.js vendored
View File

@ -1,10 +1,121 @@
import { Component, NewState, RefreshState, Util } from "@web/state";
import { VirtualScroll, State } from "@web/base";
(function(factory) {
typeof define === "function" && define.amd ? define(factory) : factory();
})(function() {
"use strict";
const MODE_MAP = {
text: ["contains", "equals", "starts", "ends"],
textarea: ["contains", "equals", "starts", "ends"],
number: ["=", ">", "<", "between"],
date: ["=", ">", "<", "between"],
select: ["contains", "equals"],
TagsInput: ["contains", "equals", "starts", "ends"]
};
const MODE_ICONS = {
"contains": "bi-search",
"equals": "bi-distribute-vertical",
"starts": "bi-align-start",
"ends": "bi-align-end",
"=": "bi-calculator",
">": "bi-chevron-right",
"<": "bi-chevron-left",
"between": "bi-arrows-expand"
};
const DataTableConfig = {
_fieldTypes: /* @__PURE__ */ new Map(),
registerFieldType: (config) => {
DataTableConfig._fieldTypes.set(config.value, config);
},
getFieldTypes: () => Array.from(DataTableConfig._fieldTypes.values())
};
DataTableConfig.registerFieldType({
value: "text",
label: "{#Text#}",
typeForDB: "v4096",
schema: [{ name: "placeholder", label: "Placeholder", type: "text", if: 'this.data.user_type=="text"' }]
});
DataTableConfig.registerFieldType({
value: "number",
label: "{#Number#}",
typeForDB: "ff",
schema: [
{ name: "decimals", label: "Decimals", type: "number", setting: { min: 0, max: 10 }, if: 'this.data.user_type=="number"' },
{ name: "prefix", label: "Prefix (e.g. $)", type: "text", if: 'this.data.user_type=="number"' },
{ name: "suffix", label: "Suffix (e.g. %)", type: "text", if: 'this.data.user_type=="number"' },
{ name: "thousandSep", label: "Thousand Sep", type: "switch", if: 'this.data.user_type=="number"' }
],
formatter: (val, field) => {
if (val == null || val === "") return "";
let num = Number(val);
if (isNaN(num)) return val;
const s = field.settings || {};
if (s.decimals !== void 0) num = num.toFixed(s.decimals);
let str = String(num);
if (s.thousandSep) {
const parts = str.split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
str = parts.join(".");
}
return (s.prefix || "") + str + (s.suffix || "");
}
});
DataTableConfig.registerFieldType({
value: "select",
label: "{#Single Select#}",
typeForDB: "v1024",
schema: [{ name: "options_str", label: "Options", type: "textarea", setting: { rows: 3, placeholder: "Label:Value per line" }, if: 'this.data.user_type=="select"' }],
formatter: (val, field) => {
var _a;
if (val == null || val === "") return "";
const opts = ((_a = field.settings) == null ? void 0 : _a.options) || [];
const opt = opts.find((o) => typeof o === "object" ? o.value == val : o == val);
return opt ? typeof opt === "object" ? opt.label : opt : val;
}
});
DataTableConfig.registerFieldType({
value: "checkbox",
label: "{#Multi Select#}",
typeForDB: "v4096",
schema: [{ name: "options_str", label: "Options", type: "textarea", setting: { rows: 3, placeholder: "Label:Value per line" }, if: 'this.data.user_type=="checkbox"' }],
formatter: (val, field) => {
var _a;
if (!Array.isArray(val)) return val == null ? "" : String(val);
const opts = ((_a = field.settings) == null ? void 0 : _a.options) || [];
return val.map((v) => {
const opt = opts.find((o) => typeof o === "object" ? o.value == v : o == v);
return opt ? typeof opt === "object" ? opt.label : opt : v;
}).join(", ");
}
});
DataTableConfig.registerFieldType({
value: "switch",
label: "{#Switch#}",
typeForDB: "b",
schema: [
{ name: "labelOn", label: "Label On", type: "text", if: 'this.data.user_type=="switch"' },
{ name: "labelOff", label: "Label Off", type: "text", if: 'this.data.user_type=="switch"' }
],
formatter: (val, field) => {
const s = field.settings || {};
return val ? s.labelOn || "Yes" : s.labelOff || "No";
}
});
DataTableConfig.registerFieldType({
value: "datetime",
label: "{#DateTime#}",
typeForDB: "dt",
schema: [{ name: "format", label: "Format", type: "text", setting: { placeholder: "YYYY-MM-DD" }, if: 'this.data.user_type=="datetime"' }]
});
DataTableConfig.registerFieldType({
value: "textarea",
label: "{#Long Text#}",
typeForDB: "t",
schema: [{ name: "placeholder", label: "Placeholder", type: "text", if: 'this.data.user_type=="textarea"' }]
});
const createPerfMonitor = () => {
let enabled = !!window.__DT_PERF_MODE__;
let enabled = !!globalThis.__DT_PERF_MODE__;
const stats = { refreshTime: 0, refreshCount: 0, scrollCount: 0, totalNodes: 0 };
if (enabled && !window.__statePerformanceTelemetry) {
window.__statePerformanceTelemetry = { scanCount: 0, reuseCount: 0, moveCount: 0 };
if (enabled && !globalThis.__statePerformanceTelemetry) {
globalThis.__statePerformanceTelemetry = { scanCount: 0, reuseCount: 0, moveCount: 0 };
}
return {
get stats() {
@ -24,9 +135,9 @@ const createPerfMonitor = () => {
if (!enabled) return null;
return {
start: performance.now(),
scan: ((_a = window.__statePerformanceTelemetry) == null ? void 0 : _a.scanCount) || 0,
move: ((_b = window.__statePerformanceTelemetry) == null ? void 0 : _b.moveCount) || 0,
reuse: ((_c = window.__statePerformanceTelemetry) == null ? void 0 : _c.reuseCount) || 0
scan: ((_a = globalThis.__statePerformanceTelemetry) == null ? void 0 : _a.scanCount) || 0,
move: ((_b = globalThis.__statePerformanceTelemetry) == null ? void 0 : _b.moveCount) || 0,
reuse: ((_c = globalThis.__statePerformanceTelemetry) == null ? void 0 : _c.reuseCount) || 0
};
},
endFrame: (startData, renderedCount) => {
@ -35,7 +146,7 @@ const createPerfMonitor = () => {
stats.totalNodes += renderedCount;
const elapsed = performance.now() - startData.start;
stats.refreshTime += elapsed;
const stPerf = window.__statePerformanceTelemetry;
const stPerf = globalThis.__statePerformanceTelemetry;
if (stPerf) {
const scans = stPerf.scanCount - startData.scan;
const moves = stPerf.moveCount - startData.move;
@ -48,21 +159,14 @@ const createPerfMonitor = () => {
};
};
const createScrollManager = (container, state, onRenderedListChange) => {
const vs = VirtualScroll({ itemHeight: 40 });
const vs = globalThis.VirtualScroll({ itemHeight: 40 });
let scrollEl = null;
const refresh = (isLayoutChange = false) => {
if (!scrollEl) return;
const res = vs.calc(scrollEl, state.list);
if (res) {
if (!isLayoutChange && state.prevHeight === res.prevHeight && state.postHeight === res.postHeight && state._listStartIndex === res.listStartIndex && state._renderedList.length === res.renderedList.length) {
return;
}
Object.assign(state, {
prevHeight: res.prevHeight,
postHeight: res.postHeight,
_listStartIndex: res.listStartIndex,
_renderedList: res.renderedList
});
if (!isLayoutChange && state.prevHeight === res.prevHeight && state.postHeight === res.postHeight && state._listStartIndex === res.listStartIndex && state._renderedList.length === res.renderedList.length) return;
Object.assign(state, { prevHeight: res.prevHeight, postHeight: res.postHeight, _listStartIndex: res.listStartIndex, _renderedList: res.renderedList });
onRenderedListChange == null ? void 0 : onRenderedListChange(res.renderedList.length, isLayoutChange);
}
};
@ -73,9 +177,7 @@ const createScrollManager = (container, state, onRenderedListChange) => {
reset: (list) => {
state._listStartIndex = 0;
vs.reset(list, scrollEl || container);
if (state.list === list) {
vs.init(list, () => refresh(true));
}
if (state.list === list) vs.init(list, () => refresh(true));
},
refresh,
onScroll: () => refresh(false)
@ -91,7 +193,7 @@ const createSelectionManager = (container, state) => {
};
let lastHadSelection = false;
const applySelectionUI = () => {
if (window.__DT_FEATURES__ && !window.__DT_FEATURES__.selection) return;
if (globalThis.__DT_FEATURES__ && !globalThis.__DT_FEATURES__.selection) return;
let boundMinRow = Infinity, boundMaxRow = -Infinity;
if (activeBounds) {
boundMinRow = Math.min(boundMinRow, activeBounds.minRow);
@ -116,11 +218,8 @@ const createSelectionManager = (container, state) => {
return;
}
cells.forEach((cell, cIdx) => {
if (isCellSelected(absoluteRow, cIdx)) {
cell.classList.add("dt-cell-selected");
} else {
cell.classList.remove("dt-cell-selected");
}
if (isCellSelected(absoluteRow, cIdx)) cell.classList.add("dt-cell-selected");
else cell.classList.remove("dt-cell-selected");
});
});
};
@ -143,21 +242,13 @@ const createSelectionManager = (container, state) => {
const alreadySelected = isCellSelected(row, col);
const isRange = activeBounds && (activeBounds.minRow !== activeBounds.maxRow || activeBounds.minCol !== activeBounds.maxCol) || multiSelections.length > 0;
if (e.shiftKey && startCell) {
activeBounds = {
minRow: Math.min(startCell.row, row),
maxRow: Math.max(startCell.row, row),
minCol: Math.min(startCell.col, col),
maxCol: Math.max(startCell.col, col)
};
activeBounds = { minRow: Math.min(startCell.row, row), maxRow: Math.max(startCell.row, row), minCol: Math.min(startCell.col, col), maxCol: Math.max(startCell.col, col) };
} else {
if (alreadySelected && !e.ctrlKey && !e.metaKey) {
if (!isRange) container._potentialCancel = { row, col };
} else {
if (!e.ctrlKey && !e.metaKey) {
clearAllActive();
} else if (activeBounds && !alreadySelected) {
multiSelections.push(activeBounds);
}
if (!e.ctrlKey && !e.metaKey) clearAllActive();
else if (activeBounds && !alreadySelected) multiSelections.push(activeBounds);
startCell = { row, col };
activeBounds = { minRow: row, maxRow: row, minCol: col, maxCol: col };
}
@ -169,12 +260,7 @@ const createSelectionManager = (container, state) => {
};
const updateSelect = (row, col) => {
if (state.isSelecting && startCell) {
activeBounds = {
minRow: Math.min(startCell.row, row),
maxRow: Math.max(startCell.row, row),
minCol: Math.min(startCell.col, col),
maxCol: Math.max(startCell.col, col)
};
activeBounds = { minRow: Math.min(startCell.row, row), maxRow: Math.max(startCell.row, row), minCol: Math.min(startCell.col, col), maxCol: Math.max(startCell.col, col) };
container._potentialCancel = null;
applySelectionUI();
updateStatus();
@ -183,17 +269,14 @@ const createSelectionManager = (container, state) => {
const endSelect = () => {
if (container._potentialCancel) {
const { row, col } = container._potentialCancel;
if (isCellSelected(row, col)) {
clearAllActive();
}
if (isCellSelected(row, col)) clearAllActive();
container._potentialCancel = null;
}
state.isSelecting = false;
};
const getSelectionBounds = () => {
if (!activeBounds) return null;
let minRow = activeBounds.minRow, maxRow = activeBounds.maxRow;
let minCol = activeBounds.minCol, maxCol = activeBounds.maxCol;
let minRow = activeBounds.minRow, maxRow = activeBounds.maxRow, minCol = activeBounds.minCol, maxCol = activeBounds.maxCol;
multiSelections.forEach((s) => {
minRow = Math.min(minRow, s.minRow);
maxRow = Math.max(maxRow, s.maxRow);
@ -208,9 +291,7 @@ const createSelectionManager = (container, state) => {
const text = state.list.slice(bounds.minRow, bounds.maxRow + 1).map((row) => {
return state.fields.slice(bounds.minCol, bounds.maxCol + 1).map((f) => {
let val = String(row[f.id] ?? "");
if (val.includes(" ") || val.includes("\n") || val.includes('"')) {
val = '"' + val.replace(/"/g, '""') + '"';
}
if (val.includes(" ") || val.includes("\n") || val.includes('"')) val = '"' + val.replace(/"/g, '""') + '"';
return val;
}).join(" ");
}).join("\n");
@ -235,22 +316,18 @@ const createSelectionManager = (container, state) => {
} else if (char === " " && !inQuotes) {
cells.push(current);
current = "";
} else {
current += char;
}
} else current += char;
}
cells.push(current);
return cells;
});
const startRow = bounds.minRow;
const startCol = bounds.minCol;
const maxRow = bounds.maxRow;
const maxCol = bounds.maxCol;
const { minRow: startRow, minCol: startCol, maxRow, maxCol } = bounds;
const body = container.querySelector(".dt-body");
const rowNodes = body ? Array.from(body.childNodes).filter((n) => {
var _a;
return (_a = n.classList) == null ? void 0 : _a.contains("dt-body-row");
}) : [];
let anyRowChanged = false;
rows.forEach((rowData, rOffset) => {
const rIdx = startRow + rOffset;
if (rIdx > maxRow || rIdx >= state.list.length) return;
@ -263,58 +340,17 @@ const createSelectionManager = (container, state) => {
rowItem[field.id] = cellData;
rowChanged = true;
});
if (rowChanged && container.refreshNode) {
const domNode = rowNodes.find((n) => {
var _a;
return (((_a = n._ref) == null ? void 0 : _a.rIdx) ?? -1) + state._listStartIndex === rIdx;
});
if (domNode) container.refreshNode(domNode);
}
if (rowChanged) anyRowChanged = true;
});
if (anyRowChanged) state.list = [...state.list];
} catch (err) {
console.error("Paste Error:", err);
}
};
const deleteSelected = () => {
const bounds = getSelectionBounds();
if (!bounds) return;
const rMin = bounds.minRow, rMax = bounds.maxRow;
const newList = state.list.filter((_, i) => !(i >= rMin && i <= rMax));
state.list = newList;
clearAllActive();
return { applySelectionUI, clearAllActive, startSelect, updateSelect, endSelect, getSelectionBounds, copy, paste };
};
return {
applySelectionUI,
clearAllActive,
startSelect,
updateSelect,
endSelect,
deleteSelected,
getSelectionBounds,
copy,
paste
};
};
const MODE_MAP = {
text: ["contains", "equals", "starts", "ends"],
textarea: ["contains", "equals", "starts", "ends"],
number: ["=", ">", "<", "between"],
date: ["=", ">", "<", "between"],
select: ["contains", "equals"],
TagsInput: ["contains", "equals", "starts", "ends"]
};
const MODE_ICONS = {
"contains": "bi-search",
"equals": "bi-distribute-vertical",
"starts": "bi-align-start",
"ends": "bi-align-end",
"=": "bi-calculator",
">": "bi-chevron-right",
"<": "bi-chevron-left",
"between": "bi-arrows-expand"
};
Component.register("DataTable", (container) => {
if (!container.state) container.state = NewState({});
globalThis.Component.register("DataTable", (container) => {
if (!container.state) container.state = globalThis.NewState({});
const state = container.state;
Object.assign(state, {
list: [],
@ -327,7 +363,6 @@ Component.register("DataTable", (container) => {
_originalList: [],
sortConfig: { fieldId: null, direction: null },
filterConfig: {},
// fieldId -> { mode, value, value2, selectedValues: [] }
activeFieldId: null,
activeField: null,
activeModes: [],
@ -335,7 +370,9 @@ Component.register("DataTable", (container) => {
_internalUpdate: false,
_appliedHash: "",
_fieldsDirty: false,
_masterCellNodes: null
_masterCellNodes: null,
isDirty: false,
isBulkEdit: null
});
const perf = createPerfMonitor();
state.perf = perf.stats;
@ -353,7 +390,10 @@ Component.register("DataTable", (container) => {
};
let _editorOverlay, currentEditingNode = null;
container.format = (val, field) => {
var _a;
if (field.formatter) return field.formatter(val, field);
const typeInfo = DataTableConfig._fieldTypes.get(((_a = field.settings) == null ? void 0 : _a.formType) || field.type || "text");
if (typeInfo && typeInfo.formatter) return typeInfo.formatter(val, field);
return val == null ? "" : typeof val === "object" ? JSON.stringify(val) : String(val);
};
container.onScroll = () => {
@ -374,70 +414,61 @@ Component.register("DataTable", (container) => {
if (state._internalUpdate) return;
const targetFilters = { ...state.filterConfig, ...options.filters || {} };
const targetSort = options.sort !== void 0 ? options.sort ? { fieldId: state.activeFieldId, direction: options.sort } : { fieldId: null, direction: null } : state.sortConfig;
const currentHash = JSON.stringify({ s: targetSort, f: targetFilters });
if (state._appliedHash === currentHash && !options.force) return;
state._internalUpdate = true;
let list = [...state._originalList || []];
Object.keys(targetFilters).forEach((fieldId) => {
const filter = targetFilters[fieldId];
if (!filter) return;
const { mode = "contains", value, value2, selectedValues } = filter;
if ((selectedValues == null ? void 0 : selectedValues.length) > 0) {
list = list.filter((item) => selectedValues.includes(String(item[fieldId] ?? "")));
return;
}
if (value === "" || value == null) return;
const lowV = String(value).toLowerCase(), n1 = Number(value), n2 = Number(value2);
list = list.filter((item) => {
const iv = item[fieldId], sv = String(iv ?? "").toLowerCase();
switch (mode) {
let filtered = [...state._originalList];
Object.entries(targetFilters).forEach(([fId, cfg]) => {
if (!cfg.value && (!cfg.selectedValues || cfg.selectedValues.length === 0)) return;
filtered = filtered.filter((item) => {
var _a;
const val = item[fId];
if (((_a = cfg.selectedValues) == null ? void 0 : _a.length) > 0) return cfg.selectedValues.includes(String(val));
const search = String(cfg.value).toLowerCase();
const target = String(val ?? "").toLowerCase();
switch (cfg.mode) {
case "contains":
return sv.includes(lowV);
return target.includes(search);
case "equals":
return sv === lowV;
return target === search;
case "starts":
return sv.startsWith(lowV);
return target.startsWith(search);
case "ends":
return sv.endsWith(lowV);
case ">":
return Number(iv) > n1;
case "<":
return Number(iv) < n1;
return target.endsWith(search);
case "=":
return Number(iv) === n1;
return Number(val) === Number(cfg.value);
case ">":
return Number(val) > Number(cfg.value);
case "<":
return Number(val) < Number(cfg.value);
case "between":
return Number(iv) >= n1 && Number(iv) <= n2;
return Number(val) >= Number(cfg.value) && Number(val) <= Number(cfg.value2);
default:
return sv.includes(lowV);
return true;
}
});
});
if (targetSort.fieldId && targetSort.direction) {
list.sort((a, b) => {
let va = a[targetSort.fieldId], vb = b[targetSort.fieldId];
if (va === vb) return 0;
const res = va > vb ? 1 : -1;
return targetSort.direction === "asc" ? res : -res;
const fId = targetSort.fieldId;
const dir = targetSort.direction === "asc" ? 1 : -1;
filtered.sort((a, b) => {
if (a[fId] == b[fId]) return 0;
return a[fId] > b[fId] ? dir : -dir;
});
}
state._appliedHash = currentHash;
state._internalUpdate = true;
state.filterConfig = targetFilters;
state.sortConfig = targetSort;
state.list = list;
state.list = filtered;
state._internalUpdate = false;
};
container.showColumnMenu = (field, e) => {
e.stopPropagation();
const btn = e.currentTarget, menu = container.querySelector(".dt-column-menu");
const type = field.type || "text";
container.showColumnMenu = (field, event) => {
var _a;
const btn = event.currentTarget, menu = container.querySelector(".dt-column-menu");
const type = ((_a = field.settings) == null ? void 0 : _a.formType) || field.type || "text";
state.activeModes = MODE_MAP[type] || (["boolean", "switch", "checkbox", "radio"].includes(type) ? [] : MODE_MAP.text);
if (!state.filterConfig[field.id]) {
state.filterConfig[field.id] = { mode: state.activeModes[0] || "contains", value: "", selectedValues: [] };
}
if (!state.filterConfig[field.id]) state.filterConfig[field.id] = { mode: state.activeModes[0] || "contains", value: "", selectedValues: [] };
state.activeField = field;
state.activeFieldId = field.id;
menu.style.display = "block";
const cellNode = btn.closest(".dt-cell");
const rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect();
const cellNode = btn.closest(".dt-cell"), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect();
const menuWidth = menu.offsetWidth || 260;
let leftPos = rect.right - rootRect.left - menuWidth;
if (leftPos < 0) leftPos = Math.max(0, rect.left - rootRect.left);
@ -451,8 +482,8 @@ Component.register("DataTable", (container) => {
};
document.addEventListener("mousedown", onGlobalClick);
setTimeout(() => {
var _a;
return (_a = menu.querySelector("input")) == null ? void 0 : _a.focus();
var _a2;
return (_a2 = menu.querySelector("input")) == null ? void 0 : _a2.focus();
}, 50);
};
container.toggleSelectedValue = (val) => {
@ -494,26 +525,37 @@ Component.register("DataTable", (container) => {
}
Array.from(rowNode.children).forEach((cell) => {
const fIdx = parseInt(cell.dataset.fidx);
if (!isNaN(fIdx)) cell._refExt = { f: state.fields[fIdx], fIdx };
if (!isNaN(fIdx)) cell._ref = { ...cell._ref || rowNode._ref, f: state.fields[fIdx], fIdx };
});
};
state.__watch("fields", (fields) => {
if (!fields) return;
state._fieldsDirty = true;
container.style.setProperty("--dt-grid-template", fields.map((f) => `var(--w-${f.id}, ${f.width || 150}px)`).join(" "));
container.style.setProperty("--dt-row-width", fields.reduce((sum, f) => sum + (f.width || 150), 0) + "px");
state._masterCellNodes = null;
container.style.setProperty("--dt-grid-template", fields.map((f) => {
var _a;
return `var(--w-${f.id}, ${((_a = f.settings) == null ? void 0 : _a.width) || f.width || 150}px)`;
}).join(" "));
container.style.setProperty("--dt-row-width", fields.reduce((sum, f) => {
var _a;
return sum + (((_a = f.settings) == null ? void 0 : _a.width) || f.width || 150);
}, 0) + "px");
let leftSum = 0;
fields.forEach((f) => {
if (f.pinned === "left") {
var _a, _b;
const pinned = ((_a = f.settings) == null ? void 0 : _a.pinned) || f.pinned;
if (pinned === "left") {
container.style.setProperty(`--l-${f.id}`, leftSum + "px");
leftSum += f.width || 150;
leftSum += ((_b = f.settings) == null ? void 0 : _b.width) || f.width || 150;
}
});
let rightSum = 0;
[...fields].reverse().forEach((f) => {
if (f.pinned === "right") {
var _a, _b;
const pinned = ((_a = f.settings) == null ? void 0 : _a.pinned) || f.pinned;
if (pinned === "right") {
container.style.setProperty(`--r-${f.id}`, rightSum + "px");
rightSum += f.width || 150;
rightSum += ((_b = f.settings) == null ? void 0 : _b.width) || f.width || 150;
}
});
});
@ -528,8 +570,27 @@ Component.register("DataTable", (container) => {
fieldTemplate.setAttribute("$if", "true");
fieldTemplate.content.textContent = "";
state.fields.forEach((f, fIdx) => masters.forEach((master) => {
var _a2;
const clone = master.cloneNode(true);
if (clone.nodeType === 1) clone.dataset.fidx = fIdx;
if (clone.nodeType === 1) {
clone.dataset.fidx = fIdx;
const pinned = ((_a2 = f.settings) == null ? void 0 : _a2.pinned) || f.pinned;
if (pinned) {
clone.classList.add("pinned-" + pinned);
clone.style.position = "sticky";
clone.style.zIndex = "1";
clone.style.backgroundColor = "inherit";
if (pinned === "left") {
clone.style.left = `var(--l-${f.id})`;
clone.style.borderRight = "1px solid var(--bs-border-color)";
clone.style.boxShadow = "2px 0 5px -2px rgba(0,0,0,0.1)";
} else {
clone.style.right = `var(--r-${f.id})`;
clone.style.borderLeft = "1px solid var(--bs-border-color)";
clone.style.boxShadow = "-2px 0 5px -2px rgba(0,0,0,0.1)";
}
}
}
fieldTemplate.content.appendChild(clone);
}));
}
@ -553,32 +614,58 @@ Component.register("DataTable", (container) => {
scroll.reset(list);
});
container.editCell = (row, field, cellNode) => {
var _a, _b;
const overlay = container.querySelector(".dt-editor-overlay"), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect();
currentEditingNode = cellNode;
const formType = ((_a = field.settings) == null ? void 0 : _a.formType) || field.type || "text";
const form = overlay.querySelector("AutoForm");
if (form) {
form.data = row;
form.state.schema = [{ ...field, type: formType, options: ((_b = field.settings) == null ? void 0 : _b.options) || field.options, name: field.id, label: "" }];
}
Object.assign(overlay.style, {
display: "flex",
left: rect.left - rootRect.left + "px",
top: rect.top - rootRect.top + "px",
width: (field.type === "textarea" || field.type === "TagsInput" ? Math.max(rect.width, 300) : rect.width) + "px",
height: field.type === "textarea" || field.type === "TagsInput" ? "auto" : rect.height + "px"
width: (formType === "textarea" || formType === "TagsInput" ? Math.max(rect.width, 300) : rect.width) + "px",
height: formType === "textarea" || formType === "TagsInput" ? "auto" : rect.height + "px"
});
State.editingSchema = [{ ...field, name: field.id, label: "" }];
State.editingData = row;
setTimeout(() => {
var _a;
return (_a = overlay.querySelector("input, textarea, select, .form-control")) == null ? void 0 : _a.focus();
var _a2;
return (_a2 = overlay.querySelector("input, textarea, select, .form-control")) == null ? void 0 : _a2.focus();
}, 30);
};
container.hideEditor = (save = true) => {
if (!_editorOverlay) _editorOverlay = container.querySelector(".dt-editor-overlay");
if (!_editorOverlay || _editorOverlay.style.display === "none") return;
if (save) {
const form = _editorOverlay.querySelector("AutoForm");
if (save && form && form.data) {
const input = _editorOverlay.querySelector("input:focus, select:focus, textarea:focus");
if (input) input.dispatchEvent(new Event(input.type === "number" || input.tagName === "SELECT" ? "change" : "input", { bubbles: true }));
if (currentEditingNode) RefreshState(currentEditingNode);
const schema = form.state.schema || [];
schema.forEach((field) => {
var _a, _b;
const row = (_b = (_a = currentEditingNode == null ? void 0 : currentEditingNode.closest(".dt-row")) == null ? void 0 : _a._ref) == null ? void 0 : _b.item;
if (row) row[field.name] = form.data[field.name];
});
if (state.isBulkEdit) {
const { minRow, maxRow, fIdx } = state.isBulkEdit;
const field = state.fields[fIdx];
const newValue = form.data[field.id];
for (let i = minRow; i <= maxRow; i++) {
if (state.list[i]) state.list[i][field.id] = newValue;
}
}
state.list = [...state.list];
state.isDirty = true;
}
_editorOverlay.style.display = "none";
State.editingSchema = State.editingData = currentEditingNode = null;
if (form) {
form.state.schema = [];
form.data = null;
}
currentEditingNode = null;
state.isBulkEdit = null;
container.focus();
};
container.onMainMouseDown = (e) => {
@ -601,29 +688,156 @@ Component.register("DataTable", (container) => {
}
};
container.onMainDblClick = (e) => {
var _a;
var _a, _b, _c;
const cell = e.target.closest(".dt-cell"), row = cell == null ? void 0 : cell.closest(".dt-row");
if (row && !row.classList.contains("dt-header-row")) {
const item = (_a = row._ref) == null ? void 0 : _a.item, fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll(".dt-cell")).indexOf(cell);
if (item && state.fields[fIdx]) container.editCell(item, state.fields[fIdx], cell);
const rIdx = ((_b = row._ref) == null ? void 0 : _b.rIdx) ?? Array.from(container.querySelectorAll(".dt-body-row")).indexOf(row);
const absoluteRow = rIdx + state._listStartIndex;
if (item && state.fields[fIdx]) {
const bounds = selection.getSelectionBounds();
if (bounds && absoluteRow >= bounds.minRow && absoluteRow <= bounds.maxRow && fIdx >= bounds.minCol && fIdx <= bounds.maxCol) {
const affectedRows = bounds.maxRow - bounds.minRow + 1;
if (affectedRows > 1) {
state.isBulkEdit = { ...bounds, fIdx };
if ((_c = globalThis.UI) == null ? void 0 : _c.toast) globalThis.UI.toast(`Bulk Edit: Updating ${affectedRows} rows in column "${state.fields[fIdx].name}"`, { type: "warning" });
}
}
container.editCell(item, state.fields[fIdx], cell);
}
}
};
container.addRow = () => {
const newRow = {};
state.fields.forEach((f) => newRow[f.id] = "");
state._originalList.push(newRow);
state.list = [...state._originalList];
state.isDirty = true;
setTimeout(() => {
scroll.reset(state.list);
container.querySelector(".dt-main").scrollTop = container.querySelector(".dt-main").scrollHeight;
}, 50);
};
container.deleteSelectedRow = async () => {
const bounds = selection.getSelectionBounds();
if (!bounds) return;
const count = bounds.maxRow - bounds.minRow + 1;
if (await globalThis.UI.confirm(`Are you sure you want to delete ${count} row(s)?`)) {
const rMin = bounds.minRow, rMax = bounds.maxRow;
const removedItems = state.list.slice(rMin, rMax + 1);
state.list = state.list.filter((_, i) => !(i >= rMin && i <= rMax));
state._originalList = state._originalList.filter((item) => !removedItems.includes(item));
state.isDirty = true;
selection.clearAllActive();
container.dispatchEvent(new CustomEvent("remove", { detail: { items: removedItems } }));
}
};
container.saveChanges = () => {
container.dispatchEvent(new CustomEvent("save", { detail: { list: state._originalList, fields: state.fields } }));
state.isDirty = false;
};
const getFieldSchema = () => {
const types = globalThis.DataTable.getFieldTypes();
const baseSchema = [
{ name: "id", label: "Field ID", type: "text", setting: { required: true, placeholder: "e.g. user_name" } },
{ name: "name", label: "Display Name", type: "text", setting: { required: true, placeholder: "e.g. 用户名" } },
{ name: "user_type", label: "Field Type", type: "select", options: types.map((t) => ({ label: t.label, value: t.value })) }
];
const dynamicSchema = types.reduce((acc, t) => acc.concat(t.schema || []), []);
return baseSchema.concat(dynamicSchema, [{ name: "isIndex", label: "Index", type: "switch" }, { name: "memo", label: "Memo", type: "text" }]);
};
const parseOptionsStr = (str) => {
if (!str) return void 0;
return str.split("\n").map((s) => s.trim()).filter(Boolean).map((line) => {
const idx = line.indexOf(":");
if (idx > -1) return { label: line.slice(0, idx).trim(), value: line.slice(idx + 1).trim() };
return line;
});
};
const formatOptionsStr = (opts) => {
if (!opts) return "";
return opts.map((o) => typeof o === "object" ? `${o.label}:${o.value}` : o).join("\n");
};
container.addField = async () => {
container.hideColumnMenu();
const data = globalThis.NewState({ id: "c" + Date.now().toString().slice(-4), name: "New Field", user_type: "text", decimals: 0, isIndex: false, memo: "", options_str: "" });
const d = container.querySelector(`Dialog[id="${container.id}_field_dialog"]`);
if (!d) return;
Object.assign(d.state, { title: "Add Field", buttons: ["Cancel", "Save"] });
const form = d.querySelector("AutoForm");
if (form) {
form.data = data;
form.state.schema = getFieldSchema();
}
d.show();
const result = await new Promise((resolve) => d.addEventListener("change", (e) => resolve(d.result), { once: true }));
if (result === 2) {
const typeInfo = globalThis.DataTable.getFieldTypes().find((t) => t.value === data.user_type);
let dbType = (typeInfo == null ? void 0 : typeInfo.typeForDB) || "v1024";
if (data.user_type === "number") dbType = data.decimals > 0 ? "ff" : "bi";
const field = { id: data.id, name: data.name, memo: data.memo, isIndex: !!data.isIndex, type: dbType, settings: { formType: data.user_type, decimals: data.decimals, prefix: data.prefix, suffix: data.suffix, thousandSep: data.thousandSep, labelOn: data.labelOn, labelOff: data.labelOff, format: data.format, placeholder: data.placeholder, options: parseOptionsStr(data.options_str) } };
state.fields = [...state.fields, field];
state.isDirty = true;
container.dispatchEvent(new CustomEvent("savefields", { detail: state.fields }));
state.list = [...state.list];
}
};
container.editField = async () => {
if (!state.activeField) return;
container.hideColumnMenu();
const f = state.activeField;
const s = f.settings || {};
const data = globalThis.NewState({ id: f.id, name: f.name, memo: f.memo || "", isIndex: !!f.isIndex, user_type: s.formType || "text", decimals: s.decimals || 0, prefix: s.prefix || "", suffix: s.suffix || "", thousandSep: !!s.thousandSep, labelOn: s.labelOn || "", labelOff: s.labelOff || "", format: s.format || "", placeholder: s.placeholder || "", options_str: formatOptionsStr(s.options) });
const d = container.querySelector(`Dialog[id="${container.id}_field_dialog"]`);
if (!d) return;
Object.assign(d.state, { title: "Edit Field", buttons: ["Cancel", "Save"] });
const form = d.querySelector("AutoForm");
if (form) {
form.data = data;
form.state.schema = getFieldSchema();
}
d.show();
const result = await new Promise((resolve) => d.addEventListener("change", (e) => resolve(d.result), { once: true }));
if (result === 2) {
const idx = state.fields.findIndex((item) => item.id === f.id);
if (idx !== -1) {
const typeInfo = globalThis.DataTable.getFieldTypes().find((t) => t.value === data.user_type);
let dbType = (typeInfo == null ? void 0 : typeInfo.typeForDB) || "v1024";
if (data.user_type === "number") dbType = data.decimals > 0 ? "ff" : "bi";
const updatedField = { ...f, id: data.id, name: data.name, memo: data.memo, isIndex: !!data.isIndex, type: dbType, settings: { ...f.settings, formType: data.user_type, decimals: data.decimals, prefix: data.prefix, suffix: data.suffix, thousandSep: data.thousandSep, labelOn: data.labelOn, labelOff: data.labelOff, format: data.format, placeholder: data.placeholder, options: parseOptionsStr(data.options_str) } };
state.fields[idx] = updatedField;
state.fields = [...state.fields];
state.isDirty = true;
container.dispatchEvent(new CustomEvent("savefields", { detail: state.fields }));
state.list = [...state.list];
}
}
};
container.deleteField = async () => {
if (!state.activeField) return;
container.hideColumnMenu();
if (await globalThis.UI.confirm(`Are you sure you want to delete field "${state.activeField.name}"?`)) {
const idx = state.fields.findIndex((f) => f.id === state.activeField.id);
if (idx !== -1) {
state.fields.splice(idx, 1);
state.fields = [...state.fields];
state.isDirty = true;
container.dispatchEvent(new CustomEvent("savefields", { detail: state.fields }));
state.list = [...state.list];
}
}
};
window.addEventListener("mouseup", selection.endSelect);
document.addEventListener("mousedown", (e) => {
const overlay = container.querySelector(".dt-editor-overlay");
const menu = container.querySelector(".dt-column-menu");
if ((overlay == null ? void 0 : overlay.style.display) !== "none" && !overlay.contains(e.target)) container.hideEditor(true);
if (!container.contains(e.target) && !(overlay == null ? void 0 : overlay.contains(e.target))) selection.clearAllActive();
if (!container.contains(e.target) && !(overlay == null ? void 0 : overlay.contains(e.target)) && !(menu == null ? void 0 : menu.contains(e.target))) selection.clearAllActive();
});
state._MODE_ICONS = MODE_ICONS;
}, Util.makeDom(
}, globalThis.Util.makeDom(
/*html*/
`
<!--
NOTE: For $class and $style directives, ALWAYS use the template literal syntax:
$class="base-class \${condition ? 'active' : ''}"
DO NOT use string concatenation like $class="'base-class ' + (condition ? 'active' : '')".
Since the HTML is wrapped in backticks (``), remember to escape the dollar sign: \${ }
-->
<div class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-hidden" style="position:relative; user-select:none; outline: none; min-height: 0" tabindex="0">
<div class="dt-main flex-grow-1 overflow-auto" $onscroll="this.onScroll()"
$onmousedown="this.onMainMouseDown(event)" $onmouseover="this.onMainMouseOver(event)" $ondblclick="this.onMainDblClick(event)"
@ -631,7 +845,7 @@ Component.register("DataTable", (container) => {
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:20">
<div class="dt-header-row fw-bold text-muted small">
<template $each="this.state?.fields || []">
<div $data-id="item.id" $class="dt-cell dt-col border-end d-flex align-items-center header-cell \${item.pinned ? 'pinned-' + item.pinned : ''}" $style="(item.pinned ? 'position: sticky; z-index: 11; background-color: inherit; ' : 'position:relative; ') + 'padding: 0; ' + (item.pinned === 'left' ? 'left: var(--l-' + item.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : (item.pinned === 'right' ? 'right: var(--r-' + item.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))">
<div $data-id="item.id" $class="dt-cell dt-col border-end d-flex align-items-center header-cell \${(item.settings?.pinned || item.pinned) ? 'pinned-' + (item.settings?.pinned || item.pinned) : ''}" $style="((item.settings?.pinned || item.pinned) ? 'position: sticky; z-index: 11; background-color: inherit; ' : 'position:relative; ') + 'padding: 0; ' + ((item.settings?.pinned || item.pinned) === 'left' ? 'left: var(--l-' + item.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : ((item.settings?.pinned || item.pinned) === 'right' ? 'right: var(--r-' + item.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))">
<div class="d-flex align-items-center overflow-hidden flex-grow-1 h-100 px-2 cursor-pointer" $onclick="this.showColumnMenu(item, event)">
<i $if="this.state?.filterConfig?.[item.id] && (this.state.filterConfig[item.id].value || this.state.filterConfig[item.id].selectedValues?.length)" class="bi bi-filter me-1 text-primary"></i>
<i $if="this.state?.sortConfig?.fieldId === item.id && this.state.sortConfig.direction" $class="bi bi-sort-\${this.state.sortConfig.direction === 'asc' ? 'down' : 'up-alt'} me-1 text-primary"></i>
@ -647,18 +861,18 @@ Component.register("DataTable", (container) => {
<div class="dt-spacer-prev flex-shrink-0" style="display:none"></div>
<template $each="this.state?._renderedList || []" key="id" index="rIdx">
<div class="dt-row dt-body-row border-bottom bg-white" $.="this._initRow(thisNode)">
<template as="f"><div $class="dt-cell border-end px-2 d-flex align-items-center \${f.pinned ? 'pinned-' + f.pinned : ''}" $style="(f.pinned ? 'position: sticky; z-index: 1; background-color: inherit; ' : '') + (f.pinned === 'left' ? 'left: var(--l-' + f.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : (f.pinned === 'right' ? 'right: var(--r-' + f.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))"><span $text="this.format(item[f.id], f)" class="text-truncate"></span></div></template>
<template as="f"><div $class="dt-cell border-end px-2 d-flex align-items-center \${(f.settings?.pinned || f.pinned) ? 'pinned-' + (f.settings?.pinned || f.pinned) : ''}" $style="((f.settings?.pinned || f.pinned) ? 'position: sticky; z-index: 1; background-color: inherit; ' : '') + ((f.settings?.pinned || f.pinned) === 'left' ? 'left: var(--l-' + f.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : ((f.settings?.pinned || f.pinned) === 'right' ? 'right: var(--r-' + f.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))"><span $text="this.format(item[f.id], f)" class="text-truncate"></span></div></template>
</div>
</template>
<div class="dt-spacer-post flex-shrink-0" style="display:none"></div>
</div>
</div>
<div class="dt-column-menu border bg-body shadow-lg rounded p-3" style="display:none; position:absolute; z-index:2000; min-width:260px; max-width:320px;">
<div class="dt-column-menu bg-body shadow-lg rounded p-2" style="display:none; position:absolute; z-index:2000; min-width:240px; max-width:300px; border: 1px solid var(--bs-primary)">
<template $if="this.state?.activeFieldId">
<div class="btn-group w-100 mb-3">
<button $class="btn btn-sm d-flex align-items-center justify-content-center \${this.state?.sortConfig?.direction === 'asc' && this.state?.sortConfig?.fieldId === this.state?.activeFieldId ? 'btn-primary' : 'btn-outline-secondary border'}" $onclick="this.setSort('asc')"><i class="bi bi-sort-alpha-down me-1"></i> ASC</button>
<button $class="btn btn-sm d-flex align-items-center justify-content-center \${this.state?.sortConfig?.direction === 'desc' && this.state?.sortConfig?.fieldId === this.state?.activeFieldId ? 'btn-primary' : 'btn-outline-secondary border'}" $onclick="this.setSort('desc')"><i class="bi bi-sort-alpha-up-alt me-1"></i> DESC</button>
<div class="d-flex gap-1 mb-2">
<button $class="btn btn-xs flex-grow-1 d-flex align-items-center justify-content-center \${this.state?.sortConfig?.direction === 'asc' && this.state?.sortConfig?.fieldId === this.state?.activeFieldId ? 'btn-primary' : 'btn-outline-secondary border'}" $onclick="this.setSort('asc')"><i class="bi bi-sort-alpha-down me-1"></i> ASC</button>
<button $class="btn btn-xs flex-grow-1 d-flex align-items-center justify-content-center \${this.state?.sortConfig?.direction === 'desc' && this.state?.sortConfig?.fieldId === this.state?.activeFieldId ? 'btn-primary' : 'btn-outline-secondary border'}" $onclick="this.setSort('desc')"><i class="bi bi-sort-alpha-up-alt me-1"></i> DESC</button>
</div>
<div $if="this.state?.activeModes?.length" class="dt-filter-tabs d-flex overflow-auto border-bottom bg-light-subtle rounded-top py-1" style="white-space:nowrap; scrollbar-width: none;">
@ -676,8 +890,8 @@ Component.register("DataTable", (container) => {
</div>
</template>
<div class="mt-3" style="max-height: 200px; overflow-y: auto;">
<div class="text-muted fw-bold mb-2" style="font-size: 10px; letter-spacing: 0.5px">TOP FREQUENT VALUES</div>
<div class="mt-2" style="max-height: 180px; overflow-y: auto;">
<div class="text-muted fw-bold mb-1" style="font-size: 9px; letter-spacing: 0.5px">TOP FREQUENT VALUES</div>
<template $each="this.state?._columnStats?.[this.state?.activeFieldId] || []">
<label class="d-flex align-items-center mb-1 small cursor-pointer p-1 rounded-1 menu-item-row" onmouseover="this.style.background='var(--bs-light)'" onmouseout="this.style.background='transparent'">
<input type="checkbox" class="form-check-input me-2" $checked="this.state?.filterConfig?.[this.state?.activeFieldId]?.selectedValues?.includes(String(item.val))" $onclick="this.toggleSelectedValue(String(item.val))">
@ -687,17 +901,44 @@ Component.register("DataTable", (container) => {
</template>
</div>
<div $if="this.state?.filterConfig?.[this.state?.activeFieldId]?.value || this.state?.filterConfig?.[this.state?.activeFieldId]?.selectedValues?.length" class="mt-3 pt-2 border-top text-center">
<div $if="this.state?.filterConfig?.[this.state?.activeFieldId]?.value || this.state?.filterConfig?.[this.state?.activeFieldId]?.selectedValues?.length" class="mt-2 pt-1 border-top text-center">
<span class="cursor-pointer text-primary small fw-bold" $onclick="this.clearColumnSettings()"><i class="bi bi-x-circle me-1"></i> Clear Filter</span>
</div>
<div class="mt-3 pt-2 border-top d-flex flex-column gap-1">
<button class="btn btn-xs btn-outline-secondary border d-flex align-items-center px-2 py-1" $onclick="this.editField()"><i class="bi bi-pencil me-2"></i> Edit Field</button>
<button class="btn btn-xs btn-outline-secondary border d-flex align-items-center px-2 py-1" $onclick="this.addField()"><i class="bi bi-plus-lg me-2"></i> Add Field</button>
<button class="btn btn-xs btn-outline-danger border d-flex align-items-center px-2 py-1" $onclick="this.deleteField()"><i class="bi bi-trash me-2"></i> Delete Field</button>
</div>
</template>
</div>
<div class="dt-editor-overlay dt-editor-container" style="display: none; position: absolute; z-index: 1000; background: var(--bs-body-bg); box-shadow: 0 4px 16px rgba(0,0,0,0.25); border: 1px solid var(--bs-primary);"><AutoForm inline class="h-100 w-100" $onsubmit="this.hideEditor(true)"/></div>
<div class="dt-footer border-top bg-light d-flex align-items-center px-2 py-1 small text-muted" style="height:32px"><span $text="(this.state?.selectedRowCount || 0) + ' / ' + (this.state?.list?.length || 0)"></span></div>
<div class="dt-editor-overlay dt-editor-container" style="display: none; position: absolute; z-index: 1000; background: var(--bs-body-bg); box-shadow: 0 4px 16px rgba(0,0,0,0.25); border: 1px solid var(--bs-primary);"><AutoForm nobutton inline class="h-100 w-100" $onsubmit="event.preventDefault(); thisNode.closest('DataTable').hideEditor(true)"/></div>
<Dialog $.id="this.id + '_field_dialog'">
<AutoForm nobutton class="p-3"></AutoForm>
</Dialog>
<div class="dt-footer border-top bg-light d-flex align-items-center px-3 py-1 shadow-sm" style="height:40px; z-index: 10">
<div class="d-flex align-items-center gap-3 flex-grow-1">
<div class="btn-group shadow-sm">
<button class="btn btn-xs btn-white border d-flex align-items-center px-2" style="background:white" $onclick="this.addRow()" title="Add Row"><i class="bi bi-plus-lg text-primary me-1"></i> Add</button>
<button class="btn btn-xs btn-white border d-flex align-items-center px-2" style="background:white" $onclick="this.deleteSelectedRow()" $disabled="!this.state?.selectedRowCount" title="Delete Selected Rows"><i class="bi bi-trash text-danger me-1"></i> Delete</button>
</div>
<div class="vr h-50 my-auto text-muted opacity-25"></div>
<div class="d-flex align-items-center gap-2 text-muted" style="font-size: 0.75rem">
<i class="bi bi-check-all fs-6"></i>
<span $text="(this.state?.selectedRowCount || 0) + ' selected / ' + (this.state?.list?.length || 0) + ' total'"></span>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<button $if="this.state?.isDirty" class="btn btn-xs btn-primary px-3 shadow-sm d-flex align-items-center fw-bold" $onclick="this.saveChanges()"><i class="bi bi-cloud-upload me-1"></i> Save Changes</button>
<button $if="!this.state?.isDirty" class="btn btn-xs btn-light border px-3 text-muted disabled d-flex align-items-center" disabled><i class="bi bi-cloud-check me-1"></i> Up to date</button>
</div>
</div>
</div>
`
), Util.makeDom(
), globalThis.Util.makeDom(
/*html*/
`
<style>
@ -720,3 +961,5 @@ Component.register("DataTable", (container) => {
</style>
`
));
globalThis.DataTable = DataTableConfig;
});

81
dist/datatable.min.js vendored

File diff suppressed because one or more lines are too long

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@web/datatable",
"version": "1.0.4",
"version": "1.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@web/datatable",
"version": "1.0.4",
"version": "1.0.5",
"devDependencies": {
"@playwright/test": "^1.40.0",
"@rollup/plugin-terser": "^1.0.0",

View File

@ -1,6 +1,6 @@
{
"name": "@web/datatable",
"version": "1.0.4",
"version": "1.0.8",
"type": "module",
"main": "dist/datatable.js",
"module": "dist/datatable.js",

737
src/DataTable.js Normal file
View File

@ -0,0 +1,737 @@
/**
* DataTable Component Module
* Consolidated with perf, scroll, and selection managers.
*/
// Global Configuration
const MODE_MAP = {
text: ['contains', 'equals', 'starts', 'ends'],
textarea: ['contains', 'equals', 'starts', 'ends'],
number: ['=', '>', '<', 'between'],
date: ['=', '>', '<', 'between'],
select: ['contains', 'equals'],
TagsInput: ['contains', 'equals', 'starts', 'ends']
};
const MODE_ICONS = {
'contains': 'bi-search', 'equals': 'bi-distribute-vertical', 'starts': 'bi-align-start', 'ends': 'bi-align-end',
'=': 'bi-calculator', '>': 'bi-chevron-right', '<': 'bi-chevron-left', 'between': 'bi-arrows-expand'
};
const DataTableConfig = {
_fieldTypes: new Map(),
registerFieldType: (config) => {
DataTableConfig._fieldTypes.set(config.value, config);
},
getFieldTypes: () => Array.from(DataTableConfig._fieldTypes.values())
};
// Register Built-in Types
DataTableConfig.registerFieldType({
value: 'text', label: '{#Text#}', typeForDB: 'v4096',
schema: [{ name: 'placeholder', label: 'Placeholder', type: 'text', if: 'this.data.user_type=="text"' }]
});
DataTableConfig.registerFieldType({
value: 'number', label: '{#Number#}', typeForDB: 'ff',
schema: [
{ name: 'decimals', label: 'Decimals', type: 'number', setting: { min: 0, max: 10 }, if: 'this.data.user_type=="number"' },
{ name: 'prefix', label: 'Prefix (e.g. $)', type: 'text', if: 'this.data.user_type=="number"' },
{ name: 'suffix', label: 'Suffix (e.g. %)', type: 'text', if: 'this.data.user_type=="number"' },
{ name: 'thousandSep', label: 'Thousand Sep', type: 'switch', if: 'this.data.user_type=="number"' }
],
formatter: (val, field) => {
if (val == null || val === '') return '';
let num = Number(val);
if (isNaN(num)) return val;
const s = field.settings || {};
if (s.decimals !== undefined) num = num.toFixed(s.decimals);
let str = String(num);
if (s.thousandSep) {
const parts = str.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
str = parts.join('.');
}
return (s.prefix || '') + str + (s.suffix || '');
}
});
DataTableConfig.registerFieldType({
value: 'select', label: '{#Single Select#}', typeForDB: 'v1024',
schema: [{ name: 'options_str', label: 'Options', type: 'textarea', setting: { rows: 3, placeholder: 'Label:Value per line' }, if: 'this.data.user_type=="select"' }],
formatter: (val, field) => {
if (val == null || val === '') return '';
const opts = field.settings?.options || [];
const opt = opts.find(o => typeof o === 'object' ? o.value == val : o == val);
return opt ? (typeof opt === 'object' ? opt.label : opt) : val;
}
});
DataTableConfig.registerFieldType({
value: 'checkbox', label: '{#Multi Select#}', typeForDB: 'v4096',
schema: [{ name: 'options_str', label: 'Options', type: 'textarea', setting: { rows: 3, placeholder: 'Label:Value per line' }, if: 'this.data.user_type=="checkbox"' }],
formatter: (val, field) => {
if (!Array.isArray(val)) return val == null ? '' : String(val);
const opts = field.settings?.options || [];
return val.map(v => {
const opt = opts.find(o => typeof o === 'object' ? o.value == v : o == v);
return opt ? (typeof opt === 'object' ? opt.label : opt) : v;
}).join(', ');
}
});
DataTableConfig.registerFieldType({
value: 'switch', label: '{#Switch#}', typeForDB: 'b',
schema: [
{ name: 'labelOn', label: 'Label On', type: 'text', if: 'this.data.user_type=="switch"' },
{ name: 'labelOff', label: 'Label Off', type: 'text', if: 'this.data.user_type=="switch"' }
],
formatter: (val, field) => {
const s = field.settings || {};
return val ? (s.labelOn || 'Yes') : (s.labelOff || 'No');
}
});
DataTableConfig.registerFieldType({
value: 'datetime', label: '{#DateTime#}', typeForDB: 'dt',
schema: [{ name: 'format', label: 'Format', type: 'text', setting: { placeholder: 'YYYY-MM-DD' }, if: 'this.data.user_type=="datetime"' }]
});
DataTableConfig.registerFieldType({
value: 'textarea', label: '{#Long Text#}', typeForDB: 't',
schema: [{ name: 'placeholder', label: 'Placeholder', type: 'text', if: 'this.data.user_type=="textarea"' }]
});
// Managers Factory Functions (Internalized)
const createPerfMonitor = () => {
let enabled = !!globalThis.__DT_PERF_MODE__;
const stats = { refreshTime: 0, refreshCount: 0, scrollCount: 0, totalNodes: 0 };
if (enabled && !globalThis.__statePerformanceTelemetry) {
globalThis.__statePerformanceTelemetry = { scanCount: 0, reuseCount: 0, moveCount: 0 };
}
return {
get stats() { return stats; },
enable: () => { enabled = true; },
disable: () => { enabled = false; },
onScroll: () => { if (enabled) stats.scrollCount++; },
startFrame: () => {
if (!enabled) return null;
return {
start: performance.now(),
scan: globalThis.__statePerformanceTelemetry?.scanCount || 0,
move: globalThis.__statePerformanceTelemetry?.moveCount || 0,
reuse: globalThis.__statePerformanceTelemetry?.reuseCount || 0
};
},
endFrame: (startData, renderedCount) => {
if (!enabled || !startData) return;
stats.refreshCount++; stats.totalNodes += renderedCount;
const elapsed = performance.now() - startData.start;
stats.refreshTime += elapsed;
const stPerf = globalThis.__statePerformanceTelemetry;
if (stPerf) {
const scans = stPerf.scanCount - startData.scan;
const moves = stPerf.moveCount - startData.move;
const reuses = stPerf.reuseCount - startData.reuse;
if (scans > 0 || elapsed > 2) {
console.log(`[DataTable Frame] Time: ${elapsed.toFixed(2)}ms, Scans: ${scans}, Moves: ${moves}, Reuses: ${reuses}, Rows: ${renderedCount}`);
}
}
}
};
};
const createScrollManager = (container, state, onRenderedListChange) => {
const vs = globalThis.VirtualScroll({ itemHeight: 40 });
let scrollEl = null;
const refresh = (isLayoutChange = false) => {
if (!scrollEl) return;
const res = vs.calc(scrollEl, state.list);
if (res) {
if (!isLayoutChange && state.prevHeight === res.prevHeight && state.postHeight === res.postHeight && state._listStartIndex === res.listStartIndex && state._renderedList.length === res.renderedList.length) return;
Object.assign(state, { prevHeight: res.prevHeight, postHeight: res.postHeight, _listStartIndex: res.listStartIndex, _renderedList: res.renderedList });
onRenderedListChange?.(res.renderedList.length, isLayoutChange);
}
};
return {
init: () => { scrollEl = container.querySelector('.dt-main'); },
reset: (list) => { state._listStartIndex = 0; vs.reset(list, scrollEl || container); if (state.list === list) vs.init(list, () => refresh(true)); },
refresh, onScroll: () => refresh(false)
};
};
const createSelectionManager = (container, state) => {
let activeBounds = null; let startCell = null; let multiSelections = [];
const isCellSelected = (r, c) => {
if (activeBounds && r >= activeBounds.minRow && r <= activeBounds.maxRow && c >= activeBounds.minCol && c <= activeBounds.maxCol) return true;
return multiSelections.some(s => r >= s.minRow && r <= s.maxRow && c >= s.minCol && c <= s.maxCol);
};
let lastHadSelection = false;
const applySelectionUI = () => {
if (globalThis.__DT_FEATURES__ && !globalThis.__DT_FEATURES__.selection) return;
let boundMinRow = Infinity, boundMaxRow = -Infinity;
if (activeBounds) { boundMinRow = Math.min(boundMinRow, activeBounds.minRow); boundMaxRow = Math.max(boundMaxRow, activeBounds.maxRow); }
multiSelections.forEach(s => { boundMinRow = Math.min(boundMinRow, s.minRow); boundMaxRow = Math.max(boundMaxRow, s.maxRow); });
const hasSelection = boundMinRow !== Infinity;
if (!hasSelection && !lastHadSelection) return;
lastHadSelection = hasSelection;
const body = container.querySelector('.dt-body');
if (!body) return;
const rowNodes = body.querySelectorAll('.dt-body-row');
rowNodes.forEach(rowNode => {
const absoluteRow = (rowNode._ref?.rIdx ?? -1) + state._listStartIndex;
const cells = rowNode.querySelectorAll('.dt-cell');
if (!hasSelection || absoluteRow < boundMinRow || absoluteRow > boundMaxRow) { cells.forEach(cell => cell.classList.remove('dt-cell-selected')); return; }
cells.forEach((cell, cIdx) => { if (isCellSelected(absoluteRow, cIdx)) cell.classList.add('dt-cell-selected'); else cell.classList.remove('dt-cell-selected'); });
});
};
const updateStatus = () => {
let count = 0; if (activeBounds) count += (activeBounds.maxRow - activeBounds.minRow + 1);
multiSelections.forEach(s => count += (s.maxRow - s.minRow + 1));
state.selectedRowCount = count;
};
const clearAllActive = (keepSelection = false) => { if (!keepSelection) { activeBounds = null; startCell = null; multiSelections = []; applySelectionUI(); updateStatus(); } };
const startSelect = (row, col, e) => {
const alreadySelected = isCellSelected(row, col);
const isRange = (activeBounds && (activeBounds.minRow !== activeBounds.maxRow || activeBounds.minCol !== activeBounds.maxCol)) || multiSelections.length > 0;
if (e.shiftKey && startCell) { activeBounds = { minRow: Math.min(startCell.row, row), maxRow: Math.max(startCell.row, row), minCol: Math.min(startCell.col, col), maxCol: Math.max(startCell.col, col) }; }
else {
if (alreadySelected && !e.ctrlKey && !e.metaKey) { if (!isRange) container._potentialCancel = { row, col }; }
else { if (!e.ctrlKey && !e.metaKey) clearAllActive(); else if (activeBounds && !alreadySelected) multiSelections.push(activeBounds); startCell = { row, col }; activeBounds = { minRow: row, maxRow: row, minCol: col, maxCol: col }; }
state.isSelecting = true;
}
applySelectionUI(); updateStatus(); container.focus();
};
const updateSelect = (row, col) => { if (state.isSelecting && startCell) { activeBounds = { minRow: Math.min(startCell.row, row), maxRow: Math.max(startCell.row, row), minCol: Math.min(startCell.col, col), maxCol: Math.max(startCell.col, col) }; container._potentialCancel = null; applySelectionUI(); updateStatus(); } };
const endSelect = () => { if (container._potentialCancel) { const { row, col } = container._potentialCancel; if (isCellSelected(row, col)) clearAllActive(); container._potentialCancel = null; } state.isSelecting = false; };
const getSelectionBounds = () => {
if (!activeBounds) return null;
let minRow = activeBounds.minRow, maxRow = activeBounds.maxRow, minCol = activeBounds.minCol, maxCol = activeBounds.maxCol;
multiSelections.forEach(s => { minRow = Math.min(minRow, s.minRow); maxRow = Math.max(maxRow, s.maxRow); minCol = Math.min(minCol, s.minCol); maxCol = Math.max(maxCol, s.maxCol); });
return { minRow, maxRow, minCol, maxCol };
};
const copy = async () => {
const bounds = getSelectionBounds(); if (!bounds) return;
const text = state.list.slice(bounds.minRow, bounds.maxRow + 1).map(row => {
return state.fields.slice(bounds.minCol, bounds.maxCol + 1).map(f => {
let val = String(row[f.id] ?? '');
if (val.includes('\t') || val.includes('\n') || val.includes('"')) val = '"' + val.replace(/"/g, '""') + '"';
return val;
}).join('\t');
}).join('\n');
await navigator.clipboard.writeText(text);
};
const paste = async () => {
try {
const text = await navigator.clipboard.readText(); if (!text) return;
const bounds = getSelectionBounds(); if (!bounds) return;
const rows = text.split(/\r?\n/).filter(line => line.length > 0).map(line => {
const cells = []; let current = '', inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') { if (inQuotes && line[i+1] === '"') { current += '"'; i++; } else inQuotes = !inQuotes; }
else if (char === '\t' && !inQuotes) { cells.push(current); current = ''; }
else current += char;
}
cells.push(current); return cells;
});
const { minRow: startRow, minCol: startCol, maxRow, maxCol } = bounds;
const body = container.querySelector('.dt-body');
const rowNodes = body ? Array.from(body.childNodes).filter(n => n.classList?.contains('dt-body-row')) : [];
let anyRowChanged = false;
rows.forEach((rowData, rOffset) => {
const rIdx = startRow + rOffset; if (rIdx > maxRow || rIdx >= state.list.length) return;
const rowItem = state.list[rIdx]; let rowChanged = false;
rowData.forEach((cellData, cOffset) => { const cIdx = startCol + cOffset; if (cIdx > maxCol || cIdx >= state.fields.length) return; const field = state.fields[cIdx]; rowItem[field.id] = cellData; rowChanged = true; });
if (rowChanged) anyRowChanged = true;
});
if (anyRowChanged) state.list = [...state.list];
} catch (err) { console.error('Paste Error:', err); }
};
return { applySelectionUI, clearAllActive, startSelect, updateSelect, endSelect, getSelectionBounds, copy, paste };
};
// Component Registration
globalThis.Component.register('DataTable', container => {
if (!container.state) container.state = globalThis.NewState({})
const state = container.state
Object.assign(state, {
list: [], fields: [], _renderedList: [],
prevHeight: 0, postHeight: 0, _listStartIndex: 0,
selectedRowCount: 0,
_originalList: [],
sortConfig: { fieldId: null, direction: null },
filterConfig: {},
activeFieldId: null, activeField: null, activeModes: [],
_columnStats: {}, _internalUpdate: false, _appliedHash: '', _fieldsDirty: false, _masterCellNodes: null,
isDirty: false, isBulkEdit: null
})
const perf = createPerfMonitor();
state.perf = perf.stats;
const selection = createSelectionManager(container, state);
const scroll = createScrollManager(container, state, () => selection.applySelectionUI());
const menuNode = container.querySelector('.dt-column-menu');
if (menuNode) menuNode._thisObj = container;
container.onColumnResizing = (field, e) => container.style.setProperty(`--w-${field.id}`, e.detail.newSize + 'px');
container.onColumnResize = (field, e) => {
const idx = state.fields.findIndex(f => f.id === field.id);
if (idx !== -1) { state.fields[idx].width = e.detail.newSize; state.fields = [...state.fields]; }
};
let _editorOverlay, currentEditingNode = null;
container.format = (val, field) => {
if (field.formatter) return field.formatter(val, field);
const typeInfo = DataTableConfig._fieldTypes.get(field.settings?.formType || field.type || 'text');
if (typeInfo && typeInfo.formatter) return typeInfo.formatter(val, field);
return val == null ? '' : (typeof val === 'object' ? JSON.stringify(val) : String(val));
};
container.onScroll = () => {
perf.onScroll(); scroll.refresh();
container.hideColumnMenu();
const prev = container.querySelector('.dt-spacer-prev'), post = container.querySelector('.dt-spacer-post');
if (prev) { prev.style.height = (state.prevHeight || 0) + 'px'; prev.style.display = state.prevHeight > 0 ? 'block' : 'none'; }
if (post) { post.style.height = (state.postHeight || 0) + 'px'; post.style.display = state.postHeight > 0 ? 'block' : 'none'; }
};
container.applySortFilter = (options = {}) => {
if (state._internalUpdate) return;
const targetFilters = { ...state.filterConfig, ...(options.filters || {}) };
const targetSort = options.sort !== undefined ? (options.sort ? { fieldId: state.activeFieldId, direction: options.sort } : { fieldId: null, direction: null }) : state.sortConfig;
let filtered = [...state._originalList];
Object.entries(targetFilters).forEach(([fId, cfg]) => {
if (!cfg.value && (!cfg.selectedValues || cfg.selectedValues.length === 0)) return;
filtered = filtered.filter(item => {
const val = item[fId];
if (cfg.selectedValues?.length > 0) return cfg.selectedValues.includes(String(val));
const search = String(cfg.value).toLowerCase();
const target = String(val ?? '').toLowerCase();
switch (cfg.mode) {
case 'contains': return target.includes(search);
case 'equals': return target === search;
case 'starts': return target.startsWith(search);
case 'ends': return target.endsWith(search);
case '=': return Number(val) === Number(cfg.value);
case '>': return Number(val) > Number(cfg.value);
case '<': return Number(val) < Number(cfg.value);
case 'between': return Number(val) >= Number(cfg.value) && Number(val) <= Number(cfg.value2);
default: return true;
}
});
});
if (targetSort.fieldId && targetSort.direction) {
const fId = targetSort.fieldId;
const dir = targetSort.direction === 'asc' ? 1 : -1;
filtered.sort((a, b) => {
if (a[fId] == b[fId]) return 0;
return a[fId] > b[fId] ? dir : -dir;
});
}
state._internalUpdate = true;
state.filterConfig = targetFilters;
state.sortConfig = targetSort;
state.list = filtered;
state._internalUpdate = false;
};
container.showColumnMenu = (field, event) => {
const btn = event.currentTarget, menu = container.querySelector('.dt-column-menu');
const type = field.settings?.formType || field.type || 'text';
state.activeModes = MODE_MAP[type] || (['boolean', 'switch', 'checkbox', 'radio'].includes(type) ? [] : MODE_MAP.text);
if (!state.filterConfig[field.id]) state.filterConfig[field.id] = { mode: state.activeModes[0] || 'contains', value: '', selectedValues: [] };
state.activeField = field; state.activeFieldId = field.id;
menu.style.display = 'block';
const cellNode = btn.closest('.dt-cell'), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect();
const menuWidth = menu.offsetWidth || 260;
let leftPos = rect.right - rootRect.left - menuWidth;
if (leftPos < 0) leftPos = Math.max(0, rect.left - rootRect.left);
menu.style.left = leftPos + 'px'; menu.style.top = (rect.bottom - rootRect.top + 5) + 'px';
const onGlobalClick = (ev) => { if (menu.contains(ev.target) || btn.contains(ev.target)) return; container.hideColumnMenu(); container.applySortFilter(); document.removeEventListener('mousedown', onGlobalClick); };
document.addEventListener('mousedown', onGlobalClick);
setTimeout(() => menu.querySelector('input')?.focus(), 50);
};
container.toggleSelectedValue = (val) => {
const filter = state.filterConfig[state.activeFieldId]; if (!filter) return;
const idx = filter.selectedValues.indexOf(val); if (idx === -1) filter.selectedValues.push(val); else filter.selectedValues.splice(idx, 1);
state.filterConfig = { ...state.filterConfig }; container.applySortFilter();
};
container.filterOnlyThis = (val) => { state.filterConfig[state.activeFieldId] = { mode: 'contains', value: '', selectedValues: [String(val)] }; state.filterConfig = { ...state.filterConfig }; container.applySortFilter(); };
container.hideColumnMenu = () => { const menu = container.querySelector('.dt-column-menu'); if (menu) menu.style.display = 'none'; };
container.setSort = (dir) => { const newDir = state.sortConfig.direction === dir && state.sortConfig.fieldId === state.activeFieldId ? null : dir; container.applySortFilter({ sort: newDir }); };
container.clearColumnSettings = () => { if (state.activeFieldId) { delete state.filterConfig[state.activeFieldId]; state.filterConfig = { ...state.filterConfig }; container.applySortFilter(); } };
container._initRow = (rowNode) => {
const row = rowNode._ref?.item;
if (row && row._editingF === undefined) { Object.defineProperty(row, '_editingF', { set: (v) => { if (v === null) container.hideEditor(true); }, configurable: true }); }
Array.from(rowNode.children).forEach(cell => { const fIdx = parseInt(cell.dataset.fidx); if (!isNaN(fIdx)) cell._ref = { ...(cell._ref || rowNode._ref), f: state.fields[fIdx], fIdx: fIdx }; });
};
state.__watch('fields', fields => {
if (!fields) return;
state._fieldsDirty = true; state._masterCellNodes = null;
container.style.setProperty('--dt-grid-template', fields.map(f => `var(--w-${f.id}, ${(f.settings?.width || f.width) || 150}px)`).join(' '));
container.style.setProperty('--dt-row-width', fields.reduce((sum, f) => sum + ((f.settings?.width || f.width) || 150), 0) + 'px');
let leftSum = 0;
fields.forEach(f => {
const pinned = f.settings?.pinned || f.pinned;
if (pinned === 'left') { container.style.setProperty(`--l-${f.id}`, leftSum + 'px'); leftSum += ((f.settings?.width || f.width) || 150); }
});
let rightSum = 0;
[...fields].reverse().forEach(f => {
const pinned = f.settings?.pinned || f.pinned;
if (pinned === 'right') { container.style.setProperty(`--r-${f.id}`, rightSum + 'px'); rightSum += ((f.settings?.width || f.width) || 150); }
});
});
state.__watch('list', list => {
if (state._fieldsDirty) {
state._fieldsDirty = false;
const fieldTemplate = container.querySelector('.dt-body template[index="rIdx"]')?.content.querySelector('template[as="f"]');
if (fieldTemplate) {
const masters = state._masterCellNodes || (state._masterCellNodes = Array.from(fieldTemplate.content.childNodes).map(n => n.cloneNode(true)));
fieldTemplate.removeAttribute('$each'); fieldTemplate.setAttribute('$if', 'true'); fieldTemplate.content.textContent = '';
state.fields.forEach((f, fIdx) => masters.forEach(master => {
const clone = master.cloneNode(true);
if (clone.nodeType === 1) {
clone.dataset.fidx = fIdx;
const pinned = f.settings?.pinned || f.pinned;
if (pinned) {
clone.classList.add('pinned-' + pinned); clone.style.position = 'sticky'; clone.style.zIndex = '1'; clone.style.backgroundColor = 'inherit';
if (pinned === 'left') { clone.style.left = `var(--l-${f.id})`; clone.style.borderRight = '1px solid var(--bs-border-color)'; clone.style.boxShadow = '2px 0 5px -2px rgba(0,0,0,0.1)'; }
else { clone.style.right = `var(--r-${f.id})`; clone.style.borderLeft = '1px solid var(--bs-border-color)'; clone.style.boxShadow = '-2px 0 5px -2px rgba(0,0,0,0.1)'; }
}
}
fieldTemplate.content.appendChild(clone);
}));
}
}
if (!state._internalUpdate) {
state._originalList = [...(list || [])];
setTimeout(() => {
const stats = {};
state.fields.forEach(f => {
const counts = {};
state._originalList.forEach(item => { const val = item[f.id], key = (val == null || val === '') ? '' : String(val); counts[key] = (counts[key] || 0) + 1; });
stats[f.id] = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([val, count]) => ({ val, count }));
});
state._columnStats = stats;
}, 200);
}
scroll.init(); scroll.reset(list);
});
container.editCell = (row, field, cellNode) => {
const overlay = container.querySelector('.dt-editor-overlay'), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect();
currentEditingNode = cellNode;
const formType = field.settings?.formType || field.type || 'text';
const form = overlay.querySelector('AutoForm');
if (form) {
form.data = row;
form.state.schema = [{ ...field, type: formType, options: field.settings?.options || field.options, name: field.id, label: '' }];
}
Object.assign(overlay.style, {
display: 'flex', left: (rect.left - rootRect.left) + 'px', top: (rect.top - rootRect.top) + 'px',
width: (formType === 'textarea' || formType === 'TagsInput' ? Math.max(rect.width, 300) : rect.width) + 'px',
height: (formType === 'textarea' || formType === 'TagsInput' ? 'auto' : rect.height + 'px')
});
setTimeout(() => overlay.querySelector('input, textarea, select, .form-control')?.focus(), 30);
};
container.hideEditor = (save = true) => {
if (!_editorOverlay) _editorOverlay = container.querySelector('.dt-editor-overlay');
if (!_editorOverlay || _editorOverlay.style.display === 'none') return;
const form = _editorOverlay.querySelector('AutoForm');
if (save && form && form.data) {
const input = _editorOverlay.querySelector('input:focus, select:focus, textarea:focus');
if (input) input.dispatchEvent(new Event(input.type === 'number' || input.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }));
const schema = form.state.schema || [];
schema.forEach(field => {
const row = currentEditingNode?.closest('.dt-row')?._ref?.item;
if (row) row[field.name] = form.data[field.name];
});
if (state.isBulkEdit) {
const { minRow, maxRow, fIdx } = state.isBulkEdit;
const field = state.fields[fIdx]; const newValue = form.data[field.id];
for (let i = minRow; i <= maxRow; i++) { if (state.list[i]) state.list[i][field.id] = newValue; }
}
state.list = [...state.list];
state.isDirty = true;
}
_editorOverlay.style.display = 'none';
if (form) { form.state.schema = []; form.data = null; }
currentEditingNode = null; state.isBulkEdit = null; container.focus();
};
container.onMainMouseDown = e => {
const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row');
if (!row || row.classList.contains('dt-header-row')) return;
const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell);
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
selection.startSelect(rIdx + state._listStartIndex, fIdx, e);
};
container.onMainMouseOver = e => {
if (state.isSelecting) {
const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row');
if (row && !row.classList.contains('dt-header-row')) {
const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell);
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
selection.updateSelect(rIdx + state._listStartIndex, fIdx);
}
}
};
container.onMainDblClick = e => {
const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row');
if (row && !row.classList.contains('dt-header-row')) {
const item = row._ref?.item, fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell);
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
const absoluteRow = rIdx + state._listStartIndex;
if (item && state.fields[fIdx]) {
const bounds = selection.getSelectionBounds();
if (bounds && absoluteRow >= bounds.minRow && absoluteRow <= bounds.maxRow && fIdx >= bounds.minCol && fIdx <= bounds.maxCol) {
const affectedRows = bounds.maxRow - bounds.minRow + 1;
if (affectedRows > 1) {
state.isBulkEdit = { ...bounds, fIdx };
if (globalThis.UI?.toast) globalThis.UI.toast(`Bulk Edit: Updating ${affectedRows} rows in column "${state.fields[fIdx].name}"`, { type: 'warning' });
}
}
container.editCell(item, state.fields[fIdx], cell);
}
}
};
container.addRow = () => {
const newRow = {}; state.fields.forEach(f => newRow[f.id] = '');
state._originalList.push(newRow); state.list = [...state._originalList]; state.isDirty = true;
setTimeout(() => { scroll.reset(state.list); container.querySelector('.dt-main').scrollTop = container.querySelector('.dt-main').scrollHeight; }, 50);
};
container.deleteSelectedRow = async () => {
const bounds = selection.getSelectionBounds(); if (!bounds) return;
const count = bounds.maxRow - bounds.minRow + 1;
if (await globalThis.UI.confirm(`Are you sure you want to delete ${count} row(s)?`)) {
const rMin = bounds.minRow, rMax = bounds.maxRow;
const removedItems = state.list.slice(rMin, rMax + 1);
state.list = state.list.filter((_, i) => !(i >= rMin && i <= rMax));
state._originalList = state._originalList.filter(item => !removedItems.includes(item));
state.isDirty = true; selection.clearAllActive();
container.dispatchEvent(new CustomEvent('remove', { detail: { items: removedItems } }));
}
};
container.saveChanges = () => { container.dispatchEvent(new CustomEvent('save', { detail: { list: state._originalList, fields: state.fields } })); state.isDirty = false; };
const getFieldSchema = () => {
const types = globalThis.DataTable.getFieldTypes();
const baseSchema = [
{ name: 'id', label: 'Field ID', type: 'text', setting: { required: true, placeholder: 'e.g. user_name' } },
{ name: 'name', label: 'Display Name', type: 'text', setting: { required: true, placeholder: 'e.g. 用户名' } },
{ name: 'user_type', label: 'Field Type', type: 'select', options: types.map(t => ({ label: t.label, value: t.value })) }
];
const dynamicSchema = types.reduce((acc, t) => acc.concat(t.schema || []), []);
return baseSchema.concat(dynamicSchema, [{ name: 'isIndex', label: 'Index', type: 'switch' }, { name: 'memo', label: 'Memo', type: 'text' }]);
};
const parseOptionsStr = (str) => {
if (!str) return undefined;
return str.split('\n').map(s => s.trim()).filter(Boolean).map(line => {
const idx = line.indexOf(':'); if (idx > -1) return { label: line.slice(0, idx).trim(), value: line.slice(idx + 1).trim() }; return line;
});
};
const formatOptionsStr = (opts) => { if (!opts) return ''; return opts.map(o => typeof o === 'object' ? `${o.label}:${o.value}` : o).join('\n'); };
container.addField = async () => {
container.hideColumnMenu();
const data = globalThis.NewState({ id: 'c' + Date.now().toString().slice(-4), name: 'New Field', user_type: 'text', decimals: 0, isIndex: false, memo: '', options_str: '' });
const d = container.querySelector(`Dialog[id="${container.id}_field_dialog"]`);
if (!d) return;
Object.assign(d.state, { title: 'Add Field', buttons: ['Cancel', 'Save'] });
const form = d.querySelector('AutoForm');
if (form) {
form.data = data; form.state.schema = getFieldSchema();
}
d.show();
const result = await new Promise(resolve => d.addEventListener('change', e => resolve(d.result), { once: true }));
if (result === 2) {
const typeInfo = globalThis.DataTable.getFieldTypes().find(t => t.value === data.user_type);
let dbType = typeInfo?.typeForDB || 'v1024'; if (data.user_type === 'number') dbType = data.decimals > 0 ? 'ff' : 'bi';
const field = { id: data.id, name: data.name, memo: data.memo, isIndex: !!data.isIndex, type: dbType, settings: { formType: data.user_type, decimals: data.decimals, prefix: data.prefix, suffix: data.suffix, thousandSep: data.thousandSep, labelOn: data.labelOn, labelOff: data.labelOff, format: data.format, placeholder: data.placeholder, options: parseOptionsStr(data.options_str) } };
state.fields = [...state.fields, field]; state.isDirty = true; container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields })); state.list = [...state.list];
}
};
container.editField = async () => {
if (!state.activeField) return;
container.hideColumnMenu();
const f = state.activeField; const s = f.settings || {};
const data = globalThis.NewState({ id: f.id, name: f.name, memo: f.memo || '', isIndex: !!f.isIndex, user_type: s.formType || 'text', decimals: s.decimals || 0, prefix: s.prefix || '', suffix: s.suffix || '', thousandSep: !!s.thousandSep, labelOn: s.labelOn || '', labelOff: s.labelOff || '', format: s.format || '', placeholder: s.placeholder || '', options_str: formatOptionsStr(s.options) });
const d = container.querySelector(`Dialog[id="${container.id}_field_dialog"]`);
if (!d) return;
Object.assign(d.state, { title: 'Edit Field', buttons: ['Cancel', 'Save'] });
const form = d.querySelector('AutoForm');
if (form) {
form.data = data; form.state.schema = getFieldSchema();
}
d.show();
const result = await new Promise(resolve => d.addEventListener('change', e => resolve(d.result), { once: true }));
if (result === 2) {
const idx = state.fields.findIndex(item => item.id === f.id);
if (idx !== -1) {
const typeInfo = globalThis.DataTable.getFieldTypes().find(t => t.value === data.user_type);
let dbType = typeInfo?.typeForDB || 'v1024'; if (data.user_type === 'number') dbType = data.decimals > 0 ? 'ff' : 'bi';
const updatedField = { ...f, id: data.id, name: data.name, memo: data.memo, isIndex: !!data.isIndex, type: dbType, settings: { ...f.settings, formType: data.user_type, decimals: data.decimals, prefix: data.prefix, suffix: data.suffix, thousandSep: data.thousandSep, labelOn: data.labelOn, labelOff: data.labelOff, format: data.format, placeholder: data.placeholder, options: parseOptionsStr(data.options_str) } };
state.fields[idx] = updatedField; state.fields = [...state.fields]; state.isDirty = true; container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields })); state.list = [...state.list];
}
}
};
container.deleteField = async () => {
if (!state.activeField) return;
container.hideColumnMenu();
if (await globalThis.UI.confirm(`Are you sure you want to delete field "${state.activeField.name}"?`)) {
const idx = state.fields.findIndex(f => f.id === state.activeField.id);
if (idx !== -1) { state.fields.splice(idx, 1); state.fields = [...state.fields]; state.isDirty = true; container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields })); state.list = [...state.list]; }
}
};
window.addEventListener('mouseup', selection.endSelect);
document.addEventListener('mousedown', e => {
const overlay = container.querySelector('.dt-editor-overlay'); const menu = container.querySelector('.dt-column-menu');
if (overlay?.style.display !== 'none' && !overlay.contains(e.target)) container.hideEditor(true);
if (!container.contains(e.target) && !overlay?.contains(e.target) && !menu?.contains(e.target)) selection.clearAllActive();
});
state._MODE_ICONS = MODE_ICONS;
}, globalThis.Util.makeDom(/*html*/`
<div class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-hidden" style="position:relative; user-select:none; outline: none; min-height: 0" tabindex="0">
<div class="dt-main flex-grow-1 overflow-auto" $onscroll="this.onScroll()"
$onmousedown="this.onMainMouseDown(event)" $onmouseover="this.onMainMouseOver(event)" $ondblclick="this.onMainDblClick(event)"
style="overflow-anchor:none; min-height: 0">
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:20">
<div class="dt-header-row fw-bold text-muted small">
<template $each="this.state?.fields || []">
<div $data-id="item.id" $class="dt-cell dt-col border-end d-flex align-items-center header-cell \${(item.settings?.pinned || item.pinned) ? 'pinned-' + (item.settings?.pinned || item.pinned) : ''}" $style="((item.settings?.pinned || item.pinned) ? 'position: sticky; z-index: 11; background-color: inherit; ' : 'position:relative; ') + 'padding: 0; ' + ((item.settings?.pinned || item.pinned) === 'left' ? 'left: var(--l-' + item.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : ((item.settings?.pinned || item.pinned) === 'right' ? 'right: var(--r-' + item.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))">
<div class="d-flex align-items-center overflow-hidden flex-grow-1 h-100 px-2 cursor-pointer" $onclick="this.showColumnMenu(item, event)">
<i $if="this.state?.filterConfig?.[item.id] && (this.state.filterConfig[item.id].value || this.state.filterConfig[item.id].selectedValues?.length)" class="bi bi-filter me-1 text-primary"></i>
<i $if="this.state?.sortConfig?.fieldId === item.id && this.state.sortConfig.direction" $class="bi bi-sort-\${this.state.sortConfig.direction === 'asc' ? 'down' : 'up-alt'} me-1 text-primary"></i>
<span $text="item.name" class="text-truncate flex-grow-1"></span>
</div>
<button class="btn btn-xs btn-link text-muted p-0 border-0 me-1 header-menu-btn" $onclick="this.showColumnMenu(item, event)"><i class="bi bi-chevron-down"></i></button>
<Resizer $.target="thisNode.parentElement" style="position:absolute; right:0; top:0; bottom:0; width:4px; z-index:10" min="50" max="1000" $onresizing="this.onColumnResizing(item, event)" $onresize="this.onColumnResize(item, event)"></Resizer>
</div>
</template>
</div>
</div>
<div class="dt-body" style="position:relative">
<div class="dt-spacer-prev flex-shrink-0" style="display:none"></div>
<template $each="this.state?._renderedList || []" key="id" index="rIdx">
<div class="dt-row dt-body-row border-bottom bg-white" $.="this._initRow(thisNode)">
<template as="f"><div $class="dt-cell border-end px-2 d-flex align-items-center \${(f.settings?.pinned || f.pinned) ? 'pinned-' + (f.settings?.pinned || f.pinned) : ''}" $style="((f.settings?.pinned || f.pinned) ? 'position: sticky; z-index: 1; background-color: inherit; ' : '') + ((f.settings?.pinned || f.pinned) === 'left' ? 'left: var(--l-' + f.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : ((f.settings?.pinned || f.pinned) === 'right' ? 'right: var(--r-' + f.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))"><span $text="this.format(item[f.id], f)" class="text-truncate"></span></div></template>
</div>
</template>
<div class="dt-spacer-post flex-shrink-0" style="display:none"></div>
</div>
</div>
<div class="dt-column-menu bg-body shadow-lg rounded p-2" style="display:none; position:absolute; z-index:2000; min-width:240px; max-width:300px; border: 1px solid var(--bs-primary)">
<template $if="this.state?.activeFieldId">
<div class="d-flex gap-1 mb-2">
<button $class="btn btn-xs flex-grow-1 d-flex align-items-center justify-content-center \${this.state?.sortConfig?.direction === 'asc' && this.state?.sortConfig?.fieldId === this.state?.activeFieldId ? 'btn-primary' : 'btn-outline-secondary border'}" $onclick="this.setSort('asc')"><i class="bi bi-sort-alpha-down me-1"></i> ASC</button>
<button $class="btn btn-xs flex-grow-1 d-flex align-items-center justify-content-center \${this.state?.sortConfig?.direction === 'desc' && this.state?.sortConfig?.fieldId === this.state?.activeFieldId ? 'btn-primary' : 'btn-outline-secondary border'}" $onclick="this.setSort('desc')"><i class="bi bi-sort-alpha-up-alt me-1"></i> DESC</button>
</div>
<div $if="this.state?.activeModes?.length" class="dt-filter-tabs d-flex overflow-auto border-bottom bg-light-subtle rounded-top py-1" style="white-space:nowrap; scrollbar-width: none;">
<template $each="this.state?.activeModes || []" as="m">
<div $class="px-2 py-1 cursor-pointer fs-5 \${this.state?.filterConfig?.[this.state?.activeFieldId]?.mode === m ? 'text-primary border-bottom border-primary border-2' : 'text-muted'}" $title="m.toUpperCase()" $onclick="this.state.filterConfig[this.state.activeFieldId].mode = m; this.state.filterConfig = {...this.state.filterConfig}">
<i $class="bi \${this.state?._MODE_ICONS?.[m] || 'bi-filter'}"></i>
</div>
</template>
</div>
<template $if="this.state?.activeModes?.length">
<div class="py-2 border-bottom" style="min-height: 48px">
<input type="text" class="form-control form-control-sm mb-1" $placeholder="(this.state?.filterConfig?.[this.state?.activeFieldId]?.mode || 'Search').toUpperCase() + '...'" $bind="this.state?.filterConfig?.[this.state?.activeFieldId].value" $onkeydown="if(event.key==='Enter'){this.applySortFilter();this.hideColumnMenu();}">
<input $if="this.state?.filterConfig?.[this.state?.activeFieldId]?.mode === 'between'" type="text" class="form-control form-control-sm" placeholder="And..." $bind="this.state?.filterConfig?.[this.state?.activeFieldId].value2" $onkeydown="if(event.key==='Enter'){this.applySortFilter();this.hideColumnMenu();}">
</div>
</template>
<div class="mt-2" style="max-height: 180px; overflow-y: auto;">
<div class="text-muted fw-bold mb-1" style="font-size: 9px; letter-spacing: 0.5px">TOP FREQUENT VALUES</div>
<template $each="this.state?._columnStats?.[this.state?.activeFieldId] || []">
<label class="d-flex align-items-center mb-1 small cursor-pointer p-1 rounded-1 menu-item-row" onmouseover="this.style.background='var(--bs-light)'" onmouseout="this.style.background='transparent'">
<input type="checkbox" class="form-check-input me-2" $checked="this.state?.filterConfig?.[this.state?.activeFieldId]?.selectedValues?.includes(String(item.val))" $onclick="this.toggleSelectedValue(String(item.val))">
<span class="text-truncate flex-grow-1"><span $text="item.val || '(Empty)'"></span> <span class="text-muted ms-1" style="font-size: 0.7rem" $text="'(' + item.count + ')'"></span></span>
<button class="btn btn-xs btn-link p-0 text-primary only-btn" style="font-size: 10px; text-decoration: none" $onclick="this.filterOnlyThis(item.val); event.preventDefault(); event.stopPropagation();">Only</button>
</label>
</template>
</div>
<div $if="this.state?.filterConfig?.[this.state?.activeFieldId]?.value || this.state?.filterConfig?.[this.state?.activeFieldId]?.selectedValues?.length" class="mt-2 pt-1 border-top text-center">
<span class="cursor-pointer text-primary small fw-bold" $onclick="this.clearColumnSettings()"><i class="bi bi-x-circle me-1"></i> Clear Filter</span>
</div>
<div class="mt-3 pt-2 border-top d-flex flex-column gap-1">
<button class="btn btn-xs btn-outline-secondary border d-flex align-items-center px-2 py-1" $onclick="this.editField()"><i class="bi bi-pencil me-2"></i> Edit Field</button>
<button class="btn btn-xs btn-outline-secondary border d-flex align-items-center px-2 py-1" $onclick="this.addField()"><i class="bi bi-plus-lg me-2"></i> Add Field</button>
<button class="btn btn-xs btn-outline-danger border d-flex align-items-center px-2 py-1" $onclick="this.deleteField()"><i class="bi bi-trash me-2"></i> Delete Field</button>
</div>
</template>
</div>
<div class="dt-editor-overlay dt-editor-container" style="display: none; position: absolute; z-index: 1000; background: var(--bs-body-bg); box-shadow: 0 4px 16px rgba(0,0,0,0.25); border: 1px solid var(--bs-primary);"><AutoForm nobutton inline class="h-100 w-100" $onsubmit="event.preventDefault(); thisNode.closest('DataTable').hideEditor(true)"/></div>
<Dialog $.id="this.id + '_field_dialog'">
<AutoForm nobutton class="p-3"></AutoForm>
</Dialog>
<div class="dt-footer border-top bg-light d-flex align-items-center px-3 py-1 shadow-sm" style="height:40px; z-index: 10">
<div class="d-flex align-items-center gap-3 flex-grow-1">
<div class="btn-group shadow-sm">
<button class="btn btn-xs btn-white border d-flex align-items-center px-2" style="background:white" $onclick="this.addRow()" title="Add Row"><i class="bi bi-plus-lg text-primary me-1"></i> Add</button>
<button class="btn btn-xs btn-white border d-flex align-items-center px-2" style="background:white" $onclick="this.deleteSelectedRow()" $disabled="!this.state?.selectedRowCount" title="Delete Selected Rows"><i class="bi bi-trash text-danger me-1"></i> Delete</button>
</div>
<div class="vr h-50 my-auto text-muted opacity-25"></div>
<div class="d-flex align-items-center gap-2 text-muted" style="font-size: 0.75rem">
<i class="bi bi-check-all fs-6"></i>
<span $text="(this.state?.selectedRowCount || 0) + ' selected / ' + (this.state?.list?.length || 0) + ' total'"></span>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<button $if="this.state?.isDirty" class="btn btn-xs btn-primary px-3 shadow-sm d-flex align-items-center fw-bold" $onclick="this.saveChanges()"><i class="bi bi-cloud-upload me-1"></i> Save Changes</button>
<button $if="!this.state?.isDirty" class="btn btn-xs btn-light border px-3 text-muted disabled d-flex align-items-center" disabled><i class="bi bi-cloud-check me-1"></i> Up to date</button>
</div>
</div>
</div>
`), globalThis.Util.makeDom(/*html*/`
<style>
DataTable { display: block; }
.dt-root { font-size: 0.875rem; }
.dt-row, .dt-header-row { display: grid; grid-template-columns: var(--dt-grid-template); width: var(--dt-row-width, max-content); min-width: 100%; height: 40px; contain: paint layout; }
.dt-header-row { background-color: var(--bs-tertiary-bg); border-bottom: 1px solid var(--bs-border-color); }
.dt-cell { background: inherit; white-space: nowrap; flex-shrink: 0; contain: content; }
.dt-cell-selected { background-color: rgba(var(--bs-primary-rgb), 0.15) !important; outline: 1px solid var(--bs-primary); outline-offset: -1px; }
.dt-body-row:hover { background-color: var(--bs-secondary-bg) !important; }
.header-cell .header-menu-btn { opacity: 0; transition: opacity 0.2s; }
.header-cell:hover .header-menu-btn { opacity: 1; }
.dt-column-menu { background-color: var(--bs-body-bg); border: 1px solid var(--bs-primary); box-shadow: 0 10px 40px rgba(0,0,0,0.2) !important; z-index: 2100 !important; }
.btn-xs { padding: 1px 5px; line-height: 1.5; }
.cursor-pointer { cursor: pointer; }
.dt-filter-tabs i { font-size: 1.1rem; }
.dt-filter-tabs div:hover i { color: var(--bs-primary); }
.menu-item-row .only-btn { opacity: 0; }
.menu-item-row:hover .only-btn { opacity: 1; }
</style>
`))
globalThis.DataTable = DataTableConfig;
export const DataTable = DataTableConfig;

View File

@ -1,746 +1,7 @@
import { Component, NewState, Util, RefreshState } from '@web/state'
import { State } from '@web/base'
import { createPerfMonitor } from './perf.js'
import { createScrollManager } from './scroll.js'
import { createSelectionManager } from './selection.js'
/**
* DataTable Project Entry Point
* Orchestrates component loading and re-exports public API.
*/
// Static configuration maps
const MODE_MAP = {
text: ['contains', 'equals', 'starts', 'ends'],
textarea: ['contains', 'equals', 'starts', 'ends'],
number: ['=', '>', '<', 'between'],
date: ['=', '>', '<', 'between'],
select: ['contains', 'equals'],
TagsInput: ['contains', 'equals', 'starts', 'ends']
};
import './DataTable.js';
const MODE_ICONS = {
'contains': 'bi-search', 'equals': 'bi-distribute-vertical', 'starts': 'bi-align-start', 'ends': 'bi-align-end',
'=': 'bi-calculator', '>': 'bi-chevron-right', '<': 'bi-chevron-left', 'between': 'bi-arrows-expand'
};
export const DataTable = {
_fieldTypes: new Map(),
registerFieldType: (config) => {
DataTable._fieldTypes.set(config.value, config);
},
getFieldTypes: () => Array.from(DataTable._fieldTypes.values())
};
// Register Built-in Types
DataTable.registerFieldType({
value: 'text', label: '{#Text#}', typeForDB: 'v4096',
schema: [{ name: 'placeholder', label: 'Placeholder', type: 'text', if: 'this.data.user_type=="text"' }]
});
DataTable.registerFieldType({
value: 'number', label: '{#Number#}', typeForDB: 'ff',
schema: [
{ name: 'decimals', label: 'Decimals', type: 'number', setting: { min: 0, max: 10 }, if: 'this.data.user_type=="number"' },
{ name: 'prefix', label: 'Prefix (e.g. $)', type: 'text', if: 'this.data.user_type=="number"' },
{ name: 'suffix', label: 'Suffix (e.g. %)', type: 'text', if: 'this.data.user_type=="number"' },
{ name: 'thousandSep', label: 'Thousand Sep', type: 'switch', if: 'this.data.user_type=="number"' }
],
formatter: (val, field) => {
if (val == null || val === '') return '';
let num = Number(val);
if (isNaN(num)) return val;
const s = field.settings || {};
if (s.decimals !== undefined) num = num.toFixed(s.decimals);
let str = String(num);
if (s.thousandSep) {
const parts = str.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
str = parts.join('.');
}
return (s.prefix || '') + str + (s.suffix || '');
}
});
DataTable.registerFieldType({
value: 'select', label: '{#Single Select#}', typeForDB: 'v1024',
schema: [{ name: 'options_str', label: 'Options', type: 'textarea', setting: { rows: 3, placeholder: 'Label:Value per line' }, if: 'this.data.user_type=="select"' }],
formatter: (val, field) => {
if (val == null || val === '') return '';
const opts = field.settings?.options || [];
const opt = opts.find(o => typeof o === 'object' ? o.value == val : o == val);
return opt ? (typeof opt === 'object' ? opt.label : opt) : val;
}
});
DataTable.registerFieldType({
value: 'checkbox', label: '{#Multi Select#}', typeForDB: 'v4096',
schema: [{ name: 'options_str', label: 'Options', type: 'textarea', setting: { rows: 3, placeholder: 'Label:Value per line' }, if: 'this.data.user_type=="checkbox"' }],
formatter: (val, field) => {
if (!Array.isArray(val)) return val == null ? '' : String(val);
const opts = field.settings?.options || [];
return val.map(v => {
const opt = opts.find(o => typeof o === 'object' ? o.value == v : o == v);
return opt ? (typeof opt === 'object' ? opt.label : opt) : v;
}).join(', ');
}
});
DataTable.registerFieldType({
value: 'switch', label: '{#Switch#}', typeForDB: 'b',
schema: [
{ name: 'labelOn', label: 'Label On', type: 'text', if: 'this.data.user_type=="switch"' },
{ name: 'labelOff', label: 'Label Off', type: 'text', if: 'this.data.user_type=="switch"' }
],
formatter: (val, field) => {
const s = field.settings || {};
return val ? (s.labelOn || 'Yes') : (s.labelOff || 'No');
}
});
DataTable.registerFieldType({
value: 'datetime', label: '{#DateTime#}', typeForDB: 'dt',
schema: [{ name: 'format', label: 'Format', type: 'text', setting: { placeholder: 'YYYY-MM-DD' }, if: 'this.data.user_type=="datetime"' }]
});
DataTable.registerFieldType({
value: 'textarea', label: '{#Long Text#}', typeForDB: 't',
schema: [{ name: 'placeholder', label: 'Placeholder', type: 'text', if: 'this.data.user_type=="textarea"' }]
});
Component.register('DataTable', container => {
if (!container.state) container.state = NewState({})
const state = container.state
Object.assign(state, {
list: [], fields: [], _renderedList: [],
prevHeight: 0, postHeight: 0, _listStartIndex: 0,
selectedRowCount: 0,
_originalList: [],
sortConfig: { fieldId: null, direction: null },
filterConfig: {}, // fieldId -> { mode, value, value2, selectedValues: [] }
activeFieldId: null,
activeField: null,
activeModes: [],
_columnStats: {},
_internalUpdate: false,
_appliedHash: '',
_fieldsDirty: false,
_masterCellNodes: null,
isDirty: false,
isBulkEdit: null
})
const perf = createPerfMonitor();
state.perf = perf.stats;
const selection = createSelectionManager(container, state);
const scroll = createScrollManager(container, state, () => selection.applySelectionUI());
const menuNode = container.querySelector('.dt-column-menu');
if (menuNode) menuNode._thisObj = container;
container.onColumnResizing = (field, e) => container.style.setProperty(`--w-${field.id}`, e.detail.newSize + 'px');
container.onColumnResize = (field, e) => {
const idx = state.fields.findIndex(f => f.id === field.id);
if (idx !== -1) { state.fields[idx].width = e.detail.newSize; state.fields = [...state.fields]; }
};
let _editorOverlay, currentEditingNode = null;
container.format = (val, field) => {
if (field.formatter) return field.formatter(val, field);
const typeInfo = DataTable._fieldTypes.get(field.settings?.formType || field.type || 'text');
if (typeInfo && typeInfo.formatter) return typeInfo.formatter(val, field);
return val == null ? '' : (typeof val === 'object' ? JSON.stringify(val) : String(val));
};
container.onScroll = () => {
perf.onScroll(); scroll.refresh();
container.hideColumnMenu();
const prev = container.querySelector('.dt-spacer-prev'), post = container.querySelector('.dt-spacer-post');
if (prev) { prev.style.height = (state.prevHeight || 0) + 'px'; prev.style.display = state.prevHeight > 0 ? 'block' : 'none'; }
if (post) { post.style.height = (state.postHeight || 0) + 'px'; post.style.display = state.postHeight > 0 ? 'block' : 'none'; }
};
container.applySortFilter = (options = {}) => {
if (state._internalUpdate) return;
const targetFilters = { ...state.filterConfig, ...(options.filters || {}) };
const targetSort = options.sort !== undefined ? (options.sort ? { fieldId: state.activeFieldId, direction: options.sort } : { fieldId: null, direction: null }) : state.sortConfig;
const currentHash = JSON.stringify({ s: targetSort, f: targetFilters });
if (state._appliedHash === currentHash && !options.force) return;
state._internalUpdate = true;
let list = [...(state._originalList || [])];
Object.keys(targetFilters).forEach(fieldId => {
const filter = targetFilters[fieldId];
if (!filter) return;
const { mode = 'contains', value, value2, selectedValues } = filter;
if (selectedValues?.length > 0) { list = list.filter(item => selectedValues.includes(String(item[fieldId] ?? ''))); return; }
if (value === '' || value == null) return;
const lowV = String(value).toLowerCase(), n1 = Number(value), n2 = Number(value2);
list = list.filter(item => {
const iv = item[fieldId], sv = String(iv ?? '').toLowerCase();
switch (mode) {
case 'contains': return sv.includes(lowV);
case 'equals': return sv === lowV;
case 'starts': return sv.startsWith(lowV);
case 'ends': return sv.endsWith(lowV);
case '>': return Number(iv) > n1;
case '<': return Number(iv) < n1;
case '=': return Number(iv) === n1;
case 'between': return Number(iv) >= n1 && Number(iv) <= n2;
default: return sv.includes(lowV);
}
});
});
if (targetSort.fieldId && targetSort.direction) {
list.sort((a, b) => {
let va = a[targetSort.fieldId], vb = b[targetSort.fieldId];
if (va === vb) return 0;
const res = va > vb ? 1 : -1;
return targetSort.direction === 'asc' ? res : -res;
});
}
state._appliedHash = currentHash; state.sortConfig = targetSort; state.list = list; state._internalUpdate = false;
};
container.showColumnMenu = (field, e) => {
e.stopPropagation();
const btn = e.currentTarget, menu = container.querySelector('.dt-column-menu');
const type = field.type || 'text';
state.activeModes = MODE_MAP[type] || (['boolean', 'switch', 'checkbox', 'radio'].includes(type) ? [] : MODE_MAP.text);
if (!state.filterConfig[field.id]) {
state.filterConfig[field.id] = { mode: state.activeModes[0] || 'contains', value: '', selectedValues: [] };
}
state.activeField = field; state.activeFieldId = field.id;
menu.style.display = 'block';
const cellNode = btn.closest('.dt-cell');
const rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect();
const menuWidth = menu.offsetWidth || 260;
let leftPos = rect.right - rootRect.left - menuWidth;
if (leftPos < 0) leftPos = Math.max(0, rect.left - rootRect.left);
menu.style.left = leftPos + 'px';
menu.style.top = (rect.bottom - rootRect.top + 5) + 'px';
const onGlobalClick = (ev) => {
if (menu.contains(ev.target) || btn.contains(ev.target)) return;
container.hideColumnMenu(); container.applySortFilter();
document.removeEventListener('mousedown', onGlobalClick);
};
document.addEventListener('mousedown', onGlobalClick);
setTimeout(() => menu.querySelector('input')?.focus(), 50);
};
container.toggleSelectedValue = (val) => {
const filter = state.filterConfig[state.activeFieldId];
if (!filter) return;
const idx = filter.selectedValues.indexOf(val);
if (idx === -1) filter.selectedValues.push(val); else filter.selectedValues.splice(idx, 1);
state.filterConfig = { ...state.filterConfig };
container.applySortFilter();
};
container.filterOnlyThis = (val) => {
state.filterConfig[state.activeFieldId] = { mode: 'contains', value: '', selectedValues: [String(val)] };
state.filterConfig = { ...state.filterConfig };
container.applySortFilter();
};
container.hideColumnMenu = () => {
const menu = container.querySelector('.dt-column-menu');
if (menu) menu.style.display = 'none';
};
container.setSort = (dir) => {
const newDir = state.sortConfig.direction === dir && state.sortConfig.fieldId === state.activeFieldId ? null : dir;
container.applySortFilter({ sort: newDir });
};
container.clearColumnSettings = () => {
if (state.activeFieldId) {
delete state.filterConfig[state.activeFieldId];
state.filterConfig = { ...state.filterConfig };
container.applySortFilter();
}
};
container._initRow = (rowNode) => {
const row = rowNode._ref?.item;
if (row && row._editingF === undefined) {
Object.defineProperty(row, '_editingF', { set: (v) => { if (v === null) container.hideEditor(true); }, configurable: true });
}
Array.from(rowNode.children).forEach(cell => {
const fIdx = parseInt(cell.dataset.fidx);
if (!isNaN(fIdx)) cell._refExt = { f: state.fields[fIdx], fIdx: fIdx };
});
};
state.__watch('fields', fields => {
if (!fields) return;
state._fieldsDirty = true;
state._masterCellNodes = null; // Force template rebuild
container.style.setProperty('--dt-grid-template', fields.map(f => `var(--w-${f.id}, ${(f.settings?.width || f.width) || 150}px)`).join(' '));
container.style.setProperty('--dt-row-width', fields.reduce((sum, f) => sum + ((f.settings?.width || f.width) || 150), 0) + 'px');
let leftSum = 0;
fields.forEach(f => {
const pinned = f.settings?.pinned || f.pinned;
if (pinned === 'left') {
container.style.setProperty(`--l-${f.id}`, leftSum + 'px');
leftSum += ((f.settings?.width || f.width) || 150);
}
});
let rightSum = 0;
[...fields].reverse().forEach(f => {
const pinned = f.settings?.pinned || f.pinned;
if (pinned === 'right') {
container.style.setProperty(`--r-${f.id}`, rightSum + 'px');
rightSum += ((f.settings?.width || f.width) || 150);
}
});
});
state.__watch('list', list => {
if (state._fieldsDirty) {
state._fieldsDirty = false;
const fieldTemplate = container.querySelector('.dt-body template[index="rIdx"]')?.content.querySelector('template[as="f"]');
if (fieldTemplate) {
const masters = state._masterCellNodes || (state._masterCellNodes = Array.from(fieldTemplate.content.childNodes).map(n => n.cloneNode(true)));
fieldTemplate.removeAttribute('$each'); fieldTemplate.setAttribute('$if', 'true'); fieldTemplate.content.textContent = '';
state.fields.forEach((f, fIdx) => masters.forEach(master => {
const clone = master.cloneNode(true);
if (clone.nodeType === 1) {
clone.dataset.fidx = fIdx;
const pinned = f.settings?.pinned || f.pinned;
if (pinned) {
clone.classList.add('pinned-' + pinned);
clone.style.position = 'sticky';
clone.style.zIndex = '1';
clone.style.backgroundColor = 'inherit';
if (pinned === 'left') {
clone.style.left = `var(--l-${f.id})`;
clone.style.borderRight = '1px solid var(--bs-border-color)';
clone.style.boxShadow = '2px 0 5px -2px rgba(0,0,0,0.1)';
} else {
clone.style.right = `var(--r-${f.id})`;
clone.style.borderLeft = '1px solid var(--bs-border-color)';
clone.style.boxShadow = '-2px 0 5px -2px rgba(0,0,0,0.1)';
}
}
}
fieldTemplate.content.appendChild(clone);
}));
}
}
if (!state._internalUpdate) {
state._originalList = [...(list || [])];
setTimeout(() => {
const stats = {};
state.fields.forEach(f => {
const counts = {};
state._originalList.forEach(item => { const val = item[f.id], key = (val == null || val === '') ? '' : String(val); counts[key] = (counts[key] || 0) + 1; });
stats[f.id] = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([val, count]) => ({ val, count }));
});
state._columnStats = stats;
}, 200);
}
scroll.init(); scroll.reset(list);
});
container.editCell = (row, field, cellNode) => {
const overlay = container.querySelector('.dt-editor-overlay'), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect();
currentEditingNode = cellNode;
const formType = field.settings?.formType || field.type || 'text';
const form = overlay.querySelector('AutoForm');
if (form) {
// row 已经是 Proxy (NewState),直接赋值即可实现双向同步
form.data = row;
form.state.schema = [{ ...field, type: formType, options: field.settings?.options || field.options, name: field.id, label: '' }];
RefreshState(form);
}
Object.assign(overlay.style, {
display: 'flex', left: (rect.left - rootRect.left) + 'px', top: (rect.top - rootRect.top) + 'px',
width: (formType === 'textarea' || formType === 'TagsInput' ? Math.max(rect.width, 300) : rect.width) + 'px',
height: (formType === 'textarea' || formType === 'TagsInput' ? 'auto' : rect.height + 'px')
});
setTimeout(() => overlay.querySelector('input, textarea, select, .form-control')?.focus(), 30);
};
container.hideEditor = (save = true) => {
if (!_editorOverlay) _editorOverlay = container.querySelector('.dt-editor-overlay');
if (!_editorOverlay || _editorOverlay.style.display === 'none') return;
const form = _editorOverlay.querySelector('AutoForm');
if (save && form && form.data) {
const input = _editorOverlay.querySelector('input:focus, select:focus, textarea:focus');
if (input) input.dispatchEvent(new Event(input.type === 'number' || input.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }));
RefreshState(form);
// 手动同步数据回原始行 (解决 Proxy 隔离问题)
const schema = form.state.schema || [];
schema.forEach(field => {
const row = currentEditingNode?.closest('.dt-row')?._ref?.item;
if (row) row[field.name] = form.data[field.name];
});
if (state.isBulkEdit) {
const { minRow, maxRow, fIdx } = state.isBulkEdit;
const field = state.fields[fIdx];
const newValue = form.data[field.id];
for (let i = minRow; i <= maxRow; i++) {
if (state.list[i]) {
state.list[i][field.id] = newValue;
}
}
state.list = [...state.list];
}
if (currentEditingNode) RefreshState(currentEditingNode);
state.isDirty = true;
}
_editorOverlay.style.display = 'none';
if (form) { form.state.schema = []; form.data = null; }
currentEditingNode = null;
state.isBulkEdit = null;
container.focus();
};
container.onMainMouseDown = e => {
const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row');
if (!row || row.classList.contains('dt-header-row')) return;
const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell);
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
selection.startSelect(rIdx + state._listStartIndex, fIdx, e);
};
container.onMainMouseOver = e => {
if (state.isSelecting) {
const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row');
if (row && !row.classList.contains('dt-header-row')) {
const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell);
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
selection.updateSelect(rIdx + state._listStartIndex, fIdx);
}
}
};
container.onMainDblClick = e => {
const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row');
if (row && !row.classList.contains('dt-header-row')) {
const item = row._ref?.item, fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell);
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
const absoluteRow = rIdx + state._listStartIndex;
if (item && state.fields[fIdx]) {
const bounds = selection.getSelectionBounds();
if (bounds && absoluteRow >= bounds.minRow && absoluteRow <= bounds.maxRow && fIdx >= bounds.minCol && fIdx <= bounds.maxCol) {
const affectedRows = bounds.maxRow - bounds.minRow + 1;
if (affectedRows > 1) {
state.isBulkEdit = { ...bounds, fIdx };
if (globalThis.UI?.toast) UI.toast(`Bulk Edit: Updating ${affectedRows} rows in column "${state.fields[fIdx].name}"`, { type: 'warning' });
}
}
container.editCell(item, state.fields[fIdx], cell);
}
}
};
container.addRow = () => {
const newRow = {};
state.fields.forEach(f => newRow[f.id] = '');
state._originalList.push(newRow);
state.list = [...state._originalList]; // Apply to current view too
state.isDirty = true;
setTimeout(() => {
scroll.reset(state.list);
container.querySelector('.dt-main').scrollTop = container.querySelector('.dt-main').scrollHeight;
}, 50);
};
container.deleteSelectedRow = async () => {
const bounds = selection.getSelectionBounds();
if (!bounds) return;
const count = bounds.maxRow - bounds.minRow + 1;
if (await UI.confirm(`Are you sure you want to delete ${count} row(s)?`)) {
const rMin = bounds.minRow, rMax = bounds.maxRow;
const removedItems = state.list.slice(rMin, rMax + 1);
state.list = state.list.filter((_, i) => !(i >= rMin && i <= rMax));
state._originalList = state._originalList.filter(item => !removedItems.includes(item));
state.isDirty = true;
selection.clearAllActive();
container.dispatchEvent(new CustomEvent('remove', { detail: { items: removedItems } }));
}
};
container.saveChanges = () => {
container.dispatchEvent(new CustomEvent('save', { detail: { list: state._originalList, fields: state.fields } }));
state.isDirty = false;
};
const getFieldSchema = () => {
const types = DataTable.getFieldTypes();
const baseSchema = [
{ name: 'id', label: 'Field ID', type: 'text', setting: { required: true, placeholder: 'e.g. user_name' } },
{ name: 'name', label: 'Display Name', type: 'text', setting: { required: true, placeholder: 'e.g. 用户名' } },
{ name: 'user_type', label: 'Field Type', type: 'select', options: types.map(t => ({ label: t.label, value: t.value })) }
];
const dynamicSchema = types.reduce((acc, t) => acc.concat(t.schema || []), []);
return baseSchema.concat(dynamicSchema, [
{ name: 'isIndex', label: 'Index', type: 'switch' },
{ name: 'memo', label: 'Memo', type: 'text' }
]);
};
const parseOptionsStr = (str) => {
if (!str) return undefined;
return str.split('\n').map(s => s.trim()).filter(Boolean).map(line => {
const idx = line.indexOf(':');
if (idx > -1) return { label: line.slice(0, idx).trim(), value: line.slice(idx + 1).trim() };
return line;
});
};
const formatOptionsStr = (opts) => {
if (!opts) return '';
return opts.map(o => typeof o === 'object' ? `${o.label}:${o.value}` : o).join('\n');
};
container.addField = async () => {
container.hideColumnMenu();
const data = NewState({ id: 'c' + Date.now().toString().slice(-4), name: 'New Field', user_type: 'text', decimals: 0, isIndex: false, memo: '', options_str: '' });
const d = document.body.appendChild(document.createElement('Dialog'));
await new Promise(r => setTimeout(r, 0));
Object.assign(d.state, { title: 'Add Field', buttons: ['Cancel', 'Save'] });
RefreshState(d);
const body = d.querySelector('.modal-body');
const form = body.appendChild(document.createElement('AutoForm'));
form.setAttribute('nobutton', '');
RefreshState(form);
form.data = data;
form.state.schema = getFieldSchema();
form.addEventListener('change', (e) => e.stopPropagation());
d.show();
const result = await new Promise(resolve => d.addEventListener('change', e => resolve(d.result)));
if (result === 2) {
const typeInfo = DataTable._fieldTypes.get(data.user_type);
let dbType = typeInfo?.typeForDB || 'v1024';
if (data.user_type === 'number') dbType = data.decimals > 0 ? 'ff' : 'bi';
const field = {
id: data.id, name: data.name, memo: data.memo, isIndex: !!data.isIndex,
type: dbType,
settings: {
formType: data.user_type,
decimals: data.decimals,
prefix: data.prefix, suffix: data.suffix, thousandSep: data.thousandSep,
labelOn: data.labelOn, labelOff: data.labelOff, format: data.format,
placeholder: data.placeholder,
options: parseOptionsStr(data.options_str)
}
};
state.fields = [...state.fields, field];
state.isDirty = true;
container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields }));
state.list = [...state.list];
}
d.remove();
};
container.editField = async () => {
if (!state.activeField) return;
container.hideColumnMenu();
const f = state.activeField;
const s = f.settings || {};
const data = NewState({
id: f.id, name: f.name, memo: f.memo || '', isIndex: !!f.isIndex,
user_type: s.formType || 'text',
decimals: s.decimals || 0,
prefix: s.prefix || '', suffix: s.suffix || '', thousandSep: !!s.thousandSep,
labelOn: s.labelOn || '', labelOff: s.labelOff || '', format: s.format || '',
placeholder: s.placeholder || '',
options_str: formatOptionsStr(s.options)
});
const d = document.body.appendChild(document.createElement('Dialog'));
await new Promise(r => setTimeout(r, 0));
Object.assign(d.state, { title: 'Edit Field', buttons: ['Cancel', 'Save'] });
RefreshState(d);
const body = d.querySelector('.modal-body');
const form = body.appendChild(document.createElement('AutoForm'));
form.setAttribute('nobutton', '');
RefreshState(form);
form.data = data;
form.state.schema = getFieldSchema();
form.addEventListener('change', (e) => e.stopPropagation());
d.show();
const result = await new Promise(resolve => d.addEventListener('change', e => resolve(d.result)));
if (result === 2) {
const idx = state.fields.findIndex(item => item.id === f.id);
if (idx !== -1) {
const typeInfo = DataTable._fieldTypes.get(data.user_type);
let dbType = typeInfo?.typeForDB || 'v1024';
if (data.user_type === 'number') dbType = data.decimals > 0 ? 'ff' : 'bi';
const updatedField = {
...f, id: data.id, name: data.name, memo: data.memo, isIndex: !!data.isIndex,
type: dbType,
settings: {
...f.settings,
formType: data.user_type,
decimals: data.decimals,
prefix: data.prefix, suffix: data.suffix, thousandSep: data.thousandSep,
labelOn: data.labelOn, labelOff: data.labelOff, format: data.format,
placeholder: data.placeholder,
options: parseOptionsStr(data.options_str)
}
};
state.fields[idx] = updatedField;
state.fields = [...state.fields];
state.isDirty = true;
container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields }));
state.list = [...state.list];
}
}
d.remove();
};
container.deleteField = async () => {
if (!state.activeField) return;
container.hideColumnMenu();
if (await UI.confirm(`Are you sure you want to delete field "${state.activeField.name}"?`)) {
const idx = state.fields.findIndex(f => f.id === state.activeField.id);
if (idx !== -1) {
state.fields.splice(idx, 1);
state.fields = [...state.fields];
state.isDirty = true;
container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields }));
state.list = [...state.list]; // Trigger body refresh
}
}
};
window.addEventListener('mouseup', selection.endSelect);
document.addEventListener('mousedown', e => {
const overlay = container.querySelector('.dt-editor-overlay');
const menu = container.querySelector('.dt-column-menu');
if (overlay?.style.display !== 'none' && !overlay.contains(e.target)) container.hideEditor(true);
if (!container.contains(e.target) && !overlay?.contains(e.target) && !menu?.contains(e.target)) selection.clearAllActive();
});
// Exposure for templates
state._MODE_ICONS = MODE_ICONS;
// NOTE: For $class and $style directives, ALWAYS use the template literal syntax:
// $class="base-class \${condition ? 'active' : ''}"
// DO NOT use string concatenation like $class="'base-class ' + (condition ? 'active' : '')".
// Since the HTML is wrapped in backticks (``), remember to escape the dollar sign: \${ }
}, Util.makeDom(/*html*/`
<div class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-hidden" style="position:relative; user-select:none; outline: none; min-height: 0" tabindex="0">
<div class="dt-main flex-grow-1 overflow-auto" $onscroll="this.onScroll()"
$onmousedown="this.onMainMouseDown(event)" $onmouseover="this.onMainMouseOver(event)" $ondblclick="this.onMainDblClick(event)"
style="overflow-anchor:none; min-height: 0">
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:20">
<div class="dt-header-row fw-bold text-muted small">
<template $each="this.state?.fields || []">
<div $data-id="item.id" $class="dt-cell dt-col border-end d-flex align-items-center header-cell \${(item.settings?.pinned || item.pinned) ? 'pinned-' + (item.settings?.pinned || item.pinned) : ''}" $style="((item.settings?.pinned || item.pinned) ? 'position: sticky; z-index: 11; background-color: inherit; ' : 'position:relative; ') + 'padding: 0; ' + ((item.settings?.pinned || item.pinned) === 'left' ? 'left: var(--l-' + item.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : ((item.settings?.pinned || item.pinned) === 'right' ? 'right: var(--r-' + item.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))">
<div class="d-flex align-items-center overflow-hidden flex-grow-1 h-100 px-2 cursor-pointer" $onclick="this.showColumnMenu(item, event)">
<i $if="this.state?.filterConfig?.[item.id] && (this.state.filterConfig[item.id].value || this.state.filterConfig[item.id].selectedValues?.length)" class="bi bi-filter me-1 text-primary"></i>
<i $if="this.state?.sortConfig?.fieldId === item.id && this.state.sortConfig.direction" $class="bi bi-sort-\${this.state.sortConfig.direction === 'asc' ? 'down' : 'up-alt'} me-1 text-primary"></i>
<span $text="item.name" class="text-truncate flex-grow-1"></span>
</div>
<button class="btn btn-xs btn-link text-muted p-0 border-0 me-1 header-menu-btn" $onclick="this.showColumnMenu(item, event)"><i class="bi bi-chevron-down"></i></button>
<Resizer $.target="thisNode.parentElement" style="position:absolute; right:0; top:0; bottom:0; width:4px; z-index:10" min="50" max="1000" $onresizing="this.onColumnResizing(item, event)" $onresize="this.onColumnResize(item, event)"></Resizer>
</div>
</template>
</div>
</div>
<div class="dt-body" style="position:relative">
<div class="dt-spacer-prev flex-shrink-0" style="display:none"></div>
<template $each="this.state?._renderedList || []" key="id" index="rIdx">
<div class="dt-row dt-body-row border-bottom bg-white" $.="this._initRow(thisNode)">
<template as="f"><div $class="dt-cell border-end px-2 d-flex align-items-center \${(f.settings?.pinned || f.pinned) ? 'pinned-' + (f.settings?.pinned || f.pinned) : ''}" $style="((f.settings?.pinned || f.pinned) ? 'position: sticky; z-index: 1; background-color: inherit; ' : '') + ((f.settings?.pinned || f.pinned) === 'left' ? 'left: var(--l-' + f.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : ((f.settings?.pinned || f.pinned) === 'right' ? 'right: var(--r-' + f.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))"><span $text="this.format(item[f.id], f)" class="text-truncate"></span></div></template>
</div>
</template>
<div class="dt-spacer-post flex-shrink-0" style="display:none"></div>
</div>
</div>
<div class="dt-column-menu bg-body shadow-lg rounded p-2" style="display:none; position:absolute; z-index:2000; min-width:240px; max-width:300px; border: 1px solid var(--bs-primary)">
<template $if="this.state?.activeFieldId">
<div class="d-flex gap-1 mb-2">
<button $class="btn btn-xs flex-grow-1 d-flex align-items-center justify-content-center \${this.state?.sortConfig?.direction === 'asc' && this.state?.sortConfig?.fieldId === this.state?.activeFieldId ? 'btn-primary' : 'btn-outline-secondary border'}" $onclick="this.setSort('asc')"><i class="bi bi-sort-alpha-down me-1"></i> ASC</button>
<button $class="btn btn-xs flex-grow-1 d-flex align-items-center justify-content-center \${this.state?.sortConfig?.direction === 'desc' && this.state?.sortConfig?.fieldId === this.state?.activeFieldId ? 'btn-primary' : 'btn-outline-secondary border'}" $onclick="this.setSort('desc')"><i class="bi bi-sort-alpha-up-alt me-1"></i> DESC</button>
</div>
<div $if="this.state?.activeModes?.length" class="dt-filter-tabs d-flex overflow-auto border-bottom bg-light-subtle rounded-top py-1" style="white-space:nowrap; scrollbar-width: none;">
<template $each="this.state?.activeModes || []" as="m">
<div $class="px-2 py-1 cursor-pointer fs-5 \${this.state?.filterConfig?.[this.state?.activeFieldId]?.mode === m ? 'text-primary border-bottom border-primary border-2' : 'text-muted'}" $title="m.toUpperCase()" $onclick="this.state.filterConfig[this.state.activeFieldId].mode = m; this.state.filterConfig = {...this.state.filterConfig}">
<i $class="bi \${this.state?._MODE_ICONS?.[m] || 'bi-filter'}"></i>
</div>
</template>
</div>
<template $if="this.state?.activeModes?.length">
<div class="py-2 border-bottom" style="min-height: 48px">
<input type="text" class="form-control form-control-sm mb-1" $placeholder="(this.state?.filterConfig?.[this.state?.activeFieldId]?.mode || 'Search').toUpperCase() + '...'" $bind="this.state?.filterConfig?.[this.state?.activeFieldId].value" $onkeydown="if(event.key==='Enter'){this.applySortFilter();this.hideColumnMenu();}">
<input $if="this.state?.filterConfig?.[this.state?.activeFieldId]?.mode === 'between'" type="text" class="form-control form-control-sm" placeholder="And..." $bind="this.state?.filterConfig?.[this.state?.activeFieldId].value2" $onkeydown="if(event.key==='Enter'){this.applySortFilter();this.hideColumnMenu();}">
</div>
</template>
<div class="mt-2" style="max-height: 180px; overflow-y: auto;">
<div class="text-muted fw-bold mb-1" style="font-size: 9px; letter-spacing: 0.5px">TOP FREQUENT VALUES</div>
<template $each="this.state?._columnStats?.[this.state?.activeFieldId] || []">
<label class="d-flex align-items-center mb-1 small cursor-pointer p-1 rounded-1 menu-item-row" onmouseover="this.style.background='var(--bs-light)'" onmouseout="this.style.background='transparent'">
<input type="checkbox" class="form-check-input me-2" $checked="this.state?.filterConfig?.[this.state?.activeFieldId]?.selectedValues?.includes(String(item.val))" $onclick="this.toggleSelectedValue(String(item.val))">
<span class="text-truncate flex-grow-1"><span $text="item.val || '(Empty)'"></span> <span class="text-muted ms-1" style="font-size: 0.7rem" $text="'(' + item.count + ')'"></span></span>
<button class="btn btn-xs btn-link p-0 text-primary only-btn" style="font-size: 10px; text-decoration: none" $onclick="this.filterOnlyThis(item.val); event.preventDefault(); event.stopPropagation();">Only</button>
</label>
</template>
</div>
<div $if="this.state?.filterConfig?.[this.state?.activeFieldId]?.value || this.state?.filterConfig?.[this.state?.activeFieldId]?.selectedValues?.length" class="mt-2 pt-1 border-top text-center">
<span class="cursor-pointer text-primary small fw-bold" $onclick="this.clearColumnSettings()"><i class="bi bi-x-circle me-1"></i> Clear Filter</span>
</div>
<div class="mt-3 pt-2 border-top d-flex flex-column gap-1">
<button class="btn btn-xs btn-outline-secondary border d-flex align-items-center px-2 py-1" $onclick="this.editField()"><i class="bi bi-pencil me-2"></i> Edit Field</button>
<button class="btn btn-xs btn-outline-secondary border d-flex align-items-center px-2 py-1" $onclick="this.addField()"><i class="bi bi-plus-lg me-2"></i> Add Field</button>
<button class="btn btn-xs btn-outline-danger border d-flex align-items-center px-2 py-1" $onclick="this.deleteField()"><i class="bi bi-trash me-2"></i> Delete Field</button>
</div>
</template>
</div>
<div class="dt-editor-overlay dt-editor-container" style="display: none; position: absolute; z-index: 1000; background: var(--bs-body-bg); box-shadow: 0 4px 16px rgba(0,0,0,0.25); border: 1px solid var(--bs-primary);"><AutoForm inline class="h-100 w-100" $onsubmit="event.preventDefault(); thisNode.closest('DataTable').hideEditor(true)"/></div>
<div class="dt-footer border-top bg-light d-flex align-items-center px-3 py-1 shadow-sm" style="height:40px; z-index: 10">
<div class="d-flex align-items-center gap-3 flex-grow-1">
<div class="btn-group shadow-sm">
<button class="btn btn-xs btn-white border d-flex align-items-center px-2" style="background:white" $onclick="this.addRow()" title="Add Row"><i class="bi bi-plus-lg text-primary me-1"></i> Add</button>
<button class="btn btn-xs btn-white border d-flex align-items-center px-2" style="background:white" $onclick="this.deleteSelectedRow()" $disabled="!this.state?.selectedRowCount" title="Delete Selected Rows"><i class="bi bi-trash text-danger me-1"></i> Delete</button>
</div>
<div class="vr h-50 my-auto text-muted opacity-25"></div>
<div class="d-flex align-items-center gap-2 text-muted" style="font-size: 0.75rem">
<i class="bi bi-check-all fs-6"></i>
<span $text="(this.state?.selectedRowCount || 0) + ' selected / ' + (this.state?.list?.length || 0) + ' total'"></span>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<button $if="this.state?.isDirty" class="btn btn-xs btn-primary px-3 shadow-sm d-flex align-items-center fw-bold" $onclick="this.saveChanges()"><i class="bi bi-cloud-upload me-1"></i> Save Changes</button>
<button $if="!this.state?.isDirty" class="btn btn-xs btn-light border px-3 text-muted disabled d-flex align-items-center" disabled><i class="bi bi-cloud-check me-1"></i> Up to date</button>
</div>
</div>
</div>
`), Util.makeDom(/*html*/`
<style>
DataTable { display: block; }
.dt-root { font-size: 0.875rem; }
.dt-row, .dt-header-row { display: grid; grid-template-columns: var(--dt-grid-template); width: var(--dt-row-width, max-content); min-width: 100%; height: 40px; contain: paint layout; }
.dt-header-row { background-color: var(--bs-tertiary-bg); border-bottom: 1px solid var(--bs-border-color); }
.dt-cell { background: inherit; white-space: nowrap; flex-shrink: 0; contain: content; }
.dt-cell-selected { background-color: rgba(var(--bs-primary-rgb), 0.15) !important; outline: 1px solid var(--bs-primary); outline-offset: -1px; }
.dt-body-row:hover { background-color: var(--bs-secondary-bg) !important; }
.header-cell .header-menu-btn { opacity: 0; transition: opacity 0.2s; }
.header-cell:hover .header-menu-btn { opacity: 1; }
.dt-column-menu { background-color: var(--bs-body-bg); border: 1px solid var(--bs-primary); box-shadow: 0 10px 40px rgba(0,0,0,0.2) !important; z-index: 2100 !important; }
.btn-xs { padding: 1px 5px; line-height: 1.5; }
.cursor-pointer { cursor: pointer; }
.dt-filter-tabs i { font-size: 1.1rem; }
.dt-filter-tabs div:hover i { color: var(--bs-primary); }
.menu-item-row .only-btn { opacity: 0; }
.menu-item-row:hover .only-btn { opacity: 1; }
</style>
`))

View File

@ -1,43 +0,0 @@
export const createPerfMonitor = () => {
let enabled = !!window.__DT_PERF_MODE__;
const stats = { refreshTime: 0, refreshCount: 0, scrollCount: 0, totalNodes: 0 };
if (enabled && !window.__statePerformanceTelemetry) {
window.__statePerformanceTelemetry = { scanCount: 0, reuseCount: 0, moveCount: 0 };
}
return {
get stats() { return stats; },
enable: () => { enabled = true; },
disable: () => { enabled = false; },
onScroll: () => { if (enabled) stats.scrollCount++; },
startFrame: () => {
if (!enabled) return null;
return {
start: performance.now(),
scan: window.__statePerformanceTelemetry?.scanCount || 0,
move: window.__statePerformanceTelemetry?.moveCount || 0,
reuse: window.__statePerformanceTelemetry?.reuseCount || 0
};
},
endFrame: (startData, renderedCount) => {
if (!enabled || !startData) return;
stats.refreshCount++;
stats.totalNodes += renderedCount;
const elapsed = performance.now() - startData.start;
stats.refreshTime += elapsed;
const stPerf = window.__statePerformanceTelemetry;
if (stPerf) {
const scans = stPerf.scanCount - startData.scan;
const moves = stPerf.moveCount - startData.move;
const reuses = stPerf.reuseCount - startData.reuse;
if (scans > 0 || elapsed > 2) {
console.log(`[DataTable Frame] Time: ${elapsed.toFixed(2)}ms, Scans: ${scans}, Moves: ${moves}, Reuses: ${reuses}, Rows: ${renderedCount}`);
}
}
}
};
};

View File

@ -1,46 +0,0 @@
import { VirtualScroll } from '@web/base';
export const createScrollManager = (container, state, onRenderedListChange) => {
const vs = VirtualScroll({ itemHeight: 40 });
let scrollEl = null;
const refresh = (isLayoutChange = false) => {
if (!scrollEl) return;
const res = vs.calc(scrollEl, state.list);
if (res) {
// Dirty check: Abort if layout boundaries haven't actually changed
if (!isLayoutChange &&
state.prevHeight === res.prevHeight &&
state.postHeight === res.postHeight &&
state._listStartIndex === res.listStartIndex &&
state._renderedList.length === res.renderedList.length) {
return;
}
Object.assign(state, {
prevHeight: res.prevHeight,
postHeight: res.postHeight,
_listStartIndex: res.listStartIndex,
_renderedList: res.renderedList
});
onRenderedListChange?.(res.renderedList.length, isLayoutChange);
}
};
return {
init: () => {
scrollEl = container.querySelector('.dt-main');
},
reset: (list) => {
state._listStartIndex = 0;
// Optimization: Don't update _renderedList here.
// vs.init will trigger refresh(true) which does it properly once.
vs.reset(list, scrollEl || container);
if (state.list === list) {
vs.init(list, () => refresh(true));
}
},
refresh,
onScroll: () => refresh(false)
};
};

View File

@ -1,242 +0,0 @@
export const createSelectionManager = (container, state) => {
let activeBounds = null; // { minRow, maxRow, minCol, maxCol }
let startCell = null; // { row, col }
let multiSelections = [];
const isCellSelected = (r, c) => {
if (activeBounds && r >= activeBounds.minRow && r <= activeBounds.maxRow && c >= activeBounds.minCol && c <= activeBounds.maxCol) return true;
return multiSelections.some(s => r >= s.minRow && r <= s.maxRow && c >= s.minCol && c <= s.maxCol);
};
let lastHadSelection = false;
const applySelectionUI = () => {
/* DEBUG: Performance Isolation Toggle */
if (window.__DT_FEATURES__ && !window.__DT_FEATURES__.selection) return;
let boundMinRow = Infinity, boundMaxRow = -Infinity;
if (activeBounds) {
boundMinRow = Math.min(boundMinRow, activeBounds.minRow);
boundMaxRow = Math.max(boundMaxRow, activeBounds.maxRow);
}
multiSelections.forEach(s => {
boundMinRow = Math.min(boundMinRow, s.minRow);
boundMaxRow = Math.max(boundMaxRow, s.maxRow);
});
const hasSelection = boundMinRow !== Infinity;
if (!hasSelection && !lastHadSelection) return;
lastHadSelection = hasSelection;
const body = container.querySelector('.dt-body');
if (!body) return;
const rowNodes = body.querySelectorAll('.dt-body-row');
rowNodes.forEach(rowNode => {
const absoluteRow = (rowNode._ref?.rIdx ?? -1) + state._listStartIndex;
const cells = rowNode.querySelectorAll('.dt-cell');
if (!hasSelection || absoluteRow < boundMinRow || absoluteRow > boundMaxRow) {
cells.forEach(cell => cell.classList.remove('dt-cell-selected'));
return;
}
cells.forEach((cell, cIdx) => {
if (isCellSelected(absoluteRow, cIdx)) {
cell.classList.add('dt-cell-selected');
} else {
cell.classList.remove('dt-cell-selected');
}
});
});
};
const updateStatus = () => {
let count = 0;
if (activeBounds) count += (activeBounds.maxRow - activeBounds.minRow + 1);
multiSelections.forEach(s => count += (s.maxRow - s.minRow + 1));
state.selectedRowCount = count;
};
const clearAllActive = (keepSelection = false) => {
if (!keepSelection) {
activeBounds = null;
startCell = null;
multiSelections = [];
applySelectionUI();
updateStatus();
}
};
const startSelect = (row, col, e) => {
const alreadySelected = isCellSelected(row, col);
const isRange = (activeBounds && (activeBounds.minRow !== activeBounds.maxRow || activeBounds.minCol !== activeBounds.maxCol)) || multiSelections.length > 0;
if (e.shiftKey && startCell) {
activeBounds = {
minRow: Math.min(startCell.row, row),
maxRow: Math.max(startCell.row, row),
minCol: Math.min(startCell.col, col),
maxCol: Math.max(startCell.col, col)
};
} else {
if (alreadySelected && !e.ctrlKey && !e.metaKey) {
// If clicking inside a range or a single selection, don't clear yet.
// Mark for potential single-cell toggle-off on mouseup if it was just a single cell.
if (!isRange) container._potentialCancel = { row, col };
// else: Keep current selection as is (sticky selection)
} else {
if (!e.ctrlKey && !e.metaKey) {
clearAllActive();
} else if (activeBounds && !alreadySelected) {
multiSelections.push(activeBounds);
}
startCell = { row, col };
activeBounds = { minRow: row, maxRow: row, minCol: col, maxCol: col };
}
state.isSelecting = true;
}
applySelectionUI();
updateStatus();
container.focus();
};
const updateSelect = (row, col) => {
if (state.isSelecting && startCell) {
activeBounds = {
minRow: Math.min(startCell.row, row),
maxRow: Math.max(startCell.row, row),
minCol: Math.min(startCell.col, col),
maxCol: Math.max(startCell.col, col)
};
container._potentialCancel = null; // Dragging means no cancel
applySelectionUI();
updateStatus();
}
};
const endSelect = () => {
if (container._potentialCancel) {
const { row, col } = container._potentialCancel;
if (isCellSelected(row, col)) {
clearAllActive();
}
container._potentialCancel = null;
}
state.isSelecting = false;
};
const getSelectionBounds = () => {
if (!activeBounds) return null;
let minRow = activeBounds.minRow, maxRow = activeBounds.maxRow;
let minCol = activeBounds.minCol, maxCol = activeBounds.maxCol;
multiSelections.forEach(s => {
minRow = Math.min(minRow, s.minRow);
maxRow = Math.max(maxRow, s.maxRow);
minCol = Math.min(minCol, s.minCol);
maxCol = Math.max(maxCol, s.maxCol);
});
return { minRow, maxRow, minCol, maxCol };
};
// Excel-style TSV Copy
const copy = async () => {
const bounds = getSelectionBounds();
if (!bounds) return;
const text = state.list.slice(bounds.minRow, bounds.maxRow + 1).map(row => {
return state.fields.slice(bounds.minCol, bounds.maxCol + 1).map(f => {
let val = String(row[f.id] ?? '');
if (val.includes('\t') || val.includes('\n') || val.includes('"')) {
val = '"' + val.replace(/"/g, '""') + '"';
}
return val;
}).join('\t');
}).join('\n');
await navigator.clipboard.writeText(text);
};
// Excel-style TSV Paste (Optimized for targeted refresh)
const paste = async () => {
try {
const text = await navigator.clipboard.readText();
if (!text) return;
const bounds = getSelectionBounds();
if (!bounds) return;
// Simple TSV parser
const rows = text.split(/\r?\n/).filter(line => line.length > 0).map(line => {
const cells = [];
let current = '', inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i+1] === '"') { current += '"'; i++; }
else inQuotes = !inQuotes;
} else if (char === '\t' && !inQuotes) {
cells.push(current);
current = '';
} else {
current += char;
}
}
cells.push(current);
return cells;
});
const startRow = bounds.minRow;
const startCol = bounds.minCol;
const maxRow = bounds.maxRow;
const maxCol = bounds.maxCol;
const body = container.querySelector('.dt-body');
const rowNodes = body ? Array.from(body.childNodes).filter(n => n.classList?.contains('dt-body-row')) : [];
rows.forEach((rowData, rOffset) => {
const rIdx = startRow + rOffset;
if (rIdx > maxRow || rIdx >= state.list.length) return;
const rowItem = state.list[rIdx];
let rowChanged = false;
rowData.forEach((cellData, cOffset) => {
const cIdx = startCol + cOffset;
if (cIdx > maxCol || cIdx >= state.fields.length) return;
const field = state.fields[cIdx];
rowItem[field.id] = cellData;
rowChanged = true;
});
// Targeted DOM refresh to avoid full list scan lag
if (rowChanged && container.refreshNode) {
const domNode = rowNodes.find(n => (n._ref?.rIdx ?? -1) + state._listStartIndex === rIdx);
if (domNode) container.refreshNode(domNode);
}
});
} catch (err) {
console.error('Paste Error:', err);
}
};
const deleteSelected = () => {
const bounds = getSelectionBounds();
if (!bounds) return;
const rMin = bounds.minRow, rMax = bounds.maxRow;
const newList = state.list.filter((_, i) => !(i >= rMin && i <= rMax));
state.list = newList;
clearAllActive();
};
return {
applySelectionUI,
clearAllActive,
startSelect,
updateSelect,
endSelect,
deleteSelected,
getSelectionBounds,
copy,
paste
};
};

View File

@ -4,18 +4,11 @@
<meta charset="UTF-8">
<title>AutoForm Diagnostic</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script type="importmap">
{
"imports": {
"@web/state": "../../state/src/index.js",
"@web/base": "../../base/src/index.js"
}
}
</script>
<script type="module">
import { Component, NewState, RefreshState } from '@web/state';
import '@web/base';
window.RefreshState = RefreshState;
<script src="lib/state.js"></script>
<script src="lib/base.js"></script>
<script src="lib/datatable.js"></script>
<script>
const { NewState } = globalThis;
window.DiagnosticState = NewState({
schema: [],
@ -31,7 +24,6 @@
DiagnosticState.data = { t1: 'Hello World' };
form.state.schema = DiagnosticState.schema;
form.data = DiagnosticState.data;
RefreshState(form);
await new Promise(r => setTimeout(r, 200));
console.log('Input value:', form.querySelector('input')?.value);
@ -40,7 +32,6 @@
DiagnosticState.data = { tags: ['A', 'B'] };
form.state.schema = DiagnosticState.schema;
form.data = DiagnosticState.data;
RefreshState(form);
await new Promise(r => setTimeout(r, 200));
console.log('Tags count:', form.querySelectorAll('TagsInput button').length);
@ -49,14 +40,9 @@
DiagnosticState.data = { r1: 'Op2' };
form.state.schema = DiagnosticState.schema;
form.data = DiagnosticState.data;
RefreshState(form);
await new Promise(r => setTimeout(r, 200));
console.log('Radio Op2 checked:', form.querySelector('input[value="Op2"]')?.checked);
};
document.addEventListener('DOMContentLoaded', () => {
RefreshState(document.documentElement);
});
</script>
</head>
<body class="p-4">

View File

@ -22,18 +22,15 @@
</div>
</div>
<script type="importmap">
{
"imports": {
"@web/state": "../../state/src/index.js",
"@web/base": "../../base/src/index.js"
}
}
</script>
<script type="module">
import { RefreshState } from '@web/state'
import '@web/base'
import { DataTable } from '../src/index.js'
<!-- 1. 基础状态机 -->
<script src="lib/state.js"></script>
<!-- 2. 业务组件库 -->
<script src="lib/base.js"></script>
<!-- 3. DataTable -->
<script src="lib/datatable.js"></script>
<script>
const { NewState, DataTable } = globalThis;
// Externally register TagsInput
DataTable.registerFieldType({
@ -76,7 +73,6 @@
if (table.state) {
table.state.fields = fields;
table.state.list = data;
RefreshState(table);
window.testStatus = 'passed';
} else {
setTimeout(init, 50);
@ -84,7 +80,6 @@
};
document.addEventListener('DOMContentLoaded', () => {
RefreshState(document.documentElement);
init();
});
</script>

939
test/lib/base.js Normal file

File diff suppressed because one or more lines are too long

965
test/lib/datatable.js Normal file
View File

@ -0,0 +1,965 @@
(function(factory) {
typeof define === "function" && define.amd ? define(factory) : factory();
})(function() {
"use strict";
const MODE_MAP = {
text: ["contains", "equals", "starts", "ends"],
textarea: ["contains", "equals", "starts", "ends"],
number: ["=", ">", "<", "between"],
date: ["=", ">", "<", "between"],
select: ["contains", "equals"],
TagsInput: ["contains", "equals", "starts", "ends"]
};
const MODE_ICONS = {
"contains": "bi-search",
"equals": "bi-distribute-vertical",
"starts": "bi-align-start",
"ends": "bi-align-end",
"=": "bi-calculator",
">": "bi-chevron-right",
"<": "bi-chevron-left",
"between": "bi-arrows-expand"
};
const DataTableConfig = {
_fieldTypes: /* @__PURE__ */ new Map(),
registerFieldType: (config) => {
DataTableConfig._fieldTypes.set(config.value, config);
},
getFieldTypes: () => Array.from(DataTableConfig._fieldTypes.values())
};
DataTableConfig.registerFieldType({
value: "text",
label: "{#Text#}",
typeForDB: "v4096",
schema: [{ name: "placeholder", label: "Placeholder", type: "text", if: 'this.data.user_type=="text"' }]
});
DataTableConfig.registerFieldType({
value: "number",
label: "{#Number#}",
typeForDB: "ff",
schema: [
{ name: "decimals", label: "Decimals", type: "number", setting: { min: 0, max: 10 }, if: 'this.data.user_type=="number"' },
{ name: "prefix", label: "Prefix (e.g. $)", type: "text", if: 'this.data.user_type=="number"' },
{ name: "suffix", label: "Suffix (e.g. %)", type: "text", if: 'this.data.user_type=="number"' },
{ name: "thousandSep", label: "Thousand Sep", type: "switch", if: 'this.data.user_type=="number"' }
],
formatter: (val, field) => {
if (val == null || val === "") return "";
let num = Number(val);
if (isNaN(num)) return val;
const s = field.settings || {};
if (s.decimals !== void 0) num = num.toFixed(s.decimals);
let str = String(num);
if (s.thousandSep) {
const parts = str.split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
str = parts.join(".");
}
return (s.prefix || "") + str + (s.suffix || "");
}
});
DataTableConfig.registerFieldType({
value: "select",
label: "{#Single Select#}",
typeForDB: "v1024",
schema: [{ name: "options_str", label: "Options", type: "textarea", setting: { rows: 3, placeholder: "Label:Value per line" }, if: 'this.data.user_type=="select"' }],
formatter: (val, field) => {
var _a;
if (val == null || val === "") return "";
const opts = ((_a = field.settings) == null ? void 0 : _a.options) || [];
const opt = opts.find((o) => typeof o === "object" ? o.value == val : o == val);
return opt ? typeof opt === "object" ? opt.label : opt : val;
}
});
DataTableConfig.registerFieldType({
value: "checkbox",
label: "{#Multi Select#}",
typeForDB: "v4096",
schema: [{ name: "options_str", label: "Options", type: "textarea", setting: { rows: 3, placeholder: "Label:Value per line" }, if: 'this.data.user_type=="checkbox"' }],
formatter: (val, field) => {
var _a;
if (!Array.isArray(val)) return val == null ? "" : String(val);
const opts = ((_a = field.settings) == null ? void 0 : _a.options) || [];
return val.map((v) => {
const opt = opts.find((o) => typeof o === "object" ? o.value == v : o == v);
return opt ? typeof opt === "object" ? opt.label : opt : v;
}).join(", ");
}
});
DataTableConfig.registerFieldType({
value: "switch",
label: "{#Switch#}",
typeForDB: "b",
schema: [
{ name: "labelOn", label: "Label On", type: "text", if: 'this.data.user_type=="switch"' },
{ name: "labelOff", label: "Label Off", type: "text", if: 'this.data.user_type=="switch"' }
],
formatter: (val, field) => {
const s = field.settings || {};
return val ? s.labelOn || "Yes" : s.labelOff || "No";
}
});
DataTableConfig.registerFieldType({
value: "datetime",
label: "{#DateTime#}",
typeForDB: "dt",
schema: [{ name: "format", label: "Format", type: "text", setting: { placeholder: "YYYY-MM-DD" }, if: 'this.data.user_type=="datetime"' }]
});
DataTableConfig.registerFieldType({
value: "textarea",
label: "{#Long Text#}",
typeForDB: "t",
schema: [{ name: "placeholder", label: "Placeholder", type: "text", if: 'this.data.user_type=="textarea"' }]
});
const createPerfMonitor = () => {
let enabled = !!globalThis.__DT_PERF_MODE__;
const stats = { refreshTime: 0, refreshCount: 0, scrollCount: 0, totalNodes: 0 };
if (enabled && !globalThis.__statePerformanceTelemetry) {
globalThis.__statePerformanceTelemetry = { scanCount: 0, reuseCount: 0, moveCount: 0 };
}
return {
get stats() {
return stats;
},
enable: () => {
enabled = true;
},
disable: () => {
enabled = false;
},
onScroll: () => {
if (enabled) stats.scrollCount++;
},
startFrame: () => {
var _a, _b, _c;
if (!enabled) return null;
return {
start: performance.now(),
scan: ((_a = globalThis.__statePerformanceTelemetry) == null ? void 0 : _a.scanCount) || 0,
move: ((_b = globalThis.__statePerformanceTelemetry) == null ? void 0 : _b.moveCount) || 0,
reuse: ((_c = globalThis.__statePerformanceTelemetry) == null ? void 0 : _c.reuseCount) || 0
};
},
endFrame: (startData, renderedCount) => {
if (!enabled || !startData) return;
stats.refreshCount++;
stats.totalNodes += renderedCount;
const elapsed = performance.now() - startData.start;
stats.refreshTime += elapsed;
const stPerf = globalThis.__statePerformanceTelemetry;
if (stPerf) {
const scans = stPerf.scanCount - startData.scan;
const moves = stPerf.moveCount - startData.move;
const reuses = stPerf.reuseCount - startData.reuse;
if (scans > 0 || elapsed > 2) {
console.log(`[DataTable Frame] Time: ${elapsed.toFixed(2)}ms, Scans: ${scans}, Moves: ${moves}, Reuses: ${reuses}, Rows: ${renderedCount}`);
}
}
}
};
};
const createScrollManager = (container, state, onRenderedListChange) => {
const vs = globalThis.VirtualScroll({ itemHeight: 40 });
let scrollEl = null;
const refresh = (isLayoutChange = false) => {
if (!scrollEl) return;
const res = vs.calc(scrollEl, state.list);
if (res) {
if (!isLayoutChange && state.prevHeight === res.prevHeight && state.postHeight === res.postHeight && state._listStartIndex === res.listStartIndex && state._renderedList.length === res.renderedList.length) return;
Object.assign(state, { prevHeight: res.prevHeight, postHeight: res.postHeight, _listStartIndex: res.listStartIndex, _renderedList: res.renderedList });
onRenderedListChange == null ? void 0 : onRenderedListChange(res.renderedList.length, isLayoutChange);
}
};
return {
init: () => {
scrollEl = container.querySelector(".dt-main");
},
reset: (list) => {
state._listStartIndex = 0;
vs.reset(list, scrollEl || container);
if (state.list === list) vs.init(list, () => refresh(true));
},
refresh,
onScroll: () => refresh(false)
};
};
const createSelectionManager = (container, state) => {
let activeBounds = null;
let startCell = null;
let multiSelections = [];
const isCellSelected = (r, c) => {
if (activeBounds && r >= activeBounds.minRow && r <= activeBounds.maxRow && c >= activeBounds.minCol && c <= activeBounds.maxCol) return true;
return multiSelections.some((s) => r >= s.minRow && r <= s.maxRow && c >= s.minCol && c <= s.maxCol);
};
let lastHadSelection = false;
const applySelectionUI = () => {
if (globalThis.__DT_FEATURES__ && !globalThis.__DT_FEATURES__.selection) return;
let boundMinRow = Infinity, boundMaxRow = -Infinity;
if (activeBounds) {
boundMinRow = Math.min(boundMinRow, activeBounds.minRow);
boundMaxRow = Math.max(boundMaxRow, activeBounds.maxRow);
}
multiSelections.forEach((s) => {
boundMinRow = Math.min(boundMinRow, s.minRow);
boundMaxRow = Math.max(boundMaxRow, s.maxRow);
});
const hasSelection = boundMinRow !== Infinity;
if (!hasSelection && !lastHadSelection) return;
lastHadSelection = hasSelection;
const body = container.querySelector(".dt-body");
if (!body) return;
const rowNodes = body.querySelectorAll(".dt-body-row");
rowNodes.forEach((rowNode) => {
var _a;
const absoluteRow = (((_a = rowNode._ref) == null ? void 0 : _a.rIdx) ?? -1) + state._listStartIndex;
const cells = rowNode.querySelectorAll(".dt-cell");
if (!hasSelection || absoluteRow < boundMinRow || absoluteRow > boundMaxRow) {
cells.forEach((cell) => cell.classList.remove("dt-cell-selected"));
return;
}
cells.forEach((cell, cIdx) => {
if (isCellSelected(absoluteRow, cIdx)) cell.classList.add("dt-cell-selected");
else cell.classList.remove("dt-cell-selected");
});
});
};
const updateStatus = () => {
let count = 0;
if (activeBounds) count += activeBounds.maxRow - activeBounds.minRow + 1;
multiSelections.forEach((s) => count += s.maxRow - s.minRow + 1);
state.selectedRowCount = count;
};
const clearAllActive = (keepSelection = false) => {
if (!keepSelection) {
activeBounds = null;
startCell = null;
multiSelections = [];
applySelectionUI();
updateStatus();
}
};
const startSelect = (row, col, e) => {
const alreadySelected = isCellSelected(row, col);
const isRange = activeBounds && (activeBounds.minRow !== activeBounds.maxRow || activeBounds.minCol !== activeBounds.maxCol) || multiSelections.length > 0;
if (e.shiftKey && startCell) {
activeBounds = { minRow: Math.min(startCell.row, row), maxRow: Math.max(startCell.row, row), minCol: Math.min(startCell.col, col), maxCol: Math.max(startCell.col, col) };
} else {
if (alreadySelected && !e.ctrlKey && !e.metaKey) {
if (!isRange) container._potentialCancel = { row, col };
} else {
if (!e.ctrlKey && !e.metaKey) clearAllActive();
else if (activeBounds && !alreadySelected) multiSelections.push(activeBounds);
startCell = { row, col };
activeBounds = { minRow: row, maxRow: row, minCol: col, maxCol: col };
}
state.isSelecting = true;
}
applySelectionUI();
updateStatus();
container.focus();
};
const updateSelect = (row, col) => {
if (state.isSelecting && startCell) {
activeBounds = { minRow: Math.min(startCell.row, row), maxRow: Math.max(startCell.row, row), minCol: Math.min(startCell.col, col), maxCol: Math.max(startCell.col, col) };
container._potentialCancel = null;
applySelectionUI();
updateStatus();
}
};
const endSelect = () => {
if (container._potentialCancel) {
const { row, col } = container._potentialCancel;
if (isCellSelected(row, col)) clearAllActive();
container._potentialCancel = null;
}
state.isSelecting = false;
};
const getSelectionBounds = () => {
if (!activeBounds) return null;
let minRow = activeBounds.minRow, maxRow = activeBounds.maxRow, minCol = activeBounds.minCol, maxCol = activeBounds.maxCol;
multiSelections.forEach((s) => {
minRow = Math.min(minRow, s.minRow);
maxRow = Math.max(maxRow, s.maxRow);
minCol = Math.min(minCol, s.minCol);
maxCol = Math.max(maxCol, s.maxCol);
});
return { minRow, maxRow, minCol, maxCol };
};
const copy = async () => {
const bounds = getSelectionBounds();
if (!bounds) return;
const text = state.list.slice(bounds.minRow, bounds.maxRow + 1).map((row) => {
return state.fields.slice(bounds.minCol, bounds.maxCol + 1).map((f) => {
let val = String(row[f.id] ?? "");
if (val.includes(" ") || val.includes("\n") || val.includes('"')) val = '"' + val.replace(/"/g, '""') + '"';
return val;
}).join(" ");
}).join("\n");
await navigator.clipboard.writeText(text);
};
const paste = async () => {
try {
const text = await navigator.clipboard.readText();
if (!text) return;
const bounds = getSelectionBounds();
if (!bounds) return;
const rows = text.split(/\r?\n/).filter((line) => line.length > 0).map((line) => {
const cells = [];
let current = "", inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++;
} else inQuotes = !inQuotes;
} else if (char === " " && !inQuotes) {
cells.push(current);
current = "";
} else current += char;
}
cells.push(current);
return cells;
});
const { minRow: startRow, minCol: startCol, maxRow, maxCol } = bounds;
const body = container.querySelector(".dt-body");
const rowNodes = body ? Array.from(body.childNodes).filter((n) => {
var _a;
return (_a = n.classList) == null ? void 0 : _a.contains("dt-body-row");
}) : [];
let anyRowChanged = false;
rows.forEach((rowData, rOffset) => {
const rIdx = startRow + rOffset;
if (rIdx > maxRow || rIdx >= state.list.length) return;
const rowItem = state.list[rIdx];
let rowChanged = false;
rowData.forEach((cellData, cOffset) => {
const cIdx = startCol + cOffset;
if (cIdx > maxCol || cIdx >= state.fields.length) return;
const field = state.fields[cIdx];
rowItem[field.id] = cellData;
rowChanged = true;
});
if (rowChanged) anyRowChanged = true;
});
if (anyRowChanged) state.list = [...state.list];
} catch (err) {
console.error("Paste Error:", err);
}
};
return { applySelectionUI, clearAllActive, startSelect, updateSelect, endSelect, getSelectionBounds, copy, paste };
};
globalThis.Component.register("DataTable", (container) => {
if (!container.state) container.state = globalThis.NewState({});
const state = container.state;
Object.assign(state, {
list: [],
fields: [],
_renderedList: [],
prevHeight: 0,
postHeight: 0,
_listStartIndex: 0,
selectedRowCount: 0,
_originalList: [],
sortConfig: { fieldId: null, direction: null },
filterConfig: {},
activeFieldId: null,
activeField: null,
activeModes: [],
_columnStats: {},
_internalUpdate: false,
_appliedHash: "",
_fieldsDirty: false,
_masterCellNodes: null,
isDirty: false,
isBulkEdit: null
});
const perf = createPerfMonitor();
state.perf = perf.stats;
const selection = createSelectionManager(container, state);
const scroll = createScrollManager(container, state, () => selection.applySelectionUI());
const menuNode = container.querySelector(".dt-column-menu");
if (menuNode) menuNode._thisObj = container;
container.onColumnResizing = (field, e) => container.style.setProperty(`--w-${field.id}`, e.detail.newSize + "px");
container.onColumnResize = (field, e) => {
const idx = state.fields.findIndex((f) => f.id === field.id);
if (idx !== -1) {
state.fields[idx].width = e.detail.newSize;
state.fields = [...state.fields];
}
};
let _editorOverlay, currentEditingNode = null;
container.format = (val, field) => {
var _a;
if (field.formatter) return field.formatter(val, field);
const typeInfo = DataTableConfig._fieldTypes.get(((_a = field.settings) == null ? void 0 : _a.formType) || field.type || "text");
if (typeInfo && typeInfo.formatter) return typeInfo.formatter(val, field);
return val == null ? "" : typeof val === "object" ? JSON.stringify(val) : String(val);
};
container.onScroll = () => {
perf.onScroll();
scroll.refresh();
container.hideColumnMenu();
const prev = container.querySelector(".dt-spacer-prev"), post = container.querySelector(".dt-spacer-post");
if (prev) {
prev.style.height = (state.prevHeight || 0) + "px";
prev.style.display = state.prevHeight > 0 ? "block" : "none";
}
if (post) {
post.style.height = (state.postHeight || 0) + "px";
post.style.display = state.postHeight > 0 ? "block" : "none";
}
};
container.applySortFilter = (options = {}) => {
if (state._internalUpdate) return;
const targetFilters = { ...state.filterConfig, ...options.filters || {} };
const targetSort = options.sort !== void 0 ? options.sort ? { fieldId: state.activeFieldId, direction: options.sort } : { fieldId: null, direction: null } : state.sortConfig;
let filtered = [...state._originalList];
Object.entries(targetFilters).forEach(([fId, cfg]) => {
if (!cfg.value && (!cfg.selectedValues || cfg.selectedValues.length === 0)) return;
filtered = filtered.filter((item) => {
var _a;
const val = item[fId];
if (((_a = cfg.selectedValues) == null ? void 0 : _a.length) > 0) return cfg.selectedValues.includes(String(val));
const search = String(cfg.value).toLowerCase();
const target = String(val ?? "").toLowerCase();
switch (cfg.mode) {
case "contains":
return target.includes(search);
case "equals":
return target === search;
case "starts":
return target.startsWith(search);
case "ends":
return target.endsWith(search);
case "=":
return Number(val) === Number(cfg.value);
case ">":
return Number(val) > Number(cfg.value);
case "<":
return Number(val) < Number(cfg.value);
case "between":
return Number(val) >= Number(cfg.value) && Number(val) <= Number(cfg.value2);
default:
return true;
}
});
});
if (targetSort.fieldId && targetSort.direction) {
const fId = targetSort.fieldId;
const dir = targetSort.direction === "asc" ? 1 : -1;
filtered.sort((a, b) => {
if (a[fId] == b[fId]) return 0;
return a[fId] > b[fId] ? dir : -dir;
});
}
state._internalUpdate = true;
state.filterConfig = targetFilters;
state.sortConfig = targetSort;
state.list = filtered;
state._internalUpdate = false;
};
container.showColumnMenu = (field, event) => {
var _a;
const btn = event.currentTarget, menu = container.querySelector(".dt-column-menu");
const type = ((_a = field.settings) == null ? void 0 : _a.formType) || field.type || "text";
state.activeModes = MODE_MAP[type] || (["boolean", "switch", "checkbox", "radio"].includes(type) ? [] : MODE_MAP.text);
if (!state.filterConfig[field.id]) state.filterConfig[field.id] = { mode: state.activeModes[0] || "contains", value: "", selectedValues: [] };
state.activeField = field;
state.activeFieldId = field.id;
menu.style.display = "block";
const cellNode = btn.closest(".dt-cell"), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect();
const menuWidth = menu.offsetWidth || 260;
let leftPos = rect.right - rootRect.left - menuWidth;
if (leftPos < 0) leftPos = Math.max(0, rect.left - rootRect.left);
menu.style.left = leftPos + "px";
menu.style.top = rect.bottom - rootRect.top + 5 + "px";
const onGlobalClick = (ev) => {
if (menu.contains(ev.target) || btn.contains(ev.target)) return;
container.hideColumnMenu();
container.applySortFilter();
document.removeEventListener("mousedown", onGlobalClick);
};
document.addEventListener("mousedown", onGlobalClick);
setTimeout(() => {
var _a2;
return (_a2 = menu.querySelector("input")) == null ? void 0 : _a2.focus();
}, 50);
};
container.toggleSelectedValue = (val) => {
const filter = state.filterConfig[state.activeFieldId];
if (!filter) return;
const idx = filter.selectedValues.indexOf(val);
if (idx === -1) filter.selectedValues.push(val);
else filter.selectedValues.splice(idx, 1);
state.filterConfig = { ...state.filterConfig };
container.applySortFilter();
};
container.filterOnlyThis = (val) => {
state.filterConfig[state.activeFieldId] = { mode: "contains", value: "", selectedValues: [String(val)] };
state.filterConfig = { ...state.filterConfig };
container.applySortFilter();
};
container.hideColumnMenu = () => {
const menu = container.querySelector(".dt-column-menu");
if (menu) menu.style.display = "none";
};
container.setSort = (dir) => {
const newDir = state.sortConfig.direction === dir && state.sortConfig.fieldId === state.activeFieldId ? null : dir;
container.applySortFilter({ sort: newDir });
};
container.clearColumnSettings = () => {
if (state.activeFieldId) {
delete state.filterConfig[state.activeFieldId];
state.filterConfig = { ...state.filterConfig };
container.applySortFilter();
}
};
container._initRow = (rowNode) => {
var _a;
const row = (_a = rowNode._ref) == null ? void 0 : _a.item;
if (row && row._editingF === void 0) {
Object.defineProperty(row, "_editingF", { set: (v) => {
if (v === null) container.hideEditor(true);
}, configurable: true });
}
Array.from(rowNode.children).forEach((cell) => {
const fIdx = parseInt(cell.dataset.fidx);
if (!isNaN(fIdx)) cell._ref = { ...cell._ref || rowNode._ref, f: state.fields[fIdx], fIdx };
});
};
state.__watch("fields", (fields) => {
if (!fields) return;
state._fieldsDirty = true;
state._masterCellNodes = null;
container.style.setProperty("--dt-grid-template", fields.map((f) => {
var _a;
return `var(--w-${f.id}, ${((_a = f.settings) == null ? void 0 : _a.width) || f.width || 150}px)`;
}).join(" "));
container.style.setProperty("--dt-row-width", fields.reduce((sum, f) => {
var _a;
return sum + (((_a = f.settings) == null ? void 0 : _a.width) || f.width || 150);
}, 0) + "px");
let leftSum = 0;
fields.forEach((f) => {
var _a, _b;
const pinned = ((_a = f.settings) == null ? void 0 : _a.pinned) || f.pinned;
if (pinned === "left") {
container.style.setProperty(`--l-${f.id}`, leftSum + "px");
leftSum += ((_b = f.settings) == null ? void 0 : _b.width) || f.width || 150;
}
});
let rightSum = 0;
[...fields].reverse().forEach((f) => {
var _a, _b;
const pinned = ((_a = f.settings) == null ? void 0 : _a.pinned) || f.pinned;
if (pinned === "right") {
container.style.setProperty(`--r-${f.id}`, rightSum + "px");
rightSum += ((_b = f.settings) == null ? void 0 : _b.width) || f.width || 150;
}
});
});
state.__watch("list", (list) => {
var _a;
if (state._fieldsDirty) {
state._fieldsDirty = false;
const fieldTemplate = (_a = container.querySelector('.dt-body template[index="rIdx"]')) == null ? void 0 : _a.content.querySelector('template[as="f"]');
if (fieldTemplate) {
const masters = state._masterCellNodes || (state._masterCellNodes = Array.from(fieldTemplate.content.childNodes).map((n) => n.cloneNode(true)));
fieldTemplate.removeAttribute("$each");
fieldTemplate.setAttribute("$if", "true");
fieldTemplate.content.textContent = "";
state.fields.forEach((f, fIdx) => masters.forEach((master) => {
var _a2;
const clone = master.cloneNode(true);
if (clone.nodeType === 1) {
clone.dataset.fidx = fIdx;
const pinned = ((_a2 = f.settings) == null ? void 0 : _a2.pinned) || f.pinned;
if (pinned) {
clone.classList.add("pinned-" + pinned);
clone.style.position = "sticky";
clone.style.zIndex = "1";
clone.style.backgroundColor = "inherit";
if (pinned === "left") {
clone.style.left = `var(--l-${f.id})`;
clone.style.borderRight = "1px solid var(--bs-border-color)";
clone.style.boxShadow = "2px 0 5px -2px rgba(0,0,0,0.1)";
} else {
clone.style.right = `var(--r-${f.id})`;
clone.style.borderLeft = "1px solid var(--bs-border-color)";
clone.style.boxShadow = "-2px 0 5px -2px rgba(0,0,0,0.1)";
}
}
}
fieldTemplate.content.appendChild(clone);
}));
}
}
if (!state._internalUpdate) {
state._originalList = [...list || []];
setTimeout(() => {
const stats = {};
state.fields.forEach((f) => {
const counts = {};
state._originalList.forEach((item) => {
const val = item[f.id], key = val == null || val === "" ? "" : String(val);
counts[key] = (counts[key] || 0) + 1;
});
stats[f.id] = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([val, count]) => ({ val, count }));
});
state._columnStats = stats;
}, 200);
}
scroll.init();
scroll.reset(list);
});
container.editCell = (row, field, cellNode) => {
var _a, _b;
const overlay = container.querySelector(".dt-editor-overlay"), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect();
currentEditingNode = cellNode;
const formType = ((_a = field.settings) == null ? void 0 : _a.formType) || field.type || "text";
const form = overlay.querySelector("AutoForm");
if (form) {
form.data = row;
form.state.schema = [{ ...field, type: formType, options: ((_b = field.settings) == null ? void 0 : _b.options) || field.options, name: field.id, label: "" }];
}
Object.assign(overlay.style, {
display: "flex",
left: rect.left - rootRect.left + "px",
top: rect.top - rootRect.top + "px",
width: (formType === "textarea" || formType === "TagsInput" ? Math.max(rect.width, 300) : rect.width) + "px",
height: formType === "textarea" || formType === "TagsInput" ? "auto" : rect.height + "px"
});
setTimeout(() => {
var _a2;
return (_a2 = overlay.querySelector("input, textarea, select, .form-control")) == null ? void 0 : _a2.focus();
}, 30);
};
container.hideEditor = (save = true) => {
if (!_editorOverlay) _editorOverlay = container.querySelector(".dt-editor-overlay");
if (!_editorOverlay || _editorOverlay.style.display === "none") return;
const form = _editorOverlay.querySelector("AutoForm");
if (save && form && form.data) {
const input = _editorOverlay.querySelector("input:focus, select:focus, textarea:focus");
if (input) input.dispatchEvent(new Event(input.type === "number" || input.tagName === "SELECT" ? "change" : "input", { bubbles: true }));
const schema = form.state.schema || [];
schema.forEach((field) => {
var _a, _b;
const row = (_b = (_a = currentEditingNode == null ? void 0 : currentEditingNode.closest(".dt-row")) == null ? void 0 : _a._ref) == null ? void 0 : _b.item;
if (row) row[field.name] = form.data[field.name];
});
if (state.isBulkEdit) {
const { minRow, maxRow, fIdx } = state.isBulkEdit;
const field = state.fields[fIdx];
const newValue = form.data[field.id];
for (let i = minRow; i <= maxRow; i++) {
if (state.list[i]) state.list[i][field.id] = newValue;
}
}
state.list = [...state.list];
state.isDirty = true;
}
_editorOverlay.style.display = "none";
if (form) {
form.state.schema = [];
form.data = null;
}
currentEditingNode = null;
state.isBulkEdit = null;
container.focus();
};
container.onMainMouseDown = (e) => {
var _a;
const cell = e.target.closest(".dt-cell"), row = cell == null ? void 0 : cell.closest(".dt-row");
if (!row || row.classList.contains("dt-header-row")) return;
const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll(".dt-cell")).indexOf(cell);
const rIdx = ((_a = row._ref) == null ? void 0 : _a.rIdx) ?? Array.from(container.querySelectorAll(".dt-body-row")).indexOf(row);
selection.startSelect(rIdx + state._listStartIndex, fIdx, e);
};
container.onMainMouseOver = (e) => {
var _a;
if (state.isSelecting) {
const cell = e.target.closest(".dt-cell"), row = cell == null ? void 0 : cell.closest(".dt-row");
if (row && !row.classList.contains("dt-header-row")) {
const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll(".dt-cell")).indexOf(cell);
const rIdx = ((_a = row._ref) == null ? void 0 : _a.rIdx) ?? Array.from(container.querySelectorAll(".dt-body-row")).indexOf(row);
selection.updateSelect(rIdx + state._listStartIndex, fIdx);
}
}
};
container.onMainDblClick = (e) => {
var _a, _b, _c;
const cell = e.target.closest(".dt-cell"), row = cell == null ? void 0 : cell.closest(".dt-row");
if (row && !row.classList.contains("dt-header-row")) {
const item = (_a = row._ref) == null ? void 0 : _a.item, fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll(".dt-cell")).indexOf(cell);
const rIdx = ((_b = row._ref) == null ? void 0 : _b.rIdx) ?? Array.from(container.querySelectorAll(".dt-body-row")).indexOf(row);
const absoluteRow = rIdx + state._listStartIndex;
if (item && state.fields[fIdx]) {
const bounds = selection.getSelectionBounds();
if (bounds && absoluteRow >= bounds.minRow && absoluteRow <= bounds.maxRow && fIdx >= bounds.minCol && fIdx <= bounds.maxCol) {
const affectedRows = bounds.maxRow - bounds.minRow + 1;
if (affectedRows > 1) {
state.isBulkEdit = { ...bounds, fIdx };
if ((_c = globalThis.UI) == null ? void 0 : _c.toast) globalThis.UI.toast(`Bulk Edit: Updating ${affectedRows} rows in column "${state.fields[fIdx].name}"`, { type: "warning" });
}
}
container.editCell(item, state.fields[fIdx], cell);
}
}
};
container.addRow = () => {
const newRow = {};
state.fields.forEach((f) => newRow[f.id] = "");
state._originalList.push(newRow);
state.list = [...state._originalList];
state.isDirty = true;
setTimeout(() => {
scroll.reset(state.list);
container.querySelector(".dt-main").scrollTop = container.querySelector(".dt-main").scrollHeight;
}, 50);
};
container.deleteSelectedRow = async () => {
const bounds = selection.getSelectionBounds();
if (!bounds) return;
const count = bounds.maxRow - bounds.minRow + 1;
if (await globalThis.UI.confirm(`Are you sure you want to delete ${count} row(s)?`)) {
const rMin = bounds.minRow, rMax = bounds.maxRow;
const removedItems = state.list.slice(rMin, rMax + 1);
state.list = state.list.filter((_, i) => !(i >= rMin && i <= rMax));
state._originalList = state._originalList.filter((item) => !removedItems.includes(item));
state.isDirty = true;
selection.clearAllActive();
container.dispatchEvent(new CustomEvent("remove", { detail: { items: removedItems } }));
}
};
container.saveChanges = () => {
container.dispatchEvent(new CustomEvent("save", { detail: { list: state._originalList, fields: state.fields } }));
state.isDirty = false;
};
const getFieldSchema = () => {
const types = globalThis.DataTable.getFieldTypes();
const baseSchema = [
{ name: "id", label: "Field ID", type: "text", setting: { required: true, placeholder: "e.g. user_name" } },
{ name: "name", label: "Display Name", type: "text", setting: { required: true, placeholder: "e.g. 用户名" } },
{ name: "user_type", label: "Field Type", type: "select", options: types.map((t) => ({ label: t.label, value: t.value })) }
];
const dynamicSchema = types.reduce((acc, t) => acc.concat(t.schema || []), []);
return baseSchema.concat(dynamicSchema, [{ name: "isIndex", label: "Index", type: "switch" }, { name: "memo", label: "Memo", type: "text" }]);
};
const parseOptionsStr = (str) => {
if (!str) return void 0;
return str.split("\n").map((s) => s.trim()).filter(Boolean).map((line) => {
const idx = line.indexOf(":");
if (idx > -1) return { label: line.slice(0, idx).trim(), value: line.slice(idx + 1).trim() };
return line;
});
};
const formatOptionsStr = (opts) => {
if (!opts) return "";
return opts.map((o) => typeof o === "object" ? `${o.label}:${o.value}` : o).join("\n");
};
container.addField = async () => {
container.hideColumnMenu();
const data = globalThis.NewState({ id: "c" + Date.now().toString().slice(-4), name: "New Field", user_type: "text", decimals: 0, isIndex: false, memo: "", options_str: "" });
const d = container.querySelector(`Dialog[id="${container.id}_field_dialog"]`);
if (!d) return;
Object.assign(d.state, { title: "Add Field", buttons: ["Cancel", "Save"] });
const form = d.querySelector("AutoForm");
if (form) {
form.data = data;
form.state.schema = getFieldSchema();
}
d.show();
const result = await new Promise((resolve) => d.addEventListener("change", (e) => resolve(d.result), { once: true }));
if (result === 2) {
const typeInfo = globalThis.DataTable.getFieldTypes().find((t) => t.value === data.user_type);
let dbType = (typeInfo == null ? void 0 : typeInfo.typeForDB) || "v1024";
if (data.user_type === "number") dbType = data.decimals > 0 ? "ff" : "bi";
const field = { id: data.id, name: data.name, memo: data.memo, isIndex: !!data.isIndex, type: dbType, settings: { formType: data.user_type, decimals: data.decimals, prefix: data.prefix, suffix: data.suffix, thousandSep: data.thousandSep, labelOn: data.labelOn, labelOff: data.labelOff, format: data.format, placeholder: data.placeholder, options: parseOptionsStr(data.options_str) } };
state.fields = [...state.fields, field];
state.isDirty = true;
container.dispatchEvent(new CustomEvent("savefields", { detail: state.fields }));
state.list = [...state.list];
}
};
container.editField = async () => {
if (!state.activeField) return;
container.hideColumnMenu();
const f = state.activeField;
const s = f.settings || {};
const data = globalThis.NewState({ id: f.id, name: f.name, memo: f.memo || "", isIndex: !!f.isIndex, user_type: s.formType || "text", decimals: s.decimals || 0, prefix: s.prefix || "", suffix: s.suffix || "", thousandSep: !!s.thousandSep, labelOn: s.labelOn || "", labelOff: s.labelOff || "", format: s.format || "", placeholder: s.placeholder || "", options_str: formatOptionsStr(s.options) });
const d = container.querySelector(`Dialog[id="${container.id}_field_dialog"]`);
if (!d) return;
Object.assign(d.state, { title: "Edit Field", buttons: ["Cancel", "Save"] });
const form = d.querySelector("AutoForm");
if (form) {
form.data = data;
form.state.schema = getFieldSchema();
}
d.show();
const result = await new Promise((resolve) => d.addEventListener("change", (e) => resolve(d.result), { once: true }));
if (result === 2) {
const idx = state.fields.findIndex((item) => item.id === f.id);
if (idx !== -1) {
const typeInfo = globalThis.DataTable.getFieldTypes().find((t) => t.value === data.user_type);
let dbType = (typeInfo == null ? void 0 : typeInfo.typeForDB) || "v1024";
if (data.user_type === "number") dbType = data.decimals > 0 ? "ff" : "bi";
const updatedField = { ...f, id: data.id, name: data.name, memo: data.memo, isIndex: !!data.isIndex, type: dbType, settings: { ...f.settings, formType: data.user_type, decimals: data.decimals, prefix: data.prefix, suffix: data.suffix, thousandSep: data.thousandSep, labelOn: data.labelOn, labelOff: data.labelOff, format: data.format, placeholder: data.placeholder, options: parseOptionsStr(data.options_str) } };
state.fields[idx] = updatedField;
state.fields = [...state.fields];
state.isDirty = true;
container.dispatchEvent(new CustomEvent("savefields", { detail: state.fields }));
state.list = [...state.list];
}
}
};
container.deleteField = async () => {
if (!state.activeField) return;
container.hideColumnMenu();
if (await globalThis.UI.confirm(`Are you sure you want to delete field "${state.activeField.name}"?`)) {
const idx = state.fields.findIndex((f) => f.id === state.activeField.id);
if (idx !== -1) {
state.fields.splice(idx, 1);
state.fields = [...state.fields];
state.isDirty = true;
container.dispatchEvent(new CustomEvent("savefields", { detail: state.fields }));
state.list = [...state.list];
}
}
};
window.addEventListener("mouseup", selection.endSelect);
document.addEventListener("mousedown", (e) => {
const overlay = container.querySelector(".dt-editor-overlay");
const menu = container.querySelector(".dt-column-menu");
if ((overlay == null ? void 0 : overlay.style.display) !== "none" && !overlay.contains(e.target)) container.hideEditor(true);
if (!container.contains(e.target) && !(overlay == null ? void 0 : overlay.contains(e.target)) && !(menu == null ? void 0 : menu.contains(e.target))) selection.clearAllActive();
});
state._MODE_ICONS = MODE_ICONS;
}, globalThis.Util.makeDom(
/*html*/
`
<div class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-hidden" style="position:relative; user-select:none; outline: none; min-height: 0" tabindex="0">
<div class="dt-main flex-grow-1 overflow-auto" $onscroll="this.onScroll()"
$onmousedown="this.onMainMouseDown(event)" $onmouseover="this.onMainMouseOver(event)" $ondblclick="this.onMainDblClick(event)"
style="overflow-anchor:none; min-height: 0">
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:20">
<div class="dt-header-row fw-bold text-muted small">
<template $each="this.state?.fields || []">
<div $data-id="item.id" $class="dt-cell dt-col border-end d-flex align-items-center header-cell \${(item.settings?.pinned || item.pinned) ? 'pinned-' + (item.settings?.pinned || item.pinned) : ''}" $style="((item.settings?.pinned || item.pinned) ? 'position: sticky; z-index: 11; background-color: inherit; ' : 'position:relative; ') + 'padding: 0; ' + ((item.settings?.pinned || item.pinned) === 'left' ? 'left: var(--l-' + item.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : ((item.settings?.pinned || item.pinned) === 'right' ? 'right: var(--r-' + item.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))">
<div class="d-flex align-items-center overflow-hidden flex-grow-1 h-100 px-2 cursor-pointer" $onclick="this.showColumnMenu(item, event)">
<i $if="this.state?.filterConfig?.[item.id] && (this.state.filterConfig[item.id].value || this.state.filterConfig[item.id].selectedValues?.length)" class="bi bi-filter me-1 text-primary"></i>
<i $if="this.state?.sortConfig?.fieldId === item.id && this.state.sortConfig.direction" $class="bi bi-sort-\${this.state.sortConfig.direction === 'asc' ? 'down' : 'up-alt'} me-1 text-primary"></i>
<span $text="item.name" class="text-truncate flex-grow-1"></span>
</div>
<button class="btn btn-xs btn-link text-muted p-0 border-0 me-1 header-menu-btn" $onclick="this.showColumnMenu(item, event)"><i class="bi bi-chevron-down"></i></button>
<Resizer $.target="thisNode.parentElement" style="position:absolute; right:0; top:0; bottom:0; width:4px; z-index:10" min="50" max="1000" $onresizing="this.onColumnResizing(item, event)" $onresize="this.onColumnResize(item, event)"></Resizer>
</div>
</template>
</div>
</div>
<div class="dt-body" style="position:relative">
<div class="dt-spacer-prev flex-shrink-0" style="display:none"></div>
<template $each="this.state?._renderedList || []" key="id" index="rIdx">
<div class="dt-row dt-body-row border-bottom bg-white" $.="this._initRow(thisNode)">
<template as="f"><div $class="dt-cell border-end px-2 d-flex align-items-center \${(f.settings?.pinned || f.pinned) ? 'pinned-' + (f.settings?.pinned || f.pinned) : ''}" $style="((f.settings?.pinned || f.pinned) ? 'position: sticky; z-index: 1; background-color: inherit; ' : '') + ((f.settings?.pinned || f.pinned) === 'left' ? 'left: var(--l-' + f.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : ((f.settings?.pinned || f.pinned) === 'right' ? 'right: var(--r-' + f.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))"><span $text="this.format(item[f.id], f)" class="text-truncate"></span></div></template>
</div>
</template>
<div class="dt-spacer-post flex-shrink-0" style="display:none"></div>
</div>
</div>
<div class="dt-column-menu bg-body shadow-lg rounded p-2" style="display:none; position:absolute; z-index:2000; min-width:240px; max-width:300px; border: 1px solid var(--bs-primary)">
<template $if="this.state?.activeFieldId">
<div class="d-flex gap-1 mb-2">
<button $class="btn btn-xs flex-grow-1 d-flex align-items-center justify-content-center \${this.state?.sortConfig?.direction === 'asc' && this.state?.sortConfig?.fieldId === this.state?.activeFieldId ? 'btn-primary' : 'btn-outline-secondary border'}" $onclick="this.setSort('asc')"><i class="bi bi-sort-alpha-down me-1"></i> ASC</button>
<button $class="btn btn-xs flex-grow-1 d-flex align-items-center justify-content-center \${this.state?.sortConfig?.direction === 'desc' && this.state?.sortConfig?.fieldId === this.state?.activeFieldId ? 'btn-primary' : 'btn-outline-secondary border'}" $onclick="this.setSort('desc')"><i class="bi bi-sort-alpha-up-alt me-1"></i> DESC</button>
</div>
<div $if="this.state?.activeModes?.length" class="dt-filter-tabs d-flex overflow-auto border-bottom bg-light-subtle rounded-top py-1" style="white-space:nowrap; scrollbar-width: none;">
<template $each="this.state?.activeModes || []" as="m">
<div $class="px-2 py-1 cursor-pointer fs-5 \${this.state?.filterConfig?.[this.state?.activeFieldId]?.mode === m ? 'text-primary border-bottom border-primary border-2' : 'text-muted'}" $title="m.toUpperCase()" $onclick="this.state.filterConfig[this.state.activeFieldId].mode = m; this.state.filterConfig = {...this.state.filterConfig}">
<i $class="bi \${this.state?._MODE_ICONS?.[m] || 'bi-filter'}"></i>
</div>
</template>
</div>
<template $if="this.state?.activeModes?.length">
<div class="py-2 border-bottom" style="min-height: 48px">
<input type="text" class="form-control form-control-sm mb-1" $placeholder="(this.state?.filterConfig?.[this.state?.activeFieldId]?.mode || 'Search').toUpperCase() + '...'" $bind="this.state?.filterConfig?.[this.state?.activeFieldId].value" $onkeydown="if(event.key==='Enter'){this.applySortFilter();this.hideColumnMenu();}">
<input $if="this.state?.filterConfig?.[this.state?.activeFieldId]?.mode === 'between'" type="text" class="form-control form-control-sm" placeholder="And..." $bind="this.state?.filterConfig?.[this.state?.activeFieldId].value2" $onkeydown="if(event.key==='Enter'){this.applySortFilter();this.hideColumnMenu();}">
</div>
</template>
<div class="mt-2" style="max-height: 180px; overflow-y: auto;">
<div class="text-muted fw-bold mb-1" style="font-size: 9px; letter-spacing: 0.5px">TOP FREQUENT VALUES</div>
<template $each="this.state?._columnStats?.[this.state?.activeFieldId] || []">
<label class="d-flex align-items-center mb-1 small cursor-pointer p-1 rounded-1 menu-item-row" onmouseover="this.style.background='var(--bs-light)'" onmouseout="this.style.background='transparent'">
<input type="checkbox" class="form-check-input me-2" $checked="this.state?.filterConfig?.[this.state?.activeFieldId]?.selectedValues?.includes(String(item.val))" $onclick="this.toggleSelectedValue(String(item.val))">
<span class="text-truncate flex-grow-1"><span $text="item.val || '(Empty)'"></span> <span class="text-muted ms-1" style="font-size: 0.7rem" $text="'(' + item.count + ')'"></span></span>
<button class="btn btn-xs btn-link p-0 text-primary only-btn" style="font-size: 10px; text-decoration: none" $onclick="this.filterOnlyThis(item.val); event.preventDefault(); event.stopPropagation();">Only</button>
</label>
</template>
</div>
<div $if="this.state?.filterConfig?.[this.state?.activeFieldId]?.value || this.state?.filterConfig?.[this.state?.activeFieldId]?.selectedValues?.length" class="mt-2 pt-1 border-top text-center">
<span class="cursor-pointer text-primary small fw-bold" $onclick="this.clearColumnSettings()"><i class="bi bi-x-circle me-1"></i> Clear Filter</span>
</div>
<div class="mt-3 pt-2 border-top d-flex flex-column gap-1">
<button class="btn btn-xs btn-outline-secondary border d-flex align-items-center px-2 py-1" $onclick="this.editField()"><i class="bi bi-pencil me-2"></i> Edit Field</button>
<button class="btn btn-xs btn-outline-secondary border d-flex align-items-center px-2 py-1" $onclick="this.addField()"><i class="bi bi-plus-lg me-2"></i> Add Field</button>
<button class="btn btn-xs btn-outline-danger border d-flex align-items-center px-2 py-1" $onclick="this.deleteField()"><i class="bi bi-trash me-2"></i> Delete Field</button>
</div>
</template>
</div>
<div class="dt-editor-overlay dt-editor-container" style="display: none; position: absolute; z-index: 1000; background: var(--bs-body-bg); box-shadow: 0 4px 16px rgba(0,0,0,0.25); border: 1px solid var(--bs-primary);"><AutoForm nobutton inline class="h-100 w-100" $onsubmit="event.preventDefault(); thisNode.closest('DataTable').hideEditor(true)"/></div>
<Dialog $.id="this.id + '_field_dialog'">
<AutoForm nobutton class="p-3"></AutoForm>
</Dialog>
<div class="dt-footer border-top bg-light d-flex align-items-center px-3 py-1 shadow-sm" style="height:40px; z-index: 10">
<div class="d-flex align-items-center gap-3 flex-grow-1">
<div class="btn-group shadow-sm">
<button class="btn btn-xs btn-white border d-flex align-items-center px-2" style="background:white" $onclick="this.addRow()" title="Add Row"><i class="bi bi-plus-lg text-primary me-1"></i> Add</button>
<button class="btn btn-xs btn-white border d-flex align-items-center px-2" style="background:white" $onclick="this.deleteSelectedRow()" $disabled="!this.state?.selectedRowCount" title="Delete Selected Rows"><i class="bi bi-trash text-danger me-1"></i> Delete</button>
</div>
<div class="vr h-50 my-auto text-muted opacity-25"></div>
<div class="d-flex align-items-center gap-2 text-muted" style="font-size: 0.75rem">
<i class="bi bi-check-all fs-6"></i>
<span $text="(this.state?.selectedRowCount || 0) + ' selected / ' + (this.state?.list?.length || 0) + ' total'"></span>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<button $if="this.state?.isDirty" class="btn btn-xs btn-primary px-3 shadow-sm d-flex align-items-center fw-bold" $onclick="this.saveChanges()"><i class="bi bi-cloud-upload me-1"></i> Save Changes</button>
<button $if="!this.state?.isDirty" class="btn btn-xs btn-light border px-3 text-muted disabled d-flex align-items-center" disabled><i class="bi bi-cloud-check me-1"></i> Up to date</button>
</div>
</div>
</div>
`
), globalThis.Util.makeDom(
/*html*/
`
<style>
DataTable { display: block; }
.dt-root { font-size: 0.875rem; }
.dt-row, .dt-header-row { display: grid; grid-template-columns: var(--dt-grid-template); width: var(--dt-row-width, max-content); min-width: 100%; height: 40px; contain: paint layout; }
.dt-header-row { background-color: var(--bs-tertiary-bg); border-bottom: 1px solid var(--bs-border-color); }
.dt-cell { background: inherit; white-space: nowrap; flex-shrink: 0; contain: content; }
.dt-cell-selected { background-color: rgba(var(--bs-primary-rgb), 0.15) !important; outline: 1px solid var(--bs-primary); outline-offset: -1px; }
.dt-body-row:hover { background-color: var(--bs-secondary-bg) !important; }
.header-cell .header-menu-btn { opacity: 0; transition: opacity 0.2s; }
.header-cell:hover .header-menu-btn { opacity: 1; }
.dt-column-menu { background-color: var(--bs-body-bg); border: 1px solid var(--bs-primary); box-shadow: 0 10px 40px rgba(0,0,0,0.2) !important; z-index: 2100 !important; }
.btn-xs { padding: 1px 5px; line-height: 1.5; }
.cursor-pointer { cursor: pointer; }
.dt-filter-tabs i { font-size: 1.1rem; }
.dt-filter-tabs div:hover i { color: var(--bs-primary); }
.menu-item-row .only-btn { opacity: 0; }
.menu-item-row:hover .only-btn { opacity: 1; }
</style>
`
));
globalThis.DataTable = DataTableConfig;
});

1
test/lib/datatable.min.js vendored Normal file

File diff suppressed because one or more lines are too long

661
test/lib/state.js Normal file
View File

@ -0,0 +1,661 @@
(function(global, factory) {
typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define(["exports"], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, factory(global.ApigoState = global.ApigoState || {}));
})(this, function(exports2) {
"use strict";
var _a, _b;
const Util = {
clone: globalThis.structuredClone || ((obj) => JSON.parse(JSON.stringify(obj))),
base64: (str) => btoa(String.fromCharCode(...new TextEncoder().encode(str))),
unbase64: (str) => new TextDecoder().decode(Uint8Array.from(atob(str), (c) => c.charCodeAt(0))),
urlbase64: (str) => Util.base64(str).replace(/[+/=]/g, (m) => ({ "+": "-", "/": "", "=": "" })[m]),
unurlbase64: (str) => Util.unbase64(str.replace(/[-_.]/g, (m) => ({ "-": "+", "_": "/", ".": "=" })[m]).padEnd(Math.ceil(str.length / 4) * 4, "=")),
safeJson: (str) => {
try {
return JSON.parse(str);
} catch {
return null;
}
},
updateDefaults: (obj, defaults) => {
for (const k in defaults) if (obj[k] === void 0) obj[k] = defaults[k];
},
copyFunction: (toObj, fromObj, ...funcNames) => {
funcNames.forEach((name) => toObj[name] = fromObj[name].bind(fromObj));
},
getFunctionBody: (fn) => {
const code = fn.toString();
return code.slice(code.indexOf("{") + 1, code.lastIndexOf("}")).trim();
},
makeDom: (html) => {
if (html.includes(">\n")) html = html.replace(/>\s+</g, "><").trim();
const node = document.createElement("div");
node.innerHTML = html;
return node.children[0];
},
newAvg: () => {
let total = 0, count = 0, avg = 0;
return {
add: (v) => {
total += v;
count++;
return avg = total / count;
},
get: () => avg,
clear: () => {
total = 0, count = 0, avg = 0;
}
};
},
newTimeCount: () => {
let startTime = 0, total = 0, count = 0;
return {
start: () => startTime = (/* @__PURE__ */ new Date()).getTime(),
end: () => {
const endTime = (/* @__PURE__ */ new Date()).getTime();
const left = endTime - startTime;
startTime = endTime;
total += left;
count++;
return left;
},
avg: () => total / count
};
}
};
const $ = (a, b) => b ? a.querySelector(b) : document.querySelector(a);
const $$ = (a, b) => b ? a.querySelectorAll(b) : document.querySelectorAll(a);
globalThis.Util = Util;
globalThis.$ = $;
globalThis.$$ = $$;
let __activeBinding = null;
let __noWriteBack = null;
const _setActiveBinding = (val) => __activeBinding = val;
const _setNoWriteBack = (val) => __noWriteBack = val;
const _notifiers = /* @__PURE__ */ new Set();
const _onNotifyUpdate = (fn) => _notifiers.add(fn);
function NewState(defaults = {}, getter = null, setter = null) {
const _defaults = {};
const _stateMappings = /* @__PURE__ */ new Map();
const _watchers = /* @__PURE__ */ new Map();
const _watchFunc = (k, cb) => {
if (!_watchers.has(k)) _watchers.set(k, /* @__PURE__ */ new Set());
!cb ? _watchers.get(k).clear() : _watchers.get(k).add(cb);
return () => _watchers.get(k).delete(cb);
};
const _unwatchFunc = (k, cb) => {
if (_watchers.has(k)) _watchers.set(k, /* @__PURE__ */ new Set());
_watchers.get(k).delete(cb);
};
const __getter = getter || ((k) => _defaults[k]);
const __setter = setter || ((k, v) => _defaults[k] = v);
Object.assign(_defaults, defaults);
return new Proxy(_defaults, {
get(target, key) {
if (key === "__watch") return _watchFunc;
if (key === "__unwatch") return _unwatchFunc;
if (key === "__isProxy") return true;
if (__activeBinding) {
if (!_stateMappings.has(key)) _stateMappings.set(key, /* @__PURE__ */ new Set());
_stateMappings.get(key).add(__activeBinding);
if (!__activeBinding.node._states) __activeBinding.node._states = /* @__PURE__ */ new Set();
__activeBinding.node._states.add(_stateMappings);
}
return __getter(key);
},
set(target, key, value) {
if (__getter(key) !== value) {
__setter(key, value);
}
if (_watchers.has(key)) {
_watchers.get(key).forEach((cb) => {
const r = cb(value);
if (r !== void 0) {
value = r;
target[key] = value;
}
});
}
if (_watchers.has(null)) {
_watchers.get(null).forEach((cb) => cb(value));
}
if (_stateMappings.has(key)) {
const bindings = _stateMappings.get(key);
for (const binding of bindings) {
if (!binding.node.isConnected) {
bindings.delete(binding);
continue;
}
if (__noWriteBack !== binding.node) {
_notifiers.forEach((fn) => fn(binding));
}
}
}
return true;
}
});
}
globalThis.NewState = NewState;
let _hashParams = new URLSearchParams(typeof globalThis !== "undefined" ? ((_b = (_a = globalThis.location) == null ? void 0 : _a.hash) == null ? void 0 : _b.substring(1)) || "" : "");
const Hash = NewState({}, (k) => Util.safeJson(_hashParams.get(k)), (k, v) => {
const oldStr = _hashParams.get(k);
const newStr = v === void 0 ? void 0 : JSON.stringify(v);
if (oldStr === newStr || oldStr === null && newStr === void 0) return;
v === void 0 ? _hashParams.delete(k) : _hashParams.set(k, newStr);
globalThis.location.hash = "#" + _hashParams.toString();
});
if (typeof globalThis !== "undefined") {
globalThis.addEventListener("hashchange", () => {
var _a2;
const newParams = new URLSearchParams(((_a2 = globalThis.location.hash) == null ? void 0 : _a2.substring(1)) || "");
const keys = /* @__PURE__ */ new Set([..._hashParams.keys(), ...newParams.keys()]);
_hashParams = newParams;
keys.forEach((k) => Hash[k] = Hash[k]);
});
}
const LocalStorage = NewState({}, (k) => Util.safeJson(localStorage.getItem(k)), (k, v) => {
const oldStr = localStorage.getItem(k);
const newStr = v === void 0 ? void 0 : JSON.stringify(v);
if (oldStr === newStr || oldStr === null && newStr === void 0) return;
v === void 0 ? localStorage.removeItem(k) : localStorage.setItem(k, newStr);
});
const State = NewState({
exitBlocks: 0
});
globalThis.Hash = Hash;
globalThis.LocalStorage = LocalStorage;
globalThis.State = State;
let _disableRunCodeError = false;
const setDisableRunCodeError = (value) => {
_disableRunCodeError = value;
};
const _fnCache = /* @__PURE__ */ new Map();
function _runCode(code, vars, thisObj, extendVars) {
const allVars = { ...extendVars || {}, ...vars || {} };
const argKeys = Object.keys(allVars);
const argValues = Object.values(allVars);
const cacheKey = code + argKeys.join(",");
try {
let fn = _fnCache.get(cacheKey);
if (!fn) {
fn = new Function("Hash", "LocalStorage", "State", ...argKeys, code);
_fnCache.set(cacheKey, fn);
}
return fn.apply(thisObj, [globalThis.Hash, globalThis.LocalStorage, globalThis.State, ...argValues]);
} catch (e) {
if (!_disableRunCodeError) console.error(e, extendVars, [code, extendVars, vars, thisObj]);
return null;
}
}
function _returnCode(code, vars, thisObj, extendVars) {
if (code.includes("${")) return _runCode("return `" + code + "`", vars, thisObj, extendVars);
else return _runCode("return " + code, vars, thisObj, extendVars);
}
const _components = /* @__PURE__ */ new Map();
const _pendingTemplates = [];
const Component = {
getTemplate: (name) => document.querySelector(`template[component="${name.toUpperCase()}"]`),
register: (name, setupFunc, templateNode = null, ...globalNodes) => {
_components.set(name.toUpperCase(), setupFunc);
if (document.readyState !== "loading") Component._addTemplate(name, templateNode, globalNodes);
else _pendingTemplates.push([name, templateNode, globalNodes]);
},
exists: (name) => _components.has(name.toUpperCase()),
getSetupFunction: (name) => _components.get(name.toUpperCase()),
_addTemplate: (name, templateNode, globalNodes) => {
if (templateNode) {
const template = document.createElement("TEMPLATE");
template.setAttribute("component", name.toUpperCase());
template.content.appendChild(templateNode);
document.body.appendChild(template);
}
if (globalNodes) globalNodes.forEach((node) => document.body.appendChild(node));
},
_initPending: () => {
_pendingTemplates.forEach(([name, templateNode, globalNodes]) => Component._addTemplate(name, templateNode, globalNodes));
_pendingTemplates.length = 0;
}
};
function _mergeNode(from, to, scanObj, exists = {}) {
if (from.attributes) {
Array.from(from.attributes).forEach((attr) => {
if (attr.name === "class") return;
if (attr.name === "style") {
if (to.hasAttribute("style")) to.setAttribute("style", `${attr.value}; ${to.getAttribute("style")}`);
else to.setAttribute("style", attr.value);
} else if (!to.hasAttribute(attr.name)) {
to.setAttribute(attr.name, attr.value);
}
});
}
to.classList.add(...from.classList);
const target = to.tagName === "TEMPLATE" ? to.content : to;
const sourceNodes = from.tagName === "TEMPLATE" ? from.content.childNodes : from.childNodes;
Array.from(sourceNodes).forEach((child) => target.appendChild(child));
if (from.tagName && Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists);
}
function _makeComponent(name, node, scanObj, exists = {}) {
if (exists[name]) return;
exists[name] = true;
if (scanObj.thisObj) {
Array.from(node.attributes).forEach((attr) => {
if ((attr.name.startsWith("$") || attr.name.startsWith("st-")) && attr.value.includes("this.")) {
attr.value = attr.value.replace(/\bthis\./g, "this.parent.");
}
});
}
const componentFunc = Component.getSetupFunction(name);
const slots = {};
Array.from(node.childNodes).forEach((child) => {
if (child.nodeType === Node.ELEMENT_NODE && child.hasAttribute("slot")) {
slots[child.getAttribute("slot")] = child;
child.removeAttribute("slot");
}
});
node.innerHTML = "";
node.state = NewState(node.state || {});
const template = Component.getTemplate(name);
if (template) {
const tplnode = template.content.cloneNode(true);
if (tplnode.childNodes.length) {
const rootNode = tplnode.children[0];
if (rootNode) _mergeNode(rootNode, node, scanObj, exists);
$$(node, "[slot-id]").forEach((placeholder) => {
const slotName = placeholder.getAttribute("slot-id");
if (slots[slotName]) {
placeholder.removeAttribute("slot-id");
placeholder.innerHTML = "";
_mergeNode(slots[slotName], placeholder, scanObj, exists);
}
});
}
}
if (componentFunc) componentFunc(node);
}
let _translator = (text, args) => {
if (!text || typeof text !== "string") return text;
return text.replace(/\{(.+?)\}/g, (match, key) => args.hasOwnProperty(key) ? args[key] : match);
};
const SetTranslator = (fn) => _translator = fn;
const _translate = (text) => {
if (!text || typeof text !== "string" || !text.includes("{#")) return text;
return text.replace(/\{#(.+?)#\}/g, (m, content) => {
const parts = content.split("||").map((s) => s.trim());
const args = {};
if (parts.length > 1) {
const matches = parts[0].match(/\{(.+?)\}/g);
if (matches) matches.forEach((match, i) => args[match.substring(1, match.length - 1)] = parts[i + 1] || "");
}
return _translator(parts[0], args);
});
};
if (typeof document !== "undefined") {
try {
document.createElement("div").setAttribute("$t", "1");
} catch (e) {
const originalSetAttribute = Element.prototype.setAttribute;
Element.prototype.setAttribute = function(name, value) {
if (!name.startsWith("$")) return originalSetAttribute.call(this, name, value);
return originalSetAttribute.call(this, "st-" + name.substring(1), value);
};
}
}
_onNotifyUpdate((binding) => _updateBinding(binding));
function _clearRenderedNodes(node) {
if (node._renderedNodes) node._renderedNodes.forEach((nodes) => nodes.forEach((child) => {
child.remove();
if (child._renderedNodes) _clearRenderedNodes(child);
}));
}
function _updateBinding(binding) {
const node = binding.node;
if (!node.isConnected && node.tagName !== "TEMPLATE") return;
_setActiveBinding(binding);
let result = binding.exp ? binding.tpl ? _returnCode(binding.tpl, { thisNode: node }, node._thisObj || node, node._ref || null) : null : binding.tpl;
if (binding.exp === 2 && typeof result === "string") {
try {
result = _returnCode(result, { thisNode: node }, node._thisObj || node, node._ref || null);
} catch (e) {
}
}
_setActiveBinding(null);
if (binding.prop) {
const prop = binding.prop;
let o = node;
for (let i = 0; i < prop.length - 1; i++) {
if (!prop[i]) continue;
if (o[prop[i]] == null) o[prop[i]] = {};
o = o[prop[i]];
if (typeof o !== "object") break;
}
if (typeof o === "object" && o !== null) {
const lk = prop[prop.length - 1];
if (lk) {
if (typeof result === "object" && result != null && !Array.isArray(result) && o[lk] == null) o[lk] = {};
const lo = o[lk];
if (typeof lo === "object" && lo != null && lo.__watch) Object.assign(lo, result);
else {
if (o[lk] !== result) o[lk] = result;
}
} else if (typeof result === "object" && result != null && !Array.isArray(result)) {
Object.assign(o, result);
}
}
} else if (binding.attr) {
const attr = binding.attr;
if (attr === "if") {
if (result) {
if (!node._renderedNodes || node._renderedNodes.length === 0) {
node._children.forEach((child) => {
node.parentNode.insertBefore(child, node);
child._ref = { ...node._ref };
child._thisObj = node._thisObj;
});
node._renderedNodes = [node._children];
}
} else {
_clearRenderedNodes(node);
node._renderedNodes = [];
}
} else if (attr === "each") {
if (result && typeof result === "object") {
const asName = node.getAttribute("as") || "item";
const indexName = node.getAttribute("index") || "index";
const keyName = node.getAttribute("key");
let keys, getVal;
if (result instanceof Map) {
keys = Array.from(result.keys());
getVal = (k) => result.get(k);
} else if (typeof result[Symbol.iterator] === "function") {
const arr = Array.isArray(result) ? result : Array.from(result);
keys = new Array(arr.length);
for (let i = 0; i < arr.length; i++) keys[i] = i;
getVal = (k) => arr[k];
} else {
keys = Object.keys(result);
getVal = (k) => result[k];
}
if (!node._keyedNodes) node._keyedNodes = /* @__PURE__ */ new Map();
const newKeyedNodes = /* @__PURE__ */ new Map();
const currentRenderedNodes = [];
keys.forEach((k, i) => {
const item = getVal(k);
const rawKey = keyName ? item && typeof item === "object" ? item[keyName] : item : k;
const keyVal = rawKey === void 0 || rawKey === null || newKeyedNodes.has(rawKey) ? `st_key_${i}` : rawKey;
let existingNodes = node._keyedNodes.get(keyVal);
if (existingNodes) {
node._keyedNodes.delete(keyVal);
existingNodes.forEach((child) => {
child._ref[indexName] = k;
child._ref[asName] = item;
_scanTree(child);
});
} else {
existingNodes = [];
node._children.forEach((child) => {
const cloned = child.cloneNode(true);
cloned._ref = { ...node._ref, [indexName]: k, [asName]: item };
cloned._thisObj = node._thisObj;
node.parentNode.insertBefore(cloned, node);
existingNodes.push(cloned);
});
}
newKeyedNodes.set(keyVal, existingNodes);
currentRenderedNodes.push(existingNodes);
});
node._keyedNodes.forEach((nodes) => nodes.forEach((child) => {
_clearRenderedNodes(child);
child.remove();
}));
node._keyedNodes = newKeyedNodes;
node._renderedNodes = currentRenderedNodes;
} else {
_clearRenderedNodes(node);
node._renderedNodes = [];
}
} else if (attr === "bind") {
if (["INPUT", "SELECT", "TEXTAREA"].includes(node.tagName) && !node.hasAttribute("autocomplete")) node.setAttribute("autocomplete", "off");
if (node.type === "checkbox") {
if (node.value !== "on" && !result) {
_runCode(`${binding.tpl} = []`, { thisNode: node }, node._thisObj || node, node._ref || {});
result = [];
}
node._checkboxMultiMode = result instanceof Array;
const isChecked = result instanceof Array ? result.includes(node.value) : !!result;
if (node.checked !== isChecked) node.checked = isChecked;
} else if (node.type === "radio") {
if (node.checked !== (node.value === String(result ?? ""))) node.checked = node.value === String(result ?? "");
} else if ("value" in node && node.type !== "file") {
Promise.resolve().then(() => {
if (node.value !== String(result ?? "")) node.value = result;
});
} else if (node.isContentEditable) {
if (node.innerHTML !== String(result ?? "")) node.innerHTML = result;
}
node.dispatchEvent(new CustomEvent("bind", { bubbles: false, detail: result }));
} else {
if (["checked", "disabled", "readonly"].includes(attr)) result = !!result;
if (typeof result === "boolean") result ? node.setAttribute(attr, "") : node.removeAttribute(attr);
else if (result !== void 0) {
if (typeof result !== "string") result = JSON.stringify(result);
if (attr === "text") node.textContent = result ?? "";
else if (attr === "html") node.innerHTML = result ?? "";
else if (node.tagName === "IMG" && attr === "src" && result.includes(".svg")) node.setAttribute("_src", result ?? "");
else node.setAttribute(attr, result ?? "");
}
}
}
}
function _initBinding(binding) {
if (!binding.node._bindings) binding.node._bindings = [];
binding.node._bindings.push({ attr: binding.attr, prop: binding.prop, tpl: binding.tpl, exp: binding.exp });
_updateBinding(binding);
}
function _parseNode(node, scanObj) {
if (node._bindings) {
node._states = /* @__PURE__ */ new Set();
node._bindings.forEach((b) => _updateBinding({ node, ...b }));
if (node._hasOnUpdate) node.dispatchEvent(new Event("update", { bubbles: false }));
return;
}
if (Component.exists(node.tagName) && !node._componentInitialized) {
Array.from(node.attributes).forEach((attr) => {
var _a2;
if (attr.name.startsWith("$.")) {
const realAttrName = attr.name.slice(2);
let tpl = _translate(attr.value);
if (scanObj.thisObj && tpl.includes("this.")) tpl = tpl.replace(/\bthis\./g, "this.parent.");
const result = _returnCode(tpl, { thisNode: node }, { parent: scanObj.thisObj || node }, node._ref || {});
let o = node;
const prop = realAttrName.split(".");
for (let i = 0; i < prop.length - 1; i++) {
if (prop[i]) o = o[_a2 = prop[i]] ?? (o[_a2] = {});
}
o[prop[prop.length - 1]] = result;
node.removeAttribute(attr.name);
}
});
_makeComponent(node.tagName, node, scanObj);
$$(node, "[slot-id]").forEach((p) => p.removeAttribute("slot-id"));
node._componentInitialized = true;
if (!node._thisObj) node._thisObj = node;
}
if (node.tagName === "TEMPLATE") {
node._children = [...node.content.childNodes];
if (!node._renderedNodes) node._renderedNodes = [];
}
let attrs = [];
if (node.tagName === "TEMPLATE") {
["$if", "$each", "st-if", "st-each"].forEach((n) => node.hasAttribute(n) && attrs.push(node.getAttributeNode(n)));
} else {
attrs = Array.from(node.attributes).filter((a) => (a.name.startsWith("$") || a.name.startsWith("st-")) && !["$if", "$each", "st-if", "st-each"].includes(a.name) || a.name.includes("."));
}
if (node._thisObj && scanObj.thisObj && node._thisObj !== scanObj.thisObj) node._thisObj.parent = scanObj.thisObj;
if (!node._thisObj) node._thisObj = scanObj.thisObj || null;
if (!node._ref) node._ref = scanObj.extendVars || {};
node._states = /* @__PURE__ */ new Set();
attrs.forEach((attr) => {
let exp = 0;
if (attr.name.startsWith("$$") || attr.name.startsWith("st-st-")) exp = 2;
else if (attr.name.startsWith("$") || attr.name.startsWith("st-")) exp = 1;
const realAttrName = exp === 2 ? attr.name.startsWith("$$") ? attr.name.slice(2) : attr.name.slice(6) : exp === 1 ? attr.name.startsWith("$") ? attr.name.slice(1) : attr.name.slice(3) : attr.name;
let tpl = attr.value;
node.removeAttribute(attr.name);
if (realAttrName.startsWith(".")) _initBinding({ node, prop: realAttrName.split("."), tpl, exp });
else if (realAttrName.startsWith("on")) {
const eventName = realAttrName.slice(2);
if (eventName === "update") node._hasOnUpdate = true;
if (eventName === "load" && !["BODY", "IMG", "IFRAME"].includes(node.tagName)) node._hasOnLoad = true;
if (eventName === "unload" && !["BODY", "IMG", "IFRAME"].includes(node.tagName)) node._hasOnUnload = true;
node.addEventListener(eventName, (e) => _runCode(tpl, { event: e, thisNode: node, ...e.detail || {} }, scanObj.thisObj || node, node._ref || {}));
} else {
if (realAttrName === "bind") {
const isTextInput = ["INPUT", "TEXTAREA"].includes(node.tagName) && ["textarea", "text", "password", "email", "number", "search", "url", "tel"].includes(node.type || "text") || node.isContentEditable;
node.addEventListener(isTextInput ? "input" : "change", (e) => {
let newVal = node.isContentEditable ? e.target.innerHTML : node.type === "checkbox" ? e.target.checked : e.target.files || e.target.value || e.detail;
_setNoWriteBack(node);
setDisableRunCodeError(true);
if (node.type === "checkbox" && node._checkboxMultiMode) _runCode(`!!checked ? (!${tpl}.includes(val) && ${tpl}.push(val)) : (index = ${tpl}.indexOf(val), index > -1 && ${tpl}.splice(index, 1))`, { val: node.value, checked: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
else _runCode(`${tpl} = val`, { val: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
setDisableRunCodeError(false);
_setNoWriteBack(null);
});
} else if (realAttrName === "text" && !tpl) {
tpl = node.textContent;
node.textContent = "";
}
if (tpl) {
tpl = _translate(tpl);
_initBinding({ node, attr: realAttrName, tpl, exp });
}
}
});
if (node._hasOnLoad || node._componentInitialized) Promise.resolve().then(() => node.dispatchEvent(new Event("load", { bubbles: false })));
if (node._hasOnUpdate) node.dispatchEvent(new Event("update", { bubbles: false }));
if (node._thisObj) scanObj.thisObj = node._thisObj;
}
const _scanTree = (node, scanObj = {}) => {
if (node.nodeType === 3) {
if (node._stTranslated) return;
const translated = _translate(node.textContent);
if (translated !== node.textContent) node.textContent = translated;
node._stTranslated = true;
return;
}
if (node.nodeType !== 1) return;
if (!node._stTranslated) {
Array.from(node.attributes).forEach((attr) => {
if (!attr.name.startsWith("$") && !attr.name.startsWith("st-") && !attr.name.startsWith(".")) {
const translated = _translate(attr.value);
if (translated !== attr.value) attr.value = translated;
}
});
node._stTranslated = true;
}
if (node.tagName !== "TEMPLATE" && (node.hasAttribute("$if") || node.hasAttribute("$each") || node.hasAttribute("st-if") || node.hasAttribute("st-each"))) {
const template = document.createElement("TEMPLATE");
const attrs = Array.from(node.attributes).filter((attr) => ["$if", "$each", "st-if", "st-each"].includes(attr.name) || (node.hasAttribute("$each") || node.hasAttribute("st-each")) && ["as", "index"].includes(attr.name));
attrs.forEach((attr) => {
template.setAttribute(attr.name, attr.value);
node.removeAttribute(attr.name);
});
node.parentNode.insertBefore(template, node);
template.content.appendChild(node);
template._ref = node._ref;
_scanTree(template, scanObj);
return;
}
if (node.tagName === "TEMPLATE" && (node.hasAttribute("$if") || node.hasAttribute("st-if")) && (node.hasAttribute("$each") || node.hasAttribute("st-each"))) {
const template = document.createElement("TEMPLATE");
const attrs = Array.from(node.attributes).filter((attr2) => ["$if", "$each", "st-if", "st-each"].includes(attr2.name));
const attr = attrs[attrs.length - 1];
template.setAttribute(attr.name, attr.value);
node.removeAttribute(attr.name);
if (attr.name === "$each" || attr.name === "st-each") {
Array.from(node.attributes).filter((attr2) => ["as", "index"].includes(attr2.name)).forEach((attr2) => {
template.setAttribute(attr2.name, attr2.value);
node.removeAttribute(attr2.name);
});
}
Array.from(node.content.childNodes).forEach((child) => template.content.appendChild(child));
node.content.appendChild(template);
template._ref = node._ref;
}
if (node.tagName === "IMG" && (node.hasAttribute("src") || node.hasAttribute("_src") || node.hasAttribute("$src"))) {
const imgNode = node;
Promise.resolve().then(() => {
const url = imgNode.getAttribute("_src") || imgNode.getAttribute("src");
if (url) fetch(url, { cache: "force-cache" }).then((r) => r.text()).then((svgText) => {
const realSvg = new DOMParser().parseFromString(svgText, "image/svg+xml").querySelector("svg");
if (realSvg) {
Array.from(imgNode.attributes).forEach((attr) => realSvg.setAttribute(attr.name, attr.value));
imgNode.replaceWith(realSvg);
}
});
});
}
if (node._thisObj !== void 0) scanObj.thisObj = node._thisObj || null;
else {
let curr = node;
while (curr && curr._thisObj === void 0) curr = curr.parentNode;
scanObj.thisObj = curr ? curr._thisObj : null;
}
if (node._ref === void 0) {
let curr = node;
while (curr && curr._ref === void 0) curr = curr.parentNode;
node._ref = curr ? { ...curr._ref } : {};
}
if (scanObj.extendVars) Object.assign(node._ref, scanObj.extendVars);
_parseNode(node, { ...scanObj });
const nodes = [...node.childNodes || []];
nodes.forEach((child) => _scanTree(child, { thisObj: scanObj.thisObj, extendVars: { ...node._ref } }));
};
const _unbindTree = (node) => {
if (node.nodeType !== 1) return;
if (node._hasOnUnload) node.dispatchEvent(new Event("unload", { bubbles: false }));
if (node._states) node._states.forEach((mappings) => {
for (const [key, bindingSet] of mappings) {
for (const binding of bindingSet) {
if (binding.node === node) bindingSet.delete(binding);
}
}
});
node.childNodes && node.childNodes.forEach((child) => _unbindTree(child));
};
globalThis.Component = Component;
globalThis.SetTranslator = SetTranslator;
globalThis.__unsafeRefreshState = _scanTree;
if (typeof document !== "undefined") {
const init = () => {
if (globalThis.Component && globalThis.Component._initPending) {
globalThis.Component._initPending();
}
const htmlNode = document.documentElement;
if (!htmlNode.hasAttribute("$data-bs-theme") && !htmlNode.hasAttribute("data-bs-theme")) {
htmlNode.setAttribute("$data-bs-theme", "LocalStorage.darkMode?'dark':'light'");
}
new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((newNode) => {
if (newNode.isConnected) _scanTree(newNode);
});
mutation.removedNodes.forEach((oldNode) => _unbindTree(oldNode));
});
}).observe(document.documentElement, { childList: true, subtree: true });
_scanTree(document.documentElement);
};
if (document.readyState !== "loading") init();
else document.addEventListener("DOMContentLoaded", init, true);
}
const __unsafeRefreshState = _scanTree;
exports2.$ = $;
exports2.$$ = $$;
exports2.Component = Component;
exports2.Hash = Hash;
exports2.LocalStorage = LocalStorage;
exports2.NewState = NewState;
exports2.SetTranslator = SetTranslator;
exports2.State = State;
exports2.Util = Util;
exports2.__unsafeRefreshState = __unsafeRefreshState;
Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
});

View File

@ -1,13 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<script src="lib/state.js"></script>
<script src="lib/base.js"></script>
<script src="lib/datatable.js"></script>
<script>
window.__DT_PERF_MODE__ = true;
</script>
<script type="module">
import { Component, RefreshState } from '@web/state';
import '../src/index.js';
<script>
window.startTest = async () => {
const fields = Array.from({length: 10}, (_, i) => ({
id: 'col' + i,
@ -26,7 +26,8 @@
div.innerHTML = '<DataTable id="dt" style="display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden"></DataTable>';
document.body.appendChild(div);
RefreshState(document.body);
// Wait for MutationObserver to pick up the new element
await new Promise(r => setTimeout(r, 0));
const dt = document.getElementById('dt');
dt.state.fields = fields;

View File

@ -18,20 +18,31 @@ export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.js'),
name: 'DataTable',
formats: ['es']
name: 'ApigoDataTable',
formats: ['umd']
},
rollupOptions: {
external: ['@web/state', '@web/base'],
output: [
{
format: 'es',
format: 'umd',
name: 'ApigoDataTable',
entryFileNames: 'datatable.js',
minifyInternalExports: false
extend: true,
globals: {
'@web/state': 'ApigoState',
'@web/base': 'ApigoBase'
}
},
{
format: 'es',
format: 'umd',
name: 'ApigoDataTable',
entryFileNames: 'datatable.min.js',
extend: true,
globals: {
'@web/state': 'ApigoState',
'@web/base': 'ApigoBase'
},
plugins: [terser()]
}
]