react-extjs 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +36 -0
- package/src/components/AbsoluteLayout.tsx +206 -0
- package/src/components/Accordion.tsx +266 -0
- package/src/components/AnchorLayout.tsx +131 -0
- package/src/components/BorderLayout.tsx +512 -0
- package/src/components/Box.tsx +289 -0
- package/src/components/Button.tsx +300 -0
- package/src/components/ButtonGroup.tsx +170 -0
- package/src/components/CalendarPicker.tsx +431 -0
- package/src/components/CardLayout.tsx +122 -0
- package/src/components/CenterLayout.tsx +95 -0
- package/src/components/CheckboxGroup.tsx +200 -0
- package/src/components/ColorMenu.tsx +195 -0
- package/src/components/ColorPalette.tsx +126 -0
- package/src/components/ColumnLayout.tsx +183 -0
- package/src/components/ColumnTree.tsx +383 -0
- package/src/components/CycleButton.tsx +302 -0
- package/src/components/DataView.tsx +244 -0
- package/src/components/DataViewMore.tsx +337 -0
- package/src/components/DataViewTransition.tsx +235 -0
- package/src/components/DateMenu.tsx +163 -0
- package/src/components/DatePicker.tsx +387 -0
- package/src/components/DisplayField.tsx +73 -0
- package/src/components/Editor.tsx +214 -0
- package/src/components/EditorGrid.tsx +361 -0
- package/src/components/FieldLabeler.tsx +81 -0
- package/src/components/FieldReplicator.tsx +135 -0
- package/src/components/FieldSet.tsx +149 -0
- package/src/components/FitLayout.tsx +76 -0
- package/src/components/Form.tsx +3484 -0
- package/src/components/Grid.tsx +2173 -0
- package/src/components/GridFilters.tsx +332 -0
- package/src/components/GroupTab.tsx +274 -0
- package/src/components/HtmlEditor.tsx +391 -0
- package/src/components/ImageOrganizer.tsx +984 -0
- package/src/components/ImageOrganizerService.ts +361 -0
- package/src/components/ListView.tsx +221 -0
- package/src/components/LoadMask.tsx +74 -0
- package/src/components/Menu.tsx +227 -0
- package/src/components/MessageBox.tsx +138 -0
- package/src/components/MultiSlider.tsx +369 -0
- package/src/components/Organizer.tsx +403 -0
- package/src/components/PagingToolbar.tsx +255 -0
- package/src/components/Panel.tsx +451 -0
- package/src/components/ProgressBar.tsx +127 -0
- package/src/components/PropertyGrid.tsx +142 -0
- package/src/components/QuickTip.tsx +174 -0
- package/src/components/Resizable.tsx +241 -0
- package/src/components/RowLayout.tsx +126 -0
- package/src/components/Slider.tsx +353 -0
- package/src/components/SliderField.tsx +206 -0
- package/src/components/SpinnerField.tsx +141 -0
- package/src/components/SplitBar.tsx +215 -0
- package/src/components/Spotlight.tsx +262 -0
- package/src/components/StatusBar.tsx +118 -0
- package/src/components/TabPanel.tsx +540 -0
- package/src/components/TableLayout.tsx +132 -0
- package/src/components/ThemeSwitcher.tsx +167 -0
- package/src/components/ToolTip.tsx +226 -0
- package/src/components/Toolbar.tsx +321 -0
- package/src/components/Tree.tsx +809 -0
- package/src/components/TreeGrid.tsx +418 -0
- package/src/components/Viewport.tsx +218 -0
- package/src/components/Window.tsx +441 -0
- package/src/components/Writer.tsx +303 -0
- package/src/components/XmlTreeLoader.tsx +233 -0
- package/src/components/dnd/DndContext.tsx +328 -0
- package/src/components/dnd/DragSource.tsx +202 -0
- package/src/components/dnd/DragZone.tsx +66 -0
- package/src/components/dnd/DropTarget.tsx +86 -0
- package/src/components/dnd/DropZone.tsx +166 -0
- package/src/components/dnd/PanelProxy.tsx +81 -0
- package/src/components/dnd/PanelResizer.tsx +143 -0
- package/src/components/dnd/Portal.tsx +254 -0
- package/src/components/dnd/Reorderer.tsx +73 -0
- package/src/components/dnd/ToolbarReorderer.tsx +226 -0
- package/src/main.tsx +2649 -0
- package/src/styles/ext-theme.css +889 -0
- package/src/util/theme.ts +95 -0
|
@@ -0,0 +1,2173 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { resolveThemeImageUrl } from '../util/theme';
|
|
3
|
+
import { Panel } from './Panel';
|
|
4
|
+
import { Menu } from './Menu';
|
|
5
|
+
import type { MenuItem } from './Menu';
|
|
6
|
+
import { Button } from './Button';
|
|
7
|
+
import {
|
|
8
|
+
FilterWidget,
|
|
9
|
+
GridFilterConfig,
|
|
10
|
+
inferFilterType,
|
|
11
|
+
validateFilter,
|
|
12
|
+
isFilterActivatable,
|
|
13
|
+
defaultEmpty,
|
|
14
|
+
} from './GridFilters';
|
|
15
|
+
|
|
16
|
+
// 与原版 Ext.BLANK_IMAGE_URL 等价:1x1 透明 gif,避免网络请求
|
|
17
|
+
const BLANK =
|
|
18
|
+
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
19
|
+
|
|
20
|
+
// 与 Ext.getScrollBarWidth() 一致:运行时测量纵向滚动条布局宽。
|
|
21
|
+
// 关键:覆盖式滚动条浏览器(现代 Chromium / 多数 Mac)返回 0,经典 17px 滚动条浏览器返回 17。
|
|
22
|
+
// 之前写死 19 → 覆盖式浏览器里真 Ext 预留 0(无空隙),本组件却预留 19(末列右侧凭空多 19px 空隙)。
|
|
23
|
+
// 改为运行时测量值,与真 Ext 行为对齐:覆盖式环境无空隙,经典环境仍预留 17 以杜绝编辑聚焦时的列偏移。
|
|
24
|
+
let _scrollBarWidth: number | null = null;
|
|
25
|
+
function getScrollBarWidth(): number {
|
|
26
|
+
if (_scrollBarWidth != null) return _scrollBarWidth;
|
|
27
|
+
if (typeof document === 'undefined') return 17;
|
|
28
|
+
const probe = document.createElement('div');
|
|
29
|
+
probe.style.cssText =
|
|
30
|
+
'position:absolute;top:-9999px;left:-9999px;width:100px;height:100px;overflow:scroll;visibility:hidden';
|
|
31
|
+
document.body.appendChild(probe);
|
|
32
|
+
_scrollBarWidth = probe.offsetWidth - probe.clientWidth;
|
|
33
|
+
document.body.removeChild(probe);
|
|
34
|
+
return _scrollBarWidth;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GridColumn {
|
|
38
|
+
/** 数据字段名(同时作为列 id,对应 DOM 类 x-grid3-td-{dataIndex}) */
|
|
39
|
+
dataIndex: string;
|
|
40
|
+
/** 列 id(默认等于 dataIndex;checker 列为 'checker') */
|
|
41
|
+
id?: string;
|
|
42
|
+
/** 表头文字 */
|
|
43
|
+
header: string;
|
|
44
|
+
/** 列宽(px),默认 100 */
|
|
45
|
+
width?: number;
|
|
46
|
+
/** 内容对齐(默认 left) */
|
|
47
|
+
align?: 'left' | 'right' | 'center';
|
|
48
|
+
/** 是否可排序(默认 true,对齐 Ext 列 sortable) */
|
|
49
|
+
sortable?: boolean;
|
|
50
|
+
/** 是否可拖拽改列宽(默认 true,对齐 Ext enableColumnResize) */
|
|
51
|
+
resizable?: boolean;
|
|
52
|
+
/** 自定义单元格渲染(返回 React 节点) */
|
|
53
|
+
renderer?: (value: unknown, row: Record<string, unknown>) => React.ReactNode;
|
|
54
|
+
/** 是否禁用该列表头菜单按钮(对齐 Ext ColumnModel menuDisabled;PropertyGrid 用) */
|
|
55
|
+
menuDisabled?: boolean;
|
|
56
|
+
/** 特殊列类型:'rownumberer' 行号列(id 固定 'numberer'、宽 23、不可排序/缩放);'checkcolumn' 复选框列(对齐 Ext.ux.grid.CheckColumn,渲染可勾选复选框,点击切换该行 dataIndex 布尔值,不可排序/缩放)。普通文本列不设 type。 */
|
|
57
|
+
type?: 'rownumberer' | 'checkcolumn';
|
|
58
|
+
/** 初始隐藏该列(对齐 Ext ColumnModel hidden:true;列菜单可重新显示) */
|
|
59
|
+
hidden?: boolean;
|
|
60
|
+
/** 分组汇总类型(对齐 Ext.ux.grid.GroupSummary.summaryType):仅当 Grid 启用 groupField 时生效。
|
|
61
|
+
* 对该分组内本列数值做聚合,渲染到分组底部汇总行对应单元格。取值 sum/count/average/min/max。 */
|
|
62
|
+
summaryType?: 'sum' | 'count' | 'average' | 'min' | 'max';
|
|
63
|
+
/** 分组汇总自定义渲染(对齐 Ext.ux.grid.GroupSummary.summaryRenderer):参数 (聚合值, 该分组数据行[], dataIndex)。缺省直接显示聚合值。 */
|
|
64
|
+
summaryRenderer?: (value: number | string, groupRows: Record<string, unknown>[], dataIndex: string) => React.ReactNode;
|
|
65
|
+
/** 行编辑器字段类型(对齐 Ext ColumnModel 列 editor):'text' 文本框 / 'number' 数字框。仅当 Grid 启用 rowEditor 时作为该行编辑字段;省略时按当前值类型推断(数字→number,其余→text)。与 EditorGrid 的 editor(boolean | { type })共用同一属性名,故类型取并集。 */
|
|
66
|
+
editor?: 'text' | 'number' | boolean | { type?: 'text' | 'number' };
|
|
67
|
+
/** 锁定列(对齐 Ext.ux.grid.LockingGridView + LockingColumnModel:列 config.locked=true 即锁定)。
|
|
68
|
+
* 锁定列渲染在左侧固定面板(x-grid3-locked,不随水平滚动移动、纵向滚动与右侧解锁面板同步);
|
|
69
|
+
* 其余为解锁列,渲染在右侧可水平滚动面板(x-grid3-unlocked)。至少 1 锁定 + 1 解锁才会触发分栏渲染,
|
|
70
|
+
* 否则退化为普通 Grid,行为不变(零回归)。样式复用原版 examples/ux/css/LockingGridView.css(零新增手写 CSS)。 */
|
|
71
|
+
locked?: boolean;
|
|
72
|
+
/** 列过滤配置(对齐 Ext.ux.grid.GridFilters:列定义式 filter 属性)。
|
|
73
|
+
* 传 true 等价于 {},type 缺省时按该列数据值推断(string/numeric/date/list/boolean)。
|
|
74
|
+
* 支持的子字段见 GridFilterConfig:value/active/options(列表)/菜单文案等。 */
|
|
75
|
+
filter?: GridFilterConfig | boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 列分组表头单元(对齐 Ext.ux.grid.ColumnHeaderGroup 的 cm.rows 元素):
|
|
80
|
+
* 把连续若干列在表头顶部合并为一个跨列分组标题(如「Client」跨 2 列、「Product」跨 3 列)。
|
|
81
|
+
* 每行分组为 HeaderGroup[];Grid.headerGroups 为这样的行数组(可多层)。
|
|
82
|
+
* 分组单元本身不可排序/缩放/打开菜单,仅做视觉分组;宽度 = 所跨列宽之和(末列吸收 slack)。
|
|
83
|
+
*/
|
|
84
|
+
export interface HeaderGroup {
|
|
85
|
+
/** 分组标题文字(React 节点,可含 HTML) */
|
|
86
|
+
header: React.ReactNode;
|
|
87
|
+
/** 跨列数(默认 1) */
|
|
88
|
+
colspan?: number;
|
|
89
|
+
/** 标题对齐(默认 left) */
|
|
90
|
+
align?: 'left' | 'right' | 'center';
|
|
91
|
+
/** 关联列 dataIndex(用于生成 x-grid3-td-{id},缺省用首个被跨列 id) */
|
|
92
|
+
dataIndex?: string;
|
|
93
|
+
/** 悬浮提示(对齐 Ext grid tooltip) */
|
|
94
|
+
tooltip?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 行展开器配置(对齐 Ext.ux.grid.RowExpander 插件):在 Grid 前置一列展开/收起按钮,
|
|
99
|
+
* 点击/双击/回车可切换显示该行下方的「行体」详情区(由 getRowBody 渲染任意内容)。
|
|
100
|
+
* 复刻自 examples/ux/RowExpander.js 的 getBody / expandOnDblClick / expandOnEnter / expandedField。
|
|
101
|
+
*/
|
|
102
|
+
export interface RowExpanderConfig {
|
|
103
|
+
/** 返回展开行体的内容(对齐 Ext.ux.grid.RowExpander.getBody,可为 React 节点 / HTML 字符串) */
|
|
104
|
+
getRowBody: (row: Record<string, unknown>, rowIndex: number) => React.ReactNode;
|
|
105
|
+
/** 初始展开的 dataIndex 字段名(对齐 Ext expandedField,默认 'expanded');其值为真表示初始展开 */
|
|
106
|
+
expandedField?: string;
|
|
107
|
+
/** 双击行切换展开(默认 true,对齐 expandOnDblClick) */
|
|
108
|
+
expandOnDblClick?: boolean;
|
|
109
|
+
/** 回车键切换展开(默认 true,对齐 expandOnEnter) */
|
|
110
|
+
expandOnEnter?: boolean;
|
|
111
|
+
/** 展开列宽(默认 20,对齐 Ext expander column width) */
|
|
112
|
+
width?: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 行编辑器配置(对齐 Ext.ux.grid.RowEditor 插件):双击(或单击,见 clicksToEdit)某行进入
|
|
117
|
+
* 行内编辑,每行底部浮出编辑条(x-row-editor:字段按列对齐 + 居中 Save/Cancel 按钮,样式复用
|
|
118
|
+
* 原版 examples/ux/css/RowEditor.css),保存回写整行、取消丢弃。复刻自 examples/ux/RowEditor.js
|
|
119
|
+
* 的 startEditing / stopEditing(true|false) / positionButtons 行为。
|
|
120
|
+
*/
|
|
121
|
+
export interface RowEditorConfig {
|
|
122
|
+
/** 进入编辑所需点击次数(对齐 Ext clicksToEdit):2=双击行进入(默认),1=单击行进入。 */
|
|
123
|
+
clicksToEdit?: 1 | 2;
|
|
124
|
+
/** 保存按钮文字(默认 'Save',对齐 saveText) */
|
|
125
|
+
saveText?: string;
|
|
126
|
+
/** 取消按钮文字(默认 'Cancel',对齐 cancelText) */
|
|
127
|
+
cancelText?: string;
|
|
128
|
+
/** 编辑提交回调:参数为(原始数据索引 origIdx, 编辑后的整行对象)。外部据此更新数据源(对齐 Ext afteredit 提交到 store record)。 */
|
|
129
|
+
onCommit?: (origIdx: number, newRow: Record<string, unknown>) => void;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface GridProps {
|
|
133
|
+
/** 列模型(TableGrid 模式下可由 fromMarkup 推导,可省略) */
|
|
134
|
+
columns?: GridColumn[];
|
|
135
|
+
/** 数据行(TableGrid 模式下可由 fromMarkup 推导,可省略) */
|
|
136
|
+
data?: Record<string, unknown>[];
|
|
137
|
+
title?: string;
|
|
138
|
+
width?: number | string;
|
|
139
|
+
/** 固定高度(px)触发滚动区;不传则 autoHeight */
|
|
140
|
+
height?: number;
|
|
141
|
+
/** 斑马纹(默认 true) */
|
|
142
|
+
stripeRows?: boolean;
|
|
143
|
+
/** 自动撑满剩余宽度的列 id(对齐 Ext autoExpandColumn):网格整宽铺满时,该列吸收 slack 而非末列。 */
|
|
144
|
+
autoExpandColumn?: string;
|
|
145
|
+
/** 高亮行索引(对应 x-grid3-row-selected),受控单选 */
|
|
146
|
+
selectedRow?: number;
|
|
147
|
+
/** 选择模式:'row' 点击行单选;'checkbox' 前置勾选列,支持多选/全选;'radio' 前置单选框列,单行单选(对齐 Ext RadioSelectionModel)。默认无交互选择。 */
|
|
148
|
+
selectionMode?: 'row' | 'checkbox' | 'radio';
|
|
149
|
+
/** 分组字段(对齐 Ext.grid.GroupingView + store.groupBy):按该字段相邻分组并渲染分组头栏。
|
|
150
|
+
* 未设置时退化为普通 Grid,行为不变(零回归)。 */
|
|
151
|
+
groupField?: string;
|
|
152
|
+
/** 是否启用表头列菜单(对齐 Ext enableColumnHide,默认 true):hd-btn 点击打开列显示菜单 */
|
|
153
|
+
enableColumnHide?: boolean;
|
|
154
|
+
/** 是否允许拖拽表头重排列(对齐 Ext enableColumnMove,默认 false) */
|
|
155
|
+
enableColumnReorder?: boolean;
|
|
156
|
+
/** 是否允许拖拽数据行重排(对齐 Ext 行 DnD:拖动行改变底层数据顺序,默认 false) */
|
|
157
|
+
enableRowReorder?: boolean;
|
|
158
|
+
/** 行展开器插件(对齐 Ext.ux.grid.RowExpander):前置展开列,点击/双击/回车切换行体详情区。未设置则网格退化为普通表格,行为不变(零回归)。 */
|
|
159
|
+
expander?: RowExpanderConfig;
|
|
160
|
+
/** 行编辑器插件(对齐 Ext.ux.grid.RowEditor):双击/单击行进入行内编辑,浮出按列对齐的编辑条 + Save/Cancel。未设置则网格不变(零回归)。 */
|
|
161
|
+
rowEditor?: RowEditorConfig;
|
|
162
|
+
/** 列分组表头(对齐 Ext.ux.grid.ColumnHeaderGroup):在表头顶部叠加一层/多层跨列分组标题。
|
|
163
|
+
* 每层为 HeaderGroup[](colspan 之和须等于可见列数,未设置则网格退化为普通表格,行为不变(零回归)。
|
|
164
|
+
* 样式复用原版 examples/ux/css/ColumnHeaderGroup.css(零新增手写 CSS)。 */
|
|
165
|
+
headerGroups?: HeaderGroup[][];
|
|
166
|
+
/** 复选框列勾选变化回调(对齐 Ext.ux.grid.CheckColumn 点击切换 record 字段值):参数为(原始数据索引 origIdx, dataIndex, 新布尔值)。仅当某列 type='checkcolumn' 时触发。 */
|
|
167
|
+
onCheckColumnChange?: (origIdx: number, dataIndex: string, newValue: boolean) => void;
|
|
168
|
+
/** TableGrid 保真(对齐 Ext.ux.grid.TableGrid):直接传入一段 HTML <table> 标记,内部解析 thead/tbody
|
|
169
|
+
* 生成 columns/data(列名 'tcol-{i}'、sortable=true,原版会移除源表格)。指定时覆盖 columns/data 入参;
|
|
170
|
+
* 仍允许外层用 columns 补充分列字段(如 renderer),或传 height 控制是否 autoHeight(原版默认 autoHeight:true)。 */
|
|
171
|
+
fromMarkup?: string;
|
|
172
|
+
className?: string;
|
|
173
|
+
style?: React.CSSProperties;
|
|
174
|
+
/** 透传 ref:暴露 addColumn / removeColumn / showColumn / hideColumn / getColumns(对齐 Ext ColumnModel 运行时增删)。 */
|
|
175
|
+
ref?: React.Ref<GridHandle>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Grid 命令句柄(对齐 Ext ColumnModel/GridPanel 运行时列增删):
|
|
180
|
+
* - addColumn(col, id?):追加一列到当前列列表尾部,返回稳定 id(便于后续 removeColumn)。
|
|
181
|
+
* - removeColumn(id):按 id 移除一列(动态追加列走 dynamicCols;静态列走 removedStatic,不破坏 columns prop 受控契约)。
|
|
182
|
+
* - showColumn(id) / hideColumn(id):切换列显示/隐藏(对齐 colModel.setHidden)。
|
|
183
|
+
* - getColumns():返回当前列列表(含 id + 原始列定义),顺序为 [静态列剔隐藏, 动态追加列]。
|
|
184
|
+
*/
|
|
185
|
+
export interface GridHandle {
|
|
186
|
+
addColumn: (col: GridColumn, id?: string) => string;
|
|
187
|
+
removeColumn: (id: string) => void;
|
|
188
|
+
showColumn: (id: string) => void;
|
|
189
|
+
hideColumn: (id: string) => void;
|
|
190
|
+
getColumns: () => Array<{ id: string; column: GridColumn }>;
|
|
191
|
+
/** GridFilters 保真:清空所有过滤器(对齐 Ext.ux.grid.GridFilters.clearFilters)。 */
|
|
192
|
+
clearFilters: () => void;
|
|
193
|
+
/** GridFilters 保真:返回当前激活过滤器的序列化数据(对齐 Ext.ux.grid.GridFilters.getFilterData)。 */
|
|
194
|
+
getFilterData: () => Array<{ field: string; data: { type: string; value: any } }>;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 保真映射(来源 src/widgets/grid/GridView.js:initTemplates 267-329、doRender 673-741、renderUI 796-806):
|
|
199
|
+
* - 内核 DOM:x-grid3 > x-grid3-viewport > (x-grid3-header > x-grid3-header-inner > x-grid3-header-offset + x-clear)
|
|
200
|
+
* + (x-grid3-scroller > x-grid3-body + a.x-grid3-focus) + resize-marker + resize-proxy
|
|
201
|
+
* - 表头单元格:td.x-grid3-hd.x-grid3-cell.x-grid3-td-{id} > div.x-grid3-hd-inner.x-grid3-hd-{id}
|
|
202
|
+
* > a.x-grid3-hd-btn(enableHdMenu 默认 true)+ {value} + img.x-grid3-sort-icon(默认 display:none)
|
|
203
|
+
* - 数据行:div.x-grid3-row (+x-grid3-row-alt) > table.x-grid3-row-table > tbody > tr > td.x-grid3-col.x-grid3-td-{id}
|
|
204
|
+
* > div.x-grid3-cell-inner.x-grid3-col-{id}
|
|
205
|
+
* - 宽度:表头窗口(header-inner)与 body 均按 scroller 真实可见宽(clientWidth,自动含滚动条位)对齐;
|
|
206
|
+
* header-offset 在无横向滚动时铺满可见宽(列左对齐、右侧背景铺满),有横向滚动时=Σ列宽+竖向滚动条位(对齐 body gutter)。
|
|
207
|
+
*/
|
|
208
|
+
/**
|
|
209
|
+
* TableGrid 保真解析(对齐 Ext.ux.grid.TableGrid):
|
|
210
|
+
* 输入一段 HTML <table> 标记,解析 thead th → 列定义、tbody tr/td → 数据行。
|
|
211
|
+
* - 列(对齐 TableGrid 的 cf/ch 推导):name = 'tcol-{i}';header = th 文本;dataIndex = name;
|
|
212
|
+
* width 优先取内联 style.width(原版读 offsetWidth,此处对标记字符串取等价内联宽);
|
|
213
|
+
* tooltip = th.title;sortable = true。
|
|
214
|
+
* - 行:每条 tbody tr 生成 { 'tcol-{i}': td 文本 }(对齐原版 XmlReader mapping 'td:nth(i+1)/@innerHTML')。
|
|
215
|
+
* 原版会把源表格从 DOM 移除(config.remove !== false);本实现仅以标记生成,调用方不再渲染源表格。
|
|
216
|
+
*/
|
|
217
|
+
export function parseTableGrid(markup: string): { columns: GridColumn[]; data: Record<string, unknown>[] } {
|
|
218
|
+
const doc = typeof DOMParser !== 'undefined'
|
|
219
|
+
? new DOMParser().parseFromString(markup, 'text/html')
|
|
220
|
+
: (() => { const d = document.implementation.createHTMLDocument(''); d.body.innerHTML = markup; return d; })();
|
|
221
|
+
const table = doc.querySelector('table');
|
|
222
|
+
if (!table) return { columns: [], data: [] };
|
|
223
|
+
const headers = Array.from(table.querySelectorAll('thead th'));
|
|
224
|
+
const columns: GridColumn[] = headers.map((th, i) => {
|
|
225
|
+
const name = `tcol-${i}`;
|
|
226
|
+
const widthAttr = (th.getAttribute('width') || '').trim();
|
|
227
|
+
const styleW = (th.getAttribute('style') || '').match(/width\s*:\s*(\d+)(?:\.\d+)?\s*px/i);
|
|
228
|
+
const width = styleW ? Number(styleW[1]) : widthAttr ? Number(widthAttr) : 100;
|
|
229
|
+
return {
|
|
230
|
+
header: th.textContent?.trim() ?? '',
|
|
231
|
+
dataIndex: name,
|
|
232
|
+
width,
|
|
233
|
+
tooltip: th.getAttribute('title') || undefined,
|
|
234
|
+
sortable: true,
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
const rows = Array.from(table.querySelectorAll('tbody tr'));
|
|
238
|
+
const data = rows.map((tr) => {
|
|
239
|
+
const tds = Array.from(tr.querySelectorAll('td'));
|
|
240
|
+
const row: Record<string, unknown> = {};
|
|
241
|
+
tds.forEach((td, i) => { row[`tcol-${i}`] = td.textContent?.trim() ?? ''; });
|
|
242
|
+
return row;
|
|
243
|
+
});
|
|
244
|
+
return { columns, data };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export const Grid = React.forwardRef<GridHandle, GridProps>(({
|
|
248
|
+
columns: propsColumns,
|
|
249
|
+
data: propsData,
|
|
250
|
+
fromMarkup,
|
|
251
|
+
title,
|
|
252
|
+
width,
|
|
253
|
+
height,
|
|
254
|
+
stripeRows = true,
|
|
255
|
+
autoExpandColumn,
|
|
256
|
+
selectedRow,
|
|
257
|
+
selectionMode,
|
|
258
|
+
groupField,
|
|
259
|
+
className,
|
|
260
|
+
style,
|
|
261
|
+
/** 是否启用表头列菜单(对齐 Ext enableColumnHide,默认 true):hd-btn 点击打开列显示菜单 */
|
|
262
|
+
enableColumnHide = true,
|
|
263
|
+
/** 是否允许拖拽表头重排列(默认 false) */
|
|
264
|
+
enableColumnReorder = false,
|
|
265
|
+
/** 是否允许拖拽数据行重排(默认 false) */
|
|
266
|
+
enableRowReorder = false,
|
|
267
|
+
/** 行展开器插件(对齐 Ext.ux.grid.RowExpander) */
|
|
268
|
+
expander,
|
|
269
|
+
/** 行编辑器插件(对齐 Ext.ux.grid.RowEditor) */
|
|
270
|
+
rowEditor,
|
|
271
|
+
/** 列分组表头(对齐 Ext.ux.grid.ColumnHeaderGroup) */
|
|
272
|
+
headerGroups,
|
|
273
|
+
/** 复选框列勾选变化回调(对齐 Ext.ux.grid.CheckColumn 点击切换 record 字段值) */
|
|
274
|
+
onCheckColumnChange,
|
|
275
|
+
}: GridProps, ref) => {
|
|
276
|
+
// TableGrid 保真(对齐 Ext.ux.grid.TableGrid):fromMarkup 优先,解析标记覆盖 columns/data。
|
|
277
|
+
const markupParsed = React.useMemo(
|
|
278
|
+
() => (fromMarkup ? parseTableGrid(fromMarkup) : null),
|
|
279
|
+
[fromMarkup],
|
|
280
|
+
);
|
|
281
|
+
const columns = markupParsed ? markupParsed.columns : (propsColumns ?? []);
|
|
282
|
+
const data = markupParsed ? markupParsed.data : (propsData ?? []);
|
|
283
|
+
// checker 列(对齐 Ext.grid.CheckboxSelectionModel 的列定义)
|
|
284
|
+
type ColWithId = GridColumn & { id: string; isNumberer?: boolean };
|
|
285
|
+
const checkerCol: ColWithId = React.useMemo(
|
|
286
|
+
() => ({
|
|
287
|
+
dataIndex: '',
|
|
288
|
+
id: 'checker',
|
|
289
|
+
header: '',
|
|
290
|
+
width: 20,
|
|
291
|
+
sortable: false,
|
|
292
|
+
resizable: false,
|
|
293
|
+
}),
|
|
294
|
+
[],
|
|
295
|
+
);
|
|
296
|
+
// radio 列(对齐 Ext.grid.RadioSelectionModel 的列定义)
|
|
297
|
+
const radioCol: ColWithId = React.useMemo(
|
|
298
|
+
() => ({
|
|
299
|
+
dataIndex: '',
|
|
300
|
+
id: 'radio',
|
|
301
|
+
header: '',
|
|
302
|
+
width: 20,
|
|
303
|
+
sortable: false,
|
|
304
|
+
resizable: false,
|
|
305
|
+
}),
|
|
306
|
+
[],
|
|
307
|
+
);
|
|
308
|
+
const baseCols: ColWithId[] = React.useMemo(
|
|
309
|
+
() =>
|
|
310
|
+
columns.map((c) => {
|
|
311
|
+
if (c.type === 'rownumberer') {
|
|
312
|
+
// 对齐 Ext.grid.RowNumberer:id 固定 'numberer'、宽 23、不可排序/缩放、menuDisabled
|
|
313
|
+
return {
|
|
314
|
+
...c,
|
|
315
|
+
dataIndex: '__numberer__',
|
|
316
|
+
id: 'numberer',
|
|
317
|
+
header: c.header ?? '',
|
|
318
|
+
width: c.width ?? 23,
|
|
319
|
+
sortable: false,
|
|
320
|
+
resizable: false,
|
|
321
|
+
menuDisabled: true,
|
|
322
|
+
isNumberer: true,
|
|
323
|
+
} as ColWithId;
|
|
324
|
+
}
|
|
325
|
+
if (c.type === 'checkcolumn') {
|
|
326
|
+
// 对齐 Ext.ux.grid.CheckColumn:id 取 dataIndex、不可排序/缩放、menuDisabled
|
|
327
|
+
return {
|
|
328
|
+
...c,
|
|
329
|
+
id: c.dataIndex,
|
|
330
|
+
width: c.width ?? 40,
|
|
331
|
+
sortable: false,
|
|
332
|
+
resizable: false,
|
|
333
|
+
menuDisabled: true,
|
|
334
|
+
} as ColWithId;
|
|
335
|
+
}
|
|
336
|
+
return { ...c, id: c.dataIndex, width: c.width ?? 100 };
|
|
337
|
+
}),
|
|
338
|
+
[columns],
|
|
339
|
+
);
|
|
340
|
+
// 稳定引用 cols:依赖 prop 列与 selectionMode,避免每次渲染都重建导致 managedCols/colOrder 副作用死循环
|
|
341
|
+
// 行展开器列(对齐 Ext.ux.grid.RowExpander 的前置展开列):始终是首个列,宽度默认 20。
|
|
342
|
+
const hasExpander = !!expander;
|
|
343
|
+
const expanderCol: ColWithId = React.useMemo(
|
|
344
|
+
() => ({
|
|
345
|
+
dataIndex: '',
|
|
346
|
+
id: 'expander',
|
|
347
|
+
header: '',
|
|
348
|
+
width: expander?.width ?? 20,
|
|
349
|
+
sortable: false,
|
|
350
|
+
resizable: false,
|
|
351
|
+
}),
|
|
352
|
+
[expander?.width, hasExpander],
|
|
353
|
+
);
|
|
354
|
+
const cols: ColWithId[] = React.useMemo(
|
|
355
|
+
() => {
|
|
356
|
+
let base: ColWithId[];
|
|
357
|
+
if (selectionMode === 'checkbox') base = [checkerCol, ...baseCols];
|
|
358
|
+
else if (selectionMode === 'radio') base = [radioCol, ...baseCols];
|
|
359
|
+
else base = baseCols;
|
|
360
|
+
return hasExpander ? [expanderCol, ...base] : base;
|
|
361
|
+
},
|
|
362
|
+
[selectionMode, checkerCol, radioCol, baseCols, hasExpander, expanderCol],
|
|
363
|
+
);
|
|
364
|
+
// 展开行集合(存原始 data 索引 origIdx,与行拖拽/排序无关,始终稳定指向同一行数据)。
|
|
365
|
+
// 分组折叠态(用于 groupField 行分组时按组折叠/展开)。提前声明,供下方 vScroll 重算 effect 依赖。
|
|
366
|
+
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
|
367
|
+
// 初始展开由 expander.expandedField 决定(对齐 Ext expandedField,默认 'expanded')。
|
|
368
|
+
const [expandedRows, setExpandedRows] = React.useState<Set<number>>(() => {
|
|
369
|
+
const s = new Set<number>();
|
|
370
|
+
const field = expander?.expandedField;
|
|
371
|
+
if (field) data.forEach((r, i) => { if (r[field]) s.add(i); });
|
|
372
|
+
return s;
|
|
373
|
+
});
|
|
374
|
+
const toggleExpand = React.useCallback((origIdx: number) => {
|
|
375
|
+
setExpandedRows((prev) => {
|
|
376
|
+
const n = new Set(prev);
|
|
377
|
+
if (n.has(origIdx)) n.delete(origIdx);
|
|
378
|
+
else n.add(origIdx);
|
|
379
|
+
return n;
|
|
380
|
+
});
|
|
381
|
+
}, []);
|
|
382
|
+
// 复选框列(CheckColumn)勾选覆盖态:key = `${origIdx}:${dataIndex}`,值为当前勾选布尔。
|
|
383
|
+
// 渲染时若 key 不存在则回退到 data 行的原始布尔值;点击切换只更新本 Map,并回调 onCheckColumnChange
|
|
384
|
+
// (对齐 Ext.ux.grid.CheckColumn 点击切换 record 字段值,store 层由外部回调更新)。
|
|
385
|
+
const [checks, setChecks] = React.useState<Record<string, boolean>>({});
|
|
386
|
+
const toggleCheck = React.useCallback(
|
|
387
|
+
(origIdx: number, dataIndex: string) => {
|
|
388
|
+
const key = `${origIdx}:${dataIndex}`;
|
|
389
|
+
const base = data[origIdx]?.[dataIndex] as boolean | undefined;
|
|
390
|
+
const cur = key in checks ? checks[key] : !!base;
|
|
391
|
+
const next = !cur;
|
|
392
|
+
onCheckColumnChange?.(origIdx, dataIndex, next);
|
|
393
|
+
setChecks((prev) => ({ ...prev, [key]: next }));
|
|
394
|
+
},
|
|
395
|
+
[data, checks, onCheckColumnChange],
|
|
396
|
+
);
|
|
397
|
+
// 列动态增删(双轨:columns prop 恒为静态列来源,dynamicCols 为独立态;未增删时 managedCols≡cols,DOM 完全一致,保真零回归)
|
|
398
|
+
const [dynamicCols, setDynamicCols] = React.useState<ColWithId[]>([]);
|
|
399
|
+
const [removedStatic, setRemovedStatic] = React.useState<Set<string>>(new Set());
|
|
400
|
+
const seqRef = React.useRef(0);
|
|
401
|
+
const managedCols = React.useMemo<ColWithId[]>(() => {
|
|
402
|
+
const staticItems = cols.filter((c) => !removedStatic.has(c.id));
|
|
403
|
+
return [...staticItems, ...dynamicCols];
|
|
404
|
+
}, [cols, removedStatic, dynamicCols]);
|
|
405
|
+
// 隐藏列状态(对齐 Ext colModel.setHidden / enableColumnHide):hidden 列从渲染中剔除。
|
|
406
|
+
const [hiddenCols, setHiddenCols] = React.useState<Set<string>>(
|
|
407
|
+
() => new Set(columns.filter((c) => c.hidden).map((c) => c.dataIndex)),
|
|
408
|
+
);
|
|
409
|
+
// 列菜单(表头 x-grid3-hd-btn 点击打开,check item 切换列隐藏/显示)
|
|
410
|
+
const [menuCol, setMenuCol] = React.useState<string | null>(null);
|
|
411
|
+
const [menuStyle, setMenuStyle] = React.useState<React.CSSProperties>({});
|
|
412
|
+
// 表头行高度(对齐 Ext GridView 运行时 JS 给 x-grid3-hd-btn 内联设置的高度:
|
|
413
|
+
// ext-all.css 只给 width:14px 无 height,静态渲染高度塌缩为 0 导致箭头不可见——与 TabPanel scroller 同款坑)
|
|
414
|
+
const hdRowRef = React.useRef<HTMLTableRowElement>(null);
|
|
415
|
+
const [hdH, setHdH] = React.useState(0);
|
|
416
|
+
React.useLayoutEffect(() => {
|
|
417
|
+
if (hdRowRef.current) setHdH(hdRowRef.current.offsetHeight);
|
|
418
|
+
}, [managedCols.length, hiddenCols.size]);
|
|
419
|
+
// 列显示顺序(对齐 Ext ColumnModel/GridView 的列重排):visible 顺序由本数组决定,
|
|
420
|
+
// 隐藏列保留在数组尾部以便取消隐藏时回到原相对位置。重排只调整该数组顺序。
|
|
421
|
+
const [colOrder, setColOrder] = React.useState<string[]>(() => cols.map((c) => c.id));
|
|
422
|
+
React.useEffect(() => {
|
|
423
|
+
// columns / selectionMode / 动态增删变化时的合并:保留既有列顺序(含手动重排结果),
|
|
424
|
+
// 仅丢弃已移除的列、追加新增列到末尾。绝不能按 managedCols 原始顺序重建,否则会冲掉拖拽重排结果。
|
|
425
|
+
setColOrder((prev) => {
|
|
426
|
+
const mSet = new Set(managedCols.map((c) => c.id));
|
|
427
|
+
const prevSet = new Set(prev);
|
|
428
|
+
const next = prev.filter((id) => mSet.has(id));
|
|
429
|
+
managedCols.forEach((c) => {
|
|
430
|
+
if (!prevSet.has(c.id)) next.push(c.id);
|
|
431
|
+
});
|
|
432
|
+
return next;
|
|
433
|
+
});
|
|
434
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
435
|
+
}, [managedCols]);
|
|
436
|
+
const colsById = React.useMemo(() => {
|
|
437
|
+
const m: Record<string, ColWithId> = {};
|
|
438
|
+
managedCols.forEach((c) => (m[c.id] = c));
|
|
439
|
+
return m;
|
|
440
|
+
}, [managedCols]);
|
|
441
|
+
// ===== GridFilters(保真复刻 Ext.ux.grid.GridFilters,本地过滤)=====
|
|
442
|
+
// 列定义式:从 managedCols 上读取 filter 配置,按 dataIndex 收集过滤器定义。
|
|
443
|
+
// 前置声明于此(在 GridHandle useImperativeHandle 之前),以便 clearFilters/getFilterData 句柄可引用。
|
|
444
|
+
const MENU_FILTER_TEXT = 'Filters';
|
|
445
|
+
const filterDefs = React.useMemo<Record<string, GridFilterConfig>>(() => {
|
|
446
|
+
const map: Record<string, GridFilterConfig> = {};
|
|
447
|
+
managedCols.forEach((c) => {
|
|
448
|
+
if (c.filter && c.dataIndex) {
|
|
449
|
+
const f: GridFilterConfig =
|
|
450
|
+
c.filter === true ? {} : { ...(c.filter as GridFilterConfig) };
|
|
451
|
+
if (!f.type) f.type = inferFilterType(data, c.dataIndex);
|
|
452
|
+
if (!f.dataIndex) f.dataIndex = c.dataIndex;
|
|
453
|
+
map[c.dataIndex] = f;
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
return map;
|
|
457
|
+
}, [managedCols, data]);
|
|
458
|
+
|
|
459
|
+
// 过滤器运行态:dataIndex -> { active, value }
|
|
460
|
+
const [filterState, setFilterState] = React.useState<
|
|
461
|
+
Record<string, { active: boolean; value: any }>
|
|
462
|
+
>({});
|
|
463
|
+
// 依据 filterDefs 初始化/清理运行态(首次出现建默认态,移除的列清掉对应态)
|
|
464
|
+
React.useEffect(() => {
|
|
465
|
+
setFilterState((prev) => {
|
|
466
|
+
let changed = false;
|
|
467
|
+
const next: Record<string, { active: boolean; value: any }> = {};
|
|
468
|
+
for (const di of Object.keys(filterDefs)) {
|
|
469
|
+
if (prev[di]) {
|
|
470
|
+
next[di] = prev[di];
|
|
471
|
+
} else {
|
|
472
|
+
const initVal = filterDefs[di].value ?? defaultEmpty(filterDefs[di]);
|
|
473
|
+
next[di] = {
|
|
474
|
+
active: !!filterDefs[di].active && isFilterActivatable(filterDefs[di], initVal),
|
|
475
|
+
value: initVal,
|
|
476
|
+
};
|
|
477
|
+
changed = true;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (Object.keys(prev).length !== Object.keys(next).length) changed = true;
|
|
481
|
+
return changed ? next : prev;
|
|
482
|
+
});
|
|
483
|
+
}, [filterDefs]);
|
|
484
|
+
|
|
485
|
+
// Grid 命令句柄(列运行时增删;对齐 Ext ColumnModel/GridPanel reconfigure)
|
|
486
|
+
React.useImperativeHandle(
|
|
487
|
+
ref,
|
|
488
|
+
() => ({
|
|
489
|
+
addColumn: (col: GridColumn, id?: string) => {
|
|
490
|
+
const cid = id ?? col.id ?? col.dataIndex ?? `gc-${++seqRef.current}`;
|
|
491
|
+
const colWithId: ColWithId = {
|
|
492
|
+
...col,
|
|
493
|
+
id: cid,
|
|
494
|
+
width: col.width ?? 100,
|
|
495
|
+
} as ColWithId;
|
|
496
|
+
setDynamicCols((prev) => [...prev, colWithId]);
|
|
497
|
+
return cid;
|
|
498
|
+
},
|
|
499
|
+
removeColumn: (id: string) => {
|
|
500
|
+
const isDynamic = dynamicCols.some((c) => c.id === id);
|
|
501
|
+
if (isDynamic) {
|
|
502
|
+
setDynamicCols((prev) => prev.filter((c) => c.id !== id));
|
|
503
|
+
} else {
|
|
504
|
+
setRemovedStatic((prev) => {
|
|
505
|
+
const n = new Set(prev);
|
|
506
|
+
n.add(id);
|
|
507
|
+
return n;
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
showColumn: (id: string) =>
|
|
512
|
+
setHiddenCols((prev) => {
|
|
513
|
+
const n = new Set(prev);
|
|
514
|
+
n.delete(id);
|
|
515
|
+
return n;
|
|
516
|
+
}),
|
|
517
|
+
hideColumn: (id: string) =>
|
|
518
|
+
setHiddenCols((prev) => {
|
|
519
|
+
const n = new Set(prev);
|
|
520
|
+
n.add(id);
|
|
521
|
+
return n;
|
|
522
|
+
}),
|
|
523
|
+
getColumns: () => managedCols.map((c) => ({ id: c.id, column: c })),
|
|
524
|
+
// 对齐 Ext.ux.grid.GridFilters.clearFilters:把所有过滤器 active 置否、value 复位默认,
|
|
525
|
+
// 不删除 filterDefs(列仍在),等价于示例里「Clear Filter Data」按钮。
|
|
526
|
+
clearFilters: () => {
|
|
527
|
+
setFilterState((prev) => {
|
|
528
|
+
const next: Record<string, { active: boolean; value: any }> = {};
|
|
529
|
+
for (const di of Object.keys(prev)) {
|
|
530
|
+
next[di] = { active: false, value: defaultEmpty(filterDefs[di] || {}) };
|
|
531
|
+
}
|
|
532
|
+
return next;
|
|
533
|
+
});
|
|
534
|
+
},
|
|
535
|
+
// 对齐 Ext.ux.grid.GridFilters.getFilterData:仅返回当前激活的过滤器,
|
|
536
|
+
// 每项 { field, data: { type, value } }(value 取控件当前值)。
|
|
537
|
+
getFilterData: () => {
|
|
538
|
+
const out: Array<{ field: string; data: { type: string; value: any } }> = [];
|
|
539
|
+
for (const di of Object.keys(filterState)) {
|
|
540
|
+
const st = filterState[di];
|
|
541
|
+
const def = filterDefs[di];
|
|
542
|
+
if (st && st.active && def) {
|
|
543
|
+
out.push({ field: di, data: { type: def.type || 'string', value: st.value } });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return out;
|
|
547
|
+
},
|
|
548
|
+
}),
|
|
549
|
+
[managedCols, dynamicCols, filterDefs, filterState],
|
|
550
|
+
);
|
|
551
|
+
// 行显示顺序(对齐 Ext 行 DnD / store 记录顺序):rowOrder 存「原始 data 索引」的当前显示顺序,
|
|
552
|
+
// 拖拽行重排只调整该数组,sort/group 均作用于按 rowOrder 还原后的有序数据,互不冲突。
|
|
553
|
+
const [rowOrder, setRowOrder] = React.useState<number[]>(() => data.map((_, i) => i));
|
|
554
|
+
React.useEffect(() => {
|
|
555
|
+
// data 变化(引用/长度改变)时重置为原始顺序,丢弃手动行重排结果(与列重排保持一致的合并策略:
|
|
556
|
+
// 仅当结构真正变化时重建,避免选中态/交互触发的无关 data 引用变化冲掉重排)。
|
|
557
|
+
setRowOrder((prev) => {
|
|
558
|
+
if (prev.length !== data.length) return data.map((_, i) => i);
|
|
559
|
+
return prev;
|
|
560
|
+
});
|
|
561
|
+
}, [data]);
|
|
562
|
+
// 数据长度变化(增删行)时,rowOrder 旧索引会越界产生 undefined 行;render 阶段即按长度重建顺序,
|
|
563
|
+
// 避免「useEffect 重置滞后一帧」导致的崩溃/空渲染(与列重排一致的合并策略:仅结构变化时重建)。
|
|
564
|
+
const orderedData = React.useMemo(() => {
|
|
565
|
+
const order = rowOrder.length === data.length ? rowOrder : data.map((_, i) => i);
|
|
566
|
+
return order.map((i) => data[i]);
|
|
567
|
+
}, [data, rowOrder]);
|
|
568
|
+
// 行对象(来自 data,引用稳定)→ 原始索引,供拖拽时定位「被拖/目标行」的原始索引
|
|
569
|
+
const idxByRow = React.useMemo(() => {
|
|
570
|
+
const m = new Map<Record<string, unknown>, number>();
|
|
571
|
+
data.forEach((r, i) => m.set(r, i));
|
|
572
|
+
return m;
|
|
573
|
+
}, [data]);
|
|
574
|
+
|
|
575
|
+
// 单过滤器值更新(控件 onChange)
|
|
576
|
+
const updateFilterValue = React.useCallback(
|
|
577
|
+
(di: string, value: any) => {
|
|
578
|
+
setFilterState((prev) => {
|
|
579
|
+
const cur = prev[di] || { active: false, value: defaultEmpty(filterDefs[di] || {}) };
|
|
580
|
+
const active = isFilterActivatable(filterDefs[di] || {}, value);
|
|
581
|
+
return { ...prev, [di]: { value, active: cur.active || active } };
|
|
582
|
+
});
|
|
583
|
+
},
|
|
584
|
+
[filterDefs],
|
|
585
|
+
);
|
|
586
|
+
// 表头菜单「Filters」勾选项:手动切换激活(对齐 onCheckChange:仅当 isActivatable 才允许激活)
|
|
587
|
+
const toggleFilterActive = React.useCallback(
|
|
588
|
+
(di: string) => {
|
|
589
|
+
setFilterState((prev) => {
|
|
590
|
+
const cur = prev[di];
|
|
591
|
+
if (!cur) return prev;
|
|
592
|
+
const wantActive = !cur.active;
|
|
593
|
+
const canActive = isFilterActivatable(filterDefs[di] || {}, cur.value);
|
|
594
|
+
return {
|
|
595
|
+
...prev,
|
|
596
|
+
[di]: { ...cur, active: wantActive ? (canActive ? true : cur.active) : false },
|
|
597
|
+
};
|
|
598
|
+
});
|
|
599
|
+
},
|
|
600
|
+
[filterDefs],
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
// 本地过滤:在 orderedData 之后、排序/分组之前应用(对齐 Ext store.filterBy 影响排序/分组数据源)
|
|
604
|
+
const filteredData = React.useMemo(() => {
|
|
605
|
+
const active = Object.keys(filterState).filter(
|
|
606
|
+
(di) => filterState[di].active && filterDefs[di],
|
|
607
|
+
);
|
|
608
|
+
if (active.length === 0) return orderedData;
|
|
609
|
+
return orderedData.filter((row) =>
|
|
610
|
+
active.every((di) => validateFilter(filterDefs[di], filterState[di].value, row)),
|
|
611
|
+
);
|
|
612
|
+
}, [orderedData, filterState, filterDefs]);
|
|
613
|
+
const visibleCols = colOrder
|
|
614
|
+
.map((id) => colsById[id])
|
|
615
|
+
.filter(Boolean)
|
|
616
|
+
.filter((c) => !hiddenCols.has(c.id)) as ColWithId[];
|
|
617
|
+
// 列宽状态:拖拽改列宽后覆盖默认值(对齐 Ext colModel.setColumnWidth)
|
|
618
|
+
const [colWidths, setColWidths] = React.useState<Record<string, number>>({});
|
|
619
|
+
// 网格可见宽度(由 ResizeObserver 测量 .x-grid3 的 clientWidth,即面板内容宽),
|
|
620
|
+
// 用于让行 div 拉满整格、行内 table 仅等于列总宽,右侧多余宽度由「行背景」铺满
|
|
621
|
+
// (对齐 Ext:行 div 满宽、行内 table 仅列总宽,无带边框的 filler 空列)。
|
|
622
|
+
// 网格整宽(ResizeObserver 测量 .x-grid3 clientWidth)。仅随容器尺寸变化,
|
|
623
|
+
// 不随内部滚动条出现/消失而变 —— 这是杜绝「滚动条闪动 / 反馈循环」的关键:
|
|
624
|
+
// 下面所有列宽 / gutter 决策都基于此稳定值与内容量,绝不反测 scroller 的 clientWidth。
|
|
625
|
+
const [gridW, setGridW] = React.useState(0);
|
|
626
|
+
// 垂直滚动条是否存在:用【垂直】指标 scrollHeight > clientHeight 判定。
|
|
627
|
+
// 该指标只取决于行数与容器高度,与列宽 / 横向滚动完全无关,因此绝不会因我们的布局改动而反跳,
|
|
628
|
+
// 从根本上切断「滚动条出现 → 宽度变 → 重算 → 滚动条消失 → …」的死循环。
|
|
629
|
+
const [vScroll, setVScroll] = React.useState(false);
|
|
630
|
+
const widths = visibleCols.map((c) => colWidths[c.id] ?? c.width);
|
|
631
|
+
const totalWidth = widths.reduce((s, w) => s + w, 0);
|
|
632
|
+
// 纵向滚动条布局宽:对齐 Ext.getScrollBarWidth()(覆盖式浏览器 0 / 经典 17),
|
|
633
|
+
// 不写死 19,否则覆盖式滚动条浏览器下末列右侧会凭空多出 19px 空隙(真 Ext 在该环境预留 0)。
|
|
634
|
+
const vScrollW = getScrollBarWidth();
|
|
635
|
+
const SHRINK_CAP = 30; // 末列最多可收缩的像素,用于「差一点点铺满」时避免冒出横向滚动条
|
|
636
|
+
// 纵向滚动条 gutter 仅在「数据行确实出现纵向滚动条」时预留——vScroll 由下方 useEffect 实测
|
|
637
|
+
// scroller 的 scrollHeight > clientHeight 得到(即 user 方案:先判断数据列是否有滚动条,有才预留)。
|
|
638
|
+
// 不再以 fixedHeight 提前预留:固定高度但内容不溢出(如基础表格 3 行 < 160px)时数据列本无滚动条,
|
|
639
|
+
// 就不应留那 19px,否则末列右侧凭空一段空隙。gutter 实际像素 = 运行时测量的滚动条宽 vScrollW
|
|
640
|
+
// (覆盖式 0 / 经典 17);仅 vScroll 为真时才取它,无滚动条时为 0、零空隙。
|
|
641
|
+
const needGutter = vScroll;
|
|
642
|
+
const gutter = needGutter ? vScrollW : 0;
|
|
643
|
+
// 横向滚动:仅由「列总宽 vs 网格整宽」决定(两者皆不随滚动条变化),稳定不反跳。
|
|
644
|
+
// body 可用宽 = 网格整宽 − 垂直滚动条 gutter(垂直滚动条占用的右侧空间单独预留,不蚕食列宽判定)。
|
|
645
|
+
const bodyAvail = gridW > 0 ? gridW - (needGutter ? vScrollW : 0) : totalWidth;
|
|
646
|
+
// 是否「恰好铺满」:列总宽落在 (gridW − SHRINK_CAP) ~ (gridW + fitEps) 区间内即视为可铺满:
|
|
647
|
+
// - 列偏窄 → 末列撑宽填满(正 slack);
|
|
648
|
+
// - 列偏宽(余量 <= SHRINK_CAP)→ 末列收缩吸收(负 slack),不产生横向滚动条;
|
|
649
|
+
// - 列明显超出 → 进入横向滚动分支。
|
|
650
|
+
// 注意:判定只针对【横向内容】用稳定的 gridW,绝不包含纵向滚动条宽 vScrollW —— 纵向滚动条
|
|
651
|
+
// 的空间由下方 header/body 的 gutter 机制单独预留(不蚕食列宽判定),否则展开行导致纵向滚动条
|
|
652
|
+
// 出现时会多吞 19px,使本已「勉强铺满」的列越过 SHRINK_CAP 而错误地冒出横向滚动条。
|
|
653
|
+
const fits = gridW > 0 && totalWidth <= gridW + SHRINK_CAP;
|
|
654
|
+
// autoExpandColumn:网格整宽铺满时,由该列吸收 slack(而非末列),对齐 Ext autoExpandColumn。
|
|
655
|
+
const expandColIdx = React.useMemo(() => {
|
|
656
|
+
if (!autoExpandColumn) return -1;
|
|
657
|
+
return visibleCols.findIndex((c) => c.id === autoExpandColumn);
|
|
658
|
+
}, [autoExpandColumn, visibleCols]);
|
|
659
|
+
// 给定当前渲染子集 cols,返回吸收 slack 的列下标(非锁定主路径且设了 autoExpandColumn 时指向该列,否则末列)。
|
|
660
|
+
const slackIndexOf = (cols: ColWithId[]): number => {
|
|
661
|
+
if (expandColIdx >= 0 && cols === visibleCols) return expandColIdx;
|
|
662
|
+
return cols.length - 1;
|
|
663
|
+
};
|
|
664
|
+
if (fits) {
|
|
665
|
+
// 恰好铺满(无横向滚动):表体可视宽 = bodyAvail = gridW - gutter(纵向滚动条落在其右 gutter 位)。
|
|
666
|
+
// 表头 100% 铺满整宽 gridW,并让「吸收 slack 的末列」再延伸 gutter —— 表头列总宽 = gridW,
|
|
667
|
+
// 末列右侧那段正好覆盖纵向滚动条所在的 gutter 位,表头与表体列 0..n-2 完全对齐、末列无可见空隙。
|
|
668
|
+
// (gutter=0 即无滚动条时末列不延伸,表头仍铺满 gridW,零空隙。)
|
|
669
|
+
var headerInnerW = gridW > 0 ? gridW : bodyAvail;
|
|
670
|
+
var headerOffsetW = gridW;
|
|
671
|
+
var rowW = bodyAvail;
|
|
672
|
+
var slack = Math.round(bodyAvail - totalWidth);
|
|
673
|
+
// 行内表宽必须等于 bodyAvail(= gridW − gutter),而非整宽 gridW:gutter 已被末列 slack 吸收进
|
|
674
|
+
// 列总宽(列组和 = bodyAvail),若表宽仍取 gridW,则固定的 table-layout 会把剩余的 gutter 宽
|
|
675
|
+
// 平摊到各列(表现为"每列被拉宽"),且 438 宽的表在 421 宽的经典滚动条数据区里横向溢出
|
|
676
|
+
// (表现为"横向滚动宽度增加")。行 div 已是 bodyAvail,表宽与之对齐才不溢、列不被拉宽。
|
|
677
|
+
var tableW = bodyAvail;
|
|
678
|
+
} else {
|
|
679
|
+
// 真·横向滚动:
|
|
680
|
+
// - 表头「窗口」(header-inner) = bodyAvail(= gridW − vScrollW,与 body scroller 可视宽一致);
|
|
681
|
+
// 这样 header-inner 与 body 的可视宽/可滚范围完全相同,syncScroll 的 1:1 scrollLeft 赋值
|
|
682
|
+
// 在全程(含接近底部时)都精确同步。此前用 gridW 作 headerInnerW 导致 header 视口比 body 宽
|
|
683
|
+
// vScrollW,滚动到底部时 header 先到终点而 body 还需再滚一小段(即"header和body滚动不同")。
|
|
684
|
+
// - offset / 表宽 / 行宽 = 列总宽(不加 vScrollW gutter——gutter 已通过缩小视口体现)。
|
|
685
|
+
// - 行区同宽,由 scroller 横向滚动。
|
|
686
|
+
var headerInnerW = gridW > 0 ? bodyAvail : bodyAvail;
|
|
687
|
+
var headerOffsetW = totalWidth;
|
|
688
|
+
var rowW = totalWidth;
|
|
689
|
+
var slack = 0;
|
|
690
|
+
var tableW = totalWidth;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// 锁定列分栏(对齐 Ext.ux.grid.LockingGridView + LockingColumnModel):
|
|
694
|
+
// 把 visibleCols 按 c.locked 拆成「锁定列」与「解锁列」两半,分别渲染进左侧固定面板 x-grid3-locked
|
|
695
|
+
// 与右侧可横向滚动面板 x-grid3-unlocked;两面板纵向滚动同步(syncScroll)。
|
|
696
|
+
// 仅当「至少 1 锁定 + 1 解锁」才触发分栏渲染,否则退化为普通 Grid(零回归)。
|
|
697
|
+
const colWOf = (c: ColWithId): number => colWidths[c.id] ?? c.width;
|
|
698
|
+
const lockedCols = visibleCols.filter((c) => c.locked);
|
|
699
|
+
const unlockedCols = visibleCols.filter((c) => !c.locked);
|
|
700
|
+
const hasLock = lockedCols.length > 0 && unlockedCols.length > 0;
|
|
701
|
+
const lockedTotal = lockedCols.reduce((s, c) => s + colWOf(c), 0);
|
|
702
|
+
const unlockedTotal = unlockedCols.reduce((s, c) => s + colWOf(c), 0);
|
|
703
|
+
let lockedPaneW = lockedTotal;
|
|
704
|
+
let unlockedPaneW = unlockedTotal;
|
|
705
|
+
let unlockedSlack = 0;
|
|
706
|
+
if (hasLock) {
|
|
707
|
+
if (fits) {
|
|
708
|
+
// 网格整宽铺满:解锁面板占右侧剩余宽,末列吸收 slack 撑满(对齐 Ext 写死 lockedWidth、解锁侧填满)。
|
|
709
|
+
unlockedPaneW = Math.max(unlockedTotal, gridW - lockedTotal - gutter);
|
|
710
|
+
unlockedSlack = Math.max(0, unlockedPaneW - unlockedTotal);
|
|
711
|
+
} else {
|
|
712
|
+
// 真·横向滚动:解锁面板 = 解除列总宽 + 滚动条 gutter,面板内横向滚动。
|
|
713
|
+
unlockedPaneW = unlockedTotal + gutter;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
const lockedWidths = lockedCols.map((c) => colWOf(c));
|
|
717
|
+
const unlockedWidths = unlockedCols.map((c, i) =>
|
|
718
|
+
i === unlockedCols.length - 1 ? colWOf(c) + unlockedSlack : colWOf(c),
|
|
719
|
+
);
|
|
720
|
+
// 解锁面板表头窗口/偏移宽(对齐 Ext getOffsetWidth = totalWidth - lockedWidth + scrollOffset):
|
|
721
|
+
// - 铺满无横向滚动 → 窗口=偏移=解锁面板可视宽=unlockedPaneW,scrollLeft 恒 0;
|
|
722
|
+
// - 真·横向滚动 → 窗口须等于「解锁面板可视宽」(= 整宽−锁定面宽−纵向滚动条位),使 header-inner
|
|
723
|
+
// 比内容窄、可 scrollLeft 且与 body 滚动范围一致(关键:此前窗口=内容宽−gutter,仅比内容窄 17px,
|
|
724
|
+
// 导致 header 滚动范围远小于 body,同步时 header 卡死、不跟随)。偏移=内容宽承载整表。
|
|
725
|
+
const uHeaderInnerW = hasLock
|
|
726
|
+
? (fits ? unlockedPaneW : Math.max(0, gridW - lockedPaneW - (vScroll ? vScrollW : 0)))
|
|
727
|
+
: headerInnerW;
|
|
728
|
+
const uHeaderOffsetW = hasLock ? unlockedPaneW : headerOffsetW;
|
|
729
|
+
|
|
730
|
+
// 列宽拖拽(对齐 Ext.grid.GridView.SplitDragZone):拖拽时显示 1px .x-grid3-resize-proxy 线,
|
|
731
|
+
// mouseup 提交新列宽。split 手柄透明(无 CSS 规则),不影响静态像素。
|
|
732
|
+
const MIN_COL_WIDTH = 25; // Ext 默认 minColumnWidth
|
|
733
|
+
const gridRef = React.useRef<HTMLDivElement>(null);
|
|
734
|
+
// 横向滚动同步(对齐 Ext GridView.syncScroll:scroller 滚动时把 scrollLeft 写给
|
|
735
|
+
// header-inner,使表头跟随 body 一起横向滚动。原生 ExtJS 3.2.1 正是靠此保持对齐)。
|
|
736
|
+
const scrollerRef = React.useRef<HTMLDivElement>(null);
|
|
737
|
+
const innerHdRef = React.useRef<HTMLDivElement>(null);
|
|
738
|
+
// 锁定分栏:解锁面板纵向滚动时,把 scrollTop 同步给锁定面板(对齐 Ext.ux.grid.LockingGridView.syncScroll)。
|
|
739
|
+
const lockedScrollerRef = React.useRef<HTMLDivElement>(null);
|
|
740
|
+
React.useEffect(() => {
|
|
741
|
+
const sc = scrollerRef.current;
|
|
742
|
+
const hd = innerHdRef.current;
|
|
743
|
+
if (!sc || !hd) return;
|
|
744
|
+
const onScroll = () => {
|
|
745
|
+
hd.scrollLeft = sc.scrollLeft;
|
|
746
|
+
};
|
|
747
|
+
sc.addEventListener('scroll', onScroll);
|
|
748
|
+
return () => sc.removeEventListener('scroll', onScroll);
|
|
749
|
+
}, []);
|
|
750
|
+
// 测量:仅观察「网格整宽 gridW」(外层 .x-grid3 的 clientWidth)。
|
|
751
|
+
// 刻意【不】观察滚动容器 sc——以往闪烁就源于观察 sc 后,vScroll 变化改了布局又触发 sc 的
|
|
752
|
+
// resize 回调,形成「滚动条出现→宽度变→重算→滚动条消失」的反馈循环。
|
|
753
|
+
// gridW 只随容器尺寸变化,与内部滚动条无关,因此绝不会反跳。
|
|
754
|
+
// 用 useLayoutEffect(而非 useEffect):首帧绘制前就量到 gridW,否则初始 gridW=0 会让首屏
|
|
755
|
+
// 走「横向滚动」分支、表头按 totalWidth 渲染(< 容器宽)→ 右侧闪一下空带,下一帧量到 gridW
|
|
756
|
+
// 才铺满。改为 layout effect 后首屏即用真实 gridW 计算,无闪隙。
|
|
757
|
+
React.useLayoutEffect(() => {
|
|
758
|
+
const el = gridRef.current;
|
|
759
|
+
if (!el) return;
|
|
760
|
+
const measure = () => setGridW(el.clientWidth);
|
|
761
|
+
measure();
|
|
762
|
+
const ro = new ResizeObserver(measure);
|
|
763
|
+
ro.observe(el);
|
|
764
|
+
return () => ro.disconnect();
|
|
765
|
+
}, []);
|
|
766
|
+
// 垂直滚动条是否存在(vScroll):用【垂直】指标 scrollHeight > clientHeight 判定,
|
|
767
|
+
// 该指标只取决于行数 / 容器高度,与列宽、横向滚动完全无关,因此不会因我们的布局改动而反跳。
|
|
768
|
+
// 当内容量(data / 分组 / 列总宽 / 容器宽高)变化时需重算——这些依赖都是「输入」而非「布局结果」,
|
|
769
|
+
// 不会发生自我回写的循环。
|
|
770
|
+
// 同样用 useLayoutEffect(与 gridW 一致):确保首屏绘制前已拿到正确 vScroll,否则首帧
|
|
771
|
+
// vScroll=false(gutter=0)会让溢出网格的表体先按 318 宽渲染、下一帧才缩到 318-17,轻微抖一下。
|
|
772
|
+
React.useLayoutEffect(() => {
|
|
773
|
+
const sc = scrollerRef.current;
|
|
774
|
+
if (!sc) return;
|
|
775
|
+
setVScroll(sc.scrollHeight > sc.clientHeight);
|
|
776
|
+
}, [data, totalWidth, gridW, height, groupField, expandedRows, collapsed]);
|
|
777
|
+
const resizeRef = React.useRef<{ col: string; startX: number; startW: number } | null>(null);
|
|
778
|
+
const resizingRef = React.useRef(false); // 拖拽后抑制误触排序 click
|
|
779
|
+
const [proxy, setProxy] = React.useState<{ left: number; height: number } | null>(null);
|
|
780
|
+
|
|
781
|
+
React.useEffect(() => {
|
|
782
|
+
if (!resizeRef.current) return;
|
|
783
|
+
const onMove = (e: MouseEvent) => {
|
|
784
|
+
const r = resizeRef.current!;
|
|
785
|
+
const diff = e.clientX - r.startX;
|
|
786
|
+
const newW = Math.max(MIN_COL_WIDTH, r.startW + diff);
|
|
787
|
+
const gridRect = gridRef.current?.getBoundingClientRect();
|
|
788
|
+
const left = gridRect ? e.clientX - gridRect.left : 0;
|
|
789
|
+
const height = gridRef.current?.offsetHeight ?? 0;
|
|
790
|
+
setProxy({ left, height });
|
|
791
|
+
// 实时预览:直接改列宽(与 Ext「仅 proxy 线、mouseup 提交」不同,但视觉等价且更顺滑)
|
|
792
|
+
setColWidths((prev) => ({ ...prev, [r.col]: newW }));
|
|
793
|
+
};
|
|
794
|
+
const onUp = () => {
|
|
795
|
+
resizeRef.current = null;
|
|
796
|
+
setProxy(null);
|
|
797
|
+
resizingRef.current = true;
|
|
798
|
+
window.setTimeout(() => (resizingRef.current = false), 0);
|
|
799
|
+
window.removeEventListener('mousemove', onMove);
|
|
800
|
+
window.removeEventListener('mouseup', onUp);
|
|
801
|
+
};
|
|
802
|
+
window.addEventListener('mousemove', onMove);
|
|
803
|
+
window.addEventListener('mouseup', onUp);
|
|
804
|
+
return () => {
|
|
805
|
+
window.removeEventListener('mousemove', onMove);
|
|
806
|
+
window.removeEventListener('mouseup', onUp);
|
|
807
|
+
};
|
|
808
|
+
}, [proxy !== null]);
|
|
809
|
+
|
|
810
|
+
const startResize = (e: React.MouseEvent, colIdx: number) => {
|
|
811
|
+
e.preventDefault();
|
|
812
|
+
e.stopPropagation();
|
|
813
|
+
const col = visibleCols[colIdx];
|
|
814
|
+
resizeRef.current = { col: col.id, startX: e.clientX, startW: widths[colIdx] };
|
|
815
|
+
const gridRect = gridRef.current?.getBoundingClientRect();
|
|
816
|
+
const left = gridRect ? e.clientX - gridRect.left : 0;
|
|
817
|
+
const height = gridRef.current?.offsetHeight ?? 0;
|
|
818
|
+
setProxy({ left, height });
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
// 列拖拽重排(对齐 Ext.grid.GridView ColumnDragZone / enableColumnMove):
|
|
822
|
+
// 在表头非 split/非 hd-btn 区域按下并移动即触发,mouseup 按光标落在某列中点左侧/右侧提交新顺序。
|
|
823
|
+
const dragColRef = React.useRef<{ id: string } | null>(null);
|
|
824
|
+
const movedRef = React.useRef(false);
|
|
825
|
+
const suppressReorderClickRef = React.useRef(false);
|
|
826
|
+
const [reordering, setReordering] = React.useState(false);
|
|
827
|
+
const [proxyLeft, setProxyLeft] = React.useState<number | null>(null);
|
|
828
|
+
|
|
829
|
+
React.useEffect(() => {
|
|
830
|
+
if (!dragColRef.current) return;
|
|
831
|
+
const onMove = (e: MouseEvent) => {
|
|
832
|
+
const gridRect = gridRef.current?.getBoundingClientRect();
|
|
833
|
+
if (!gridRect) return;
|
|
834
|
+
movedRef.current = true;
|
|
835
|
+
setProxyLeft(e.clientX - gridRect.left);
|
|
836
|
+
};
|
|
837
|
+
const onUp = (e: MouseEvent) => {
|
|
838
|
+
const from = dragColRef.current!;
|
|
839
|
+
const gridRect = gridRef.current?.getBoundingClientRect();
|
|
840
|
+
if (gridRect && movedRef.current) {
|
|
841
|
+
const tds = Array.from(
|
|
842
|
+
gridRef.current!.querySelectorAll('td[data-col-id]'),
|
|
843
|
+
) as HTMLElement[];
|
|
844
|
+
// 仅数据列参与重排(checker / numberer 固定不动)
|
|
845
|
+
const dataTds = tds.filter(
|
|
846
|
+
(td) => !['checker', 'numberer', 'radio'].includes(td.getAttribute('data-col-id') || ''),
|
|
847
|
+
);
|
|
848
|
+
// 落在某列左半区 → 插到该列之前;右半区 → 继续向后;越过后仍无命中 → 追加到末尾。
|
|
849
|
+
let targetIndex = dataTds.length;
|
|
850
|
+
for (let i = 0; i < dataTds.length; i++) {
|
|
851
|
+
const r = dataTds[i].getBoundingClientRect();
|
|
852
|
+
if (e.clientX < r.left + r.width / 2) {
|
|
853
|
+
targetIndex = i;
|
|
854
|
+
break;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
const visibleIds = visibleCols
|
|
858
|
+
.filter((c) => c.id !== 'checker' && c.id !== 'radio' && !c.isNumberer)
|
|
859
|
+
.map((c) => c.id);
|
|
860
|
+
const fromIndex = visibleIds.indexOf(from.id);
|
|
861
|
+
if (fromIndex !== -1 && targetIndex !== fromIndex) {
|
|
862
|
+
const newVisible = visibleIds.slice();
|
|
863
|
+
newVisible.splice(fromIndex, 1);
|
|
864
|
+
const insertAt = targetIndex > fromIndex ? targetIndex - 1 : targetIndex;
|
|
865
|
+
newVisible.splice(insertAt, 0, from.id);
|
|
866
|
+
const fixed = colOrder.filter(
|
|
867
|
+
(id) => id === 'checker' || id === 'radio' || colsById[id]?.isNumberer,
|
|
868
|
+
);
|
|
869
|
+
const hidden = colOrder.filter((id) => hiddenCols.has(id));
|
|
870
|
+
setColOrder([...fixed, ...newVisible, ...hidden]);
|
|
871
|
+
// 重排后抑制随后可能触发的表头 onClick(排序)
|
|
872
|
+
suppressReorderClickRef.current = true;
|
|
873
|
+
window.setTimeout(() => (suppressReorderClickRef.current = false), 0);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
dragColRef.current = null;
|
|
877
|
+
movedRef.current = false;
|
|
878
|
+
setProxyLeft(null);
|
|
879
|
+
setReordering(false);
|
|
880
|
+
window.removeEventListener('mousemove', onMove);
|
|
881
|
+
window.removeEventListener('mouseup', onUp);
|
|
882
|
+
};
|
|
883
|
+
window.addEventListener('mousemove', onMove);
|
|
884
|
+
window.addEventListener('mouseup', onUp);
|
|
885
|
+
return () => {
|
|
886
|
+
window.removeEventListener('mousemove', onMove);
|
|
887
|
+
window.removeEventListener('mouseup', onUp);
|
|
888
|
+
};
|
|
889
|
+
}, [reordering]);
|
|
890
|
+
|
|
891
|
+
const startReorder = (e: React.MouseEvent, colId: string) => {
|
|
892
|
+
if (!enableColumnReorder) return;
|
|
893
|
+
if (e.button !== 0) return;
|
|
894
|
+
const t = e.target as HTMLElement;
|
|
895
|
+
if (t.closest('.x-grid-split') || t.closest('.x-grid3-hd-btn')) return;
|
|
896
|
+
e.preventDefault();
|
|
897
|
+
dragColRef.current = { id: colId };
|
|
898
|
+
movedRef.current = false;
|
|
899
|
+
setReordering(true);
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
// 行拖拽重排(对齐 Ext 行 DnD:拖动数据行改变底层 data 顺序):
|
|
903
|
+
// 在行体按下并移动即触发,mouseup 按光标落在某行中点上方/下方提交新顺序。
|
|
904
|
+
const rowDragRef = React.useRef<{ origIdx: number } | null>(null);
|
|
905
|
+
const rowMovedRef = React.useRef(false); // 拖拽后抑制误触行点击
|
|
906
|
+
const [rowDragging, setRowDragging] = React.useState(false);
|
|
907
|
+
const [rowGhost, setRowGhost] = React.useState<{ text: string; x: number; y: number } | null>(null);
|
|
908
|
+
const [rowDropLine, setRowDropLine] = React.useState<number | null>(null);
|
|
909
|
+
|
|
910
|
+
React.useEffect(() => {
|
|
911
|
+
if (!rowDragging) return;
|
|
912
|
+
const onMove = (e: MouseEvent) => {
|
|
913
|
+
const gridRect = gridRef.current?.getBoundingClientRect();
|
|
914
|
+
if (!gridRect) return;
|
|
915
|
+
rowMovedRef.current = true;
|
|
916
|
+
setRowGhost((g) => (g ? { ...g, x: e.clientX, y: e.clientY } : g));
|
|
917
|
+
// 计算插入线:落在某行上半区 → 插到该行之前;越过全部 → 末尾
|
|
918
|
+
const rowEls = Array.from(
|
|
919
|
+
gridRef.current!.querySelectorAll('[data-row-index]'),
|
|
920
|
+
) as HTMLElement[];
|
|
921
|
+
let top: number | null = null;
|
|
922
|
+
for (const el of rowEls) {
|
|
923
|
+
const r = el.getBoundingClientRect();
|
|
924
|
+
if (e.clientY < r.top + r.height / 2) {
|
|
925
|
+
top = r.top - gridRect.top;
|
|
926
|
+
break;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
if (top === null && rowEls.length) {
|
|
930
|
+
const last = rowEls[rowEls.length - 1].getBoundingClientRect();
|
|
931
|
+
top = last.bottom - gridRect.top;
|
|
932
|
+
}
|
|
933
|
+
setRowDropLine(top);
|
|
934
|
+
};
|
|
935
|
+
const onUp = (e: MouseEvent) => {
|
|
936
|
+
const from = rowDragRef.current?.origIdx;
|
|
937
|
+
if (from != null) {
|
|
938
|
+
const rowEls = Array.from(
|
|
939
|
+
gridRef.current!.querySelectorAll('[data-row-index]'),
|
|
940
|
+
) as HTMLElement[];
|
|
941
|
+
let targetOrig: number | null = null;
|
|
942
|
+
let insertAfter = false;
|
|
943
|
+
for (const el of rowEls) {
|
|
944
|
+
const r = el.getBoundingClientRect();
|
|
945
|
+
if (e.clientY < r.top + r.height / 2) {
|
|
946
|
+
targetOrig = Number(el.getAttribute('data-row-index'));
|
|
947
|
+
insertAfter = false;
|
|
948
|
+
break;
|
|
949
|
+
}
|
|
950
|
+
targetOrig = Number(el.getAttribute('data-row-index'));
|
|
951
|
+
insertAfter = true;
|
|
952
|
+
}
|
|
953
|
+
if (targetOrig !== null && targetOrig !== from) {
|
|
954
|
+
setRowOrder((prev) => {
|
|
955
|
+
const next = prev.filter((i) => i !== from);
|
|
956
|
+
let pos = next.indexOf(targetOrig as number);
|
|
957
|
+
if (pos === -1) pos = next.length;
|
|
958
|
+
else if (insertAfter) pos = pos + 1;
|
|
959
|
+
next.splice(pos, 0, from);
|
|
960
|
+
return next;
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
rowDragRef.current = null;
|
|
965
|
+
setRowGhost(null);
|
|
966
|
+
setRowDropLine(null);
|
|
967
|
+
setRowDragging(false);
|
|
968
|
+
// 延迟清除 moved 标记,使随后的行 onClick 仍判定为拖拽(避免误触选中)
|
|
969
|
+
window.setTimeout(() => (rowMovedRef.current = false), 0);
|
|
970
|
+
window.removeEventListener('mousemove', onMove);
|
|
971
|
+
window.removeEventListener('mouseup', onUp);
|
|
972
|
+
};
|
|
973
|
+
window.addEventListener('mousemove', onMove);
|
|
974
|
+
window.addEventListener('mouseup', onUp);
|
|
975
|
+
return () => {
|
|
976
|
+
window.removeEventListener('mousemove', onMove);
|
|
977
|
+
window.removeEventListener('mouseup', onUp);
|
|
978
|
+
};
|
|
979
|
+
}, [rowDragging]);
|
|
980
|
+
|
|
981
|
+
const startRowDrag = (e: React.MouseEvent, origIdx: number) => {
|
|
982
|
+
if (!enableRowReorder) return;
|
|
983
|
+
if (e.button !== 0) return;
|
|
984
|
+
const t = e.target as HTMLElement;
|
|
985
|
+
if (t.closest('.x-grid3-row-checker') || t.closest('a, input, button')) return;
|
|
986
|
+
e.preventDefault();
|
|
987
|
+
const firstCol =
|
|
988
|
+
visibleCols.find((c) => c.id !== 'checker' && c.id !== 'radio' && !c.isNumberer) ??
|
|
989
|
+
visibleCols[0];
|
|
990
|
+
const rowText = firstCol ? String(data[origIdx]?.[firstCol.dataIndex] ?? '') : '';
|
|
991
|
+
rowDragRef.current = { origIdx };
|
|
992
|
+
rowMovedRef.current = false;
|
|
993
|
+
setRowGhost({ text: rowText, x: e.clientX, y: e.clientY });
|
|
994
|
+
setRowDragging(true);
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
// 列菜单:点击表头 hd-btn 打开列显示菜单(对齐 Ext 默认 enableHdMenu + enableColumnHide)
|
|
998
|
+
const onHdBtnClick = (e: React.MouseEvent, colId: string) => {
|
|
999
|
+
e.preventDefault();
|
|
1000
|
+
e.stopPropagation();
|
|
1001
|
+
if (enableColumnHide === false) return;
|
|
1002
|
+
const td = (e.currentTarget as HTMLElement).closest('td');
|
|
1003
|
+
const gridRect = gridRef.current?.getBoundingClientRect();
|
|
1004
|
+
const tdRect = td?.getBoundingClientRect();
|
|
1005
|
+
const style: React.CSSProperties = {};
|
|
1006
|
+
if (gridRect && tdRect) {
|
|
1007
|
+
style.left = tdRect.left - gridRect.left;
|
|
1008
|
+
style.top = tdRect.bottom - gridRect.top;
|
|
1009
|
+
}
|
|
1010
|
+
setMenuStyle(style);
|
|
1011
|
+
setMenuCol(colId);
|
|
1012
|
+
};
|
|
1013
|
+
const toggleColumn = (colId: string) => {
|
|
1014
|
+
setHiddenCols((prev) => {
|
|
1015
|
+
const n = new Set(prev);
|
|
1016
|
+
if (n.has(colId)) n.delete(colId);
|
|
1017
|
+
else n.add(colId);
|
|
1018
|
+
return n;
|
|
1019
|
+
});
|
|
1020
|
+
setMenuCol(null);
|
|
1021
|
+
};
|
|
1022
|
+
// 点击表格外部关闭列菜单
|
|
1023
|
+
React.useEffect(() => {
|
|
1024
|
+
if (menuCol === null) return;
|
|
1025
|
+
const onDown = (e: MouseEvent) => {
|
|
1026
|
+
const t = e.target as HTMLElement;
|
|
1027
|
+
if (t.closest('.x-menu') || t.closest('.x-grid3-hd-btn')) return;
|
|
1028
|
+
setMenuCol(null);
|
|
1029
|
+
};
|
|
1030
|
+
document.addEventListener('mousedown', onDown);
|
|
1031
|
+
return () => document.removeEventListener('mousedown', onDown);
|
|
1032
|
+
}, [menuCol]);
|
|
1033
|
+
|
|
1034
|
+
// 列排序状态(对齐 Ext 默认 sortable 行为:首击 asc,再击 desc,往复切换)
|
|
1035
|
+
const [sort, setSort] = React.useState<{ col: string; dir: 'asc' | 'desc' } | null>(null);
|
|
1036
|
+
const sortedData = React.useMemo(() => {
|
|
1037
|
+
if (!sort) return filteredData;
|
|
1038
|
+
const col = managedCols.find((c) => c.id === sort.col);
|
|
1039
|
+
if (!col) return filteredData;
|
|
1040
|
+
const key = col.dataIndex;
|
|
1041
|
+
const arr = [...filteredData];
|
|
1042
|
+
arr.sort((a, b) => {
|
|
1043
|
+
const av = a[key];
|
|
1044
|
+
const bv = b[key];
|
|
1045
|
+
// 对齐 Ext 'auto' sortType:数值按大小、其余按大写字符串比较
|
|
1046
|
+
let cmp: number;
|
|
1047
|
+
if (typeof av === 'number' && typeof bv === 'number') cmp = av - bv;
|
|
1048
|
+
else cmp = String(av).toUpperCase().localeCompare(String(bv).toUpperCase());
|
|
1049
|
+
return sort.dir === 'asc' ? cmp : -cmp;
|
|
1050
|
+
});
|
|
1051
|
+
return arr;
|
|
1052
|
+
}, [filteredData, sort, managedCols]);
|
|
1053
|
+
|
|
1054
|
+
// 分组模式(对齐 Ext.grid.GroupingView + store.groupBy):按 groupField 升序排列并相邻分组。
|
|
1055
|
+
// 分组不忽略手动 sort——以已手动排序的 sortedData 为源,再按 groupField 稳定排序:
|
|
1056
|
+
// 组间顺序由 groupField 决定,组内保留手动排序(V8 Array.sort 稳定,等 groupField 值不会重排组内行)。
|
|
1057
|
+
const displayData = React.useMemo(() => {
|
|
1058
|
+
if (!groupField) return sortedData;
|
|
1059
|
+
const gcol = managedCols.find((c) => c.dataIndex === groupField);
|
|
1060
|
+
if (!gcol) return sortedData;
|
|
1061
|
+
const arr = [...sortedData];
|
|
1062
|
+
arr.sort((a, b) => {
|
|
1063
|
+
const av = a[groupField];
|
|
1064
|
+
const bv = b[groupField];
|
|
1065
|
+
if (typeof av === 'number' && typeof bv === 'number') return av - bv;
|
|
1066
|
+
return String(av).toUpperCase().localeCompare(String(bv).toUpperCase());
|
|
1067
|
+
});
|
|
1068
|
+
return arr;
|
|
1069
|
+
}, [filteredData, groupField, managedCols, sortedData]);
|
|
1070
|
+
|
|
1071
|
+
// 分组折叠状态:groupId(= 字段值)-> 是否折叠
|
|
1072
|
+
const groupHeader =
|
|
1073
|
+
groupField && cols.find((c) => c.dataIndex === groupField)?.header;
|
|
1074
|
+
// 计算分组结构(displayData 已按 groupField 排序,相邻相同值归一组)
|
|
1075
|
+
const groups = React.useMemo(() => {
|
|
1076
|
+
if (!groupField || !groupHeader) return [] as { key: string; label: string; rows: number[] }[];
|
|
1077
|
+
const out: { key: string; label: string; rows: number[] }[] = [];
|
|
1078
|
+
let cur: { key: string; label: string; rows: number[] } | null = null;
|
|
1079
|
+
displayData.forEach((row, ri) => {
|
|
1080
|
+
const key = String(row[groupField]);
|
|
1081
|
+
if (!cur || cur.key !== key) {
|
|
1082
|
+
cur = { key, label: `${groupHeader}: ${key}`, rows: [] };
|
|
1083
|
+
out.push(cur);
|
|
1084
|
+
}
|
|
1085
|
+
cur.rows.push(ri);
|
|
1086
|
+
});
|
|
1087
|
+
return out;
|
|
1088
|
+
}, [displayData, groupField, groupHeader]);
|
|
1089
|
+
|
|
1090
|
+
// 分组汇总(对齐 Ext.ux.grid.GroupSummary.summaryType):对分组内某列做聚合。
|
|
1091
|
+
// 仅当该列声明 summaryType 时返回内容,否则返回 null(对应单元格留空)。
|
|
1092
|
+
const computeSummary = (
|
|
1093
|
+
c: GridColumn,
|
|
1094
|
+
groupRows: Record<string, unknown>[],
|
|
1095
|
+
): React.ReactNode => {
|
|
1096
|
+
if (!c.summaryType) return null;
|
|
1097
|
+
const di = c.dataIndex;
|
|
1098
|
+
const vals = groupRows
|
|
1099
|
+
.map((r) => r[di])
|
|
1100
|
+
.filter((v) => v !== undefined && v !== null && v !== '');
|
|
1101
|
+
const numVals = vals.map(Number).filter((n) => !Number.isNaN(n));
|
|
1102
|
+
let value: number | string = '';
|
|
1103
|
+
switch (c.summaryType) {
|
|
1104
|
+
case 'count':
|
|
1105
|
+
value = groupRows.length;
|
|
1106
|
+
break;
|
|
1107
|
+
case 'sum':
|
|
1108
|
+
value = numVals.reduce((a, b) => a + b, 0);
|
|
1109
|
+
break;
|
|
1110
|
+
case 'average':
|
|
1111
|
+
value = numVals.length
|
|
1112
|
+
? numVals.reduce((a, b) => a + b, 0) / numVals.length
|
|
1113
|
+
: 0;
|
|
1114
|
+
break;
|
|
1115
|
+
case 'min':
|
|
1116
|
+
value = numVals.length ? Math.min(...numVals) : 0;
|
|
1117
|
+
break;
|
|
1118
|
+
case 'max':
|
|
1119
|
+
value = numVals.length ? Math.max(...numVals) : 0;
|
|
1120
|
+
break;
|
|
1121
|
+
}
|
|
1122
|
+
if (c.summaryRenderer) return c.summaryRenderer(value, groupRows, di);
|
|
1123
|
+
// 无自定义 summaryRenderer 时,对浮点聚合结果收敛精度(去掉 JS 浮点噪声、最多 4 位小数),
|
|
1124
|
+
// 避免 average 等非整除结果(如 29.666666666666668)超长溢出窄列单元格。整数原样输出。
|
|
1125
|
+
if (typeof value === 'number' && !Number.isInteger(value)) {
|
|
1126
|
+
return String(Math.round(value * 1e4) / 1e4);
|
|
1127
|
+
}
|
|
1128
|
+
return String(value);
|
|
1129
|
+
};
|
|
1130
|
+
// 是否存在任意汇总列(决定是否渲染汇总行,零汇总列时退化为普通分组,零回归)
|
|
1131
|
+
const hasSummary = visibleCols.some((c) => c.summaryType);
|
|
1132
|
+
|
|
1133
|
+
// 行编辑器(对齐 Ext.ux.grid.RowEditor):editing 记录正在编辑的原始数据索引;editValues 为编辑中副本。
|
|
1134
|
+
const [editing, setEditing] = React.useState<{ origIdx: number } | null>(null);
|
|
1135
|
+
const [editValues, setEditValues] = React.useState<Record<string, unknown>>({});
|
|
1136
|
+
const startEdit = (origIdx: number) => {
|
|
1137
|
+
if (!rowEditor) return;
|
|
1138
|
+
setEditing({ origIdx });
|
|
1139
|
+
setEditValues({ ...(data[origIdx] as Record<string, unknown>) });
|
|
1140
|
+
};
|
|
1141
|
+
const updateEdit = (di: string, v: string) =>
|
|
1142
|
+
setEditValues((prev) => ({ ...prev, [di]: v }));
|
|
1143
|
+
const saveEdit = (origIdx: number) => {
|
|
1144
|
+
const base = data[origIdx] as Record<string, unknown>;
|
|
1145
|
+
const next: Record<string, unknown> = { ...base };
|
|
1146
|
+
for (const c of visibleCols) {
|
|
1147
|
+
if (c.id === 'expander' || c.isNumberer || c.id === 'checker' || c.id === 'radio' || c.type === 'checkcolumn') continue;
|
|
1148
|
+
const di = c.dataIndex;
|
|
1149
|
+
if (!(di in editValues)) continue;
|
|
1150
|
+
const raw = editValues[di];
|
|
1151
|
+
// 原值为数字则把输入字符串转回 number(对齐 Ext postEditValue 的字段类型),其余保持字符串
|
|
1152
|
+
next[di] = typeof base[di] === 'number' && raw !== '' && raw !== null ? Number(raw) : raw;
|
|
1153
|
+
}
|
|
1154
|
+
rowEditor?.onCommit?.(origIdx, next);
|
|
1155
|
+
setEditing(null);
|
|
1156
|
+
setEditValues({});
|
|
1157
|
+
};
|
|
1158
|
+
const cancelEdit = () => {
|
|
1159
|
+
setEditing(null);
|
|
1160
|
+
setEditValues({});
|
|
1161
|
+
};
|
|
1162
|
+
|
|
1163
|
+
// 选中状态:selectionMode 时内部维护(受 selectedRow 初始化);否则仅受控 selectedRow
|
|
1164
|
+
const [internalSelected, setInternalSelected] = React.useState<Set<number>>(() => {
|
|
1165
|
+
const s = new Set<number>();
|
|
1166
|
+
if (selectedRow != null) s.add(selectedRow);
|
|
1167
|
+
return s;
|
|
1168
|
+
});
|
|
1169
|
+
const selected = selectionMode
|
|
1170
|
+
? internalSelected
|
|
1171
|
+
: selectedRow != null
|
|
1172
|
+
? new Set<number>([selectedRow])
|
|
1173
|
+
: new Set<number>();
|
|
1174
|
+
const allSelected = data.length > 0 && selected.size === data.length;
|
|
1175
|
+
const hdOn = allSelected;
|
|
1176
|
+
|
|
1177
|
+
const onRowClick = (ri: number) => {
|
|
1178
|
+
if (rowMovedRef.current) return; // 行拖拽后抑制误触选择
|
|
1179
|
+
if (!selectionMode) return;
|
|
1180
|
+
// 行体点击:单选替换(对齐 Ext RowSelectionModel 普通点击 keepExisting=false)
|
|
1181
|
+
setInternalSelected(new Set<number>([ri]));
|
|
1182
|
+
};
|
|
1183
|
+
const onCheckerClick = (ri: number) => {
|
|
1184
|
+
if (!selectionMode) return;
|
|
1185
|
+
setInternalSelected((prev) => {
|
|
1186
|
+
const n = new Set(prev);
|
|
1187
|
+
if (n.has(ri)) n.delete(ri);
|
|
1188
|
+
else n.add(ri);
|
|
1189
|
+
return n;
|
|
1190
|
+
});
|
|
1191
|
+
};
|
|
1192
|
+
const onRadioClick = (ri: number) => {
|
|
1193
|
+
if (!selectionMode) return;
|
|
1194
|
+
// 单选:直接替换(对齐 Ext RadioSelectionModel,始终单选)
|
|
1195
|
+
setInternalSelected(new Set<number>([ri]));
|
|
1196
|
+
};
|
|
1197
|
+
const onHdCheckerClick = () => {
|
|
1198
|
+
if (!selectionMode) return;
|
|
1199
|
+
if (allSelected) setInternalSelected(new Set<number>());
|
|
1200
|
+
else setInternalSelected(new Set(data.map((_, i) => i)));
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
// 表头单元格(对齐 Ext.ux.grid.LockingGridView.renderHeaders:同一套单元格渲染,按列子集 cols 拆到
|
|
1204
|
+
// 锁定/解锁两个面板各自的表头表;wArr/sSlack 为该子集的逐列宽与末列 slack,保证与各自 body 对齐)。
|
|
1205
|
+
const buildHeaderCells = (
|
|
1206
|
+
cols: ColWithId[],
|
|
1207
|
+
wArr: number[],
|
|
1208
|
+
sSlack: number,
|
|
1209
|
+
): React.ReactNode[] =>
|
|
1210
|
+
cols.map((c, i) => {
|
|
1211
|
+
// 行展开器列表头(对齐 Ext.ux.grid.RowExpander 前置展开列:空表头占位)
|
|
1212
|
+
if (c.id === 'expander') {
|
|
1213
|
+
return (
|
|
1214
|
+
<td
|
|
1215
|
+
key={c.id}
|
|
1216
|
+
data-col-id={c.id}
|
|
1217
|
+
className={['x-grid3-hd', 'x-grid3-cell', 'x-grid3-td-expander', i === 0 ? 'x-grid3-cell-first' : '']
|
|
1218
|
+
.filter(Boolean)
|
|
1219
|
+
.join(' ')}
|
|
1220
|
+
style={{ width: wArr[i] }}
|
|
1221
|
+
>
|
|
1222
|
+
<div className="x-grid3-hd-inner x-grid3-hd-expander" unselectable="on">
|
|
1223
|
+
{c.header}
|
|
1224
|
+
</div>
|
|
1225
|
+
</td>
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
const cellCls = [
|
|
1229
|
+
'x-grid3-hd',
|
|
1230
|
+
'x-grid3-cell',
|
|
1231
|
+
`x-grid3-td-${c.id}`,
|
|
1232
|
+
i === 0 ? 'x-grid3-cell-first' : i === cols.length - 1 ? 'x-grid3-cell-last' : '',
|
|
1233
|
+
sort && sort.col === c.id ? (sort.dir === 'asc' ? 'sort-asc' : 'sort-desc') : '',
|
|
1234
|
+
c.dataIndex && filterState[c.dataIndex]?.active ? 'ux-filtered-column' : '',
|
|
1235
|
+
]
|
|
1236
|
+
.filter(Boolean)
|
|
1237
|
+
.join(' ');
|
|
1238
|
+
const sortable = c.sortable !== false;
|
|
1239
|
+
const resizable = c.resizable !== false;
|
|
1240
|
+
// 末列吸收 slack 撑满到网格右边缘(无横向滚动且列未铺满时)
|
|
1241
|
+
const w = wArr[i] + (i === slackIndexOf(cols) ? sSlack : 0);
|
|
1242
|
+
|
|
1243
|
+
// 行号列表头(对齐 Ext.grid.RowNumberer:空文本 + hd-btn + sort-icon,无排序)
|
|
1244
|
+
if (c.isNumberer) {
|
|
1245
|
+
return (
|
|
1246
|
+
<td
|
|
1247
|
+
key={c.id}
|
|
1248
|
+
data-col-id={c.id}
|
|
1249
|
+
className={['x-grid3-hd', 'x-grid3-cell', 'x-grid3-td-numberer', i === 0 ? 'x-grid3-cell-first' : '']
|
|
1250
|
+
.filter(Boolean)
|
|
1251
|
+
.join(' ')}
|
|
1252
|
+
style={{ width: w }}
|
|
1253
|
+
>
|
|
1254
|
+
<div className="x-grid3-hd-inner x-grid3-hd-numberer" unselectable="on">
|
|
1255
|
+
<a className="x-grid3-hd-btn" href="#" onClick={(e) => e.stopPropagation()}></a>
|
|
1256
|
+
{c.header}
|
|
1257
|
+
<img className="x-grid3-sort-icon" src={BLANK} alt="" />
|
|
1258
|
+
</div>
|
|
1259
|
+
</td>
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
// checker 列表头(对齐 Ext CheckboxSelectionModel:x-grid3-hd-checker 内层)
|
|
1263
|
+
if (c.id === 'checker') {
|
|
1264
|
+
return (
|
|
1265
|
+
<td
|
|
1266
|
+
key={c.id}
|
|
1267
|
+
data-col-id={c.id}
|
|
1268
|
+
className={['x-grid3-hd', 'x-grid3-cell', 'x-grid3-td-checker', i === 0 ? 'x-grid3-cell-first' : '']
|
|
1269
|
+
.filter(Boolean)
|
|
1270
|
+
.join(' ')}
|
|
1271
|
+
style={{ width: w }}
|
|
1272
|
+
onClick={() => onHdCheckerClick()}
|
|
1273
|
+
>
|
|
1274
|
+
<div
|
|
1275
|
+
className={['x-grid3-hd-inner', hdOn ? 'x-grid3-hd-checker-on' : '']
|
|
1276
|
+
.filter(Boolean)
|
|
1277
|
+
.join(' ')}
|
|
1278
|
+
unselectable="on"
|
|
1279
|
+
style={{ width: w }}
|
|
1280
|
+
>
|
|
1281
|
+
<div className="x-grid3-hd-checker"> </div>
|
|
1282
|
+
</div>
|
|
1283
|
+
</td>
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
// radio 列表头(对齐 Ext RadioSelectionModel:空表头,无全选)
|
|
1287
|
+
if (c.id === 'radio') {
|
|
1288
|
+
return (
|
|
1289
|
+
<td
|
|
1290
|
+
key={c.id}
|
|
1291
|
+
data-col-id={c.id}
|
|
1292
|
+
className={['x-grid3-hd', 'x-grid3-cell', 'x-grid3-td-radio', i === 0 ? 'x-grid3-cell-first' : '']
|
|
1293
|
+
.filter(Boolean)
|
|
1294
|
+
.join(' ')}
|
|
1295
|
+
style={{ width: w }}
|
|
1296
|
+
>
|
|
1297
|
+
<div className="x-grid3-hd-inner x-grid3-hd-radio" unselectable="on" style={{ width: w }}>
|
|
1298
|
+
 
|
|
1299
|
+
</div>
|
|
1300
|
+
</td>
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
return (
|
|
1304
|
+
<td
|
|
1305
|
+
key={c.id}
|
|
1306
|
+
data-col-id={c.id}
|
|
1307
|
+
className={cellCls}
|
|
1308
|
+
style={{ width: w }}
|
|
1309
|
+
onMouseDown={(e) => startReorder(e, c.id)}
|
|
1310
|
+
onClick={() => {
|
|
1311
|
+
if (suppressReorderClickRef.current) return; // 重排后抑制误触排序
|
|
1312
|
+
if (resizingRef.current) return; // 拖拽改宽后抑制误触排序
|
|
1313
|
+
if (!sortable) return;
|
|
1314
|
+
setSort((prev) => {
|
|
1315
|
+
if (!prev || prev.col !== c.id) return { col: c.id, dir: 'asc' };
|
|
1316
|
+
if (prev.dir === 'asc') return { col: c.id, dir: 'desc' };
|
|
1317
|
+
return { col: c.id, dir: 'asc' };
|
|
1318
|
+
});
|
|
1319
|
+
}}
|
|
1320
|
+
>
|
|
1321
|
+
<div
|
|
1322
|
+
className={`x-grid3-hd-inner x-grid3-hd-${c.id}`}
|
|
1323
|
+
unselectable="on"
|
|
1324
|
+
style={{ width: w }}
|
|
1325
|
+
>
|
|
1326
|
+
{!c.menuDisabled && (
|
|
1327
|
+
<a
|
|
1328
|
+
className="x-grid3-hd-btn"
|
|
1329
|
+
href="#"
|
|
1330
|
+
style={hdH ? { height: `${hdH}px` } : undefined}
|
|
1331
|
+
onClick={(e) => onHdBtnClick(e, c.id)}
|
|
1332
|
+
></a>
|
|
1333
|
+
)}
|
|
1334
|
+
{c.header}
|
|
1335
|
+
<img className="x-grid3-sort-icon" src={BLANK} alt="" />
|
|
1336
|
+
{resizable && (
|
|
1337
|
+
<div
|
|
1338
|
+
className="x-grid-split"
|
|
1339
|
+
unselectable="on"
|
|
1340
|
+
onMouseDown={(e) => startResize(e, visibleCols.findIndex((x) => x.id === c.id))}
|
|
1341
|
+
style={{
|
|
1342
|
+
position: 'absolute',
|
|
1343
|
+
right: 0,
|
|
1344
|
+
top: 0,
|
|
1345
|
+
width: 5,
|
|
1346
|
+
height: '100%',
|
|
1347
|
+
cursor: 'col-resize',
|
|
1348
|
+
zIndex: 3,
|
|
1349
|
+
}}
|
|
1350
|
+
/>
|
|
1351
|
+
)}
|
|
1352
|
+
</div>
|
|
1353
|
+
</td>
|
|
1354
|
+
);
|
|
1355
|
+
});
|
|
1356
|
+
const headerCells = buildHeaderCells(visibleCols, widths, slack);
|
|
1357
|
+
|
|
1358
|
+
// 列分组表头行(对齐 Ext.ux.grid.ColumnHeaderGroup.renderHeaders:与列头共用同一张 fixed-layout 表,
|
|
1359
|
+
// 借助 <colgroup> 锁定每列宽度,分组单元用 colspan 跨列,自动精确对齐下方列头/数据;单元
|
|
1360
|
+
// td.x-grid3-hd.x-grid3-gcell.ux-grid-hd-group-cell + 行类 ux-grid-hd-group-row-{row},宽度由所跨列
|
|
1361
|
+
// 宽之和自动得出(末列吸收 slack);样式复用原版 examples/ux/css/ColumnHeaderGroup.css,零新增手写 CSS)。
|
|
1362
|
+
const renderGroupHeaderRows = (): React.ReactNode[] => {
|
|
1363
|
+
if (!headerGroups || headerGroups.length === 0) return [];
|
|
1364
|
+
return headerGroups.map((row, ri) => {
|
|
1365
|
+
let gcol = 0;
|
|
1366
|
+
const cells = row.map((gp, gi) => {
|
|
1367
|
+
const cs = gp.colspan ?? 1;
|
|
1368
|
+
const firstCol = visibleCols[gcol];
|
|
1369
|
+
const id = firstCol ? firstCol.id : `grp-${ri}-${gi}`;
|
|
1370
|
+
const cls = [
|
|
1371
|
+
'x-grid3-hd',
|
|
1372
|
+
'x-grid3-gcell',
|
|
1373
|
+
`x-grid3-td-${id}`,
|
|
1374
|
+
`ux-grid-hd-group-row-${ri}`,
|
|
1375
|
+
'ux-grid-hd-group-cell',
|
|
1376
|
+
]
|
|
1377
|
+
.filter(Boolean)
|
|
1378
|
+
.join(' ');
|
|
1379
|
+
const td = (
|
|
1380
|
+
<td key={`grp-${ri}-${gi}`} className={cls} colSpan={cs} data-col-id={id}>
|
|
1381
|
+
<div
|
|
1382
|
+
className={`x-grid3-hd-inner x-grid3-hd-${id}`}
|
|
1383
|
+
unselectable="on"
|
|
1384
|
+
style={gp.align === 'right' ? { paddingRight: 16 } : undefined}
|
|
1385
|
+
>
|
|
1386
|
+
{gp.tooltip ? <span title={gp.tooltip}>{gp.header}</span> : gp.header}
|
|
1387
|
+
</div>
|
|
1388
|
+
</td>
|
|
1389
|
+
);
|
|
1390
|
+
gcol += cs;
|
|
1391
|
+
return td;
|
|
1392
|
+
});
|
|
1393
|
+
return (
|
|
1394
|
+
<tr key={`grp-row-${ri}`} className="x-grid3-hd-row">
|
|
1395
|
+
{cells}
|
|
1396
|
+
</tr>
|
|
1397
|
+
);
|
|
1398
|
+
});
|
|
1399
|
+
};
|
|
1400
|
+
|
|
1401
|
+
// 行编辑器浮条(对齐 Ext.ux.grid.RowEditor 的 x-row-editor:表头/底部分隔条 + 蓝色编辑体 +
|
|
1402
|
+
// 按列对齐的字段 + 居中 Save/Cancel 按钮;样式复用原版 examples/ux/css/RowEditor.css)。
|
|
1403
|
+
// 锁定分栏时按 cols 子集渲染(默认全列,行为不变;降级为解锁面板内编辑条)。
|
|
1404
|
+
const renderEditor = (
|
|
1405
|
+
eOrig: number,
|
|
1406
|
+
cols: ColWithId[] = visibleCols,
|
|
1407
|
+
wArr: number[] = widths,
|
|
1408
|
+
sSlack: number = slack,
|
|
1409
|
+
paneTableW: number = tableW,
|
|
1410
|
+
): React.ReactNode => {
|
|
1411
|
+
const row = data[eOrig];
|
|
1412
|
+
const saveText = rowEditor?.saveText ?? 'Save';
|
|
1413
|
+
const cancelText = rowEditor?.cancelText ?? 'Cancel';
|
|
1414
|
+
const editorType = (c: GridColumn): 'text' | 'number' => {
|
|
1415
|
+
if (c.editor === 'number') return 'number';
|
|
1416
|
+
if (c.editor === 'text') return 'text';
|
|
1417
|
+
return typeof row[c.dataIndex] === 'number' ? 'number' : 'text';
|
|
1418
|
+
};
|
|
1419
|
+
return (
|
|
1420
|
+
<div
|
|
1421
|
+
className="x-row-editor"
|
|
1422
|
+
style={{
|
|
1423
|
+
position: 'relative',
|
|
1424
|
+
width: paneTableW,
|
|
1425
|
+
marginTop: 2,
|
|
1426
|
+
borderLeft: '1px solid #99bbe8',
|
|
1427
|
+
borderRight: '1px solid #99bbe8',
|
|
1428
|
+
}}
|
|
1429
|
+
>
|
|
1430
|
+
<div className="x-row-editor-header" />
|
|
1431
|
+
<div className="x-row-editor-body">
|
|
1432
|
+
<table
|
|
1433
|
+
className="x-row-editor-fields"
|
|
1434
|
+
border={0}
|
|
1435
|
+
cellSpacing={0}
|
|
1436
|
+
cellPadding={0}
|
|
1437
|
+
style={{ tableLayout: 'fixed', width: paneTableW }}
|
|
1438
|
+
>
|
|
1439
|
+
<tbody>
|
|
1440
|
+
<tr>
|
|
1441
|
+
{cols.map((c, i) => {
|
|
1442
|
+
const w = wArr[i] + (i === slackIndexOf(cols) ? sSlack : 0);
|
|
1443
|
+
const special =
|
|
1444
|
+
c.id === 'expander' ||
|
|
1445
|
+
!!c.isNumberer ||
|
|
1446
|
+
c.id === 'checker' ||
|
|
1447
|
+
c.id === 'radio' ||
|
|
1448
|
+
c.type === 'checkcolumn';
|
|
1449
|
+
const raw = editValues[c.dataIndex] ?? row[c.dataIndex];
|
|
1450
|
+
const val = raw === undefined || raw === null ? '' : raw;
|
|
1451
|
+
return (
|
|
1452
|
+
<td
|
|
1453
|
+
key={c.id}
|
|
1454
|
+
className={`x-grid3-td-${c.id}`}
|
|
1455
|
+
style={{ width: w, padding: '2px', verticalAlign: 'top' }}
|
|
1456
|
+
>
|
|
1457
|
+
{special ? (
|
|
1458
|
+
<div className="x-grid3-row-editor-cell" style={{ padding: '3px 4px' }}>
|
|
1459
|
+
{c.isNumberer
|
|
1460
|
+
? eOrig + 1
|
|
1461
|
+
: c.type === 'checkcolumn'
|
|
1462
|
+
? val
|
|
1463
|
+
? '✓'
|
|
1464
|
+
: ''
|
|
1465
|
+
: String(val)}
|
|
1466
|
+
</div>
|
|
1467
|
+
) : (
|
|
1468
|
+
<input
|
|
1469
|
+
className="x-form-text x-form-field"
|
|
1470
|
+
type={editorType(c) === 'number' ? 'number' : 'text'}
|
|
1471
|
+
value={String(val)}
|
|
1472
|
+
onChange={(e) => updateEdit(c.dataIndex, e.target.value)}
|
|
1473
|
+
onKeyDown={(e) => {
|
|
1474
|
+
if (e.key === 'Enter') {
|
|
1475
|
+
e.preventDefault();
|
|
1476
|
+
saveEdit(eOrig);
|
|
1477
|
+
} else if (e.key === 'Escape') {
|
|
1478
|
+
cancelEdit();
|
|
1479
|
+
}
|
|
1480
|
+
}}
|
|
1481
|
+
style={{ width: '100%', boxSizing: 'border-box' }}
|
|
1482
|
+
/>
|
|
1483
|
+
)}
|
|
1484
|
+
</td>
|
|
1485
|
+
);
|
|
1486
|
+
})}
|
|
1487
|
+
</tr>
|
|
1488
|
+
</tbody>
|
|
1489
|
+
</table>
|
|
1490
|
+
</div>
|
|
1491
|
+
<div className="x-row-editor-footer" />
|
|
1492
|
+
<div
|
|
1493
|
+
className="x-btns"
|
|
1494
|
+
style={{ position: 'relative', top: 'auto', left: 'auto', margin: '4px auto 2px', transform: 'none' }}
|
|
1495
|
+
>
|
|
1496
|
+
<div className="x-plain-bwrap">
|
|
1497
|
+
<div className="x-plain-body">
|
|
1498
|
+
<table className="x-table-layout">
|
|
1499
|
+
<tbody>
|
|
1500
|
+
<tr>
|
|
1501
|
+
<td className="x-table-layout-cell">
|
|
1502
|
+
<Button scale="small" text={saveText} onClick={(e) => { e.stopPropagation(); saveEdit(eOrig); }} />
|
|
1503
|
+
</td>
|
|
1504
|
+
<td className="x-table-layout-cell">
|
|
1505
|
+
<Button scale="small" text={cancelText} onClick={(e) => { e.stopPropagation(); cancelEdit(); }} />
|
|
1506
|
+
</td>
|
|
1507
|
+
</tr>
|
|
1508
|
+
</tbody>
|
|
1509
|
+
</table>
|
|
1510
|
+
</div>
|
|
1511
|
+
</div>
|
|
1512
|
+
</div>
|
|
1513
|
+
</div>
|
|
1514
|
+
);
|
|
1515
|
+
};
|
|
1516
|
+
|
|
1517
|
+
// 数据行渲染(锁定分栏时按 cols 子集拆分到锁定/解锁两个面板;默认全列,行为不变)。
|
|
1518
|
+
// wArr/sSlack 为该子集逐列宽与末列 slack;paneRowW/paneTableW 为该面板行区/表宽(用于行 div、展开体、行表)。
|
|
1519
|
+
const renderRow = (
|
|
1520
|
+
ri: number,
|
|
1521
|
+
cols: ColWithId[] = visibleCols,
|
|
1522
|
+
wArr: number[] = widths,
|
|
1523
|
+
sSlack: number = slack,
|
|
1524
|
+
paneRowW: number = rowW,
|
|
1525
|
+
paneTableW: number = tableW,
|
|
1526
|
+
) => {
|
|
1527
|
+
const row = displayData[ri];
|
|
1528
|
+
const origIdx = idxByRow.get(row)!;
|
|
1529
|
+
const altCls = stripeRows && (ri + 1) % 2 === 0 ? ' x-grid3-row-alt' : '';
|
|
1530
|
+
const selCls = selected.has(ri) ? ' x-grid3-row-selected' : '';
|
|
1531
|
+
const cells = cols.map((c, i) => {
|
|
1532
|
+
// 列宽:末列吸收 slack 撑满到网格右边缘,保证与表头(同样末列吸收 slack)严格对齐。
|
|
1533
|
+
// 配合 .x-grid3-row-table 的 table-layout:fixed,避免浏览器按内容自动分配列宽导致错位。
|
|
1534
|
+
const cellW = wArr[i] + (i === slackIndexOf(cols) ? sSlack : 0);
|
|
1535
|
+
// 行展开器列 body(对齐 Ext.ux.grid.RowExpander.renderer:x-grid3-row-expander 切换按钮)
|
|
1536
|
+
if (c.id === 'expander') {
|
|
1537
|
+
const isExpanded = expandedRows.has(origIdx);
|
|
1538
|
+
return (
|
|
1539
|
+
<td
|
|
1540
|
+
key={c.id}
|
|
1541
|
+
className={['x-grid3-col', 'x-grid3-cell', 'x-grid3-td-expander', i === 0 ? 'x-grid3-cell-first' : '', i === cols.length - 1 ? 'x-grid3-cell-last' : '']
|
|
1542
|
+
.filter(Boolean)
|
|
1543
|
+
.join(' ')}
|
|
1544
|
+
style={{ width: cellW, boxSizing: 'border-box' }}
|
|
1545
|
+
tabIndex={0}
|
|
1546
|
+
>
|
|
1547
|
+
<div className="x-grid3-cell-inner" unselectable="on">
|
|
1548
|
+
<div
|
|
1549
|
+
className="x-grid3-row-expander"
|
|
1550
|
+
role="button"
|
|
1551
|
+
aria-label={isExpanded ? '收起行' : '展开行'}
|
|
1552
|
+
aria-expanded={isExpanded}
|
|
1553
|
+
onClick={(e) => {
|
|
1554
|
+
e.stopPropagation();
|
|
1555
|
+
toggleExpand(origIdx);
|
|
1556
|
+
}}
|
|
1557
|
+
onKeyDown={(e) => {
|
|
1558
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
1559
|
+
e.preventDefault();
|
|
1560
|
+
e.stopPropagation();
|
|
1561
|
+
toggleExpand(origIdx);
|
|
1562
|
+
}
|
|
1563
|
+
}}
|
|
1564
|
+
/>
|
|
1565
|
+
</div>
|
|
1566
|
+
</td>
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
// 行号列 body(对齐 Ext.grid.RowNumberer.renderer:内容 = 行索引+1,右对齐)
|
|
1570
|
+
if (c.isNumberer) {
|
|
1571
|
+
return (
|
|
1572
|
+
<td
|
|
1573
|
+
key={c.id}
|
|
1574
|
+
className={['x-grid3-col', 'x-grid3-cell', 'x-grid3-td-numberer', i === 0 ? 'x-grid3-cell-first' : '', i === cols.length - 1 ? 'x-grid3-cell-last' : '']
|
|
1575
|
+
.filter(Boolean)
|
|
1576
|
+
.join(' ')}
|
|
1577
|
+
style={{ width: cellW, boxSizing: 'border-box' }}
|
|
1578
|
+
tabIndex={0}
|
|
1579
|
+
>
|
|
1580
|
+
<div
|
|
1581
|
+
className="x-grid3-cell-inner x-grid3-col-numberer"
|
|
1582
|
+
unselectable="on"
|
|
1583
|
+
style={{ textAlign: 'right' }}
|
|
1584
|
+
>
|
|
1585
|
+
{ri + 1}
|
|
1586
|
+
</div>
|
|
1587
|
+
</td>
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1590
|
+
// checker 列 body(对齐 Ext CheckboxSelectionModel.renderer:x-grid3-row-checker 内层)
|
|
1591
|
+
if (c.id === 'checker') {
|
|
1592
|
+
return (
|
|
1593
|
+
<td
|
|
1594
|
+
key={c.id}
|
|
1595
|
+
className={['x-grid3-col', 'x-grid3-cell', 'x-grid3-td-checker', i === 0 ? 'x-grid3-cell-first' : '']
|
|
1596
|
+
.filter(Boolean)
|
|
1597
|
+
.join(' ')}
|
|
1598
|
+
style={{ width: cellW, boxSizing: 'border-box' }}
|
|
1599
|
+
tabIndex={0}
|
|
1600
|
+
>
|
|
1601
|
+
<div className="x-grid3-cell-inner">
|
|
1602
|
+
<div
|
|
1603
|
+
className="x-grid3-row-checker"
|
|
1604
|
+
onClick={(e) => {
|
|
1605
|
+
e.stopPropagation();
|
|
1606
|
+
onCheckerClick(ri);
|
|
1607
|
+
}}
|
|
1608
|
+
>
|
|
1609
|
+
 
|
|
1610
|
+
</div>
|
|
1611
|
+
</div>
|
|
1612
|
+
</td>
|
|
1613
|
+
);
|
|
1614
|
+
}
|
|
1615
|
+
// radio 列 body(对齐 Ext RadioSelectionModel.renderer:x-grid3-row-radio 内层渲染 radio)
|
|
1616
|
+
if (c.id === 'radio') {
|
|
1617
|
+
const checked = selected.has(ri);
|
|
1618
|
+
return (
|
|
1619
|
+
<td
|
|
1620
|
+
key={c.id}
|
|
1621
|
+
className={['x-grid3-col', 'x-grid3-cell', 'x-grid3-td-radio', i === 0 ? 'x-grid3-cell-first' : '']
|
|
1622
|
+
.filter(Boolean)
|
|
1623
|
+
.join(' ')}
|
|
1624
|
+
style={{ width: cellW, boxSizing: 'border-box' }}
|
|
1625
|
+
tabIndex={0}
|
|
1626
|
+
>
|
|
1627
|
+
<div className="x-grid3-cell-inner">
|
|
1628
|
+
<div
|
|
1629
|
+
className="x-grid3-row-radio"
|
|
1630
|
+
onClick={(e) => {
|
|
1631
|
+
e.stopPropagation();
|
|
1632
|
+
onRadioClick(ri);
|
|
1633
|
+
}}
|
|
1634
|
+
>
|
|
1635
|
+
<input
|
|
1636
|
+
type="radio"
|
|
1637
|
+
readOnly
|
|
1638
|
+
checked={checked}
|
|
1639
|
+
onClick={(e) => {
|
|
1640
|
+
e.stopPropagation();
|
|
1641
|
+
onRadioClick(ri);
|
|
1642
|
+
}}
|
|
1643
|
+
/>
|
|
1644
|
+
</div>
|
|
1645
|
+
</div>
|
|
1646
|
+
</td>
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
// 复选框列 body(对齐 Ext.ux.grid.CheckColumn.renderer:x-grid3-checkcol 可勾选框 + 勾选态 x-grid3-checkcol-on)
|
|
1650
|
+
if (c.type === 'checkcolumn') {
|
|
1651
|
+
const key = `${origIdx}:${c.dataIndex}`;
|
|
1652
|
+
const checked = key in checks ? checks[key] : !!row[c.dataIndex];
|
|
1653
|
+
return (
|
|
1654
|
+
<td
|
|
1655
|
+
key={c.id}
|
|
1656
|
+
className={['x-grid3-col', 'x-grid3-cell', 'x-grid3-td-checkcolumn', i === 0 ? 'x-grid3-cell-first' : i === cols.length - 1 ? 'x-grid3-cell-last' : '']
|
|
1657
|
+
.filter(Boolean)
|
|
1658
|
+
.join(' ')}
|
|
1659
|
+
style={{ width: cellW, boxSizing: 'border-box' }}
|
|
1660
|
+
tabIndex={0}
|
|
1661
|
+
>
|
|
1662
|
+
<div className="x-grid3-cell-inner" unselectable="on" style={{ textAlign: 'center' }}>
|
|
1663
|
+
<div
|
|
1664
|
+
className={`x-grid3-checkcol${checked ? ' x-grid3-checkcol-on' : ''}`}
|
|
1665
|
+
role="checkbox"
|
|
1666
|
+
aria-checked={checked}
|
|
1667
|
+
aria-label={c.header}
|
|
1668
|
+
onClick={(e) => {
|
|
1669
|
+
e.stopPropagation();
|
|
1670
|
+
toggleCheck(origIdx, c.dataIndex);
|
|
1671
|
+
}}
|
|
1672
|
+
onKeyDown={(e) => {
|
|
1673
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
1674
|
+
e.preventDefault();
|
|
1675
|
+
e.stopPropagation();
|
|
1676
|
+
toggleCheck(origIdx, c.dataIndex);
|
|
1677
|
+
}
|
|
1678
|
+
}}
|
|
1679
|
+
>
|
|
1680
|
+
{checked && (
|
|
1681
|
+
<svg className="x-grid3-checkcol-icon" width="11" height="11" viewBox="0 0 11 11" aria-hidden>
|
|
1682
|
+
<path
|
|
1683
|
+
d="M2 5.5L4.2 7.8L9 2.5"
|
|
1684
|
+
fill="none"
|
|
1685
|
+
stroke="currentColor"
|
|
1686
|
+
strokeWidth="1.6"
|
|
1687
|
+
strokeLinecap="round"
|
|
1688
|
+
strokeLinejoin="round"
|
|
1689
|
+
/>
|
|
1690
|
+
</svg>
|
|
1691
|
+
)}
|
|
1692
|
+
</div>
|
|
1693
|
+
</div>
|
|
1694
|
+
</td>
|
|
1695
|
+
);
|
|
1696
|
+
}
|
|
1697
|
+
const cellCls = [
|
|
1698
|
+
'x-grid3-col',
|
|
1699
|
+
'x-grid3-cell',
|
|
1700
|
+
`x-grid3-td-${c.id}`,
|
|
1701
|
+
i === 0 ? 'x-grid3-cell-first' : i === cols.length - 1 ? 'x-grid3-cell-last' : '',
|
|
1702
|
+
]
|
|
1703
|
+
.filter(Boolean)
|
|
1704
|
+
.join(' ');
|
|
1705
|
+
const raw = row[c.dataIndex];
|
|
1706
|
+
const value = c.renderer ? c.renderer(raw, row) : raw;
|
|
1707
|
+
const display: React.ReactNode =
|
|
1708
|
+
value === undefined || value === null || value === '' ? ' ' : (value as React.ReactNode);
|
|
1709
|
+
return (
|
|
1710
|
+
<td
|
|
1711
|
+
key={c.id}
|
|
1712
|
+
className={cellCls}
|
|
1713
|
+
style={{ width: wArr[i] + (i === slackIndexOf(cols) ? sSlack : 0), textAlign: c.align || 'left', boxSizing: 'border-box' }}
|
|
1714
|
+
tabIndex={0}
|
|
1715
|
+
>
|
|
1716
|
+
<div
|
|
1717
|
+
className={`x-grid3-cell-inner x-grid3-col-${c.id}`}
|
|
1718
|
+
unselectable="on"
|
|
1719
|
+
style={{ textAlign: c.align || 'left' }}
|
|
1720
|
+
>
|
|
1721
|
+
{display}
|
|
1722
|
+
</div>
|
|
1723
|
+
</td>
|
|
1724
|
+
);
|
|
1725
|
+
});
|
|
1726
|
+
const isExpanded = expandedRows.has(origIdx);
|
|
1727
|
+
return (
|
|
1728
|
+
<React.Fragment key={ri}>
|
|
1729
|
+
<div
|
|
1730
|
+
key={ri}
|
|
1731
|
+
data-row-index={origIdx}
|
|
1732
|
+
className={`x-grid3-row${altCls}${selCls}${isExpanded ? ' x-grid3-row-expanded' : ' x-grid3-row-collapsed'}`}
|
|
1733
|
+
style={{ width: paneRowW, cursor: enableRowReorder ? 'move' : undefined, display: editing && editing.origIdx === origIdx ? 'none' : undefined }}
|
|
1734
|
+
tabIndex={0}
|
|
1735
|
+
onMouseDown={(e) => startRowDrag(e, origIdx)}
|
|
1736
|
+
onClick={() => {
|
|
1737
|
+
onRowClick(ri);
|
|
1738
|
+
// 行编辑器:clicksToEdit===1 时单击行进入编辑(对齐 Ext RowEditor 的 rowclick→startEditing)
|
|
1739
|
+
if (rowEditor && (rowEditor.clicksToEdit ?? 2) === 1) startEdit(origIdx);
|
|
1740
|
+
}}
|
|
1741
|
+
onDoubleClick={() => {
|
|
1742
|
+
// 行编辑器优先:clicksToEdit===2(默认)双击进入编辑,否则按展开器逻辑处理
|
|
1743
|
+
if (rowEditor && (rowEditor.clicksToEdit ?? 2) === 2) {
|
|
1744
|
+
startEdit(origIdx);
|
|
1745
|
+
} else if (expander?.expandOnDblClick !== false) {
|
|
1746
|
+
toggleExpand(origIdx);
|
|
1747
|
+
}
|
|
1748
|
+
}}
|
|
1749
|
+
onKeyDown={(e) => {
|
|
1750
|
+
if (e.key === 'Enter') {
|
|
1751
|
+
// 行编辑器激活时回车进入编辑;否则按展开器逻辑处理
|
|
1752
|
+
if (rowEditor) {
|
|
1753
|
+
e.preventDefault();
|
|
1754
|
+
startEdit(origIdx);
|
|
1755
|
+
} else if (expander?.expandOnEnter !== false) {
|
|
1756
|
+
e.preventDefault();
|
|
1757
|
+
toggleExpand(origIdx);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
}}
|
|
1761
|
+
>
|
|
1762
|
+
<table
|
|
1763
|
+
className="x-grid3-row-table"
|
|
1764
|
+
border={0}
|
|
1765
|
+
cellSpacing={0}
|
|
1766
|
+
cellPadding={0}
|
|
1767
|
+
style={{ width: paneTableW }}
|
|
1768
|
+
>
|
|
1769
|
+
<colgroup>
|
|
1770
|
+
{cols.map((c, i) => (
|
|
1771
|
+
<col key={c.id} style={{ width: wArr[i] + (i === slackIndexOf(cols) ? sSlack : 0) }} />
|
|
1772
|
+
))}
|
|
1773
|
+
</colgroup>
|
|
1774
|
+
<tbody>
|
|
1775
|
+
<tr>
|
|
1776
|
+
{cells}
|
|
1777
|
+
</tr>
|
|
1778
|
+
</tbody>
|
|
1779
|
+
</table>
|
|
1780
|
+
</div>
|
|
1781
|
+
{isExpanded && expander && (
|
|
1782
|
+
<div
|
|
1783
|
+
className="x-grid3-row-body"
|
|
1784
|
+
style={{ width: paneRowW }}
|
|
1785
|
+
data-row-body={origIdx}
|
|
1786
|
+
>
|
|
1787
|
+
<div className="x-grid3-row-body-inner">
|
|
1788
|
+
{expander.getRowBody(row, ri)}
|
|
1789
|
+
</div>
|
|
1790
|
+
</div>
|
|
1791
|
+
)}
|
|
1792
|
+
{editing && editing.origIdx === origIdx ? renderEditor(origIdx, cols, wArr, sSlack, paneTableW) : null}
|
|
1793
|
+
</React.Fragment>
|
|
1794
|
+
);
|
|
1795
|
+
};
|
|
1796
|
+
|
|
1797
|
+
// 主体内容(分组头 + 数据行 / 分组汇总)。锁定分栏时按 cols 子集渲染到对应面板;默认全列,行为不变。
|
|
1798
|
+
// 注意:锁定 + 分组(groupField)组合不在保真范围内(Ext 官方亦无内置 Locking+Grouping 组合视图),
|
|
1799
|
+
// 故锁定面板内的分组/汇总仅作用于解锁列子集,仅作降级渲染。
|
|
1800
|
+
const renderBodyContent = (
|
|
1801
|
+
cols: ColWithId[] = visibleCols,
|
|
1802
|
+
wArr: number[] = widths,
|
|
1803
|
+
sSlack: number = slack,
|
|
1804
|
+
paneRowW: number = rowW,
|
|
1805
|
+
paneTableW: number = tableW,
|
|
1806
|
+
) =>
|
|
1807
|
+
groupField && groupHeader
|
|
1808
|
+
? groups.map((g) => (
|
|
1809
|
+
<div
|
|
1810
|
+
key={g.key}
|
|
1811
|
+
className={'x-grid-group' + (collapsed.has(g.key) ? ' x-grid-group-collapsed' : '')}
|
|
1812
|
+
>
|
|
1813
|
+
<div
|
|
1814
|
+
className="x-grid-group-hd"
|
|
1815
|
+
style={{ width: paneRowW }}
|
|
1816
|
+
onClick={() =>
|
|
1817
|
+
setCollapsed((prev) => {
|
|
1818
|
+
const n = new Set(prev);
|
|
1819
|
+
if (n.has(g.key)) n.delete(g.key);
|
|
1820
|
+
else n.add(g.key);
|
|
1821
|
+
return n;
|
|
1822
|
+
})
|
|
1823
|
+
}
|
|
1824
|
+
>
|
|
1825
|
+
<div className="x-grid-group-title">{g.label}</div>
|
|
1826
|
+
</div>
|
|
1827
|
+
<div className="x-grid-group-body">
|
|
1828
|
+
{g.rows.map((ri) => renderRow(ri, cols, wArr, sSlack, paneRowW, paneTableW))}
|
|
1829
|
+
{hasSummary && (
|
|
1830
|
+
<div className="x-grid3-summary-row" style={{ width: paneRowW }}>
|
|
1831
|
+
<table
|
|
1832
|
+
className="x-grid3-summary-table"
|
|
1833
|
+
border={0}
|
|
1834
|
+
cellSpacing={0}
|
|
1835
|
+
cellPadding={0}
|
|
1836
|
+
style={{ width: paneTableW, tableLayout: 'fixed' }}
|
|
1837
|
+
>
|
|
1838
|
+
<colgroup>
|
|
1839
|
+
{cols.map((c, i) => (
|
|
1840
|
+
<col
|
|
1841
|
+
key={c.id}
|
|
1842
|
+
style={{ width: wArr[i] + (i === slackIndexOf(cols) ? sSlack : 0) }}
|
|
1843
|
+
/>
|
|
1844
|
+
))}
|
|
1845
|
+
</colgroup>
|
|
1846
|
+
<tbody>
|
|
1847
|
+
<tr>
|
|
1848
|
+
{cols.map((c, i) => {
|
|
1849
|
+
const sw = wArr[i] + (i === slackIndexOf(cols) ? sSlack : 0);
|
|
1850
|
+
const content = computeSummary(c, g.rows.map((ri) => displayData[ri]));
|
|
1851
|
+
return (
|
|
1852
|
+
<td
|
|
1853
|
+
key={c.id}
|
|
1854
|
+
className={`x-grid3-grid-summary x-grid3-td-${c.id}`}
|
|
1855
|
+
style={{ width: sw, boxSizing: 'border-box', overflow: 'hidden' }}
|
|
1856
|
+
>
|
|
1857
|
+
<div className="x-grid3-summary-cell" unselectable="on">
|
|
1858
|
+
{content ?? ''}
|
|
1859
|
+
</div>
|
|
1860
|
+
</td>
|
|
1861
|
+
);
|
|
1862
|
+
})}
|
|
1863
|
+
</tr>
|
|
1864
|
+
</tbody>
|
|
1865
|
+
</table>
|
|
1866
|
+
</div>
|
|
1867
|
+
)}
|
|
1868
|
+
</div>
|
|
1869
|
+
</div>
|
|
1870
|
+
))
|
|
1871
|
+
: displayData.map((_, ri) => renderRow(ri, cols, wArr, sSlack, paneRowW, paneTableW));
|
|
1872
|
+
|
|
1873
|
+
return (
|
|
1874
|
+
<Panel
|
|
1875
|
+
cls="x-grid-panel"
|
|
1876
|
+
title={title}
|
|
1877
|
+
width={width}
|
|
1878
|
+
height={height}
|
|
1879
|
+
bodyFlex={!!height}
|
|
1880
|
+
style={style}
|
|
1881
|
+
>
|
|
1882
|
+
<div
|
|
1883
|
+
ref={gridRef}
|
|
1884
|
+
className={['x-grid3', className].filter(Boolean).join(' ')}
|
|
1885
|
+
style={{
|
|
1886
|
+
width: width ? '100%' : totalWidth,
|
|
1887
|
+
height: height ? '100%' : undefined,
|
|
1888
|
+
position: 'relative',
|
|
1889
|
+
display: 'flex',
|
|
1890
|
+
flexDirection: hasLock ? 'row' : 'column',
|
|
1891
|
+
}}
|
|
1892
|
+
unselectable="on"
|
|
1893
|
+
>
|
|
1894
|
+
{!hasLock && (
|
|
1895
|
+
<div
|
|
1896
|
+
className="x-grid3-viewport"
|
|
1897
|
+
style={{ display: 'flex', flexDirection: 'column', flex: '1 1 auto', minHeight: 0 }}
|
|
1898
|
+
>
|
|
1899
|
+
<div
|
|
1900
|
+
className="x-grid3-header"
|
|
1901
|
+
style={{ flex: '0 0 auto', width: '100%', overflow: 'hidden' }}
|
|
1902
|
+
>
|
|
1903
|
+
<div
|
|
1904
|
+
className="x-grid3-header-inner"
|
|
1905
|
+
ref={innerHdRef}
|
|
1906
|
+
style={{ width: headerInnerW, overflow: 'hidden' }}
|
|
1907
|
+
>
|
|
1908
|
+
<div className="x-grid3-header-offset" style={{ width: headerOffsetW }}>
|
|
1909
|
+
<table
|
|
1910
|
+
border={0}
|
|
1911
|
+
cellSpacing={0}
|
|
1912
|
+
cellPadding={0}
|
|
1913
|
+
style={{ tableLayout: 'fixed', width: tableW }}
|
|
1914
|
+
>
|
|
1915
|
+
<colgroup>
|
|
1916
|
+
{visibleCols.map((c, i) => (
|
|
1917
|
+
<col
|
|
1918
|
+
key={c.id}
|
|
1919
|
+
style={{
|
|
1920
|
+
width:
|
|
1921
|
+
widths[i] +
|
|
1922
|
+
(i === slackIndexOf(visibleCols) ? slack + (gutter > 0 ? gutter : 0) : 0),
|
|
1923
|
+
}}
|
|
1924
|
+
/>
|
|
1925
|
+
))}
|
|
1926
|
+
</colgroup>
|
|
1927
|
+
<thead>
|
|
1928
|
+
{renderGroupHeaderRows()}
|
|
1929
|
+
<tr className="x-grid3-hd-row" ref={hdRowRef}>
|
|
1930
|
+
{headerCells}
|
|
1931
|
+
</tr>
|
|
1932
|
+
</thead>
|
|
1933
|
+
</table>
|
|
1934
|
+
</div>
|
|
1935
|
+
</div>
|
|
1936
|
+
<div className="x-clear" />
|
|
1937
|
+
</div>
|
|
1938
|
+
<div
|
|
1939
|
+
className="x-grid3-scroller"
|
|
1940
|
+
ref={scrollerRef}
|
|
1941
|
+
style={{ flex: '1 1 auto', minHeight: 0, width: '100%' }}
|
|
1942
|
+
>
|
|
1943
|
+
<div className="x-grid3-body" style={{ width: rowW }}>
|
|
1944
|
+
{renderBodyContent()}
|
|
1945
|
+
</div>
|
|
1946
|
+
<a href="#" className="x-grid3-focus" tabIndex={-1}>
|
|
1947
|
+
 
|
|
1948
|
+
</a>
|
|
1949
|
+
</div>
|
|
1950
|
+
</div>
|
|
1951
|
+
)}
|
|
1952
|
+
{hasLock && (
|
|
1953
|
+
<>
|
|
1954
|
+
{/* 锁定面板(x-grid3-locked):左侧固定,纵向随解锁面板同步滚动,自身不横向滚动 */}
|
|
1955
|
+
<div
|
|
1956
|
+
className="x-grid3-locked"
|
|
1957
|
+
style={{
|
|
1958
|
+
flex: `0 0 ${lockedPaneW}px`,
|
|
1959
|
+
display: 'flex',
|
|
1960
|
+
flexDirection: 'column',
|
|
1961
|
+
position: 'relative',
|
|
1962
|
+
overflow: 'hidden',
|
|
1963
|
+
}}
|
|
1964
|
+
>
|
|
1965
|
+
<div
|
|
1966
|
+
className="x-grid3-header"
|
|
1967
|
+
style={{ flex: '0 0 auto', width: lockedPaneW, overflow: 'hidden' }}
|
|
1968
|
+
>
|
|
1969
|
+
<div
|
|
1970
|
+
className="x-grid3-header-inner"
|
|
1971
|
+
style={{ width: lockedPaneW, overflow: 'hidden' }}
|
|
1972
|
+
>
|
|
1973
|
+
<div className="x-grid3-header-offset" style={{ width: lockedPaneW }}>
|
|
1974
|
+
<table
|
|
1975
|
+
border={0}
|
|
1976
|
+
cellSpacing={0}
|
|
1977
|
+
cellPadding={0}
|
|
1978
|
+
style={{ tableLayout: 'fixed', width: lockedPaneW }}
|
|
1979
|
+
>
|
|
1980
|
+
<colgroup>
|
|
1981
|
+
{lockedCols.map((c, i) => (
|
|
1982
|
+
<col key={c.id} style={{ width: lockedWidths[i] }} />
|
|
1983
|
+
))}
|
|
1984
|
+
</colgroup>
|
|
1985
|
+
<thead>
|
|
1986
|
+
<tr className="x-grid3-hd-row">{buildHeaderCells(lockedCols, lockedWidths, 0)}</tr>
|
|
1987
|
+
</thead>
|
|
1988
|
+
</table>
|
|
1989
|
+
</div>
|
|
1990
|
+
</div>
|
|
1991
|
+
<div className="x-clear" />
|
|
1992
|
+
</div>
|
|
1993
|
+
<div
|
|
1994
|
+
className="x-grid3-scroller"
|
|
1995
|
+
ref={lockedScrollerRef}
|
|
1996
|
+
style={{ flex: '1 1 auto', minHeight: 0, width: '100%', overflow: 'hidden' }}
|
|
1997
|
+
>
|
|
1998
|
+
<div className="x-grid3-body" style={{ width: lockedPaneW }}>
|
|
1999
|
+
{renderBodyContent(lockedCols, lockedWidths, 0, lockedPaneW, lockedPaneW)}
|
|
2000
|
+
</div>
|
|
2001
|
+
<div className="x-grid3-scroll-spacer" />
|
|
2002
|
+
</div>
|
|
2003
|
+
</div>
|
|
2004
|
+
{/* 解锁面板(x-grid3-viewport x-grid3-unlocked):右侧可横向滚动,纵向滚动同步锁定面板 */}
|
|
2005
|
+
<div
|
|
2006
|
+
className="x-grid3-viewport x-grid3-unlocked"
|
|
2007
|
+
style={{ position: 'relative', display: 'flex', flexDirection: 'column', flex: '1 1 0%', minWidth: 0, minHeight: 0, height: '100%' }}
|
|
2008
|
+
>
|
|
2009
|
+
<div
|
|
2010
|
+
className="x-grid3-header"
|
|
2011
|
+
style={{ flex: '0 0 auto', width: '100%', overflow: 'hidden' }}
|
|
2012
|
+
>
|
|
2013
|
+
<div
|
|
2014
|
+
className="x-grid3-header-inner"
|
|
2015
|
+
ref={innerHdRef}
|
|
2016
|
+
style={{ width: uHeaderInnerW, overflow: 'hidden' }}
|
|
2017
|
+
>
|
|
2018
|
+
<div className="x-grid3-header-offset" style={{ width: uHeaderOffsetW }}>
|
|
2019
|
+
<table
|
|
2020
|
+
border={0}
|
|
2021
|
+
cellSpacing={0}
|
|
2022
|
+
cellPadding={0}
|
|
2023
|
+
style={{ tableLayout: 'fixed', width: unlockedPaneW }}
|
|
2024
|
+
>
|
|
2025
|
+
<colgroup>
|
|
2026
|
+
{unlockedCols.map((c, i) => (
|
|
2027
|
+
<col
|
|
2028
|
+
key={c.id}
|
|
2029
|
+
style={{
|
|
2030
|
+
width: unlockedWidths[i] + (i === unlockedCols.length - 1 ? unlockedSlack : 0),
|
|
2031
|
+
}}
|
|
2032
|
+
/>
|
|
2033
|
+
))}
|
|
2034
|
+
</colgroup>
|
|
2035
|
+
<thead>
|
|
2036
|
+
{renderGroupHeaderRows()}
|
|
2037
|
+
<tr className="x-grid3-hd-row" ref={hdRowRef}>
|
|
2038
|
+
{buildHeaderCells(unlockedCols, unlockedWidths, unlockedSlack)}
|
|
2039
|
+
</tr>
|
|
2040
|
+
</thead>
|
|
2041
|
+
</table>
|
|
2042
|
+
</div>
|
|
2043
|
+
</div>
|
|
2044
|
+
<div className="x-clear" />
|
|
2045
|
+
</div>
|
|
2046
|
+
<div
|
|
2047
|
+
className="x-grid3-scroller"
|
|
2048
|
+
ref={scrollerRef}
|
|
2049
|
+
style={{ flex: '1 1 auto', minHeight: 0, width: '100%' }}
|
|
2050
|
+
onScroll={(e) => {
|
|
2051
|
+
if (lockedScrollerRef.current) {
|
|
2052
|
+
lockedScrollerRef.current.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop;
|
|
2053
|
+
}
|
|
2054
|
+
}}
|
|
2055
|
+
>
|
|
2056
|
+
<div className="x-grid3-body" style={{ width: unlockedPaneW }}>
|
|
2057
|
+
{renderBodyContent(unlockedCols, unlockedWidths, unlockedSlack, unlockedPaneW, unlockedPaneW)}
|
|
2058
|
+
</div>
|
|
2059
|
+
<a href="#" className="x-grid3-focus" tabIndex={-1}>
|
|
2060
|
+
 
|
|
2061
|
+
</a>
|
|
2062
|
+
</div>
|
|
2063
|
+
</div>
|
|
2064
|
+
</>
|
|
2065
|
+
)}
|
|
2066
|
+
<div className="x-grid3-resize-marker"> </div>
|
|
2067
|
+
<div
|
|
2068
|
+
className="x-grid3-resize-proxy"
|
|
2069
|
+
style={
|
|
2070
|
+
proxy
|
|
2071
|
+
? { visibility: 'visible', left: proxy.left, height: proxy.height }
|
|
2072
|
+
: undefined
|
|
2073
|
+
}
|
|
2074
|
+
>
|
|
2075
|
+
 
|
|
2076
|
+
</div>
|
|
2077
|
+
{proxyLeft !== null && (
|
|
2078
|
+
<div
|
|
2079
|
+
className="x-grid3-resize-proxy x-grid3-col-move-proxy"
|
|
2080
|
+
style={{
|
|
2081
|
+
visibility: 'visible',
|
|
2082
|
+
left: proxyLeft,
|
|
2083
|
+
height: gridRef.current?.offsetHeight ?? 0,
|
|
2084
|
+
}}
|
|
2085
|
+
>
|
|
2086
|
+
 
|
|
2087
|
+
</div>
|
|
2088
|
+
)}
|
|
2089
|
+
{rowDropLine !== null && (
|
|
2090
|
+
<div
|
|
2091
|
+
className="x-grid3-row-drop-proxy"
|
|
2092
|
+
style={{
|
|
2093
|
+
position: 'absolute',
|
|
2094
|
+
left: 0,
|
|
2095
|
+
width: rowW,
|
|
2096
|
+
height: 16,
|
|
2097
|
+
top: rowDropLine - 7,
|
|
2098
|
+
backgroundImage: `url("${resolveThemeImageUrl('tree/drop-between.gif')}")`,
|
|
2099
|
+
backgroundRepeat: 'no-repeat',
|
|
2100
|
+
backgroundPosition: 'left center',
|
|
2101
|
+
zIndex: 20000,
|
|
2102
|
+
pointerEvents: 'none',
|
|
2103
|
+
}}
|
|
2104
|
+
>
|
|
2105
|
+
 
|
|
2106
|
+
</div>
|
|
2107
|
+
)}
|
|
2108
|
+
{rowGhost && (
|
|
2109
|
+
<div
|
|
2110
|
+
className="x-dd-drag-proxy x-dd-drag-ghost"
|
|
2111
|
+
style={{
|
|
2112
|
+
position: 'fixed',
|
|
2113
|
+
left: rowGhost.x + 14,
|
|
2114
|
+
top: rowGhost.y + 10,
|
|
2115
|
+
zIndex: 20000,
|
|
2116
|
+
pointerEvents: 'none',
|
|
2117
|
+
}}
|
|
2118
|
+
>
|
|
2119
|
+
{rowGhost.text}
|
|
2120
|
+
</div>
|
|
2121
|
+
)}
|
|
2122
|
+
{menuCol !== null && (() => {
|
|
2123
|
+
const menuColDef = managedCols.find((c) => c.id === menuCol);
|
|
2124
|
+
const menuFilterDef = menuColDef?.dataIndex ? filterDefs[menuColDef.dataIndex] : undefined;
|
|
2125
|
+
const baseItems: MenuItem[] = managedCols
|
|
2126
|
+
.filter(
|
|
2127
|
+
(c) =>
|
|
2128
|
+
c.dataIndex &&
|
|
2129
|
+
!c.menuDisabled &&
|
|
2130
|
+
c.id !== 'checker' &&
|
|
2131
|
+
c.id !== 'radio' &&
|
|
2132
|
+
!c.isNumberer,
|
|
2133
|
+
)
|
|
2134
|
+
.map((c) => ({
|
|
2135
|
+
text: c.header,
|
|
2136
|
+
checked: !hiddenCols.has(c.id),
|
|
2137
|
+
handler: () => toggleColumn(c.id),
|
|
2138
|
+
}));
|
|
2139
|
+
const columnMenuItems: MenuItem[] = menuFilterDef
|
|
2140
|
+
? [
|
|
2141
|
+
...baseItems,
|
|
2142
|
+
{ separator: true },
|
|
2143
|
+
{
|
|
2144
|
+
text: MENU_FILTER_TEXT,
|
|
2145
|
+
checked: !!filterState[menuFilterDef.dataIndex!]?.active,
|
|
2146
|
+
handler: () => toggleFilterActive(menuFilterDef.dataIndex!),
|
|
2147
|
+
menu: [
|
|
2148
|
+
{
|
|
2149
|
+
render: (
|
|
2150
|
+
<FilterWidget
|
|
2151
|
+
def={menuFilterDef}
|
|
2152
|
+
value={filterState[menuFilterDef.dataIndex!]?.value}
|
|
2153
|
+
onChange={(val) => updateFilterValue(menuFilterDef.dataIndex!, val)}
|
|
2154
|
+
/>
|
|
2155
|
+
),
|
|
2156
|
+
},
|
|
2157
|
+
],
|
|
2158
|
+
},
|
|
2159
|
+
]
|
|
2160
|
+
: baseItems;
|
|
2161
|
+
return (
|
|
2162
|
+
<Menu
|
|
2163
|
+
style={{ ...menuStyle, position: 'absolute', zIndex: 15000 }}
|
|
2164
|
+
items={columnMenuItems}
|
|
2165
|
+
/>
|
|
2166
|
+
);
|
|
2167
|
+
})()}
|
|
2168
|
+
</div>
|
|
2169
|
+
</Panel>
|
|
2170
|
+
);
|
|
2171
|
+
});
|
|
2172
|
+
|
|
2173
|
+
export default Grid;
|