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,214 @@
|
|
|
1
|
+
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface EditorProps {
|
|
4
|
+
/** 初始编辑值 */
|
|
5
|
+
value?: string;
|
|
6
|
+
/** 编辑框宽度(px),默认 180 */
|
|
7
|
+
width?: number;
|
|
8
|
+
/** 编辑框高度(px),默认 22 */
|
|
9
|
+
height?: number;
|
|
10
|
+
/** 被编辑元素(boundEl)展示的文本,编辑时隐藏(对齐 Ext.hideEl) */
|
|
11
|
+
boundText?: string;
|
|
12
|
+
/** 起始是否处于编辑态(默认 true,便于保真对比与演示) */
|
|
13
|
+
editing?: boolean;
|
|
14
|
+
/** 失焦时是否自动完成编辑(对齐 Ext.Editor.allowBlur,默认 true) */
|
|
15
|
+
allowBlur?: boolean;
|
|
16
|
+
/** 完成编辑(Enter / 失焦)回调,返回最新值 */
|
|
17
|
+
onComplete?: (value: string) => void;
|
|
18
|
+
/** 取消编辑(Esc)回调 */
|
|
19
|
+
onCancel?: (value: string) => void;
|
|
20
|
+
/** 根容器附加 className */
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 复刻 Ext.Editor(行内编辑器)的运行时句柄。
|
|
26
|
+
*
|
|
27
|
+
* 命令式方法(通过 ref 调用):
|
|
28
|
+
* startEdit() 进入编辑态(显示浮层并聚焦)
|
|
29
|
+
* completeEdit() 提交当前值并退出编辑态(触发 onComplete)
|
|
30
|
+
* cancelEdit() 回滚到 startValue 并退出编辑态(触发 onCancel)
|
|
31
|
+
* setValue(v) 运行时改写编辑值(同时更新 bound 回显值)
|
|
32
|
+
* getValue() 读取当前编辑值
|
|
33
|
+
* isEditing() 是否处于编辑态
|
|
34
|
+
*/
|
|
35
|
+
export interface EditorHandle {
|
|
36
|
+
startEdit: () => void;
|
|
37
|
+
completeEdit: () => void;
|
|
38
|
+
cancelEdit: () => void;
|
|
39
|
+
setValue: (value: string) => void;
|
|
40
|
+
getValue: () => string;
|
|
41
|
+
isEditing: () => boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 复刻 Ext.Editor(行内编辑器)。
|
|
46
|
+
*
|
|
47
|
+
* DOM 结构(对齐 Ext.Editor.onRender / startEdit):
|
|
48
|
+
* div.x-editor.x-layer (浮层,position:absolute,可见)
|
|
49
|
+
* div.x-form-field-wrap
|
|
50
|
+
* input.x-form-text.x-form-field (内部 TextField)
|
|
51
|
+
*
|
|
52
|
+
* 行为:
|
|
53
|
+
* - Enter → completeEdit(触发 onComplete,隐藏编辑框、恢复 bound 显示)
|
|
54
|
+
* - Esc → cancelEdit(触发 onCancel,值回滚 startValue)
|
|
55
|
+
* - 失焦 → 默认 allowBlur 完成编辑
|
|
56
|
+
*
|
|
57
|
+
* 注:golden 用 Ext.Editor(shadow:false)渲染到容器并 startEdit 一个 (10,10) 的
|
|
58
|
+
* bound 元素,浮层 c-c 对齐后与 react 同尺寸同位置,截图一致。
|
|
59
|
+
*/
|
|
60
|
+
export const Editor = forwardRef<EditorHandle, EditorProps>(function Editor(
|
|
61
|
+
{
|
|
62
|
+
value = '',
|
|
63
|
+
width = 180,
|
|
64
|
+
height = 22,
|
|
65
|
+
boundText,
|
|
66
|
+
editing: editingProp = true,
|
|
67
|
+
allowBlur = true,
|
|
68
|
+
onComplete,
|
|
69
|
+
onCancel,
|
|
70
|
+
className,
|
|
71
|
+
},
|
|
72
|
+
ref,
|
|
73
|
+
) {
|
|
74
|
+
const [editing, setEditing] = useState(editingProp);
|
|
75
|
+
const [current, setCurrent] = useState(value);
|
|
76
|
+
const [startValue] = useState(value);
|
|
77
|
+
// 最近提交值,用于编辑结束后 bound 文本回显(对齐 Ext.Editor.updateEl)
|
|
78
|
+
const [committed, setCommitted] = useState(value);
|
|
79
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
80
|
+
|
|
81
|
+
// refs 镜像最新值,供命令式句柄读取(避免闭包陈旧)
|
|
82
|
+
const editingRef = useRef(editing);
|
|
83
|
+
const currentRef = useRef(current);
|
|
84
|
+
const startValueRef = useRef(startValue);
|
|
85
|
+
|
|
86
|
+
// 保持 current 与受控 value 同步
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
currentRef.current = value;
|
|
89
|
+
setCurrent(value);
|
|
90
|
+
setCommitted(value);
|
|
91
|
+
}, [value]);
|
|
92
|
+
|
|
93
|
+
// 同步 editing 的 state 与 ref(供句柄读取与渲染一致)
|
|
94
|
+
const syncEditing = useCallback((v: boolean) => {
|
|
95
|
+
editingRef.current = v;
|
|
96
|
+
setEditing(v);
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
const completeEdit = useCallback(() => {
|
|
100
|
+
setCommitted(currentRef.current);
|
|
101
|
+
syncEditing(false);
|
|
102
|
+
onComplete?.(currentRef.current);
|
|
103
|
+
}, [onComplete, syncEditing]);
|
|
104
|
+
|
|
105
|
+
const cancelEdit = useCallback(() => {
|
|
106
|
+
currentRef.current = startValueRef.current;
|
|
107
|
+
setCurrent(startValueRef.current);
|
|
108
|
+
setCommitted(startValueRef.current);
|
|
109
|
+
syncEditing(false);
|
|
110
|
+
onCancel?.(startValueRef.current);
|
|
111
|
+
}, [onCancel, syncEditing]);
|
|
112
|
+
|
|
113
|
+
const startEdit = useCallback(() => {
|
|
114
|
+
syncEditing(true);
|
|
115
|
+
}, [syncEditing]);
|
|
116
|
+
|
|
117
|
+
const setValue = useCallback((v: string) => {
|
|
118
|
+
currentRef.current = v;
|
|
119
|
+
setCurrent(v);
|
|
120
|
+
setCommitted(v);
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
useImperativeHandle(
|
|
124
|
+
ref,
|
|
125
|
+
() => ({
|
|
126
|
+
startEdit,
|
|
127
|
+
completeEdit,
|
|
128
|
+
cancelEdit,
|
|
129
|
+
setValue,
|
|
130
|
+
getValue: () => currentRef.current,
|
|
131
|
+
isEditing: () => editingRef.current,
|
|
132
|
+
}),
|
|
133
|
+
[startEdit, completeEdit, cancelEdit, setValue],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const handleKeyDown = useCallback(
|
|
137
|
+
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
138
|
+
if (e.key === 'Enter') {
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
completeEdit();
|
|
141
|
+
} else if (e.key === 'Escape') {
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
cancelEdit();
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[completeEdit, cancelEdit],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const handleBlur = useCallback(() => {
|
|
150
|
+
// 对齐 Ext.Editor.allowBlur 默认 true:失焦即完成编辑;allowBlur=false 时不自动完成
|
|
151
|
+
if (allowBlur) completeEdit();
|
|
152
|
+
}, [allowBlur, completeEdit]);
|
|
153
|
+
|
|
154
|
+
// 进入编辑态后聚焦输入框(对齐 Ext.Editor.startEdit 的 focus)
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (editing && inputRef.current) {
|
|
157
|
+
inputRef.current.focus();
|
|
158
|
+
}
|
|
159
|
+
}, [editing]);
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<span
|
|
163
|
+
className={['x-editor-wrap', className].filter(Boolean).join(' ')}
|
|
164
|
+
style={{ position: 'relative', display: 'inline-block', width: '100%', height: `${height}px` }}
|
|
165
|
+
>
|
|
166
|
+
{boundText !== undefined && (
|
|
167
|
+
<span
|
|
168
|
+
className="x-editor-bound"
|
|
169
|
+
style={{
|
|
170
|
+
display: editing ? 'none' : 'block',
|
|
171
|
+
boxSizing: 'border-box',
|
|
172
|
+
width: '100%',
|
|
173
|
+
height: `${height}px`,
|
|
174
|
+
lineHeight: `${height - 4}px`,
|
|
175
|
+
padding: '2px 3px',
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
{committed}
|
|
179
|
+
</span>
|
|
180
|
+
)}
|
|
181
|
+
{editing && (
|
|
182
|
+
<div
|
|
183
|
+
className="x-editor x-layer"
|
|
184
|
+
style={{
|
|
185
|
+
position: 'absolute',
|
|
186
|
+
left: 0,
|
|
187
|
+
top: 0,
|
|
188
|
+
width,
|
|
189
|
+
height,
|
|
190
|
+
visibility: 'visible',
|
|
191
|
+
padding: 0,
|
|
192
|
+
margin: 0,
|
|
193
|
+
}}
|
|
194
|
+
>
|
|
195
|
+
<div className="x-form-field-wrap" style={{ width, height }}>
|
|
196
|
+
<input
|
|
197
|
+
ref={inputRef}
|
|
198
|
+
type="text"
|
|
199
|
+
className="x-form-text x-form-field"
|
|
200
|
+
style={{ width: width - 17, height: height - 4 }}
|
|
201
|
+
value={current}
|
|
202
|
+
onChange={(e) => {
|
|
203
|
+
currentRef.current = e.target.value;
|
|
204
|
+
setCurrent(e.target.value);
|
|
205
|
+
}}
|
|
206
|
+
onKeyDown={handleKeyDown}
|
|
207
|
+
onBlur={handleBlur}
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
</span>
|
|
213
|
+
);
|
|
214
|
+
});
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useRef,
|
|
4
|
+
useState,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useImperativeHandle,
|
|
8
|
+
type CSSProperties,
|
|
9
|
+
type KeyboardEvent,
|
|
10
|
+
type FocusEvent,
|
|
11
|
+
type ChangeEvent,
|
|
12
|
+
} from 'react';
|
|
13
|
+
import { Grid, type GridColumn } from './Grid';
|
|
14
|
+
|
|
15
|
+
export type EditorType = 'text' | 'number';
|
|
16
|
+
|
|
17
|
+
export interface EditorColumn extends GridColumn {
|
|
18
|
+
/** 单元格编辑器:true 使用文本编辑器;{ type } 指定 text/number */
|
|
19
|
+
editor?: boolean | { type?: EditorType };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface EditorGridProps {
|
|
23
|
+
columns: EditorColumn[];
|
|
24
|
+
data: Record<string, unknown>[];
|
|
25
|
+
title?: string;
|
|
26
|
+
width?: number | string;
|
|
27
|
+
height?: number;
|
|
28
|
+
stripeRows?: boolean;
|
|
29
|
+
selectedRow?: number;
|
|
30
|
+
/** 触发编辑的点击次数,默认 1(单击) */
|
|
31
|
+
clicksToEdit?: number;
|
|
32
|
+
className?: string;
|
|
33
|
+
style?: CSSProperties;
|
|
34
|
+
/** 提交回调:行索引、字段名、新值 */
|
|
35
|
+
onCellEdit?: (rowIndex: number, dataIndex: string, value: string) => void;
|
|
36
|
+
/** 数据源变更回调(对齐 Ext Store 的 datachanged):任意编辑提交/增删行后触发,参数为最新数据副本 */
|
|
37
|
+
onDataChange?: (data: Record<string, unknown>[]) => void;
|
|
38
|
+
/** 行点击事件:参数为被点击行的索引(对齐 Ext Grid 的 rowclick),编辑未开始时触发 */
|
|
39
|
+
onRowClick?: (rowIndex: number) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 复刻 Ext.EditorGrid 的运行时句柄(动态数据源 / 运行时编辑)。
|
|
44
|
+
*
|
|
45
|
+
* 命令式方法(通过 ref 调用):
|
|
46
|
+
* setData(data) 整体替换数据源(双向:兄弟组件或外部状态)
|
|
47
|
+
* getData() 读取当前数据源
|
|
48
|
+
* addRecord(record) 追加一行
|
|
49
|
+
* removeRecord(idx) 按行索引删除一行
|
|
50
|
+
* updateRecord(idx,p) 按行索引局部更新字段
|
|
51
|
+
* startEditAt(r,col) 运行时按坐标(行索引 + dataIndex)启动单元格编辑
|
|
52
|
+
* commitEdit(value?) 提交当前编辑(可选覆盖值);无编辑态时为 no-op
|
|
53
|
+
* cancelEdit() 取消当前编辑并回滚
|
|
54
|
+
* isEditing() 是否处于单元格编辑态
|
|
55
|
+
*/
|
|
56
|
+
export interface EditorGridHandle {
|
|
57
|
+
setData: (data: Record<string, unknown>[]) => void;
|
|
58
|
+
getData: () => Record<string, unknown>[];
|
|
59
|
+
addRecord: (record: Record<string, unknown>) => void;
|
|
60
|
+
removeRecord: (rowIndex: number) => void;
|
|
61
|
+
updateRecord: (rowIndex: number, patch: Record<string, unknown>) => void;
|
|
62
|
+
startEditAt: (rowIndex: number, dataIndex: string) => void;
|
|
63
|
+
commitEdit: (value?: string) => void;
|
|
64
|
+
cancelEdit: () => void;
|
|
65
|
+
isEditing: () => boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface EditingState {
|
|
69
|
+
rowIndex: number;
|
|
70
|
+
dataIndex: string;
|
|
71
|
+
value: string;
|
|
72
|
+
type: EditorType;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Pos {
|
|
76
|
+
top: number;
|
|
77
|
+
left: number;
|
|
78
|
+
width: number;
|
|
79
|
+
height: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 保真映射(来源 src/widgets/grid/EditorGrid.js + Editor.js):
|
|
84
|
+
* - EditorGridPanel = GridPanel + EditorGrid 插件;clicksToEdit 默认 1。
|
|
85
|
+
* - 点击可编辑单元格 → Editor.startEdit(cell, value):编辑器元素 class 为 x-grid-editor,
|
|
86
|
+
* 绝对定位覆盖该单元格(top/left/width/height = 单元格盒模型),内含字段(TextField → x-form-field x-form-text)。
|
|
87
|
+
* - 编辑时单元格文本被隐藏(编辑器覆盖),Enter/失焦提交写回、Esc 取消;提交后触发 afteredit/validateedit。
|
|
88
|
+
* 复用 Grid 渲染内核,编辑器为覆盖层;DOM/class 与 Ext 3.2.1 一致,复用 ext-all.css。
|
|
89
|
+
*/
|
|
90
|
+
export const EditorGrid = forwardRef<EditorGridHandle, EditorGridProps>(function EditorGrid(
|
|
91
|
+
{
|
|
92
|
+
columns,
|
|
93
|
+
data,
|
|
94
|
+
title,
|
|
95
|
+
width,
|
|
96
|
+
height,
|
|
97
|
+
stripeRows,
|
|
98
|
+
selectedRow,
|
|
99
|
+
clicksToEdit = 1,
|
|
100
|
+
className,
|
|
101
|
+
style,
|
|
102
|
+
onCellEdit,
|
|
103
|
+
onDataChange,
|
|
104
|
+
onRowClick,
|
|
105
|
+
},
|
|
106
|
+
ref,
|
|
107
|
+
) {
|
|
108
|
+
const [rows, setRows] = useState<Record<string, unknown>[]>(() => data.map((r) => ({ ...r })));
|
|
109
|
+
const [editing, setEditing] = useState<EditingState | null>(null);
|
|
110
|
+
const [pos, setPos] = useState<Pos | null>(null);
|
|
111
|
+
const [focused, setFocused] = useState(false);
|
|
112
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
113
|
+
const cellInnerRef = useRef<HTMLElement | null>(null);
|
|
114
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
115
|
+
|
|
116
|
+
// 镜像最新数据源供命令式读取(避免闭包陈旧)
|
|
117
|
+
const rowsRef = useRef(rows);
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
rowsRef.current = rows;
|
|
120
|
+
}, [rows]);
|
|
121
|
+
// 数据源变更通知(对齐 Ext Store.datachanged):编辑提交、增删行后触发,
|
|
122
|
+
// 让外部能拿到最新数据(getData 因闭包时序可能无法同步读取到最新值)。
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
onDataChange?.(rows.map((r) => ({ ...r })));
|
|
125
|
+
}, [rows, onDataChange]);
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
setRows(data.map((r) => ({ ...r })));
|
|
129
|
+
}, [data]);
|
|
130
|
+
|
|
131
|
+
const closeEdit = useCallback(() => {
|
|
132
|
+
if (cellInnerRef.current) {
|
|
133
|
+
cellInnerRef.current.style.visibility = '';
|
|
134
|
+
cellInnerRef.current = null;
|
|
135
|
+
}
|
|
136
|
+
setEditing(null);
|
|
137
|
+
setPos(null);
|
|
138
|
+
setFocused(false);
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
const commit = useCallback(
|
|
142
|
+
(value: string) => {
|
|
143
|
+
if (!editing) return;
|
|
144
|
+
const { rowIndex, dataIndex } = editing;
|
|
145
|
+
setRows((prev) => {
|
|
146
|
+
const next = prev.map((r) => ({ ...r }));
|
|
147
|
+
next[rowIndex] = { ...next[rowIndex], [dataIndex]: value };
|
|
148
|
+
return next;
|
|
149
|
+
});
|
|
150
|
+
onCellEdit?.(rowIndex, dataIndex, value);
|
|
151
|
+
closeEdit();
|
|
152
|
+
},
|
|
153
|
+
[editing, onCellEdit, closeEdit],
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const startEdit = useCallback(
|
|
157
|
+
(target: HTMLElement) => {
|
|
158
|
+
const td = target.closest('.x-grid3-cell') as HTMLElement | null;
|
|
159
|
+
if (!td) return;
|
|
160
|
+
const tdMatch = td.className.match(/x-grid3-td-([\w-]+)/);
|
|
161
|
+
if (!tdMatch) return;
|
|
162
|
+
const dataIndex = tdMatch[1];
|
|
163
|
+
const col = columns.find((c) => c.dataIndex === dataIndex);
|
|
164
|
+
if (!col || !col.editor) return; // 不可编辑列直接忽略
|
|
165
|
+
const rowEl = td.closest('.x-grid3-row') as HTMLElement | null;
|
|
166
|
+
if (!rowEl || !rowEl.parentElement) return;
|
|
167
|
+
const rowEls = Array.from(
|
|
168
|
+
rowEl.parentElement.querySelectorAll(':scope > .x-grid3-row'),
|
|
169
|
+
) as HTMLElement[];
|
|
170
|
+
const rowIndex = rowEls.indexOf(rowEl);
|
|
171
|
+
if (rowIndex < 0) return;
|
|
172
|
+
const type: EditorType =
|
|
173
|
+
typeof col.editor === 'object' ? col.editor.type ?? 'text' : 'text';
|
|
174
|
+
const value = String(rows[rowIndex]?.[dataIndex] ?? '');
|
|
175
|
+
|
|
176
|
+
const wrap = wrapRef.current;
|
|
177
|
+
if (!wrap) return;
|
|
178
|
+
const wrapRect = wrap.getBoundingClientRect();
|
|
179
|
+
const cellRect = td.getBoundingClientRect();
|
|
180
|
+
setPos({
|
|
181
|
+
top: cellRect.top - wrapRect.top,
|
|
182
|
+
left: cellRect.left - wrapRect.left,
|
|
183
|
+
width: cellRect.width,
|
|
184
|
+
height: cellRect.height,
|
|
185
|
+
});
|
|
186
|
+
setEditing({ rowIndex, dataIndex, value, type });
|
|
187
|
+
const inner = td.querySelector('.x-grid3-cell-inner') as HTMLElement | null;
|
|
188
|
+
if (inner) {
|
|
189
|
+
cellInnerRef.current = inner;
|
|
190
|
+
inner.style.visibility = 'hidden';
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
[columns, rows],
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const handleClick = useCallback(
|
|
197
|
+
(e: React.MouseEvent) => {
|
|
198
|
+
if (editing) return; // 编辑中点击编辑器不重启
|
|
199
|
+
const tr = (e.target as HTMLElement).closest('.x-grid3-row');
|
|
200
|
+
if (tr && tr.parentElement) {
|
|
201
|
+
const rowEls = Array.from(
|
|
202
|
+
tr.parentElement.querySelectorAll(':scope > .x-grid3-row'),
|
|
203
|
+
) as HTMLElement[];
|
|
204
|
+
const idx = rowEls.indexOf(tr as HTMLElement);
|
|
205
|
+
if (idx >= 0) onRowClick?.(idx);
|
|
206
|
+
}
|
|
207
|
+
startEdit(e.target as HTMLElement);
|
|
208
|
+
},
|
|
209
|
+
[editing, startEdit, onRowClick],
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// 运行时按坐标(行索引 + dataIndex)启动单元格编辑,供命令式句柄调用
|
|
213
|
+
const startEditAt = useCallback(
|
|
214
|
+
(rowIndex: number, dataIndex: string) => {
|
|
215
|
+
const wrap = wrapRef.current;
|
|
216
|
+
if (!wrap) return;
|
|
217
|
+
const rowEls = Array.from(
|
|
218
|
+
wrap.querySelectorAll(':scope .x-grid3-row'),
|
|
219
|
+
) as HTMLElement[];
|
|
220
|
+
const rowEl = rowEls[rowIndex];
|
|
221
|
+
if (!rowEl) return;
|
|
222
|
+
const td = rowEl.querySelector(`.x-grid3-td-${dataIndex}`) as HTMLElement | null;
|
|
223
|
+
if (!td) return;
|
|
224
|
+
startEdit(td);
|
|
225
|
+
},
|
|
226
|
+
[startEdit],
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// —— 动态数据源句柄 ——
|
|
230
|
+
const setData = useCallback((next: Record<string, unknown>[]) => {
|
|
231
|
+
closeEdit();
|
|
232
|
+
setRows(next.map((r) => ({ ...r })));
|
|
233
|
+
}, [closeEdit]);
|
|
234
|
+
|
|
235
|
+
const getData = useCallback(() => rowsRef.current.map((r) => ({ ...r })), []);
|
|
236
|
+
|
|
237
|
+
const addRecord = useCallback((record: Record<string, unknown>) => {
|
|
238
|
+
setRows((prev) => [...prev, { ...record }]);
|
|
239
|
+
}, []);
|
|
240
|
+
|
|
241
|
+
const removeRecord = useCallback((rowIndex: number) => {
|
|
242
|
+
setRows((prev) => prev.filter((_, i) => i !== rowIndex));
|
|
243
|
+
}, []);
|
|
244
|
+
|
|
245
|
+
const updateRecord = useCallback((rowIndex: number, patch: Record<string, unknown>) => {
|
|
246
|
+
setRows((prev) => prev.map((r, i) => (i === rowIndex ? { ...r, ...patch } : r)));
|
|
247
|
+
}, []);
|
|
248
|
+
|
|
249
|
+
const commitEdit = useCallback(
|
|
250
|
+
(value?: string) => {
|
|
251
|
+
if (!editing) return;
|
|
252
|
+
commit(value ?? editing.value);
|
|
253
|
+
},
|
|
254
|
+
[editing, commit],
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const cancelEdit = useCallback(() => {
|
|
258
|
+
closeEdit();
|
|
259
|
+
}, [closeEdit]);
|
|
260
|
+
|
|
261
|
+
const isEditing = useCallback(() => editing !== null, [editing]);
|
|
262
|
+
|
|
263
|
+
useImperativeHandle(
|
|
264
|
+
ref,
|
|
265
|
+
() => ({
|
|
266
|
+
setData,
|
|
267
|
+
getData,
|
|
268
|
+
addRecord,
|
|
269
|
+
removeRecord,
|
|
270
|
+
updateRecord,
|
|
271
|
+
startEditAt,
|
|
272
|
+
commitEdit,
|
|
273
|
+
cancelEdit,
|
|
274
|
+
isEditing,
|
|
275
|
+
}),
|
|
276
|
+
[setData, getData, addRecord, removeRecord, updateRecord, startEditAt, commitEdit, cancelEdit, isEditing],
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
if (editing && inputRef.current) {
|
|
281
|
+
// preventScroll: 阻止浏览器聚焦时自动滚动最近的可滚动祖先(Writer 左 Panel 的
|
|
282
|
+
// .x-panel-body,overflow:hidden)使编辑器可见——原生 ExtJS 的 .x-grid-editor 渲染在
|
|
283
|
+
// .x-grid3-scroller 内部,聚焦只滚表体本身、不移动整表;本封装把编辑器浮层放在 scroller
|
|
284
|
+
// 之外,若放任默认滚动,点击靠右的末列(Last)编辑会让整表左移约 17px 造成抖动。
|
|
285
|
+
inputRef.current.focus({ preventScroll: true });
|
|
286
|
+
inputRef.current.select();
|
|
287
|
+
}
|
|
288
|
+
}, [editing]);
|
|
289
|
+
|
|
290
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
291
|
+
if (e.key === 'Enter') {
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
commit(e.currentTarget.value);
|
|
294
|
+
} else if (e.key === 'Escape') {
|
|
295
|
+
e.preventDefault();
|
|
296
|
+
closeEdit();
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
301
|
+
setEditing((prev) => (prev ? { ...prev, value: e.target.value } : prev));
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const handleBlur = (e: FocusEvent<HTMLInputElement>) => {
|
|
305
|
+
setFocused(false);
|
|
306
|
+
if (editing) commit(e.currentTarget.value);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<div
|
|
311
|
+
ref={wrapRef}
|
|
312
|
+
className={className}
|
|
313
|
+
style={{ position: 'relative', ...(style as CSSProperties) }}
|
|
314
|
+
onClick={clicksToEdit === 1 ? handleClick : undefined}
|
|
315
|
+
onDoubleClick={clicksToEdit === 2 ? handleClick : undefined}
|
|
316
|
+
>
|
|
317
|
+
<Grid
|
|
318
|
+
columns={columns}
|
|
319
|
+
data={rows}
|
|
320
|
+
title={title}
|
|
321
|
+
width={width}
|
|
322
|
+
height={height}
|
|
323
|
+
stripeRows={stripeRows}
|
|
324
|
+
selectedRow={selectedRow}
|
|
325
|
+
/>
|
|
326
|
+
{editing && pos && (
|
|
327
|
+
<div
|
|
328
|
+
className="x-grid-editor"
|
|
329
|
+
style={{
|
|
330
|
+
position: 'absolute',
|
|
331
|
+
zIndex: 50,
|
|
332
|
+
top: pos.top,
|
|
333
|
+
left: pos.left,
|
|
334
|
+
width: pos.width,
|
|
335
|
+
height: pos.height,
|
|
336
|
+
}}
|
|
337
|
+
>
|
|
338
|
+
<input
|
|
339
|
+
ref={inputRef}
|
|
340
|
+
type={editing.type === 'number' ? 'number' : 'text'}
|
|
341
|
+
className={[
|
|
342
|
+
'x-form-field',
|
|
343
|
+
editing.type === 'number' ? 'x-form-num-field x-form-text' : 'x-form-text',
|
|
344
|
+
focused ? 'x-form-focus' : '',
|
|
345
|
+
]
|
|
346
|
+
.filter(Boolean)
|
|
347
|
+
.join(' ')}
|
|
348
|
+
value={editing.value}
|
|
349
|
+
style={{ width: '100%', height: '100%', boxSizing: 'border-box' }}
|
|
350
|
+
onFocus={() => setFocused(true)}
|
|
351
|
+
onBlur={handleBlur}
|
|
352
|
+
onChange={handleChange}
|
|
353
|
+
onKeyDown={handleKeyDown}
|
|
354
|
+
/>
|
|
355
|
+
</div>
|
|
356
|
+
)}
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
export default EditorGrid;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { TextField } from './Form';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 保真 Ext.ux.FieldLabeler(一个挂载到字段上的 plugin)。
|
|
6
|
+
*
|
|
7
|
+
* 作用:无论字段身处何种容器布局,都在渲染时给字段套上标准 Ext 表单的
|
|
8
|
+
* 「x-form-item + x-form-item-label」包裹并渲染 label,等价于 FormLayout 的效果。
|
|
9
|
+
*
|
|
10
|
+
* React 中以包裹组件表达:外层 x-form-label-{align}(祖先,供 ext-all.css 的
|
|
11
|
+
* left/top/right 规则命中)→ 内层 x-form-item → label.x-form-item-label + x-form-element。
|
|
12
|
+
* label 宽度 / 对齐 / 分隔符 / 内边距均对齐 Ext 默认值(100 / left / ":" / 5)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface FieldLabelerProps {
|
|
16
|
+
/** 被包裹的字段组件,默认 TextField(对齐 Ext 任意 Field 可挂 plugin) */
|
|
17
|
+
as?: React.ComponentType<any>;
|
|
18
|
+
/** 传给被包裹字段的共享 props(name / width / placeholder / data 等) */
|
|
19
|
+
fieldProps?: Record<string, any>;
|
|
20
|
+
/** label 文案(对齐 Ext fieldLabel,必填) */
|
|
21
|
+
fieldLabel: string;
|
|
22
|
+
/** label 对齐方式,默认 'left'(对齐 Ext labelAlign) */
|
|
23
|
+
labelAlign?: 'left' | 'right' | 'top';
|
|
24
|
+
/** label 宽度(px),默认 100(对齐 Ext labelWidth) */
|
|
25
|
+
labelWidth?: number;
|
|
26
|
+
/** label 分隔符,默认 ':'(对齐 Ext labelSeparator) */
|
|
27
|
+
labelSeparator?: string;
|
|
28
|
+
/** label 与字段的水平内边距(px),默认 5(对齐 Ext labelPad) */
|
|
29
|
+
labelPad?: number;
|
|
30
|
+
/** 隐藏 label(对齐 Ext hideLabels) */
|
|
31
|
+
hideLabel?: boolean;
|
|
32
|
+
id?: string;
|
|
33
|
+
className?: string;
|
|
34
|
+
style?: React.CSSProperties;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const FieldLabeler: React.FC<FieldLabelerProps> = ({
|
|
38
|
+
as: FieldComp = TextField,
|
|
39
|
+
fieldProps = {},
|
|
40
|
+
fieldLabel,
|
|
41
|
+
labelAlign = 'left',
|
|
42
|
+
labelWidth = 100,
|
|
43
|
+
labelSeparator = ':',
|
|
44
|
+
labelPad = 5,
|
|
45
|
+
hideLabel = false,
|
|
46
|
+
id,
|
|
47
|
+
className,
|
|
48
|
+
style,
|
|
49
|
+
}) => {
|
|
50
|
+
const showLabel = !hideLabel;
|
|
51
|
+
// 非 top 且显示 label 时,字段区需让出 label 宽度(对齐 Ext resizeEl.setStyle('padding-left'))
|
|
52
|
+
const elementStyle: React.CSSProperties =
|
|
53
|
+
labelAlign === 'top' || !showLabel
|
|
54
|
+
? { paddingLeft: 0 }
|
|
55
|
+
: { paddingLeft: labelWidth + labelPad };
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
id={id}
|
|
60
|
+
className={['x-form-label-' + labelAlign, className].filter(Boolean).join(' ')}
|
|
61
|
+
style={style}
|
|
62
|
+
>
|
|
63
|
+
<div className="x-form-item">
|
|
64
|
+
{showLabel && (
|
|
65
|
+
<label
|
|
66
|
+
className="x-form-item-label"
|
|
67
|
+
style={labelAlign === 'top' ? undefined : { width: labelWidth }}
|
|
68
|
+
>
|
|
69
|
+
{fieldLabel}
|
|
70
|
+
{labelSeparator}
|
|
71
|
+
</label>
|
|
72
|
+
)}
|
|
73
|
+
<div className="x-form-element" style={elementStyle}>
|
|
74
|
+
<FieldComp {...fieldProps} />
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
FieldLabeler.displayName = 'FieldLabeler';
|