import '../css/grid.scss'; import { global, isPositive, isMobile, throttle, debounce, truncate } from "../../utility"; import { r as lang } from "../../utility/lgres"; import { nullOrEmpty } from "../../utility/strings"; import { createElement } from "../../functions"; import { createIcon } from "../icon"; import { createCheckbox } from "../checkbox"; import { setTooltip } from "../tooltip"; import { Popup, showAlert, showConfirm } from "../popup"; import { convertCssStyle } from "../extension"; import { GridColumn, GridInputColumn, GridTextColumn, GridDropdownColumn, GridCheckboxColumn, GridRadioboxColumn, GridIconColumn, GridDateColumn } from "./column"; import { requestAnimationFrame } from '../../ui'; /** * @author Tsanie Lily * @license MIT * @version 1.0.7 */ const ScriptPath = (self.document == null ? self.location.href : self.document.currentScript?.src ?? '').replace(/ui\.min\.js\?.+$/, ''); const Encoder = new TextEncoder('utf-8'); const ColumnChangedType = { Reorder: 'reorder', Resize: 'resize', Sort: 'sort' }; const RefreshInterval = isMobile() ? 32 : 10; const HoverInternal = 200; const RedumCount = 4; const MiniDragOffset = 10; const MiniColumnWidth = 50; const FilterPanelWidth = 200; const ExpandableWidth = 24; /** * @private * @param {Event} e * @returns {number} */ function getClientX(e) { if (e == null) { return null; } const cx = e.touches && e.touches[0]?.clientX; return cx ?? e.clientX; } /** * @private * @param {HTMLElement} target * @returns {number} */ function indexOfParent(target) { // return [...target.parentElement.children].indexOf(target); return Array.prototype.indexOf.call(target.parentElement.children, target); } const ColumnTypeDefs = { 0: GridColumn, 1: GridInputColumn, 2: GridDropdownColumn, 3: GridCheckboxColumn, 4: GridIconColumn, 5: GridTextColumn, 6: GridDateColumn, 7: GridRadioboxColumn }; let r = lang; /** * 键值字典 * @template T * @typedef {{[key: string]: T}} KeyMap */ /** * 索引字典 * @template T * @typedef {{[index: number]: T}} IndexMap */ /** * 数据项 * @typedef ValueItem * @property {any} Value - 值 * @property {string} DisplayValue - 显示值 * @property {boolean} [__checked] - 已选中 * @interface */ /** * 行数据对象 * @typedef GridRowItem * @type {KeyMap} * @property {(ValueItem | any)} {key} - 数据项 * @extends {KeyMap} * @interface */ /** * 行数据包装接口 * @typedef GridItemWrapper * @property {GridRowItem} values - 真实数据对象 * @property {KeyMap} source - 下拉数据源缓存对象 * @property {number} __index - 行索引 * @property {number} __offset - 批量删除时暂存的索引偏移量 * @property {KeyMap} __editing - 正在编辑的列的原始值字典 * @property {boolean} __changed - 行数据是否发生改变 * @property {boolean} __expanded - 行是否已展开 * @property {GridExpandableObject} __expandable_object - 行扩展对象 * @interface */ /** * 下拉框数据源接口 * @typedef GridSourceItem * @property {string} value - 值 * @property {string} text - 显示文本 * @interface */ /** * 行数据可用性回调函数 * @callback GridItemBooleanCallback * @param {GridRowItem} item - 行数据对象 * @returns {boolean} 返回是否可用 * @this GridColumnDefinition */ /** * 行数据过滤回调函数 * @callback GridItemFilterCallback * @param {GridRowItem} item - 行数据对象 * @param {boolean} editing - 是否处于编辑状态 * @param {HTMLElement} [body] - Grid 控件的 `` 部分 * @param {number} [index] - 所在行索引(不可依赖此值,除了呈现时,其他时候该值不会传递) * @returns {ValueItem} - 返回过滤后的显示或编辑值 * @this GridColumnDefinition */ /** * 行数据处理回调函数 * @callback GridItemObjectCallback * @param {GridRowItem} item - 行数据对象 * @returns {any} 返回任意对象 * @this GridColumnDefinition */ /** * 行数据过滤项模板回调函数 * @callback GridItemHtmlCallback * @param {ValueItem} item - 行数据对象 * @returns {HTMLElement} 返回过滤项元素对象 * @this GridColumnDefinition */ /** * 行数据字符串回调函数 * @callback GridItemStringCallback * @param {GridRowItem} item - 行数据对象 * @returns {string} 返回字符串 * @this GridColumnDefinition */ /** * 列过滤器数据源回调函数 * @callback GridColumnFilterSourceCallback * @param {GridColumnDefinition} col - 列定义对象 * @returns {ValueItem[]} 返回过滤器的数据数组 * @this Grid */ /** * 行数据排序回调函数 * @callback GridItemSortCallback * @param {GridRowItem} a - 对比行数据1 * @param {GridRowItem} b - 对比行数据2 * @returns {number} 返回大小对比结果 */ /** * 下拉列表数据源回调函数 * @callback GridDropdownSourceCallback * @param {GridRowItem} item - 行数据对象 * @returns {GridSourceItem[]} 行下拉列表数据源 */ /** * 下拉列表参数对象 * @typedef DropdownOptions * @property {string} [textKey=text] - 文本关键字 * @property {string} [valueKey=value] - 值关键字 * @property {string} [htmlKey=html] - 源码显示的关键字 * @property {number} [maxLength=500] - 最大输入长度 * @property {boolean} [multiSelect] - 是否允许多选 * @property {string} [selected] - 选中值 * @property {string[]} [selectedList] - 选中的数组 * @property {boolean} [disabled] - 是否禁用 * @property {boolean} [input] - 是否支持输入 * @property {boolean} [search] - 是否支持搜索 * @property {string[]} [searchKeys] - 搜索的关键字数组 * @property {string} [searchPlaceholder] - 搜索提示文本,默认值取语言资源 `searchHolder` "Search..." * @property {number} [tabIndex] - 焦点索引 * @property {string} [placeholder] - 输入框的提示文本 * @property {boolean} [slideFixed] - 是否固定为向下展开 * @property {HTMLElement} [wrapper] - 父元素,默认添加到头元素之后 * @interface */ /** * 自定义日期格式化回调函数 * @callback DateFormatterCallback * @param {Date} date - 日期值 * @returns {any} 返回格式化后的结果 */ /** * 列定义接口 * * Column Types
* 代码参考页面下方的示例 * @typedef GridColumnDefinition * @property {string} key - 列关键字,默认以该关键字从行数据中提取单元格值,行数据的关键字属性值里包含 DisplayValue 则优先显示此值 * @property {(GridColumnTypeEnum | GridColumn)} [type=Grid.ColumnTypes.Common] - 列的类型,可以为 {@linkcode GridColumn} 的子类,或者如下内置类型 {@linkcode Grid.ColumnTypes} * * Grid.ColumnTypes.Common - 0: 通用列(只读) * * Grid.ColumnTypes.Input - 1: 单行文本列 * * Grid.ColumnTypes.Dropdown - 2: 下拉选择列 * * Grid.ColumnTypes.Checkbox - 3: 复选框列 * * Grid.ColumnTypes.Icon - 4: 图标列 * * Grid.ColumnTypes.Text - 5: 多行文本列 * * Grid.ColumnTypes.Date - 6: 日期选择列 * * Grid.ColumnTypes.Radio - 7: 单选框列 * @property {string} [caption] - 列标题文本 * @property {any} [captionStyle] - 列标题的元素样式 * @property {string} [captionTooltip] - 列标题的帮助文本 * @property {number} [width] - 大于 0 则设置为该宽度,否则根据列内容自动调整列宽 * @property {("left" |"center" | "right")} [align=left] 列对齐方式 * @property {(boolean | string | GridItemBooleanCallback)} [enabled] - 列是否可用(可编辑),允许以下类型 * * * `boolean` 则直接使用该值 * * `string` 则以该值为关键字从行数据中取值作为判断条件 * * `GridItemBooleanCallback` 则调用回调,以返回值作为判断条件 * @property {GridItemFilterCallback} [filter] - 单元格取值采用该函数返回的值 * @property {string} [text] - 单元格以该值填充内容,忽略filter与关键字属性 * @property {boolean} [visible=true] - 列是否可见 * @property {boolean} [resizable=true] - 列是否允许调整宽度 * @property {boolean} [sortable=true] - 列是否允许排序 * @property {boolean} [orderable=true] - 列是否允许重排顺序 * @property {boolean} [allcheck=false] - 列为复选框类型时是否在列头增加全选复选框 * @property {boolean} [shrink=false] - 列为收缩列,禁用自动调整大小 * @property {string} [class] - 单元格元素的额外样式类型字符串(仅在重建行元素时设置) * @property {boolean} [contentWrap=false] - 单元格文本是否换行(仅在重建行元素时设置) * @property {number} [maxLines=0] - 大于 0 时限制显示最大行数 * @property {any} [css] - 单元格css样式对象(仅在重建行元素时读取) * @property {any} [totalCss] - 合计行样式(仅在重建合计行元素时读取) * @property {(any | GridItemObjectCallback)} [style] - 单元格样式(填充行列数据时读取),支持直接返回样式对象或调用函数返回(若赋值则忽略 [styleFilter]{@linkcode GridColumnDefinition#styleFilter}) * @property {GridItemObjectCallback} [styleFilter] - **已过时**
_根据返回值填充单元格样式(填充行列数据时读取)_ * @property {(string | GridItemStringCallback)} [background] - 设置单元格背景色(填充行列数据时读取),支持直接设置颜色字符串或调用函数返回(若赋值则忽略 [bgFilter]{@linkcode GridColumnDefinition#bgFilter}) * @property {GridItemStringCallback} [bgFilter] - **已过时**
_根据返回值设置单元格背景色_ * @property {boolean} [switch=false] - 复选框为 `ui-switch` 样式 *@since* 1.0.6 * @property {(any | GridItemObjectCallback)} [attrs] - 根据返回值设置单元格元素的附加属性,允许直接设置对象也支持调用函数返回对象 * @property {KeyMap} [events] - 给单元格元素附加事件(事件函数上下文为数据行对象) * @property {boolean} [allowFilter=false] - 是否允许进行列头过滤 * @property {any[]} [filterValues] - 过滤值的数组 * @property {boolean} [filterAllowNull=false] - 是否区分 `null` 与空字符串 * @property {(ValueItem[] | GridColumnFilterSourceCallback)} [filterSource] - 自定义列过滤器的数据源,支持调用函数返回数据源 * @property {boolean} [filterAsValue=false] - 列头过滤强制使用 `Value` 字段 * @property {GridItemHtmlCallback} [filterTemplate] - 列头过滤项的模板函数 * @property {GridItemSortCallback} [sortFilter] - 自定义列排序函数 * @property {boolean} [sortAsText=false] - 按照 `DisplayValue` 排序 * @property {DropdownOptions} [dropOptions] - 列为下拉列表类型时以该值设置下拉框的参数 * @property {boolean} [dropRestrictCase=false] - 下拉列表是否区分大小写 * @property {(GridSourceItem[] | Promise | GridDropdownSourceCallback)} [source] - 列为下拉列表类型时以该值设置下拉列表数据源,支持返回异步对象,也支持调用函数返回 * @property {boolean} [sourceCache=false] - 下拉列表数据源是否缓存结果(即行数据未发生变化时仅从source属性获取一次值) * @property {("fa-light" | "fa-regular" | "fa-solid")} [iconType=fa-light] - 列为图标类型时以该值设置图标样式 * @property {string} [dateMin] - 列为日期类型时以该值作为最小可选日期值 * @property {string} [dateMax] - 列为日期类型时以该值作为最大可选日期值 * @property {string} [dateDisplayFormatter] - 列为日期类型时日期显示的格式化字符串 * @property {(string | DateFormatterCallback)} [dateValueFormatter] - 列为日期类型时自定义日期格式化字符串或函数 * @property {(string | GridItemStringCallback)} [tooltip] - 额外设置单元格的 tooltip,支持直接使用字符串或者使用函数返回的字符串 * @property {Function} [onAllChecked] - 列头复选框改变时触发事件 * @property {Function} [onChanged] - 单元格变化时触发事件 * @property {Function} [onFilterOk] - 列过滤点击 `OK` 时触发的事件 * @property {Function} [onFiltered] - 列过滤后触发的事件 * @property {Function} [onDropExpanded] - 列为下拉框类型时在下拉列表展开时触发的事件 * @interface * @example * [ * { * key: 'name', * // type: Grid.ColumnTypes.Common, * caption: 'Name', * captionStyle: { * 'font-style': 'italic' * }, * width: 150, * allowFilter: true * }, * { * key: 'birthday', * type: Grid.ColumnTypes.Date, * caption: 'Birthday', * width: 120, * dateMin: '1900-01-01', * dateMax: '2025-01-01', * dateValueFormatter: toDateValue * }, * { * key: 'age', * type: Grid.ColumnTypes.Input, * caption: 'Age', * enabled: false, * align: 'right', * filter: item => { * const ms = new Date() - new Date(item.birthday); * const age = Math.floor(ms / 1000 / 60 / 60 / 24 / 365); * return String(age); * } * }, * { * key: 'sex', * type: Grid.ColumnTypes.Dropdown, * caption: 'Sex', * source: [ * { value: 'male', text: 'Male' }, * { value: 'female', text: 'Female' }, * { value: 'other', text: 'Other' } * ] * }, * { * key: 'active', * type: Grid.ColumnTypes.Checkbox, * caption: 'Active' * }, * { * key: 'remove', * type: Grid.ColumnTypes.Icon, * text: 'times', * resizable: false, * sortable: false, * orderable: false, * tooltip: 'Remove', * events: { * onclick: () => { * showConfirm('Remove', 'Are you sure you want to remove this person?', [ * { * key: 'yes', * text: 'Yes', * trigger: () => { * console.log('yes'); * return true; * } * }, * { * key: 'no', * text: 'No' * } * ], 'question') * } * } * } * ] */ /** * 列头复选框改变时触发的事件 * @name onAllChecked * @event * @param {GridColumnDefinition} col - 列定义对象 * @param {boolean} flag - 是否选中 * @this Grid * @memberof GridColumnDefinition */ /** * 单元格发生变化时触发的事件 * @name onChanged * @event * @param {GridRowItem} item - 行数据对象 * @param {(boolean | string | number)} value - 修改后的值 * @param {(boolean | string | number)} oldValue - 修改前的值 * @param {any} [e] - 列修改事件传递过来的任意对象 * @param {GridExpandableObject} [expandableObject] - 列展开元素对象 * @this Grid * @memberof GridColumnDefinition */ /** * 列过滤点击 `OK` 时触发的事件 * @name onFilterOk * @event * @param {GridColumnDefinition} col - 列定义对象 * @param {ValueItem[]} selected - 选中的过滤项 * @this Grid * @memberof GridColumnDefinition */ /** * 列过滤后触发的事件 * @name onFiltered * @event * @param {GridColumnDefinition} col - 列定义对象 * @this Grid * @memberof GridColumnDefinition */ /** * 列为下拉框类型时在下拉列表展开时触发的事件 * @name onDropExpanded * @event * @param {GridRowItem} item - 行数据对象 * @param {Dropdown} drop - 下拉框对象 * @this GridColumnDefinition * @memberof GridColumnDefinition */ /** * 判断列是否始终编辑的回调函数 * @callback ColumnTypesEnumIsAlwaysEditing * @param {number | GridColumn} type - 列类型 * @returns {boolean} 返回是否始终编辑 */ /** * 列类型枚举 * @enum {number} */ const GridColumnTypeEnum = { /** 0 - 通用列(只读) */ Common: 0, /** 1 - 单行文本列 */ Input: 1, /** 2 - 下拉选择列 */ Dropdown: 2, /** 3 - 复选框列 */ Checkbox: 3, /** 4 - 图标列 */ Icon: 4, /** 5 - 多行文本列 */ Text: 5, /** 6 - 日期选择列 */ Date: 6, /** 7 - 单选框列 */ Radio: 7, /** * 判断列是否为复选框列 * @type {ColumnTypesEnumIsAlwaysEditing} */ isAlwaysEditing(type) { return type?.headerEditing || type === 3 || type === 7; } }; /** * 列排序枚举 * @enum {number} */ const GridColumnDirection = { /** -1 - 倒序 */ Descending: -1, /** 1 - 升序 */ Ascending: 1 }; /** * 多语言资源接口 * @typedef GridLanguages * @property {string} [all] - ( All ) * @property {string} [ok] - OK * @property {string} [yes] - Yes * @property {string} [reset] - Reset * @property {string} [cancel] - Cancel * @property {string} [null] - ( Null ) * @property {string} [addLevel] - Add Level * @property {string} [deleteLevel] - Delete Level * @property {string} [copyLevel] - Copy Level * @property {string} [sortBy] - Sort by * @property {string} [thenBy] - Then by * @property {string} [updateLayout] - Update Layout * @property {string} [asc] - Ascending * @property {string} [desc] - Descending * @property {string} [column] - Column * @property {string} [order] - Order * @property {string} [sort] - Sort * @property {string} [sortArrayExists] - This will remove the current tiered sort. Do you wish to continue? * @property {string} [requirePrompt] - All sort criteria must have a column specified. Check the selected sort criteria and try again. * @property {string} [duplicatePrompt] - {column} is being sorted more than once. Delete the duplicate sort criteria and try again. * @interface */ /** * Grid 数据导出对象 * @typedef GridExportData * @property {Uint8Array} data - 导出数据 * @property {string} [type] - 导出类型,`"compressed"` 意为压缩数据 * @property {string} [error] - 压缩中的异常信息提示 * @interface */ /** * 列排序定义接口 * @typedef GridColumnSortDefinition * @property {string} column - 排序列的关键字 * @property {("asc" | "desc")} order - 升序或降序 * @interface */ /** * 扩展行对象接口 * @typedef GridExpandableObject * @property {HTMLElement} element - 扩展行元素 * @interface */ /** * 扩展行生成回调函数 * @callback GridExpandableObjectCallback * @param {GridRowItem} item - 行数据对象 * @returns {GridExpandableObject} 返回扩展行对象 * @this Grid */ /** * @typedef GridVirtualRow * @property {boolean} editing - 行处于编辑状态 * @property {KeyMap} cells - 虚拟单元格数组 * @private */ /** * @typedef GridVirtualCell * @property {string} background - 单元格背景色 * @property {string} value - 单元格值 * @property {string} tooltip - 单元格提示文本 * @property {boolean} enabled - 单元格是否可用 * @property {string} style - 单元格样式字符串 * @property {string} attrs - 单元格附加属性字符串 * @private */ /** * @typedef GridColumnAttr * @property {boolean} dragging - 列正在拖拽 * @property {number} offset - 列拖拽偏移 * @property {Function} mousemove - 拖拽或调整大小移动回调函数 * @property {Function} mouseup - 拖拽或调整大小鼠标释放回调函数 * @property {number} resizing - 列临时大小 * @property {boolean} sizing - 列已进入修改大小的状态 * @property {boolean} autoResize - 列需要自动调整大小 * @property {any} style - 列样式对象 * @property {ValueItem[]} filterSource - 列过滤面板数据源 * @property {number} filterHeight - 列过滤面板高度 * @property {number} filterTop - 列过滤面板滚动头部间距 * @private */ /** * Grid 控件基础类 * * Grid Sample
* 函数调用流程图
* Grid * @class * @example 基础示例 *
* @example * #grid>.ui-grid { * width: 600px; * height: 400px; * } * @example * const grid = new Grid('#grid', GetTextByKey); * grid.columns = [ * { * key: 'name', * caption: 'Name', * width: 140, * allowFilter: true * }, * { * key: 'age', * caption: 'Age', * type: Grid.ColumnTypes.Input, * width: 80 * }, * { * key: 'study', * caption: 'Study', * type: Grid.ColumnTypes.Dropdown, * width: 120, * source: [ * { value: 'a', text: 'A' }, * { value: 'b', text: 'B' }, * { value: 'c', text: 'C' } * ] * } * ]; * grid.multiSelect = true; * grid.init(); * grid.source = [ * { name: '张三', age: '19', study: 'a' }, * { name: '李四', age: '24', study: 'a' }, * { name: '王五', age: '20', study: 'c' } * ]; */ export class Grid { /** * 内部引用变量 * @private */ _var = { /** * 父容器元素 * @type {HTMLElement} * @private */ parent: null, /** * Grid 元素 - `div.ui-grid` * @type {HTMLDivElement} * @private */ el: null, /** * 全部数据数组 * @type {GridItemWrapper[]} * @private */ source: null, /** * 当前已过滤显示的数据数组 * @type {GridItemWrapper[]} * @private */ currentSource: null, /** * 当前是否已全部展开 * @type {boolean} * @private */ expanded: false, /** * 列可用性的关联字典 * @type {KeyMap} * @private */ enabledDict: null, /** * 合计行数据 * @type {GridRowItem} * @private */ total: null, /** * Grid 是否只读 * @type {boolean} * @private */ readonly: false, /** * 当前选中的列索引 * @type {number} * @private */ selectedColumnIndex: -1, /** * 当前选中的行索引数组 * @type {number[]} * @private */ selectedIndexes: null, /** * 虚模式头部索引 * @type {number} * @private */ startIndex: 0, /** * 当前滚动上边距 * @type {number} * @private */ scrollTop: 0, /** * 当前滚动左边距 * @type {number} * @private */ scrollLeft: 0, /** * 浏览器是否为 Firefox * @type {boolean} * @private */ isFirefox: false, /** * 一页高度可显示的行数 * @type {number} * @private */ rowCount: -1, /** * 虚拟单元格字典 * @type {IndexMap} * @private */ virtualRows: {}, /** * 列类型缓存字典 * @type {KeyMap} * @private */ colTypes: {}, /** * 列属性字典 * @type {KeyMap} * @private */ colAttrs: { /** * 有已过滤的列 * @type {boolean} * @private */ __filtered: false, /** * 过滤面板已打开 * @type {boolean} * @private */ __filtering: false, /** * 上一个目标排序列索引 * @type {number} * @private */ __orderIndex: -1, }, /** * 是否处于渲染中 * @type {boolean} * @private */ rendering: false, /** * 头部高度 * @type {number} * @private */ headerHeight: null, /** * 正文高度 * @type {number} * @private */ containerHeight: null, /** * 合计行高度 * @type {number} * @private */ footerHeight: null, /** * 合计行底边距偏移量 * @type {number} * @private */ footerOffset: null, /** * 容器宽度 * @type {number} * @private */ wrapClientWidth: null, /** * 正文宽度 * @type {number} * @private */ bodyClientWidth: null, /** * 是否需要 resize * @type {boolean} * @private */ needResize: null, /** * 提示条消失的 Timer * @type {number} * @private */ tooltipTimer: null, /** * 页面元素引用 * @private */ refs: { /** * 表格引用 - table.ui-grid-table * @type {HTMLTableElement} * @private */ table: null, /** * 表格正文引用 - tbody * @type {HTMLTableSectionElement} * @private */ body: null, /** * 表格头部引用 - thead>tr * @type {HTMLTableSectionElement} * @private */ header: null, /** * 表格合计行引用 - tfooter>tr * @type {HTMLTableSectionElement} * @private */ footer: null, /** * 加载状态元素引用 - div.ui-grid-loading * @type {HTMLDivElement} * @private */ loading: null, /** * 大小计算元素引用 - span.ui-grid-sizer * @type {HTMLSpanElement} * @private */ sizer: null, /** * 包装元素引用 - div.ui-grid-wrapper * @type {HTMLDivElement} * @private */ wrapper: null, /** * 拖拽块引用 - div.dragger * @type {HTMLDivElement} * @private */ dragger: null, /** * 拖拽光标引用 - layer.dragger-cursor * @type {HTMLElement} * @private */ draggerCursor: null, } }; /** * 列定义的数组 * @type {GridColumnDefinition[]} * @ignore */ columns = []; /** * 多语言资源对象 * @type {GridLanguages} * @ignore */ langs = {}; /** * 行数大于等于该值则启用虚模式 * @type {number} * @default 100 * @ignore */ virtualCount = 100; /** * 未设置宽度的列自动调整列宽 * @type {boolean} * @default true * @ignore */ autoResize = true; /** * 表格行高 * @type {number} * @default 36 * @ignore */ rowHeight = 36; /** * 文本行高(多行文本列计算高度时使用) * @type {number} * @default 18 * @ignore */ lineHeight = 18; /** * 列头未过滤时的图标 * @type {string} * @default "ellipsis-h" * @ignore */ filterIcon = 'ellipsis-h'; /** * 列头已过滤时的图标 * @type {string} * @default "filter" * @ignore */ filteredIcon = 'filter'; /** * 列表底部留出额外的空白行 * @type {number} * @default 0 * @ignore */ extraRows = 0; /** * 过滤条件列表的行高 * @type {number} * @default 30 * @ignore */ filterRowHeight = 30; /** * 列表高度值,为 0 时列表始终显示全部内容(自增高),为非数字或者小于 0 则根据容器高度来确定虚模式的渲染行数 * @type {number | null} * @ignore */ height; /** * 是否允许多选 * @type {boolean} * @default false * @ignore */ multiSelect = false; /** * 为 `false` 时只有点击在单元格内才会选中行 * @type {boolean} * @default true * @ignore */ fullrowClick = true; /** * 单元格 tooltip 是否禁用 * @type {boolean} * @default false * @ignore */ tooltipDisabled = false; /** * 列头是否显示 * @type {boolean} * @default true * @ignore */ headerVisible = true; /** * 列头是否允许换行 * @type {boolean} * @default true * @ignore */ headerWrap = true; /** * 是否允许行间拖拽 * @type {boolean} * @default false * @ignore */ rowDraggable = false; /** * 监听事件的窗口载体 * @type {(Window | HTMLElement)} * @default window * @ignore */ window; /** * 排序列的索引 * @type {number} * @default -1 * @ignore */ sortIndex = -1; /** * 排序方式,正数升序,负数倒序 * @type {GridColumnDirection} * @default GridColumnDirection.Ascending * @ignore */ sortDirection = GridColumnDirection.Ascending; /** * 排序列数组 * @type {GridColumnSortDefinition[]} * @default null * @ignore */ sortArray = null; /** * 是否支持点击扩展 * @type {boolean} * @default false * @ignore */ expandable; /** * 扩展行生成器 * @type {GridExpandableObjectCallback} * @ignore */ expandableGenerator; /** * 即将选中行时触发 * @event * @param {number} index - 即将选中的行索引 * @param {number} colIndex - 即将选中的列索引 * @returns {boolean} 返回 `false`、`null`、`undefined`、`0` 等则取消选中动作 * @this Grid */ willSelect; /** * 单元格单击时触发,colIndex 为 -1 则表示点击的是行的空白处 * @event * @param {number} index - 点击的行索引 * @param {number} colIndex - 点击的列索引 * @returns {boolean} 返回 false 则取消事件冒泡 * @this Grid */ cellClicked; /** * 选中行发生变化时触发的事件 * @event * @param {number} index - 选中的行索引 * @this Grid */ onSelectedRowChanged; /** * 单元格双击时触发的事件,colIndex 为 -1 则表示点击的是行的空白处 * @event * @param {number} index - 双击的行索引 * @param {number} colIndex - 双击的列索引 * @this Grid */ onCellDblClicked; /** * 行双击时触发的事件 * @event * @param {number} index - 双击的行索引 * @this Grid */ onRowDblClicked; /** * 列发生变化时触发的事件 * @event * @param {("reorder" | "resize" | "sort")} type - 事件类型 * * * "reorder" 为发生列重排事件,此时 value 为目标列索引 * * "resize" 为发生列宽调整事件,此时 value 为列宽度值 * * "sort" 为发生列排序事件,此时 value 为 1(升序)或 -1(倒序) * @param {number} colIndex - 发生变化事件的列索引 * @param {number | GridColumnDirection} value - 变化的值 * @this Grid */ onColumnChanged; /** * 列滚动时触发的事件 * @event * @param {Event} [e] - 滚动事件对象 * @param {number} index - 起始行 * @param {number} count - 显示行数 * @this Grid */ onBodyScrolled; /** * 扩展行展开时触发的事件 * @event * @param {GridRowItem} item - 行数据对象 * @param {GridExpandableObject} expandableObject - 由 [expandableGenerator]{@linkcode Grid#expandableGenerator} 产生的行扩展对象 * @param {HTMLElement} expandableObject.element - 扩展行元素 * @this Grid */ onRowExpanded; /** * 扩展行收缩时触发的事件 * @event * @param {GridRowItem} item - 行数据对象 * @param {GridExpandableObject} expandableObject - 由 [expandableGenerator]{@linkcode Grid#expandableGenerator} 产生的行扩展对象 * @param {HTMLElement} expandableObject.element - 扩展行元素 * @this Grid */ onRowCollapsed; /** * 行发生变化时触发的事件 * @event * @param {("update" | "add" | "remove" | "drag")} action - 变动类型 * @param {GridRowItem[]} items - 发生变动的行对象 * @param {(number | number[])} indexes - 变动的索引集合 * @this Grid * @since 1.0.3 */ onRowChanged; /** * 列类型枚举 * @readonly * @type {GridColumnTypeEnum} * @property {number} Common=0 - 通用列(只读) * @property {number} Input=1 - 单行文本列 * @property {number} Dropdown=2 - 下拉选择列 * @property {number} Checkbox=3 - 复选框列 * @property {number} Icon=4 - 图标列 * @property {number} Text=5 - 多行文本列 * @property {number} Date=6 - 日期选择列 * @property {number} Radio=7 - 单选框列 */ static get ColumnTypes() { return GridColumnTypeEnum } /** * Grid 控件构造函数 * @param {(string | HTMLElement)?} container Grid 控件所在的父容器,可以是 string 表示选择器,也可以是 HTMLElement 对象
_**构造时可以不进行赋值,但是调用 init 函数时则必须进行赋值**_ * @param {Function} [getText] 获取多语言文本的函数代理 * @param {string} getText.id - 资源 ID * @param {string} [getText.def] - 默认资源 * @param {string} getText.{returns} 返回的多语言 * @property {GridColumnDefinition[]} columns - 列定义的数组 * @property {GridLanguages} [langs] - 多语言资源对象 * @property {number} [virtualCount=100] - 行数大于等于该值则启用虚模式 * @property {boolean} [autoResize=true] - 未设置宽度的列自动调整列宽 * @property {number} [rowHeight=36] - 表格行高,修改后同时需要在 `.ui-grid` 所在父容器重写 `--line-height` 的值以配合显示 * @property {number} [lineHeight=18] - 文本行高(多行文本列计算高度时使用) * @property {string} [filterIcon=ellipsis-h] - 列头未过滤时的图标 * @property {string} [filteredIcon=filter] - 列头已过滤时的图标 * @property {number} [extraRows=0] - 列表底部留出额外的空白行 * @property {number} [filterRowHeight=30] - 过滤条件列表的行高 * @property {number} [height] - 列表高度值,为 0 时列表始终显示全部内容(自增高),为非数字或者小于 0 则根据容器高度来确定虚模式的渲染行数 * @property {boolean} [multiSelect=false] - 是否允许多选 * @property {boolean} [fullrowClick=true] - 为 `false` 时只有点击在单元格内才会选中行 * @property {boolean} [tooltipDisabled=false] - 单元格 tooltip 是否禁用 * @property {boolean} [headerVisible=true] - 列头是否显示 * @property {boolean} [headerWrap=true] - 列头是否允许换行 * @property {boolean} [rowDraggable=false] - 是否允许行间拖拽 *since* 1.0.3 * @property {(Window | HTMLElement)} [window=global] - 监听事件的窗口载体 * @property {number} [sortIndex=-1] - 排序列的索引 * @property {GridColumnDirection} [sortDirection=GridColumnDirection.Ascending] - 排序方式,正数升序,负数倒序 * @property {GridColumnSortDefinition[]} [sortArray] - 排序列数组 * @property {boolean} [expandable=false] - 是否支持点击扩展 * @property {GridExpandableObjectCallback} [expandableGenerator] - 扩展行生成器 */ constructor(container, getText) { this._var.parent = typeof container === 'string' ? document.querySelector(container) : container; if (typeof getText === 'function') { r = getText; } } /** * 获取 Grid 的页面元素 * @readonly * @type {HTMLDivElement} */ get element() { return this._var.el } /** * 获取当前 Grid 是否已发生改变 * @readonly * @type {boolean} */ get changed() { const source = this._var.source; if (source == null) { return false; } return source.find(r => r.__changed) != null; } /** * 返回所有数据的数据(未过滤) * @readonly * @type {GridRowItem[]} */ get allSource() { return this._var.source?.map(s => s.values) } /** * 获取已过滤的数据中的扩展对象数组 * @readonly * @type {GridExpandableObject[]} * @property {HTMLElement} element - 扩展行元素 */ get sourceExpandable() { return this._var.currentSource?.map(s => s.__expandable_object) } /** * 获取当前是否为虚模式状态 * @readonly * @type {boolean} */ get virtual() { return this._var.currentSource?.length > this.virtualCount } /** * 获取当前排序的列关键字,为 null 则当前无排序列 * @readonly * @type {string | null} */ get sortKey() { if (this.columns == null) { return null; } return this.columns[this.sortIndex]?.key; } /** * 获取当前选中的行对象 * @readonly * @type {GridRowItem | null} * @since 1.0.7 */ get currentItem() { return this.source[this.selectedIndex]; } /** * @private * @type {HTMLTableRowElement[]} */ get _tableRows() { return Array.from(this._var.refs.body.children).filter(r => r.classList.contains('ui-grid-row')); } /** * @private * @type {HTMLTableCellElement[]} */ get _headerCells() { return Array.from(this._var.refs.header.children).filter(h => h.tagName === 'TH' && h.classList.contains('column')); } /** * @private * @type {HTMLTableCellElement[]} */ get _footerCells() { return Array.from(this._var.refs.footer.children).filter(f => f.classList.contains('ui-grid-cell')); } /** * 获取虚模式起始索引 * @readonly * @type {number} */ get startIndex() { return this._var.startIndex } /** * 获取当前选中行的索引,为 -1 则当前没有选中行 * @readonly * @type {number} */ get selectedIndex() { return (this._var.selectedIndexes && this._var.selectedIndexes[0]) ?? -1 } /** * 获取或设置 Grid 是否为只读 * @type {boolean} */ get readonly() { return this._var.readonly === true } set readonly(flag) { this._var.readonly = flag; this.refresh(); } /** * 获取已过滤的数据数组,或者设置数据并刷新列表 * @type {GridRowItem[]} */ get source() { return this._var.currentSource?.map(s => s.values) ?? [] } set source(list) { if (!Array.isArray(list)) { throw new Error('source is not an Array.') } list = list.map((it, index) => { return { __index: index, values: it }; }); this._var.source = list; this._var.scrollLeft = 0; if (this._var.el != null) { this._var.el.scrollLeft = 0; } this._refreshSource(list); } /** * 获取或设置合计行数据 * @type {GridRowItem} * @since 1.0.1 */ get total() { return this._var.total } set total(total) { this._var.total = total; this.reload(true); } /** * 获取或设置当前选中的行索引的数组,设置后会刷新列表 * @type {number[]} */ get selectedIndexes() { return this._var.selectedIndexes } set selectedIndexes(indexes) { const startIndex = this._var.startIndex; this._var.selectedIndexes.splice(0, this._var.selectedIndexes.length, ...indexes); if (this.readonly) { this._tableRows.forEach((row, i) => { if (indexes.includes(startIndex + i)) { row.classList.add('selected'); } else if (row.classList.contains('selected')) { row.classList.remove('selected'); } }); } else { this.refresh(); } if (typeof this.onSelectedRowChanged === 'function') { this.onSelectedRowChanged(); } } /** * 获取或设置 Grid 当前的加载状态 * @type {boolean} */ get loading() { return this._var.refs.loading?.style?.visibility === 'visible' } set loading(flag) { if (this._var.refs.loading == null) { return; } if (flag === false) { this._var.refs.loading.style.visibility = 'hidden'; this._var.refs.loading.style.opacity = 0; } else { this._var.refs.loading.style.visibility = 'visible'; this._var.refs.loading.style.opacity = 1; } } /** * 获取或设置 Grid 当前滚动的偏移量 * @type {number} */ get scrollTop() { return this._var.el?.scrollTop; } set scrollTop(top) { if (this._var.el == null) { return; } this._var.el.scrollTop = top; this.reload(true); } /** * 初始化Grid控件 * @param {HTMLElement} [container=.ctor#container] - 父容器元素,若未传值则采用构造方法中传入的父容器元素 */ init(container = this._var.parent) { if (container == null) { throw new Error('no specified parent.'); } if (!(container instanceof HTMLElement)) { const ele = container[0]; if (!(ele instanceof HTMLElement)) { throw new Error(`parent type not supported. ${JSON.stringify(Object.getPrototypeOf(container))}`); } container = ele; } if (!Array.isArray(this.columns)) { throw new Error('no specified column definitions.'); } if (Object.keys(this.langs).length === 0) { this.langs = { all: r('allItem', '( All )'), ok: r('ok', 'OK'), yes: r('yes', 'Yes'), reset: r('reset', 'Reset'), cancel: r('cancel', 'Cancel'), null: r('null', '( Null )'), addLevel: r('addLevel', 'Add level'), deleteLevel: r('deleteLevel', 'Delete level'), copyLevel: r('copyLevel', 'Copy level'), sortBy: r('sortBy', 'Sort by'), thenBy: r('thenBy', 'Then by'), updateLayout: r('updateLayout', 'Update Layout'), asc: r('asc', 'Ascending'), desc: r('desc', 'Descending'), column: r('column', 'Column'), order: r('order', 'Order'), sort: r('sort', 'Sort'), sortArrayExists: r('sortArrayExists', 'This will remove the current tiered sort. Do you wish to continue?'), requirePrompt: r('requirePrompt', 'All sort criteria must have a column specified. Check the selected sort criteria and try again.'), duplicatePrompt: r('duplicatePrompt', '{column} is being sorted more than once. Delete the duplicate sort criteria and try again.') }; } this._var.el = null; this._var.refs = {}; this._var.rendering = true; this._var.parent = container; this._var.isFirefox = /Firefox\//i.test(navigator.userAgent); this._var.enabledDict = {}; const grid = createElement('div', 'ui-grid'); grid.setAttribute('tabindex', 0); grid.addEventListener('keydown', e => { let index = this.selectedIndex; let flag = false; if (e.key === 'ArrowUp') { // up if (index > 0) { flag = true; index -= 1; } } else if (e.key === 'ArrowDown') { // down const count = this._var.currentSource?.length ?? 0; if (index < count - 1) { flag = true; index += 1; } } if (flag) { this._var.selectedIndexes = [index]; this.scrollToIndex(index); this.refresh(); if (typeof this.onSelectedRowChanged === 'function') { this.onSelectedRowChanged(index); } e.stopPropagation(); } }); grid.addEventListener('mousedown', e => { if (e.target === this._var.el) { // cancel selections const selectedIndexes = this._var.selectedIndexes; if (selectedIndexes?.length > 0) { selectedIndexes.splice(0); } if (this.readonly) { this._tableRows.forEach(row => { row.classList.remove('selected'); }); } else { this.refresh(); } if (typeof this.onSelectedRowChanged === 'function') { this.onSelectedRowChanged(-1); } this._var.selectedColumnIndex = -1; return; } let [parent, target] = this._getRowTarget(e.target); if (parent == null) { return; } if (this._getParentElement(parent) !== this._var.el) { // sub ui-grid return; } const rowIndex = parent.classList.contains('ui-grid-total-row') ? -1 : this._tableRows.indexOf(parent); let colIndex = indexOfParent(target) - (this.expandable ? 1 : 0); if (colIndex >= this.columns.length) { colIndex = -1; } this._onRowClicked(e, rowIndex, colIndex); }); if (this.rowDraggable) { grid.addEventListener('dragover', e => { e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = e.ctrlKey ? 'copy' : 'move'; }); grid.addEventListener('drop', e => { e.preventDefault(); e.stopPropagation(); const src = Number(e.dataTransfer.getData('text')); if (isNaN(src) || src < 0) { return; } const target = this._var.currentSource?.length ?? 0; if (target == null || src === target) { return; } const row = e.ctrlKey ? Object.assign({}, this._var.currentSource[src]?.values) : this.removeItem(src); this.addItem(row, target); this.selectedIndexes = [e.ctrlKey ? target : target - 1]; if (typeof this.onRowChanged === 'function') { this.onRowChanged('drag', [row], target); } }); } container.replaceChildren(grid); const sizer = createElement('span', 'ui-grid-sizer'); grid.appendChild(sizer); this._var.refs.sizer = sizer; grid.addEventListener('scroll', e => throttle(this._onScroll, RefreshInterval, this, e), { passive: true }); // header & body const wrapper = createElement('div', 'ui-grid-wrapper'); this._var.refs.wrapper = wrapper; grid.appendChild(wrapper); const table = createElement('table', 'ui-grid-table'); this._var.refs.table = table; this._createHeader(table); this._createBody(table); this._createFooter(table); wrapper.appendChild(table); // tooltip if (!this.tooltipDisabled) { const holder = createElement('div', 'ui-grid-hover-holder'); holder.addEventListener('mousedown', e => { const holder = e.currentTarget; const row = Number(holder.dataset.row); const col = Number(holder.dataset.col); if (holder.classList.contains('active')) { holder.classList.remove('active'); this._clearHolder(holder); } return this._onRowClicked(e, row, col); }); holder.addEventListener('dblclick', e => this._onRowDblClicked(e)); wrapper.appendChild(holder); grid.addEventListener('mousemove', e => throttle(this._onGridMouseMove, HoverInternal, this, e, holder), { passive: true }); } // loading const loading = createElement('div', 'ui-grid-loading', createElement('div', null, createIcon('fa-regular', 'spinner-third')) ); this._var.refs.loading = loading; grid.appendChild(loading); this._var.el = grid; this._var.rendering = false; if (this._var.source != null) { if (this.sortArray?.length > 0) { this.sort(true); } else if (this.sortIndex >= 0) { this.sortColumn(true); } else { this.resize(true); } } } /** * 设置数据列表,该方法为 [source]{@linkcode Grid#source} 属性的语法糖 * @param {GridRowItem[]} source - 待设置的数据列表 */ setData(source) { this.source = source; } /** * 滚动到指定行的位置 * @param {number} index - 待滚动至的行索引 */ scrollToIndex(index) { const top = this._scrollToTop(index * (this.rowHeight + 1), true); this._var.el.scrollTop = top; } /** * 调整 Grid 元素的大小,一般需要在宽度变化时(如页面大小发生变化时)调用 * @param {boolean} [force] - 是否强制 [reload]{@linkcode Grid#reload},默认只有待渲染的行数发生变化时才会调用 * @param {boolean} [keep] - 是否保持当前滚动位置 * @param {Function} [callback] - 计算大小后的回调函数,上下文为 Grid */ resize(force, keep, callback) { if (this._var.rendering || this._var.el == null) { return; } const body = this._var.refs.body; const top = this.headerVisible === false ? 0 : (this._var.refs.header.offsetHeight || this.rowHeight); let height = this.height; if (height === 0) { height = this._var.containerHeight; } else if (isNaN(height) || height < 0) { height = this._var.el.offsetHeight - top; } const count = truncate((height - 1) / (this.rowHeight + 1)) + (RedumCount * 2) + 1; if (force || count !== this._var.rowCount) { this._var.rowCount = count; if (typeof this.onBodyScrolled === 'function') { if (!this.virtual) { const tti = this._topToIndex(this._var.el.scrollTop); this.onBodyScrolled(null, tti.index, count); } else { this.onBodyScrolled(null, this._var.startIndex, count); } } if (typeof callback === 'function') { callback.call(this); } else { this.reload(keep); } } this._var.wrapClientWidth = this._var.refs.wrapper.clientWidth; this._var.bodyClientWidth = body.clientWidth; } /** * 重新计算需要渲染的行,并载入元素,一般需要在高度变化时调用 * @param {boolean} [keep] - 是否保持当前滚动位置 */ reload(keep) { if (this._var.rendering || this._var.el == null) { return; } const filtered = this.columns.some(c => c.filterValues != null); if ((filtered ^ this._var.colAttrs.__filtered) === 1) { this._var.colAttrs.__filtered = filtered; const headers = this._headerCells; for (let i = 0; i < this.columns.length; ++i) { const ele = headers[i].querySelector('.filter'); if (ele == null) { continue; } if (this.columns[i].filterValues != null) { ele.replaceChildren(createIcon('fa-solid', this.filteredIcon)); ele.classList.add('active'); } else { ele.replaceChildren(createIcon('fa-solid', this.filterIcon)); ele.classList.remove('active'); } } this._refreshSource(); return; } else if (filtered) { const headers = this._headerCells; for (let i = 0; i < this.columns.length; ++i) { const ele = headers[i].querySelector('.filter'); if (ele == null) { continue; } if (this.columns[i].filterValues != null) { ele.replaceChildren(createIcon('fa-solid', this.filteredIcon)); ele.classList.add('active'); } else { ele.replaceChildren(createIcon('fa-solid', this.filterIcon)); ele.classList.remove('active'); } } } let length = this._var.currentSource?.length ?? 0; if (this.extraRows > 0) { length += this.extraRows; } this._var.containerHeight = length * (this.rowHeight + 1); if (!keep) { this._var.scrollTop = 0; this._var.startIndex = 0; this._var.el.scrollTop = 0; this._var.refs.table.style.top = '0px'; } this._adjustRows(this._var.refs.body); this.refresh(); // size adjustment const headerHeight = this._var.headerHeight || this.rowHeight; if (this.total != null) { const footerHeight = this._var.footerHeight || this.rowHeight; this._var.refs.wrapper.style.height = `${headerHeight + this._var.containerHeight + footerHeight}px`; // footer position this._var.footerOffset = this._var.refs.table.offsetHeight - this._var.el.clientHeight; this._var.refs.footer.parentElement.style.bottom = `${this._var.refs.table.offsetTop + this._var.footerOffset - this._var.el.scrollTop}px`; } else { this._var.refs.wrapper.style.height = `${headerHeight + this._var.containerHeight}px`; } } /** * 重新填充Grid单元格数据 */ refresh() { if (this._var.refs.body == null) { throw new Error('body has not been created.'); } const widths = {}; this._fillRows(this._tableRows, this.columns, widths); if (this._var.needResize && widths.flag) { this._var.needResize = false; this.columns.forEach((col, i) => { if (!this._get(col.key, 'autoResize')) { return; } let width = widths[i]; if (width < col.width) { width = col.width; } if (width > 0) { this._changeColumnWidth(i, width, true); } }); } this._layoutHeaderFooter(); } /** * 把所有行重置为未修改的状态 */ resetChange() { if (this._var.source == null) { return; } for (let row of this._var.source) { delete row.__changed; } } /** * 根据当前排序字段进行列排序 * @param {boolean} [reload] - 为 `true` 则在列排序后调用 [reload]{@linkcode Grid#reload} 方法 */ sortColumn(reload) { const index = this.sortIndex; const col = this.columns[index]; if (col == null) { return; } this.sortArray = null; const direction = this.sortDirection; [...this._headerCells].forEach((th, i) => { const arrow = th.querySelector('.arrow'); if (arrow == null) { return; } if (i === index) { arrow.className = `arrow ${(direction !== 1 ? 'desc' : 'asc')}`; } else if (arrow.className !== 'arrow') { arrow.className = 'arrow'; } }); const comparer = this._getComparer(col, direction); this._var.source.sort(comparer); if (this._var.colAttrs.__filtered === true) { this._var.currentSource.sort(comparer); } if (this._var.rowCount < 0) { return; } if (reload) { this.reload(); } else { this.refresh(); } } /** * 根据当前排序列数组进行多列排序 * @param {boolean} [reload] - 为 `true` 则在多列排序后调用 [reload]{@linkcode Grid#reload} 方法 */ sort(reload) { const sortArray = this.sortArray; if (sortArray == null || sortArray.length === 0) { return; } this.sortIndex = -1; const comparer = (a, b) => { for (let i = 0; i < sortArray.length; ++i) { const s = sortArray[i]; const col = this.columns.find(c => c.key === s.column && c.visible !== false); if (col != null) { const result = this._getComparer(col, s.order === 'desc' ? -1 : 1)(a, b); if (result !== 0) { return result; } } } return 0; }; this._var.source.sort(comparer); if (this._var.colAttrs.__filtered === true) { this._var.currentSource.sort(comparer); } if (this._var.rowCount < 0) { return; } if (reload) { this.reload(); } else { this.refresh(); } // arrow icon [...this._headerCells].forEach((th, i) => { const arrow = th.querySelector('.arrow'); if (arrow == null) { return; } const col = this.columns[i]; const s = sortArray.find(s => s.column === col.key && col.visible !== false); if (s != null) { arrow.className = `arrow ${s.order}`; } else if (arrow.className !== 'arrow') { arrow.className = 'arrow'; } }); } /** * 清除列头复选框的选中状态 */ clearHeaderCheckbox() { const boxes = this._var.refs.header.querySelectorAll('.ui-check-wrapper>input'); boxes.forEach(box => box.checked = false); } /** * 显示多列排序设置面板 * @param {Function} [callback] - 更新回调函数 *@since* 1.0.3 * @param {boolean} [layout] - 是否显示更新 layout 复选框 *@since* 1.0.3 * @since 1.0.1 */ showSortPanel(callback, layout) { const content = createElement('div', 'ui-sort-panel-content'); const buttonWrapper = createElement('div', 'ui-sort-panel-buttons'); const grid = new Grid(null, r); grid.rowDraggable = true; grid.langs = this.langs; const rowChanged = index => { buttonWrapper.querySelector('.ui-button-delete').disabled = index < 0; buttonWrapper.querySelector('.ui-button-copy').disabled = index < 0; // buttonWrapper.querySelector('.ui-button-move-up').disabled = index < 1; // buttonWrapper.querySelector('.ui-button-move-down').disabled = index >= grid.source.length - 1; }; grid.onSelectedRowChanged = rowChanged; grid.onRowChanged = () => { const layout = pop.container.querySelector('.ui-sort-layout'); if (layout != null) { layout.classList.remove('disabled'); layout.querySelector('input').disabled = false; } }; const reload = index => { grid.selectedIndexes = [index]; grid.scrollTop = index * (grid.rowHeight + 1); rowChanged(index); grid.onRowChanged(); } buttonWrapper.append( createElement('span', button => { button.className = 'button'; button.addEventListener('click', () => { let index = grid.selectedIndex; const n = { column: '', order: 'asc' }; if (index >= 0) { index += 1; grid.addItem(n, index); } else { grid.addItem(n); index = grid.source.length - 1; } reload(index); }); }, createIcon('fa-light', 'plus'), createElement('span', span => { span.innerText = this.langs.addLevel; }) ), createElement('span', button => { button.className = 'button ui-button-delete'; button.addEventListener('click', () => { let index = grid.selectedIndex; if (index < 0) { return; } grid.removeItem(index); const length = grid.source.length; if (index >= length) { index = length - 1; } reload(index); }); }, createIcon('fa-light', 'times'), createElement('span', span => { span.innerText = this.langs.deleteLevel; }) ), createElement('span', button => { button.className = 'button ui-button-copy'; button.addEventListener('click', () => { const index = grid.selectedIndex; if (index < 0) { return; } const item = grid.source[index]; if (item == null) { return; } grid.addItem(Object.assign({}, item), index + 1); reload(index + 1); }); }, createIcon('fa-light', 'copy'), createElement('span', span => { span.innerText = this.langs.copyLevel; }) ), /* createElement('span', button => { button.className = 'button ui-button-move-up'; const icon = createIcon('fa-light', 'chevron-up'); icon.addEventListener('click', () => { const index = grid.selectedIndex; if (index < 1) { return; } const item = grid.source[index]; if (item == null) { return; } const it = grid.removeItem(index); grid.addItem(it, index - 1); reload(index - 1); }); button.appendChild(icon); }), createElement('span', button => { button.className = 'button ui-button-move-down'; const icon = createIcon('fa-light', 'chevron-down'); icon.addEventListener('click', () => { const index = grid.selectedIndex; if (index >= grid.source.length - 1) { return; } const item = grid.source[index]; if (item == null) { return; } const it = grid.removeItem(index); grid.addItem(it, index + 1); reload(index + 1); }); button.appendChild(icon); }) //*/ ); const gridWrapper = createElement('div', 'ui-sort-panel-grid'); content.append(buttonWrapper, gridWrapper); const columnSource = this.columns.filter(c => c.sortable !== false); // ticket 56389, && c.visible !== false columnSource.sort((a, b) => a.caption > b.caption ? 1 : -1); grid.columns = [ { width: 80, sortable: false, orderable: false, resizable: false, filter: (_item, _editing, _body, index) => index === 0 ? this.langs.sortBy : this.langs.thenBy }, { key: 'caption', caption: this.langs.column, width: 270, type: GridColumnTypeEnum.Dropdown, dropOptions: { textKey: 'caption', valueKey: 'caption', input: true }, dropRestrictCase: true, sourceCache: false, source: () => columnSource.filter(c => grid.source?.find(s => s.column === c.key) == null), sortable: false, orderable: false, onChanged: (item, _value, _oldValue, e) => { item.column = e.key; const layout = pop.container.querySelector('.ui-sort-layout'); if (layout != null) { layout.classList.remove('disabled'); layout.querySelector('input').disabled = false; } } }, { key: 'order', caption: this.langs.order, width: 150, type: GridColumnTypeEnum.Dropdown, source: [ { value: 'asc', text: this.langs.asc }, { value: 'desc', text: this.langs.desc } ], sortable: false, orderable: false, onChanged: () => { const layout = pop.container.querySelector('.ui-sort-layout'); if (layout != null) { layout.classList.remove('disabled'); layout.querySelector('input').disabled = false; } } } ]; const pop = new Popup({ title: this.langs.sort, content, resizable: true, buttons: [ { text: this.langs.ok, trigger: () => { const source = grid.source; if (source == null || source.length === 0) { this.sortArray = null; this.sortIndex = -1; this.sortDirection = GridColumnDirection.Ascending; // arrow icon [...this._headerCells].forEach((th, i) => { const arrow = th.querySelector('.arrow'); if (arrow == null) { return; } arrow.className = 'arrow'; }); } else { const dict = {}; for (let i = 0; i < source.length; ++i) { const it = source[i]; if (it.column == null || it.column === '') { grid.selectedIndexes = [i]; grid.refresh(); showAlert(this.langs.sort, this.langs.requirePrompt, 'warn'); return false; } if (Object.prototype.hasOwnProperty.call(dict, it.column)) { grid.selectedIndexes = [i]; grid.refresh(); let name = columnSource.find(c => c.key === it.column); if (name == null) { name = it.column; } else { name = name.caption; } showAlert(this.langs.sort, this.langs.duplicatePrompt.replace('{column}', name), 'warn'); return false; } dict[it.column] = true; } this.sortArray = source.map(s => ({ column: s.column, order: s.order })); this.sortDirection = 1; this.sort(); } if (typeof callback === 'function') { callback.call(this, this.sortArray, pop.container.querySelector('.ui-sort-layout>input')?.checked); } return true; } }, { text: this.langs.cancel } ], onResizeEnded: () => grid.resize() }); let source = this.sortArray; if (source == null && this.sortIndex >= 0) { const col = this.columns[this.sortIndex]; if (col != null) { source = [{ column: col.key, order: this.sortDirection > 0 ? 'asc' : 'desc' }]; } } source ??= [{ column: '', order: 'asc' }]; pop.create(); pop.rect = { width: 600, height: 460 }; pop.show(this._var.el).then(() => { if (layout) { const footer = pop.container.querySelector('.ui-popup-footer'); footer.insertBefore(createCheckbox({ label: this.langs.updateLayout, className: 'ui-sort-layout', switch: true, enabled: false }), footer.children[0]); } grid.init(gridWrapper); grid.source = source .map(s => (s.c = columnSource.find(c => c.key === s.column), s)) .filter(s => s.column === '' || s.c != null) .map(s => ({ column: s.column, caption: s.c?.caption ?? '', order: s.order })); grid.selectedIndexes = [0]; grid.refresh(); rowChanged(0); }); } /** * 设置单行数据 * @param {number} index - 行索引 * @param {GridRowItem} item - 待设置的行数据对象 * @since 1.0.1 */ setItem(index, item) { if (this._var.currentSource == null) { throw new Error('no source'); } const it = this._var.currentSource[index]; // clear dropdown source cache // FIXME: 清除缓存会导致选中状态下动态数据源下拉列表显示为空 // delete it.source; it.values = item; if (this.sortArray?.length > 0) { this.sort(); } else if (this.sortIndex >= 0) { this.sortColumn(); } else { this.refresh(); } if (typeof this.onRowChanged === 'function') { this.onRowChanged('update', [item], index); } } /** * 添加行数据 * @param {GridRowItem} item - 待添加的行数据值 * @param {number} [index] - 待添加的行索引 * @returns {GridRowItem} 返回已添加的行数据 * @since 1.0.1 */ addItem(item, index) { if (this._var.currentSource == null) { throw new Error('no source'); } const it = index >= 0 ? this._var.currentSource[index] : null; const newIt = { __index: null, values: item }; if (it != null) { newIt.__index = it.__index; this._var.currentSource.splice(index, 0, newIt); if (this._var.colAttrs.__filtered === true) { this._var.source.splice(it.__index, 0, newIt); } for (let i = it.__index + 1; i < this._var.source.length; ++i) { this._var.source[i].__index += 1; } } else { newIt.__index = this._var.source.length; this._var.currentSource.push(newIt); if (this._var.colAttrs.__filtered === true) { this._var.source.push(newIt); } } if (this.sortArray?.length > 0) { this.sort(true); } else if (this.sortIndex >= 0) { this.sortColumn(true); } else { this.reload(); } if (typeof this.onRowChanged === 'function') { this.onRowChanged('add', [item], index); } return item; } /** * 批量添加行数据 * @param {GridRowItem[]} array - 待添加的行数据数组 * @param {number} [index] - 待添加的行索引 * @returns {GridRowItem[]} 返回已添加的行数据数组 * @since 1.0.1 */ addItems(array, index) { if (this._var.currentSource == null) { throw new Error('no source'); } if (!Array.isArray(array) || array.length <= 0) { // throw new Error(`invalid items array: ${array}`); return; } const it = index >= 0 ? this._var.currentSource[index] : null; if (it != null) { const items = array.map((a, i) => ({ __index: it.__index + i, values: a })); this._var.currentSource.splice(index, 0, ...items); if (this._var.colAttrs.__filtered === true) { this._var.source.splice(it.__index, 0, ...items); } const offset = array.length; for (let i = it.__index + offset; i < this._var.source.length; ++i) { this._var.source[i].__index += offset; } } else { const length = this._var.source.length; const items = array.map((a, i) => ({ __index: length + i, values: a })); this._var.currentSource.push(...items); if (this._var.colAttrs.__filtered === true) { this._var.source.push(...items); } } if (this.sortArray?.length > 0) { this.sort(true); } else if (this.sortIndex >= 0) { this.sortColumn(true); } else { this.reload(); } if (typeof this.onRowChanged === 'function') { this.onRowChanged('add', array, index); } return array; } /** * 删除行数据 * @param {number} index - 待删除的行索引 * @returns {GridRowItem} 返回已删除的行数据 * @since 1.0.1 */ removeItem(index) { if (this._var.currentSource == null) { throw new Error('no source'); } const it = this._var.currentSource.splice(index, 1)[0]; if (it == null) { return null; } if (this._var.colAttrs.__filtered === true) { this._var.source.splice(it.__index, 1); } for (let i = it.__index; i < this._var.source.length; ++i) { this._var.source[i].__index -= 1; } if (index < 1) { this._var.selectedIndexes = [index - 1]; } else { this._var.selectedIndexes = []; } this.reload(); if (typeof this.onRowChanged === 'function') { this.onRowChanged('remove', [it.values], index); } return it.values; } /** * 批量删除行数据 * @param {number[]} [indexes] - 待删除的行索引数组,未传值时删除所有行 * @returns {GridRowItem[]} 返回已删除的行数据数组 * @since 1.0.1 */ removeItems(indexes) { if (this._var.currentSource == null) { throw new Error('no source'); } if (Array.isArray(indexes) && indexes.length > 0) { indexes = indexes.slice().sort(); } else { indexes = this._var.currentSource.map(a => a.__index); } const array = []; let first = 0; for (let i = indexes.length - 1; i >= 0; --i) { let it = this._var.currentSource.splice(indexes[i], 1)[0]; if (it == null) { continue; } let next = this._var.source[it.__index]; if (next != null && next.__offset == null) { next.__offset = i + 1; } if (this._var.colAttrs.__filtered === true) { this._var.source.splice(it.__index, 1); } array.splice(0, 0, it.values); first = it.__index; } let offset = 1; for (let i = first; i < this._var.source.length; ++i) { let it = this._var.source[i]; if (it.__offset > 0) { offset = it.__offset; delete it.__offset; } it.__index -= offset; } const index = indexes[0]; if (index > 0) { this._var.selectedIndexes = [index - 1]; } else { this._var.selectedIndexes = []; } if (typeof this.onRowChanged === 'function') { this.onRowChanged('remove', array, indexes); } this.reload(); return array; } /** * 导出已压缩的数据源,结构为 * ``` * { * columns: [], * source: [], * rowHeight: number, * sortDirection: number, * sortKey?: string, * sortArray?: Array<{ * column: string, * order: "asc" | "desc" * }> * } * ``` * @param {string | boolean} [compressed=deflate] - 压缩编码,传入 false 则取消压缩 * @param {string} [module] - 压缩模块,默认采用 wasm_flate.deflate 编码,自定义模块需要实现以下消息事件 * * `onmessage` - 接收消息 * * `{ type: 'init', path: string}` - 初始化消息,参数 `path` 为 `ui.min.js` 脚本所在路径 * * `{ type: 'compress', data: Uint8Array }` - 压缩消息,参数 `data` 为原始数据 * * `postMessage` - 返回消息 * * `{ type: 'init', result?: 0, error?: string}` - 初始化事件中的消息反馈,没有 `error` 意为成功 * * `{ error: string }` - 压缩事件中的消息反馈,`error` 值为错误信息 * * `Uint8Array` - 反馈压缩后的数据 * @returns {Promise} 返回 `Uint8Array` 数据对象 * @since 1.0.2 */ export(compressed, module) { const data = { columns: this.columns.map(c => ({ key: c.key, type: c.type?.toString(), caption: c.caption, width: c.width, align: c.align, visible: c.visible })), source: this.source?.map((s, i) => { const item = Object.create(null); for (let c of this.columns) { if (c.key == null) { continue; } let val; if (c.text != null) { val = c.text; } else if (typeof c.filter === 'function') { val = c.filter(s, false, this._var.refs.body, i); } else { val = s[c.key]; if (val != null) { if (Object.prototype.hasOwnProperty.call(val, 'DisplayValue') && val.Value === val.DisplayValue) { val = val.DisplayValue; } else if (Array.isArray(val)) { val = val.join(', '); } } } val ??= ''; item[c.key] = val; } for (let prop of Object.keys(s)) { if (Object.prototype.hasOwnProperty.call(item, prop)) { continue; } let val; val = s[prop]; if (val != null && Object.prototype.hasOwnProperty.call(val, 'DisplayValue')) { val = val.DisplayValue; } item[prop] = val; } return item; }) ?? [], total: ((total) => { if (total == null) { return null; } const item = Object.create(null); for (let c of this.columns) { if (c.key == null) { continue; } let val = total[c.key]; if (val != null && Object.prototype.hasOwnProperty.call(val, 'DisplayValue') && val.Value === val.DisplayValue) { val = val.DisplayValue; } val ??= ''; item[c.key] = val; } for (let prop of Object.keys(total)) { if (Object.prototype.hasOwnProperty.call(item, prop)) { continue; } let val; val = total[prop]; if (val != null && Object.prototype.hasOwnProperty.call(val, 'DisplayValue')) { val = val.DisplayValue; } item[prop] = val; } return item; })(this.total), rowHeight: this.rowHeight, sortDirection: this.sortDirection, sortKey: this.sortKey, sortArray: this.sortArray }; const json = JSON.stringify(data); if (compressed === false) { return Promise.resolve(Encoder.encode(json)); } return new Promise(resolve => { let working; let url; if (typeof module === 'string') { url = `${ScriptPath}${module}`; } else { url = URL.createObjectURL(new Blob([`let wasm,WASM_VECTOR_LEN=0,cachegetUint8Memory0=null;function getUint8Memory0(){return null!==cachegetUint8Memory0&&cachegetUint8Memory0.buffer===wasm.memory.buffer||(cachegetUint8Memory0=new Uint8Array(wasm.memory.buffer)),cachegetUint8Memory0}let cachegetInt32Memory0=null;function getInt32Memory0(){return null!==cachegetInt32Memory0&&cachegetInt32Memory0.buffer===wasm.memory.buffer||(cachegetInt32Memory0=new Int32Array(wasm.memory.buffer)),cachegetInt32Memory0}function passArray8ToWasm0(e,t){const a=t(1*e.length);return getUint8Memory0().set(e,a/1),WASM_VECTOR_LEN=e.length,a}function getArrayU8FromWasm0(e,t){return getUint8Memory0().subarray(e/1,e/1+t)}function encode_raw(e,t){var a=passArray8ToWasm0(t,wasm.__wbindgen_malloc),r=WASM_VECTOR_LEN;wasm[e+"_encode_raw"](8,a,r);var s=getInt32Memory0()[2],n=getInt32Memory0()[3],m=getArrayU8FromWasm0(s,n).slice();return wasm.__wbindgen_free(s,1*n),m}self.addEventListener("message",e=>{const t=e.data.type;if("init"===t)if("function"==typeof WebAssembly.instantiateStreaming){const t={},a=fetch(e.data.path+"wasm_flate_bg.wasm");WebAssembly.instantiateStreaming(a,t).then(({instance:e})=>{wasm=e.exports,self.postMessage({type:"init",result:0})}).catch(e=>a.then(t=>{"application/wasm"!==t.headers.get("Content-Type")?self.postMessage({type:"init",error:"\`WebAssembly.instantiateStreaming\` failed because your server does not serve wasm with \`application/wasm\` MIME type. Original error: "+e.message}):self.postMessage({type:"init",error:e.message})}))}else self.postMessage({type:"init",error:"no \`WebAssembly.instantiateStreaming\`"});else if("compress"===t)if(null==wasm)self.postMessage({error:"no \`wasm\` instance"});else{let t=encode_raw("${compressed ?? 'deflate'}",e.data.data);self.postMessage(t,[t.buffer])}});`])); } const worker = new Worker(url); /** * @private * @param {Function} next * @param {any} data */ const terminate = (next, data) => { working = false; worker.terminate(); URL.revokeObjectURL(url); next(data); } // 超过 30 秒则返回无压缩数据 const timer = setTimeout(() => { if (working) { // terminate(reject, { message: 'timeout' }); terminate(resolve, { data: Encoder.encode(json), error: 'timeout' }); } }, 30000); worker.addEventListener('message', e => { if (working) { if (e.data.error != null) { // terminate(reject, { message: e.data.error }); terminate(resolve, { data: Encoder.encode(json), error: e.data.error }); } else { if (e.data.type === 'init') { const uncompressed = Encoder.encode(json); worker.postMessage({ type: 'compress', data: uncompressed }, [uncompressed.buffer]); } else { clearTimeout(timer); terminate(resolve, { type: 'compressed', data: e.data }); } } } }) worker.addEventListener('error', e => { if (working) { clearTimeout(timer); // terminate(reject, e); terminate(resolve, { data: Encoder.encode(json), error: e.message }); } }) working = true; worker.postMessage({ type: 'init', path: ScriptPath }); }); } /** * 展开/收折所有行 * @param {boolean} [expanded] - 是否展开,传 null 则切换展开状态 * @returns {boolean} 展开状态 */ expandAll(expanded) { if (this._var.currentSource == null) { return; } if (expanded == null) { expanded = !this._var.expanded; } else { expanded = expanded !== false; } this._var.expanded = expanded; for (let vals of this._var.currentSource) { vals.__expanded = expanded; } this.refresh(); return expanded; } /** * @private * @callback PrivateGridComparerCallback * @param {GridItemWrapper} a * @param {GridItemWrapper} b * @returns {number} */ /** * @private * @param {GridColumnDefinition} col * @param {GridColumnDirection} direction * @returns {PrivateGridComparerCallback} */ _getComparer(col, direction) { if (typeof col.sortFilter !== 'function') { if (isNaN(direction)) { direction = 1; } const editing = col.sortAsText !== true; const comparer = (a, b) => { a = this._getItemSortProp(a, editing, col); b = this._getItemSortProp(b, editing, col); if (editing) { if (typeof a === 'boolean') { a = a ? 2 : 1; } if (typeof b === 'boolean') { b = b ? 2 : 1; } if (a == null && typeof b === 'number') { return (b >= 0 ? -1 : 1); } else if (typeof a === 'number' && b == null) { return (a >= 0 ? 1 : -1); } else if (a == null && b != null) { return -1; } else if (a != null && b == null) { return 1; } if (Array.isArray(a)) { a = a.join(', '); } if (Array.isArray(b)) { b = b.join(', '); } if (typeof a === 'string' && typeof b === 'string') { a = a.toLowerCase(); b = b.toLowerCase(); } } else { if (a == null && b != null) { return -1; } if (a != null && b == null) { return 1; } if (Array.isArray(a)) { a = a.join(', '); } if (Array.isArray(b)) { b = b.join(', '); } if (typeof a === 'string' && typeof b === 'string') { a = a.toLowerCase(); b = b.toLowerCase(); } } return a === b ? 0 : (a > b ? 1 : -1); }; return (a, b) => comparer(a.values, b.values) * direction; } return (a, b) => col.sortFilter(a.values, b.values) * direction; } /** * @private * @param {GridItemWrapper[]} [list] */ _refreshSource(list) { list ??= this._var.source; if (this._var.colAttrs.__filtered === true) { this._var.currentSource = list.filter(it => { for (let col of this.columns) { const nullValue = col.filterAllowNull ? null : ''; if (Array.isArray(col.filterValues)) { const v = this._getItemProp(it.values, false, col) ?? nullValue; if (Array.isArray(v)) { if (v.every(item => col.filterValues.indexOf(item) < 0)) { return false; } } else if (col.filterValues.indexOf(v) < 0) { return false; } } } return true; }); } else { this._var.currentSource = list; } this._var.selectedColumnIndex = -1; this._var.selectedIndexes = []; this._var.startIndex = 0; this._var.scrollTop = 0; this._var.rowCount = -1; this.resize(true, false, () => { if (this.sortArray?.length > 0) { this.sort(true); } else if (this.sortIndex >= 0) { this.sortColumn(true); } else { this.reload(); } }); } /** * @private * @param {HTMLTableElement} table * @returns {HTMLTableSectionElement} */ _createHeader(table) { const thead = createElement('thead'); if (this.headerVisible === false) { thead.style.display = 'none'; } table.appendChild(thead); const header = createElement('tr'); thead.appendChild(header); const sizer = this._var.refs.sizer; let left = this.expandable ? ExpandableWidth : 0; const readonly = this.readonly; if (this.expandable) { header.appendChild(createElement('th', th => { th.className = 'ui-expandable sticky'; const w = `${ExpandableWidth}px`; th.style.cssText = convertCssStyle({ 'width': w, 'max-width': w, 'min-width': w, 'left': '0px' }); }, createElement('div'))); } for (let col of this.columns) { if (col.visible === false) { const hidden = createElement('th', 'column'); hidden.style.display = 'none'; if (col.sortable !== false) { hidden.dataset.key = col.key; hidden.addEventListener('click', e => this._onHeaderClicked(e, col, true)); } header.appendChild(hidden); continue; } // style const alwaysEditing = GridColumnTypeEnum.isAlwaysEditing(col.type); let type = this._var.colTypes[col.key]; if (type == null) { if (isNaN(col.type)) { type = col.type; } else { type = ColumnTypeDefs[col.type]; } type ??= GridColumn; this._var.colTypes[col.key] = type; } if (col.width > 0 || col.shrink || !this.autoResize || typeof type.createCaption === 'function') { // col.autoResize = false; if (isNaN(col.width) || col.width <= 0) { col.width = 50; } } else { this._set(col.key, 'autoResize', true); this._var.needResize = true; sizer.innerText = col.caption ?? ''; let width = sizer.offsetWidth + 22; if (!readonly && col.enabled !== false && col.allcheck && alwaysEditing) { width += 32; } if (col.allowFilter === true) { width += 14; } if (width < MiniColumnWidth) { width = MiniColumnWidth; } col.width = width; } col.align ??= alwaysEditing ? 'center' : 'left'; if (col.sortable !== false) { col.sortable = true; } let style; if (col.shrink) { style = { 'text-align': col.align }; } else { const w = `${col.width}px`; style = { 'width': w, 'max-width': w, 'min-width': w, 'text-align': col.align }; } this._set(col.key, 'style', style); // element const th = createElement('th', 'column'); const thStyle = { ...style }; if (col.isfixed) { th.classList.add('sticky'); thStyle.left = `${left}px`; } left += col.width; th.dataset.key = col.key; if (col.sortable) { thStyle.cursor = 'pointer'; th.addEventListener('click', e => this._onHeaderClicked(e, col)); } th.style.cssText = convertCssStyle(thStyle); if (col.orderable !== false) { col.orderable = true; th.addEventListener('mousedown', e => this._onDragStart(e, col)); } const wrapper = createElement('div'); if (col.align === 'right') { wrapper.style.justifyContent = 'flex-end'; } else if (col.align === 'center') { wrapper.style.justifyContent = 'center'; } th.appendChild(wrapper); if (!readonly && col.enabled !== false && col.allcheck && alwaysEditing) { const check = createCheckbox({ switch: col.switch, onchange: e => this._onColumnAllChecked(col, e.target.checked) }); wrapper.appendChild(check); } let caption; if (typeof type.createCaption === 'function') { caption = type.createCaption(col); } else { caption = createElement('span'); caption.innerText = col.caption ?? ''; } if (caption instanceof HTMLElement) { if (this.headerWrap) { caption.classList.add('wrap'); } if (col.captionStyle != null) { caption.style.cssText = convertCssStyle(col.captionStyle); } wrapper.appendChild(caption); } if (col.captionTooltip != null) { const help = createIcon('fa-solid', 'question-circle'); wrapper.appendChild(help); setTooltip(help, col.captionTooltip, false, this._var.parent); } // order arrow if (col.sortable) { th.appendChild(createElement('layer', 'arrow')); } // filter if (col.allowFilter === true) { const filter = createElement('layer', 'filter'); filter.appendChild(createIcon('fa-solid', this.filterIcon)); filter.addEventListener('mousedown', e => this._onFilter(e, col)); th.classList.add('header-filter'); th.appendChild(filter); } // resize spliter if (col.resizable !== false) { const spliter = createElement('layer', 'spliter'); spliter.addEventListener('mousedown', e => this._onResizeStart(e, col)); spliter.addEventListener('dblclick', e => this._onAutoResize(e, col)); th.appendChild(spliter); } // bottom border th.appendChild(createElement('layer', 'bottom-border')); // tooltip // !nullOrEmpty(col.tooltip) && setTooltip(th, col.tooltip); header.appendChild(th); } const dragger = createElement('div', 'dragger'); const draggerCursor = createElement('layer', 'dragger-cursor'); header.appendChild( createElement('th', null, dragger, draggerCursor, createElement('div'), createElement('layer', 'bottom-border'))); sizer.replaceChildren(); this._var.refs.header = header; this._var.refs.dragger = dragger; this._var.refs.draggerCursor = draggerCursor; return thead; } /** * @private * @param {HTMLTableElement} table * @returns {HTMLTableSectionElement} */ _createBody(table) { const body = createElement('tbody'); table.appendChild(body); const cols = this.columns; let width = 1; for (let col of cols) { if (col.visible !== false && !isNaN(col.width)) { width += col.width + 1; } } if (this.expandable) { width += ExpandableWidth; } table.style.width = `${width}px`; // body content body.addEventListener('dblclick', e => this._onRowDblClicked(e)); // this._adjustRows(body); this._var.refs.body = body; // this.refresh(); return body; } /** * @private * @param {HTMLTableElement} table * @returns {HTMLTableSectionElement} */ _createFooter(table) { const tfoot = createElement('tfoot'); tfoot.style.display = 'none'; table.appendChild(tfoot); tfoot.addEventListener('dblclick', e => this._onRowDblClicked(e)); const footer = createElement('tr', 'ui-grid-row ui-grid-total-row'); tfoot.appendChild(footer); let left = this.expandable ? ExpandableWidth : 0; if (this.expandable) { footer.appendChild(createElement('td', td => { td.className = 'ui-expandable sticky'; const w = `${ExpandableWidth}px`; td.style.cssText = convertCssStyle({ 'width': w, 'max-width': w, 'min-width': w, 'left': '0px' }); }, createElement('div'))); } this.columns.forEach((col, j) => { const cell = createElement('td', 'ui-grid-cell'); if (col.visible !== false) { let style = this._get(col.key, 'style') ?? {}; if (col.isfixed) { cell.classList.add('sticky'); style.left = `${left}px`; } left += col.width; cell.dataset.col = String(j); if (col.totalCss != null) { style = { ...style, ...col.totalCss }; } style = convertCssStyle(style); if (style !== '') { cell.style.cssText = style; } const element = GridColumn.create(col); if (typeof col.class === 'string') { GridColumn.setClass(element, col.class); } if (col.contentWrap) { element.classList.add('wrap'); } cell.appendChild(element); } else { cell.style.display = 'none'; } footer.appendChild(cell); }) footer.appendChild(createElement('td', td => td.innerText = '\u00a0')); this._var.refs.footer = footer; return tfoot; } /** * @private * @param {HTMLTableSectionElement} content */ _adjustRows(content) { let count = this._var.rowCount; if (isNaN(count) || count < 0 || !this.virtual) { count = this._var.currentSource?.length ?? 0; } const cols = this.columns; const rows = Array.from(content.children).filter(r => r.classList.contains('ui-grid-row')); const exists = rows.length; count -= exists; if (count > 0) { const readonly = this.readonly; for (let i = 0; i < count; ++i) { const row = createElement('tr', 'ui-grid-row'); if (this.rowDraggable) { row.draggable = true; row.addEventListener('dragstart', e => { e.dataTransfer.setData('text', String(this._var.startIndex + exists + i)); }); row.addEventListener('dragover', e => { e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = e.ctrlKey ? 'copy' : 'move'; }); row.addEventListener('drop', e => { e.preventDefault(); e.stopPropagation(); const src = Number(e.dataTransfer.getData('text')); if (isNaN(src) || src < 0) { return; } const target = this._var.startIndex + exists + i; if (src === target) { return; } const row = e.ctrlKey ? Object.assign({}, this._var.currentSource[src]?.values) : this.removeItem(src); this.addItem(row, target); this.selectedIndexes = [target]; if (typeof this.onRowChanged === 'function') { this.onRowChanged('drag', [row], target); } }); } const virtualRow = { cells: {} }; this._var.virtualRows[exists + i] = virtualRow; let left = this.expandable ? ExpandableWidth : 0; if (this.expandable) { const icon = createIcon('fa-solid', 'caret-right'); icon.dataset.expanded = '0'; row.appendChild(createElement('td', td => { td.className = 'ui-expandable sticky'; td.style.cssText = 'left: 0px'; td.addEventListener('mousedown', e => { this._onExpandable(e, exists + i, row); e.stopPropagation(); }); }, icon )); } cols.forEach((col, j) => { const cell = createElement('td', 'ui-grid-cell'); virtualRow.cells[col.key ?? j] = { style: '' }; if (col.visible !== false) { let style = this._get(col.key, 'style') ?? {}; if (col.isfixed) { cell.classList.add('sticky'); style.left = `${left}px`; } left += col.width; cell.dataset.row = String(exists + i); cell.dataset.col = String(j); if (col.css != null) { style = { ...style, ...col.css }; } style = convertCssStyle(style); if (style !== '') { cell.style.cssText = style; } let type = this._var.colTypes[col.key]; if (type == null) { if (isNaN(col.type)) { type = col.type; } else { type = ColumnTypeDefs[col.type]; } type ??= GridColumn; this._var.colTypes[col.key] = type; } let element; if (!readonly && GridColumnTypeEnum.isAlwaysEditing(col.type)) { element = type.createEdit(e => this._onRowChanged(e, exists + i, col, e.target.checked, cell), col, exists + i); } else { element = type.create(col, exists + i, this); if (typeof col.class === 'string') { type.setClass(element, col.class); } if (col.contentWrap) { element.classList.add('wrap'); } } cell.appendChild(element); if (col.events != null) { for (let ev of Object.entries(col.events)) { element[ev[0]] = e => { const item = this._var.currentSource[this._var.startIndex + exists + i].values; ev[1].call(item, e); }; } } } else { cell.style.display = 'none'; } row.appendChild(cell); }); row.appendChild(createElement('td', td => td.innerText = '\u00a0')); content.appendChild(row); } } else if (count < 0) { let last = rows[exists + count]; while (last != null) { const next = last.nextElementSibling; last.remove(); last = next; } } } /** * @private * @param {HTMLTableRowElement[]} rows * @param {GridColumnDefinition[]} cols * @param {any} [widths] */ _fillRows(rows, cols, widths) { const startIndex = this._var.startIndex; const selectedIndexes = this._var.selectedIndexes; const offset = this.expandable ? 1 : 0; const readonly = this.readonly; rows.forEach((row, i) => { const vals = this._var.currentSource[startIndex + i]; if (vals == null) { return; } if (!isPositive(row.children.length)) { return; } const virtualRow = this._var.virtualRows[i]; const item = vals.values; const selected = selectedIndexes.includes(startIndex + i); if (selected) { row.classList.add('selected'); } else if (row.classList.contains('selected')) { row.classList.remove('selected'); } const stateChanged = virtualRow.editing !== selected; virtualRow.editing = selected; // data if (this.expandable) { const expanded = vals.__expanded; let rowExpanded = row.nextElementSibling; if (rowExpanded?.className !== 'ui-grid-row-expanded') { rowExpanded = null; } if (expanded) { let expandableObject = vals.__expandable_object; if (expandableObject == null && typeof this.expandableGenerator === 'function') { expandableObject = this.expandableGenerator(item); if (expandableObject?.element == null) { return; } expandableObject.element = createElement('td', td => { td.colSpan = cols.length + 2; }, expandableObject.element ); vals.__expandable_object = expandableObject; } if (rowExpanded == null) { rowExpanded = createElement('tr', 'ui-grid-row-expanded'); this._var.refs.body.insertBefore(rowExpanded, row.nextElementSibling); } else { rowExpanded.style.display = ''; } rowExpanded.replaceChildren(expandableObject.element); } else { if (rowExpanded != null) { rowExpanded.style.display = 'none'; } } const iconCell = row.children[0]; if (iconCell.children[0].dataset.expanded !== (expanded ? '1' : '0')) { const icon = createIcon('fa-solid', expanded ? 'caret-down' : 'caret-right'); icon.dataset.expanded = expanded ? '1' : '0'; iconCell.replaceChildren(icon); if (expanded) { if (typeof this.onRowExpanded === 'function') { this.onRowExpanded(vals.values, vals.__expandable_object); } } else { if (typeof this.onRowCollapsed === 'function') { this.onRowCollapsed(vals.values, vals.__expandable_object); } } } } cols.forEach((col, j) => { if (col.visible === false) { return; } const cell = row.children[j + offset]; if (cell == null) { return; } const virtualCell = virtualRow.cells[col.key ?? j]; let val; if (col.text != null) { val = col.text; } else if (typeof col.filter === 'function') { val = col.filter(item, selected, this._var.refs.body, startIndex + i); } else { val = item[col.key]; if (val != null) { if (Object.prototype.hasOwnProperty.call(val, 'DisplayValue')) { val = val.DisplayValue; } else if (Array.isArray(val)) { val = val.join(', '); } } } val ??= ''; // fill let bg = col.background; if (bg != null) { if (typeof bg === 'function') { bg = col.background(item); } } else if (typeof col.bgFilter === 'function') { bg = col.bgFilter(item); } bg ??= ''; if (bg !== virtualCell.background) { virtualCell.background = bg; cell.style.backgroundColor = bg; } const alwaysEditing = GridColumnTypeEnum.isAlwaysEditing(col.type); const type = this._var.colTypes[col.key] ?? GridColumn; let element; if (!readonly && !alwaysEditing && typeof type.createEdit === 'function') { const oldValue = vals.__editing?.[col.key]; if (oldValue !== undefined) { delete vals.__editing[col.key]; if (typeof type.leaveEdit === 'function') { type.leaveEdit(cell.children[0], this._var.el); } if (type.editing) { val = type.getValue({ target: cell.children[0] }, col); this._onRowChanged(null, i, col, val, cell, oldValue); if (Object.prototype.hasOwnProperty.call(val, 'value')) { val = val.value; } } } if (stateChanged) { element = selected ? type.createEdit(e => { let old; if (type.editing) { old = vals.__editing?.[col.key]; if (old === undefined) { return; } delete vals.__editing[col.key]; } this._onRowChanged(e, i, col, type.getValue(e, col), cell, old); }, col, this._var.el, vals) : type.create(col, i, this); if (typeof col.class === 'string') { type.setClass(element, col.class); } if (col.contentWrap) { element.classList.add('wrap'); } cell.replaceChildren(element); if (col.events != null) { for (let ev of Object.entries(col.events)) { element[ev[0]] = ev[1].bind(item); } } } else { element = cell.children[0]; } } else { element = cell.children[0]; } if (stateChanged) { if (typeof type.createEdit === 'function') { delete virtualCell.attrs; virtualCell.style = ''; delete virtualCell.value; delete virtualCell.enabled; } } if (val !== virtualCell.value) { virtualCell.value = val; type.setValue(element, val, vals, col, this); } if (typeof type.setEnabled === 'function') { let enabled; if (readonly) { enabled = false; } else { enabled = col.enabled; if (typeof enabled === 'function') { this._var.enabledDict[col.key] = true; enabled = enabled.call(col, item); } else if (typeof enabled === 'string') { this._var.enabledDict[col.key] = enabled; enabled = item[enabled]; } } if (enabled !== virtualCell.enabled) { virtualCell.enabled = enabled; type.setEnabled(element, enabled, selected); } } if (stateChanged && typeof type.setEditing === 'function') { type.setEditing(element, selected); } let tip = col.tooltip; if (typeof tip === 'function') { tip = tip.call(col, item); } if (tip !== virtualCell.tooltip) { virtualCell.tooltip = tip; if (nullOrEmpty(tip)) { element.querySelector('.ui-tooltip-wrapper')?.remove(); } else { setTooltip(element, tip, false, this.element); } } // auto resize if (this._var.needResize && widths != null && this._get(col.key, 'autoResize')) { const width = element.scrollWidth + 12; if (width > 0 && (isNaN(widths[j]) || widths[j] < width)) { widths[j] = width; widths.flag = true; } } let style = col.style; if (style != null) { if (typeof style === 'function') { style = col.style(item); } } else if (typeof col.styleFilter === 'function') { style = col.styleFilter(item); } const separateElement = typeof type.getElement === 'function'; let maxHeight; if (col.maxLines > 0) { maxHeight = `${col.maxLines * this.lineHeight}px`; if (!separateElement) { if (style == null) { style = { 'max-height': maxHeight }; } else { style['max-height'] = maxHeight; } } } const styleText = style != null ? convertCssStyle(style) : ''; if (styleText !== virtualCell.style) { virtualCell.style = styleText; if (style != null) { type.setStyle(element, style); } else { element.style.cssText = ''; } } if (separateElement && maxHeight != null) { const e = type.getElement(element); if (e != null) { e.style['max-height'] = maxHeight; } } if (col.attrs != null) { let attrs = col.attrs; if (typeof attrs === 'function') { attrs = attrs(item); } const attrsText = convertCssStyle(attrs); if (attrsText !== virtualCell.attrs) { virtualCell.attrs = attrsText; for (let attr of Object.entries(attrs)) { element.setAttribute(attr[0], attr[1]); } } } }); if (vals.__editing != null) { delete vals.__editing; } }); // total const tfoot = this._var.refs.footer.parentElement; if (this.total != null) { if (tfoot.style.display === 'none') { tfoot.style.display = ''; } const cells = this._var.refs.footer.children; this.columns.forEach((col, j) => { if (col.visible === false) { return; } const cell = cells[j + offset]; if (cell == null) { return; } let val = this.total[col.key]; if (val != null && Object.prototype.hasOwnProperty.call(val, 'DisplayValue')) { val = val.DisplayValue; } val ??= ''; const element = cell.children[0]; GridColumn.setValue(element, val); // auto resize if (this._var.needResize && this._get(col.key, 'autoResize')) { const width = element.scrollWidth + 12; if (width > 0 && widths != null && (isNaN(widths[j]) || widths[j] < width)) { widths[j] = width; widths.flag = true; } } }); } else if (tfoot.style.display === '') { tfoot.style.display = 'none'; } } /** * @private * @param {number} index * @param {number} width * @param {boolean} [freeze] */ _changeColumnWidth(index, width, freeze) { const col = this.columns[index]; // const oldwidth = col.width; const w = `${width}px`; col.width = width; const style = this._get(col.key, 'style'); style.width = w; style['max-width'] = w; style['min-width'] = w; const headerCells = this._headerCells; let element = headerCells[index]; element.style.width = w; element.style.maxWidth = w; element.style.minWidth = w; // element.style.cssText += `width: ${w}; max-width: ${w}; min-width: ${w}`; let left = this.expandable ? ExpandableWidth : 0; if (col.isfixed) { left = element.offsetLeft + width; let l = left; for (let i = index + 1; i < this.columns.length; ++i) { if (this.columns[i].isfixed) { headerCells[i].style.left = `${l}px`; l += this.columns[i].width; } else { break; } } } const offset = this.expandable ? 1 : 0; for (let row of this._tableRows) { element = row.children[index + offset]; if (element != null) { element.style.width = w; element.style.maxWidth = w; element.style.minWidth = w; // element.style.cssText += `width: ${w}; max-width: ${w}; min-width: ${w}`; if (col.isfixed) { let l = left; for (let i = index + offset + 1; i < this.columns.length; ++i) { if (this.columns[i].isfixed) { row.children[i].style.left = `${l}px`; l += this.columns[i].width; } else { break; } } } } } // footer if (this.total != null) { const footerCells = this._footerCells; element = footerCells[index]; element.style.width = w; element.style.maxWidth = w; element.style.minWidth = w; if (col.isfixed) { let l = left; for (let i = index + 1; i < this.columns.length; ++i) { if (this.columns[i].isfixed) { footerCells[i].style.left = `${l}px`; l += this.columns[i].width; } else { break; } } } } if (!freeze) { this._layoutHeaderFooter(); } } /** * @private */ _layoutHeaderFooter() { const children = this._var.refs.table.children; this._var.headerHeight = this.headerVisible === false ? 0 : (this.headerWrap ? children[0].offsetHeight : this.rowHeight); if (this.total != null) { this._var.footerHeight = children[2].offsetHeight; const footerOffset = this._var.refs.table.offsetHeight - this._var.el.clientHeight; if (this._var.footerOffset !== footerOffset) { this._var.footerOffset = footerOffset; this._var.refs.footer.parentElement.style.bottom = `${this._var.refs.table.offsetTop + footerOffset - this._var.el.scrollTop}px`; } } } /** * @private * @param {number} index * @param {number} offset * @param {number} mouse * @param {number} draggerCellLeft */ _changingColumnOrder(index, offset, mouse, draggerCellLeft) { const children = this._headerCells; let element = children[index]; this._var.refs.dragger.style.cssText = `left: ${element.offsetLeft - draggerCellLeft + offset}px; width: ${element.style.width}; display: block`; // offset = x + gridScrollLeft - element.offsetLeft; // getOffsetLeftFromWindow(element); offset += mouse; let idx; const toLeft = offset < 0; if (toLeft) { offset = -offset; for (let i = index - 1; i >= 0 && offset >= 0; i -= 1) { element = children[i]; if (element == null || !element.className || element.classList.contains('sticky')) { idx = i + 1; break; } if (offset < element.offsetWidth) { idx = (offset > element.offsetWidth / 2) ? i : i + 1; break; } offset -= element.offsetWidth; } idx ??= 0; } else { const count = children.length; for (let i = index; i < count - 1 && offset >= 0; ++i) { element = children[i]; if (element == null || !element.className || element.classList.contains('sticky')) { idx = i; break; } if (offset < element.offsetWidth) { idx = (offset > element.offsetWidth / 2) ? i + 1 : i; break; } offset -= element.offsetWidth; } idx ??= count - 1; } if (idx !== this._var.colAttrs.__orderIndex || this._var.refs.draggerCursor.style.display !== 'block') { element = children[idx]; if (element == null) { return; } this._var.colAttrs.__orderIndex = idx; // avoid `offsetLeft` of hidden header to be 0 let left; if (element.style.display === 'none') { left = 0; while (left === 0 && (element = children[++idx]) != null) { left = element.offsetLeft; } if (!toLeft && left === 0) { left = draggerCellLeft; } } else { left = element.offsetLeft; } // set position of dragger cursor this._var.refs.draggerCursor.style.cssText = `left: ${left - draggerCellLeft}px; display: block`; } } /** * @private * @param {number} index */ _changeColumnOrder(index) { this._var.refs.dragger.style.display = ''; this._var.refs.draggerCursor.style.display = ''; const orderIndex = this._var.colAttrs.__orderIndex; if (orderIndex >= 0 && orderIndex !== index) { let targetIndex = orderIndex - index; if (targetIndex >= 0 && targetIndex <= 1) { return; } const header = this._var.refs.header; const children = this._headerCells; const rows = this._tableRows; const columns = this.columns; const offset = this.expandable ? 1 : 0; if (targetIndex > 1) { targetIndex = orderIndex - 1; // const current = columns[index]; // for (let i = index; i < targetIndex; ++i) { // columns[i] = columns[i + 1]; // } // columns[targetIndex] = current; const current = columns.splice(index, 1)[0]; columns.splice(targetIndex, 0, current); header.insertBefore(children[index], children[targetIndex].nextElementSibling); for (let row of rows) { row.insertBefore(row.children[index + offset], row.children[targetIndex + offset].nextElementSibling); } } else { targetIndex = orderIndex; // const current = columns[index]; // for (let i = index; i > targetIndex; i -= 1) { // columns[i] = columns[i - 1]; // } // columns[targetIndex] = current; const current = columns.splice(index, 1)[0]; columns.splice(targetIndex, 0, current); header.insertBefore(children[index], children[targetIndex]); for (let row of rows) { row.insertBefore(row.children[index + offset], row.children[targetIndex + offset]); } } if (this.sortArray == null || this.sortArray.length === 0) { // refresh sortIndex [...children].forEach((th, i) => { const arrow = th.querySelector('.arrow'); if (arrow == null) { return; } if (arrow.className !== 'arrow') { this.sortIndex = i; } }); } if (typeof this.onColumnChanged === 'function') { this.onColumnChanged(ColumnChangedType.Reorder, index, targetIndex); } } } _topToIndex(top) { const rowHeight = (this.rowHeight + 1); top -= (top % (rowHeight * 2)) + (RedumCount * rowHeight); if (top < 0) { top = 0; } else { let bottomTop = this._var.containerHeight - (reload ? 0 : this._var.rowCount * rowHeight); if (bottomTop < 0) { bottomTop = 0; } if (top > bottomTop) { top = bottomTop; } } return { top, index: top / rowHeight }; } /** * @private * @param {number} top * @param {boolean} [reload] * @returns {number} */ _scrollToTop(top, reload) { const tti = this._topToIndex(top); top = tti.top; if (this._var.scrollTop !== top) { this._var.scrollTop = top; if (this.virtual) { this._var.startIndex = tti.index; } this._fillRows(this._tableRows, this.columns); if (this.virtual) { this._var.refs.table.style.top = `${top}px`; } } else if (reload) { this._fillRows(this._tableRows, this.columns); } return top; } /** * @private * @param {string} key * @param {("autoResize" | "style" | "resizing" | "dragging" | "filterSource" | "filterHeight" | "filterTop")} name * @returns {any} */ _get(key, name) { const attr = this._var.colAttrs[key]; if (attr == null) { return null; } return attr[name]; } /** * @private * @param {string} key * @param {("autoResize" | "style" | "filterSource" | "filterHeight" | "filterTop")} name * @param {any} value */ _set(key, name, value) { const attr = this._var.colAttrs[key]; if (attr == null) { this._var.colAttrs[key] = { [name]: value }; } else { attr[name] = value; } } /** * @private * @param {GridRowItem} item * @param {boolean} editing * @param {GridColumnDefinition} col * @returns {any} */ _getItemProp(item, editing, col) { let value; if (typeof col?.filter === 'function') { value = col.filter(item, editing, this._var.refs.body); } else { value = item[col.key]; } if (value == null) { return value; } const prop = editing ? 'Value' : 'DisplayValue'; if (Object.prototype.hasOwnProperty.call(value, prop)) { return value[prop]; } return value; } /** * @private * @param {GridRowItem} item * @param {boolean} editing * @param {GridColumnDefinition} col * @returns {any} */ _getItemSortProp(item, editing, col) { const value = item[col.key]; if (value != null && Object.prototype.hasOwnProperty.call(value, 'SortValue')) { return value.SortValue; } return this._getItemProp(item, editing, col); } /** * @private * @param {HTMLElement} target * @returns {HTMLElement[]} */ _getRowTarget(target) { let parent; while ((parent = target.parentElement) != null && !parent.classList.contains('ui-grid-row')) { target = parent; } return [parent, target]; } /** * @private * @param {HTMLElement} element * @returns {HTMLElement} */ _getParentElement(element) { while (element != null && element.className !== 'ui-grid') { element = element.parentElement; } return element; } /** * @private * @param {HTMLElement} e * @returns {boolean} */ _notHeader(e) { if (e.parentElement.classList.contains('ui-switch')) { return true; } return /^(input|label|layer|svg|use)$/i.test(e.tagName); } /** * @private * @param {MouseEvent} e * @param {GridColumnDefinition} col * @param {boolean} [force] */ _onHeaderClicked(e, col, force) { if (!force && (this._get(col.key, 'resizing') || this._get(col.key, 'dragging'))) { return; } if (!this._notHeader(e.target)) { if (Array.isArray(this.sortArray) && this.sortArray.length > 0) { showConfirm(this.langs.sort, this.langs.sortArrayExists, [ { key: 'yes', text: this.langs.yes }, { text: this.langs.cancel } ]).then(r => { if (r.result === 'yes') { const sortCol = this.sortArray.find(c => c.column === col.key); this.sortDirection = sortCol?.order === 'asc' ? -1 : 1; this._onDoHeaderSort(col); } }); } else { this._onDoHeaderSort(col); } } } /** * @private * @param {GridColumnDefinition} col */ _onDoHeaderSort(col) { const index = this.columns.indexOf(col); if (index < 0) { return; } if (this.sortIndex === index) { this.sortDirection = this.sortDirection === 1 ? -1 : 1; } else { this.sortIndex = index; } this.sortColumn(); if (typeof this.onColumnChanged === 'function') { this.onColumnChanged(ColumnChangedType.Sort, index, this.sortDirection); } } /** * @private * @param {MouseEvent} [e] * @returns {boolean} */ _onCloseFilter(e) { if (e != null) { if ((e.target.tagName === 'LAYER' && e.target.classList.contains('filter')) || e.target.tagName === 'use') { return false; } } const panels = this._var.el.querySelectorAll('.filter-panel.active'); if (panels.length > 0) { panels.forEach(el => el.classList.remove('active')); setTimeout(() => this._var.el.querySelectorAll('.filter-panel').forEach(el => el.remove()), 120); const filtering = this._var.colAttrs.__filtering; if (filtering instanceof HTMLElement) { filtering.classList.remove('hover'); } delete this._var.colAttrs.__filtering; document.removeEventListener('mousedown', this._onCloseFilter); return true; } return false; } /** * @private * @param {MouseEvent} e * @param {GridColumnDefinition} col */ _onFilter(e, col) { if (this._onCloseFilter()) { return; } document.addEventListener('mousedown', this._onCloseFilter.bind(this)); const panel = createElement('div', 'filter-panel'); panel.addEventListener('mousedown', e => e.stopPropagation()); const filter = e.currentTarget; const th = filter.parentElement; const width = th.offsetWidth; panel.style.top = `${th.offsetHeight + this._var.el.scrollTop}px`; const offsetLeft = th.offsetLeft; const totalWidth = th.parentElement.offsetWidth; const left = offsetLeft + FilterPanelWidth > totalWidth ? totalWidth - FilterPanelWidth : offsetLeft + (width > FilterPanelWidth ? width - FilterPanelWidth : 0); panel.style.left = `${left}px`; const maxHeight = this._var.el.offsetHeight - this._var.headerHeight; if (maxHeight < 300) { panel.style.height = `${maxHeight}px`; } else { panel.style.height = ''; } // search let searchbox; if (col.allowSearch !== false) { const searchholder = createElement('div', 'filter-search-holder'); searchbox = createElement('input', 'filter-search-box ui-text'); searchbox.type = 'text'; const searchicon = createIcon('fa-regular', 'search'); searchicon.addEventListener('mousedown', e => { searchbox.focus(); e.preventDefault(); }); searchholder.append(searchbox, searchicon); panel.append(searchholder); } // list const itemlist = createElement('div', 'filter-item-list'); itemlist.addEventListener('scroll', e => throttle(this._onFilterScroll, RefreshInterval, this, col, itemlist, e.target.scrollTop), { passive: true }); // - all const itemall = createElement('div', 'filter-item filter-all'); itemall.appendChild(createCheckbox({ label: this.langs.all, onchange: e => { const checked = e.target.checked; itemlist.querySelectorAll('.filter-content input').forEach(box => box.checked = checked); for (let it of this._get(col.key, 'filterSource')) { it.__checked = checked; } } })); itemlist.appendChild(itemall); // - items let array; if (Array.isArray(col.filterSource)) { array = col.filterSource; } else if (typeof col.filterSource === 'function') { array = col.filterSource.call(this, col); } else { const dict = Object.create(null); for (let item of this._var.source) { let displayValue = this._getItemProp(item.values, false, col); if (displayValue == null) { displayValue = col.filterAllowNull ? this.langs.null : ''; } if (Array.isArray(displayValue)) { const vals = this._getItemProp(item.values, true, col); displayValue.forEach((display, i) => { if (!Object.hasOwnProperty.call(dict, display)) { dict[display] = { Value: vals[i], DisplayValue: display, RowItem: item.values }; } }); } else if (!Object.hasOwnProperty.call(dict, displayValue)) { dict[displayValue] = { Value: this._getItemProp(item.values, true, col), DisplayValue: displayValue, RowItem: item.values }; } } array = Object.values(dict); if (typeof col.sortFilter === 'function') { array.sort((a, b) => col.sortFilter(a.RowItem, b.RowItem)); } else { const type = this._var.colTypes[col.key]; const isDateColumn = type === GridDateColumn || type instanceof GridDateColumn; const filterAsValue = col.filterAsValue; array.sort((itemA, itemB) => { let a = itemA.Value; let b = itemB.Value; if (a instanceof Date || b instanceof Date) { if (a == null) { a = 0; } else if (b == null) { b = 0; } } else if (a != null && b == null) { return 1; } else { if (!filterAsValue && !isDateColumn) { a = itemA.DisplayValue; b = itemB.DisplayValue; } if (typeof a === 'string' && typeof b === 'string') { a = a.toLowerCase(); b = b.toLowerCase(); } } return a > b ? 1 : (a < b ? -1 : 0); }); } } array = array.map(i => { if (Object.prototype.hasOwnProperty.call(i, 'Value') && Object.prototype.hasOwnProperty.call(i, 'DisplayValue')) { return i; } return { Value: i, DisplayValue: i == null ? this.langs.null : i }; }); this._fillFilterList(col, itemlist, array, itemall); itemall.querySelector('input').checked = ![...itemlist.querySelectorAll('.filter-content input')].some(i => !i.checked); panel.appendChild(itemlist); if (searchbox != null) { searchbox.addEventListener('input', e => { const key = e.currentTarget.value.toLowerCase(); const items = key.length === 0 ? array : array.filter(i => { let displayValue; if (i != null && Object.prototype.hasOwnProperty.call(i, 'DisplayValue')) { displayValue = i.DisplayValue; } else { displayValue = i; } if (displayValue == null) { displayValue = this.langs.null; } return String(displayValue).toLowerCase().includes(key); }); this._fillFilterList(col, itemlist, items, itemall); }); } // function const functions = createElement('div', 'filter-function'); functions.append( createElement('span', ok => { ok.className = 'button'; ok.innerText = this.langs.ok; ok.addEventListener('click', () => { const array = this._get(col.key, 'filterSource').filter(i => i.__checked !== false); if (typeof col.onFilterOk === 'function') { col.onFilterOk.call(this, col, array); } else { if (GridColumnTypeEnum.isAlwaysEditing(col.type)) { col.filterValues = array.map(a => a.Value); } else { const nullValue = col.filterAllowNull ? null : ''; col.filterValues = array.map(a => a.Value == null ? nullValue : a.DisplayValue); } } this._var.colAttrs.__filtered = true; this._refreshSource(); if (typeof col.onFiltered === 'function') { col.onFiltered.call(this, col); } filter.replaceChildren(createIcon('fa-solid', this.filteredIcon)); filter.classList.add('active'); this._onCloseFilter(); }); }), createElement('span', reset => { reset.className = 'button'; reset.innerText = this.langs.reset; reset.addEventListener('click', () => { delete col.filterValues; this._var.colAttrs.__filtered = this.columns.some(c => c.filterValues != null) this._refreshSource(); if (typeof col.onFiltered === 'function') { col.onFiltered.call(this, col); } filter.replaceChildren(createIcon('fa-solid', this.filterIcon)); filter.classList.remove('active'); this._onCloseFilter(); }); }) ); panel.appendChild(functions); this._var.el.appendChild(panel); requestAnimationFrame(() => panel.classList.add('active')); this._var.colAttrs.__filtering = filter; filter.classList.add('hover'); } /** * @private * @param {GridColumnDefinition} col * @param {HTMLDivElement} list * @param {ValueItem[]} array * @param {HTMLDivElement} all */ _fillFilterList(col, list, array, all) { list.querySelector('.filter-holder')?.remove(); list.querySelector('.filter-content')?.remove(); const rowHeight = this.filterRowHeight; const height = array.length * rowHeight; this._set(col.key, 'filterHeight', height); const holder = createElement('div', 'filter-holder'); holder.style.height = `${height}px`; const content = createElement('div', 'filter-content'); content.style.top = `${rowHeight}px`; this._set(col.key, 'filterSource', array); const propKey = GridColumnTypeEnum.isAlwaysEditing(col.type) ? 'Value' : 'DisplayValue'; const nullValue = col.filterAllowNull ? null : ''; const allSelected = !Array.isArray(col.filterValues); for (let item of array) { let v = item.Value ?? nullValue; if (v != null) { v = Object.prototype.hasOwnProperty.call(item, propKey) ? item[propKey] : item; } item.__checked = allSelected || col.filterValues.some(it => Array.isArray(it) ? it.includes(v) : it === v); } if (array.length > 12) { array = array.slice(0, 12); } this._doFillFilterList(col, content, array, all); list.append(holder, content); } /** * @private * @param {GridColumnDefinition} col * @param {HTMLDivElement} content * @param {ValueItem[]} array * @param {HTMLDivElement} all */ _doFillFilterList(col, content, array, all) { for (let item of array) { const div = createElement('div', 'filter-item'); const title = Object.prototype.hasOwnProperty.call(item, 'DisplayValue') ? item.DisplayValue : item; let display; if (typeof col.filterTemplate === 'function') { display = col.filterTemplate(item); } if (display == null) { display = title && String(title).replace(/( |\r\n|\n|)/g, '\u00a0'); } div.appendChild(createCheckbox({ checked: item.__checked, label: display, title, onchange: e => { item.__checked = e.target.checked; all.querySelector('input').checked = ![...content.querySelectorAll('input')].some(i => !i.checked); } })); content.appendChild(div); } } /** * @private * @param {GridColumnDefinition} col * @param {HTMLDivElement} list * @param {number} top */ _onFilterScroll(col, list, top) { const rowHeight = this.filterRowHeight; top -= (top % (rowHeight * 2)) + rowHeight; if (top < 0) { top = 0; } else { let bottomTop = this._get(col.key, 'filterHeight') - (12 * rowHeight); if (bottomTop < 0) { bottomTop = 0; } if (top > bottomTop) { top = bottomTop; } } if (this._get(col.key, 'filterTop') !== top) { this._set(col.key, 'filterTop', top); const startIndex = top / rowHeight; let array = this._get(col.key, 'filterSource'); if (startIndex + 12 < array.length) { array = array.slice(startIndex, startIndex + 12); } else { array = array.slice(-12); } const content = list.querySelector('.filter-content'); content.replaceChildren(); this._doFillFilterList(col, content, array, list.querySelector('.filter-all')); content.style.top = `${top + rowHeight}px`; } } /** * @private * @param {MouseEvent} e * @param {GridColumnDefinition} col */ _onDragStart(e, col) { if (this._notHeader(e.target)) { return; } if (e.currentTarget.classList.contains('sticky')) { return; } const index = indexOfParent(e.currentTarget) - (this.expandable ? 1 : 0); const cx = getClientX(e); const window = this.window ?? global; const clearEvents = attr => { for (let event of ['mousemove', 'mouseup']) { if (Object.prototype.hasOwnProperty.call(attr, event)) { window.removeEventListener(event, attr[event]); delete attr[event]; } } }; let attr = this._var.colAttrs[col.key]; if (attr == null) { attr = this._var.colAttrs[col.key] = {}; } else { clearEvents(attr); } attr.dragging = true; const draggerCellLeft = this._var.refs.header.querySelector('th:last-child').offsetLeft; let p = this._var.el; let gridLeftFromWindow = p.offsetLeft; while ((p = p.offsetParent) != null) { gridLeftFromWindow += p.offsetLeft + p.clientLeft; } const mouse = cx - e.currentTarget.offsetLeft + this._var.scrollLeft - gridLeftFromWindow; const dragmove = e => { const cx2 = getClientX(e); const offset = cx2 - cx; let pos = attr.offset; let dragging; if (pos == null) { if (offset > MiniDragOffset || offset < -MiniDragOffset) { dragging = true; } } else if (pos !== offset) { dragging = true; } if (dragging) { this._changingColumnOrder(index, offset, mouse, draggerCellLeft); attr.offset = offset; } }; attr.mousemove = e => throttle(dragmove, RefreshInterval, this, e); attr.mouseup = () => { clearEvents(attr); if (attr.offset == null) { delete attr.dragging; } else { requestAnimationFrame(() => { delete attr.dragging; delete attr.offset; }); this._changeColumnOrder(index); } }; ['mousemove', 'mouseup'].forEach(event => window.addEventListener(event, attr[event])); } /** * @private * @param {MouseEvent} e * @param {GridColumnDefinition} col */ _onResizeStart(e, col) { const cx = getClientX(e); const width = col.width; const index = indexOfParent(e.currentTarget.parentElement) - (this.expandable ? 1 : 0); const window = this.window ?? global; const clearEvents = attr => { for (let event of ['mousemove', 'mouseup']) { if (Object.prototype.hasOwnProperty.call(attr, event)) { window.removeEventListener(event, attr[event]); delete attr[event]; } } }; let attr = this._var.colAttrs[col.key]; if (attr == null) { attr = this._var.colAttrs[col.key] = {}; } else { clearEvents(attr); } attr.resizing = width; const resizemove = e => { const cx2 = getClientX(e); const val = width + (cx2 - cx); if (val < MiniColumnWidth) { return; } attr.resizing = val; attr.sizing = true; this._changeColumnWidth(index, val); }; attr.mousemove = e => throttle(resizemove, RefreshInterval, this, e); attr.mouseup = e => { clearEvents(attr); const width = attr.resizing; if (width != null) { requestAnimationFrame(() => delete attr.resizing); if (attr.sizing) { delete attr.sizing; delete attr.autoResize; this._changeColumnWidth(index, width); if (typeof this.onColumnChanged === 'function') { this.onColumnChanged(ColumnChangedType.Resize, index, width); } } } e.stopPropagation(); e.preventDefault(); }; ['mousemove', 'mouseup'].forEach(event => window.addEventListener(event, attr[event])); } /** * @private * @param {MouseEvent} e * @param {GridColumnDefinition} col */ _onAutoResize(e, col) { const th = e.currentTarget.parentElement; const index = indexOfParent(th); const offset = this.expandable ? 1 : 0; let width = th.querySelector('div:first-child').scrollWidth; for (let row of this._tableRows) { const element = row.children[index + offset].children[0]; const w = element.scrollWidth; if (w > width) { width = w; } } if (width < MiniColumnWidth) { width = MiniColumnWidth; } if (width > 0 && width !== col.width) { width += 12; this._changeColumnWidth(index - offset, width); if (typeof this.onColumnChanged === 'function') { this.onColumnChanged(ColumnChangedType.Resize, index - offset, width); } } } /** * @private * @param {GridColumnDefinition} col * @param {boolean} flag */ _onColumnAllChecked(col, flag) { if (this._var.currentSource == null) { return; } const key = col.key; const isFunction = typeof col.enabled === 'function'; const isString = typeof col.enabled === 'string'; if (typeof col.onAllChecked === 'function') { col.onAllChecked.call(this, col, flag); } else { for (let row of this._var.currentSource) { const item = row.values; if (item == null) { continue; } const enabled = isFunction ? col.enabled(item) : isString ? item[col.enabled] : col.enabled; if (enabled !== false) { const old = item[key]; item[key] = flag; row.__changed = true; if (typeof col.onChanged === 'function') { col.onChanged.call(this, item, flag, old, void 0, row.__expandable_object); } } } this.refresh(); } } /** * @private * @param {Event} e */ _onScroll(e) { if (this._var.colAttrs.__filtering != null) { this._onCloseFilter(); } this._var.scrollLeft = e.target.scrollLeft; const top = e.target.scrollTop; if (!this.virtual) { if (this.total != null) { this._var.refs.footer.parentElement.style.bottom = `${this._var.footerOffset - e.target.scrollTop}px`; } const tti = this._topToIndex(top); if (this.onBodyScrolled === 'function') { this.onBodyScrolled(e, tti.index, this._var.rowCount); } return; } this._scrollToTop(top); if (this.total != null) { this._var.refs.footer.parentElement.style.bottom = `${this._var.refs.table.offsetTop + this._var.footerOffset - e.target.scrollTop}px`; } if (this.onBodyScrolled === 'function') { this.onBodyScrolled(e, this._var.startIndex, this._var.rowCount); } if (this._var.isFirefox) { // 修复 firefox 下列头显示位置不正确的问题 debounce(this._fillRows, RefreshInterval, this, this._tableRows, this.columns); } } /** * 清除 tooltip 显示框,并延时后设置 `display: none` * 不设置 display 的话会导致元素依然被算入滚动范围,从而影响滚动条显示 * @private * @param {HTMLDivElement} holder */ _clearHolder(holder) { if (this._var.tooltipTimer != null) { clearTimeout(this._var.tooltipTimer); } this._var.tooltipTimer = setTimeout(() => { holder.style.display = 'none'; this._var.tooltipTimer = null; }, 120); } /** * @private * @param {MouseEvent} e * @param {HTMLDivElement} holder */ _onGridMouseMove(e, holder) { e.stopPropagation(); if (e.target.classList.contains('ui-grid-hover-holder')) { return; } let [parent, target] = this._getRowTarget(e.target); if (parent == null) { delete holder.dataset.row; delete holder.dataset.col; if (holder.classList.contains('active')) { holder.classList.remove('active'); this._clearHolder(holder); } return; } if (this._getParentElement(parent) !== this._var.el) { // sub ui-grid return; } const col = target.dataset.col; const row = target.dataset.row; if (holder.dataset.row === row && holder.dataset.col === col) { return; } const type = this._var.colTypes[this.columns[col]?.key]; if (type?.canEdit && this._var.virtualRows[row]?.editing) { delete holder.dataset.row; delete holder.dataset.col; if (holder.classList.contains('active')) { holder.classList.remove('active'); this._clearHolder(holder); } return; } let element = target.children[0]; if (type != null && typeof type.getElement === 'function') { element = type.getElement(element); } if (element?.tagName !== 'SPAN') { if (holder.classList.contains('active')) { delete holder.dataset.row; delete holder.dataset.col; holder.classList.remove('active'); this._clearHolder(holder); } return; } if (element.scrollWidth > element.offsetWidth || element.scrollHeight > element.offsetHeight) { holder.dataset.row = row; holder.dataset.col = col; holder.innerText = element.innerText; const top = (parent.classList.contains('ui-grid-total-row') ? this._var.refs.footer.parentElement.offsetTop + 1 : target.offsetTop) + this._var.refs.table.offsetTop; let left = target.offsetLeft; let width = holder.offsetWidth; if (width > this._var.wrapClientWidth) { width = this._var.wrapClientWidth; } const maxleft = this._var.wrapClientWidth + this._var.scrollLeft - width; if (left > maxleft) { left = maxleft; } const height = target.offsetHeight; holder.style.cssText = `top: ${top}px; left: ${left}px; max-width: ${this._var.wrapClientWidth}px; min-height: ${height - 2}px`; holder.classList.add('active'); } else if (holder.classList.contains('active')) { delete holder.dataset.row; delete holder.dataset.col; holder.classList.remove('active'); this._clearHolder(holder); } } /** * @private * @param {MouseEvent} e * @param {number} index * @param {number} colIndex */ _onRowClicked(e, index, colIndex) { const startIndex = this._var.startIndex; const selectedIndex = startIndex + index; if (typeof this.willSelect === 'function' && !this.willSelect(selectedIndex, colIndex)) { return; } // multi-select let flag = false; const selectedIndexes = this._var.selectedIndexes; if (this.multiSelect) { if (e.ctrlKey) { const i = selectedIndexes.indexOf(selectedIndex); if (i < 0) { selectedIndexes.push(selectedIndex); } else { selectedIndexes.splice(i, 1); } flag = true; } else if (e.shiftKey && selectedIndexes.length > 0) { if (selectedIndexes.length > 1 || selectedIndexes[0] !== selectedIndex) { let start = selectedIndexes[selectedIndexes.length - 1]; let end; if (start > selectedIndex) { end = start; start = selectedIndex; } else { end = selectedIndex; } selectedIndexes.splice(0); for (let i = start; i <= end; ++i) { selectedIndexes.push(i); } flag = true; } } } if (!flag && (selectedIndexes.length !== 1 || selectedIndexes[0] !== selectedIndex)) { selectedIndexes.splice(0, selectedIndexes.length, selectedIndex); flag = true; } // apply style if (flag) { if (this.readonly) { this._tableRows.forEach((row, i) => { if (selectedIndexes.includes(startIndex + i)) { row.classList.add('selected'); } else if (row.classList.contains('selected')) { row.classList.remove('selected'); } }); } else { this.refresh(); } if (typeof this.onSelectedRowChanged === 'function') { this.onSelectedRowChanged(selectedIndex); } } this._var.selectedColumnIndex = colIndex; if ((this.fullrowClick || colIndex >= 0) && e.buttons === 1 && typeof this.cellClicked === 'function') { if (this.cellClicked(selectedIndex, colIndex) === false) { e.stopPropagation(); e.preventDefault(); } } } /** * @private * @param {MouseEvent} e */ _onRowDblClicked(e) { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'LAYER' && e.target.className === 'ui-check-inner' || e.target.tagName === 'LABEL' && (e.target.className === 'ui-drop-text' || e.target.className === 'ui-drop-caret')) { return; } const index = this.selectedIndex; if (typeof this.onRowDblClicked === 'function') { this.onRowDblClicked(index); } if (typeof this.onCellDblClicked === 'function') { const colIndex = this._var.selectedColumnIndex; if (this.fullrowClick || colIndex >= 0) { this.onCellDblClicked(index, colIndex); } } } /** * @private * @param {any} e * @param {number} index * @param {GridColumnDefinition} col * @param {any} value * @param {HTMLTableCellElement} cell * @param {any} [oldValue] */ _onRowChanged(e, index, col, value, cell, oldValue) { if (this._var.currentSource == null) { return; } const vals = this._var.currentSource[this._var.startIndex + index]; // FIXME: 清除缓存会导致选中状态下动态数据源下拉列表显示为空 // delete vals.source; const item = vals.values; if (item == null) { return; } let enabled = col.enabled; if (typeof enabled === 'function') { enabled = enabled.call(col, item); } else if (typeof enabled === 'string') { enabled = item[enabled]; } if (enabled !== false) { let v; let t; if (value != null) { v = Object.prototype.hasOwnProperty.call(value, 'value') ? value.value : value; t = Object.prototype.hasOwnProperty.call(value, 'text') ? value.text : value; } else { v = t = value; } const val = item[col.key]; if (val != null && Object.prototype.hasOwnProperty.call(val, 'Value')) { oldValue ??= val.Value; val.Value = v; if (Object.prototype.hasOwnProperty.call(val, 'DisplayValue')) { val.DisplayValue = t; } } else { oldValue ??= val; item[col.key] = v; } const virtualRow = this._var.virtualRows[index]; const virtualCell = virtualRow.cells[col.key]; if (virtualCell != null) { virtualCell.value = v; } let tip = col.tooltip; if (typeof tip === 'function') { tip = tip.call(col, item); } if (nullOrEmpty(tip)) { cell.querySelector('.ui-tooltip-wrapper')?.remove(); } else { setTooltip(cell.children[0], tip, false, this.element); } // 调整其他列的可用性 const row = this._tableRows[index]; const offset = this.expandable ? 1 : 0; this.columns.forEach((c, j) => { const cache = this._var.enabledDict[c.key]; if (cache !== true && cache !== col.key) { return; } const cell = row.children[j + offset]; if (cell == null) { return; } const type = this._var.colTypes[c.key] ?? GridColumn; if (typeof type.setEnabled === 'function') { if (typeof c.enabled === 'function') { enabled = c.enabled(item); } else if (typeof c.enabled === 'string') { enabled = item[c.enabled]; } else { return; } const vCell = virtualRow.cells[c.key ?? j]; if (enabled !== vCell.enabled) { vCell.enabled = enabled; type.setEnabled(cell.children[0], enabled); } } }); vals.__changed = true; if (typeof col.onChanged === 'function') { col.onChanged.call(this, item, v, oldValue, e, vals.__expandable_object); } } } /** * @private * @param {MouseEvent} _e * @param {number} index * @param {HTMLTableRowElement} _row */ _onExpandable(_e, index, _row) { if (this._var.currentSource == null) { return; } const vals = this._var.currentSource[this._var.startIndex + index]; vals.__expanded = !vals.__expanded this.refresh(); } }