@splicetree/core 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/README.md +74 -0
- package/dist/index.d.ts +283 -0
- package/dist/index.js +433 -0
- package/package.json +33 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Michael Cocova
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# @splicetree/core
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@splicetree/core)
|
|
4
|
+
[](https://npmcharts.com/compare/%40splicetree%2Fcore?minimal=true)
|
|
5
|
+
[](https://www.npmjs.com/package/@splicetree/core)
|
|
6
|
+
[](https://www.splicetree.dev)
|
|
7
|
+
[](https://github.com/michaelcocova/splicetree)
|
|
8
|
+
|
|
9
|
+
为扁平数据提供轻量、可扩展的树运行时。支持插件扩展事件与能力。
|
|
10
|
+
|
|
11
|
+
## 简介
|
|
12
|
+
|
|
13
|
+
SpliceTree 是一个 Headless 树运行时,面向扁平数据构建可操作的树结构,提供精简 API,并通过插件扩展搜索、拖拽、懒加载、键盘导航等能力。
|
|
14
|
+
|
|
15
|
+
## 官方文档
|
|
16
|
+
|
|
17
|
+
文档与示例位于 [https://www.splicetree.dev](https://www.splicetree.dev)
|
|
18
|
+
|
|
19
|
+
## 安装
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pnpm add @splicetree/core
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 使用
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { createSpliceTree } from '@splicetree/core'
|
|
29
|
+
|
|
30
|
+
const data = [
|
|
31
|
+
{ id: 'a' },
|
|
32
|
+
{ id: 'b', parent: 'a' },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const { items } = createSpliceTree(data, {
|
|
36
|
+
keyField: 'id',
|
|
37
|
+
parentField: 'parent',
|
|
38
|
+
defaultExpanded: ['a'],
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// 渲染可见节点
|
|
42
|
+
for (const node of items()) {
|
|
43
|
+
console.log(node.id, node.level)
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 选项
|
|
48
|
+
|
|
49
|
+
| 名称 | 类型 | 默认值 | 说明 |
|
|
50
|
+
| ----------------- | -------------------- | ---------- | ---------------------------- |
|
|
51
|
+
| `keyField` | `string` | `'id'` | 主键字段名 |
|
|
52
|
+
| `parentField` | `string` | `'parent'` | 父级字段名 |
|
|
53
|
+
| `plugins` | `SpliceTreePlugin[]` | `[]` | 插件列表(可扩展实例与节点) |
|
|
54
|
+
| `defaultExpanded` | `string[]` | `[]` | 初始展开的节点 id 集合 |
|
|
55
|
+
|
|
56
|
+
## 实例 API
|
|
57
|
+
|
|
58
|
+
- `items()` 返回当前可见节点序列
|
|
59
|
+
- `getNode(id)` 通过 id 获取节点
|
|
60
|
+
- `events` 事件总线(`on/emit`)
|
|
61
|
+
- `expand/collapse/toggleExpand(id)` 展开/收起/切换
|
|
62
|
+
- `appendChildren(parentId, children)` 追加子节点(支持追加到根)
|
|
63
|
+
- `moveNode(id, newParentId, beforeId?)` 移动到新父级,并指定插入位置
|
|
64
|
+
|
|
65
|
+
## 节点 API
|
|
66
|
+
|
|
67
|
+
- `id/original/level`
|
|
68
|
+
- `isExpanded()` 当前是否展开
|
|
69
|
+
- `hasChildren()` 是否存在子节点(懒加载插件可覆盖)
|
|
70
|
+
- `getParent()/getChildren()` 父子节点查询
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
[MIT](https://github.com/michaelcocova/splicetree/blob/main/LICENSE),仓库地址 [https://github.com/michaelcocova/splicetree](https://github.com/michaelcocova/splicetree)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* 抽象的函数类型
|
|
4
|
+
* - 若不传泛型参数 `D`,表示无参函数
|
|
5
|
+
* - 若传入 `D`,表示函数可选择性接收该参数类型
|
|
6
|
+
*/
|
|
7
|
+
type Fn<T, D = undefined> = [D] extends [undefined] ? () => T : (data?: D) => T;
|
|
8
|
+
/**
|
|
9
|
+
* 输入数据的通用结构
|
|
10
|
+
* - 表示原始树数据的任意键值对对象
|
|
11
|
+
*/
|
|
12
|
+
interface SpliceTreeData {
|
|
13
|
+
[key: string]: any;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 树节点的运行时结构
|
|
17
|
+
* - `id`:节点唯一标识(来自原始数据的主键)
|
|
18
|
+
* - `original`:原始数据引用
|
|
19
|
+
* - `level`:层级(根为 0)
|
|
20
|
+
* - `isExpanded()`:当前是否展开
|
|
21
|
+
* - `hasChildren()`:是否存在子节点(懒加载插件可覆盖)
|
|
22
|
+
* - `getParent()`:获取父节点
|
|
23
|
+
* - `getChildren()`:获取子节点列表
|
|
24
|
+
* - `toggleExpand(expand?)`:切换或显式设置展开状态
|
|
25
|
+
*/
|
|
26
|
+
interface SpliceTreeNode<T = SpliceTreeData> {
|
|
27
|
+
/**
|
|
28
|
+
* 节点唯一标识
|
|
29
|
+
*/
|
|
30
|
+
id: string;
|
|
31
|
+
/**
|
|
32
|
+
* 原始数据引用
|
|
33
|
+
*/
|
|
34
|
+
original: T;
|
|
35
|
+
/**
|
|
36
|
+
* 节点层级(根为 0)
|
|
37
|
+
*/
|
|
38
|
+
level: number;
|
|
39
|
+
/**
|
|
40
|
+
* 当前是否处于展开状态
|
|
41
|
+
*/
|
|
42
|
+
isExpanded: Fn<boolean>;
|
|
43
|
+
/**
|
|
44
|
+
* 是否存在子节点
|
|
45
|
+
* 懒加载插件可能在未加载时返回 true 以保持交互一致性
|
|
46
|
+
*/
|
|
47
|
+
hasChildren: Fn<boolean>;
|
|
48
|
+
/**
|
|
49
|
+
* 获取父节点(根节点时返回 undefined)
|
|
50
|
+
*/
|
|
51
|
+
getParent: Fn<SpliceTreeNode<T> | undefined>;
|
|
52
|
+
/**
|
|
53
|
+
* 获取子节点列表(无子节点时返回空数组)
|
|
54
|
+
*/
|
|
55
|
+
getChildren: Fn<SpliceTreeNode<T>[]>;
|
|
56
|
+
/**
|
|
57
|
+
* 切换或显式设置展开状态
|
|
58
|
+
* @param expand 不传表示切换;true/false 表示显式设置
|
|
59
|
+
*/
|
|
60
|
+
toggleExpand: Fn<void, boolean | undefined>;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 事件负载映射(可被插件扩展)
|
|
64
|
+
* - `visibility`:视图可见性相关事件(携带当前展开的节点 id 集合)
|
|
65
|
+
*/
|
|
66
|
+
interface SpliceTreeEventPayloadMap {
|
|
67
|
+
/**
|
|
68
|
+
* 视图可见性事件负载
|
|
69
|
+
* @property keys 当前展开的节点 id 集合
|
|
70
|
+
*/
|
|
71
|
+
visibility: {
|
|
72
|
+
keys: string[];
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
type SpliceTreeEventName = keyof SpliceTreeEventPayloadMap;
|
|
76
|
+
type SpliceTreeEventPayload = { [K in keyof SpliceTreeEventPayloadMap]: {
|
|
77
|
+
name: K;
|
|
78
|
+
} & SpliceTreeEventPayloadMap[K] }[keyof SpliceTreeEventPayloadMap];
|
|
79
|
+
/**
|
|
80
|
+
* 事件总线接口
|
|
81
|
+
* - `on(name, handler)`:订阅事件,返回取消订阅函数
|
|
82
|
+
* - `emit(payload)`:派发事件
|
|
83
|
+
*/
|
|
84
|
+
interface SpliceTreeEvents {
|
|
85
|
+
/**
|
|
86
|
+
* 订阅指定事件
|
|
87
|
+
* @param name 事件名称
|
|
88
|
+
* @param handler 事件处理函数,接收事件负载
|
|
89
|
+
* @returns 取消订阅函数(返回 true 表示取消成功)
|
|
90
|
+
*/
|
|
91
|
+
on: (name: keyof SpliceTreeEventPayloadMap, handler: (payload: SpliceTreeEventPayload) => void) => () => boolean;
|
|
92
|
+
/**
|
|
93
|
+
* 派发事件
|
|
94
|
+
* @param payload 事件负载,包含名称与数据
|
|
95
|
+
*/
|
|
96
|
+
emit: (payload: SpliceTreeEventPayload) => void;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* `createSpliceTree` 的选项
|
|
100
|
+
* - `keyField`:主键字段名,默认 `'id'`
|
|
101
|
+
* - `parentField`:父级字段名,默认 `'parent'`
|
|
102
|
+
* - `plugins`:插件列表
|
|
103
|
+
* - `defaultExpanded`:默认展开的节点 id 列表(或设为 true 表示全部展开)
|
|
104
|
+
* - `defaultExpandedLevel`:默认展开的层级(或 `'deepest'` 表示展开到最深层)
|
|
105
|
+
* - 其余键允许被插件进行选项扩展
|
|
106
|
+
*/
|
|
107
|
+
interface UseSpliceTreeOptions<T = SpliceTreeData> {
|
|
108
|
+
/**
|
|
109
|
+
* 主键字段名
|
|
110
|
+
* @default 'id'
|
|
111
|
+
*/
|
|
112
|
+
keyField?: string;
|
|
113
|
+
/**
|
|
114
|
+
* 父级字段名
|
|
115
|
+
* @default 'parent'
|
|
116
|
+
*/
|
|
117
|
+
parentField?: string;
|
|
118
|
+
/**
|
|
119
|
+
* 插件列表
|
|
120
|
+
*/
|
|
121
|
+
plugins?: SpliceTreePlugin<T>[];
|
|
122
|
+
/**
|
|
123
|
+
* 默认展开的节点 ID 列表
|
|
124
|
+
* 设为 true 表示默认展开所有节点
|
|
125
|
+
*/
|
|
126
|
+
defaultExpanded?: true | string[];
|
|
127
|
+
/**
|
|
128
|
+
* 默认展开的层级
|
|
129
|
+
* 设为 'deepest' 表示默认展开所有层级
|
|
130
|
+
*/
|
|
131
|
+
defaultExpandedLevel?: number | 'deepest';
|
|
132
|
+
/**
|
|
133
|
+
* 其余选项键,供插件扩展使用
|
|
134
|
+
*/
|
|
135
|
+
[key: string]: any;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* 核心实例结构(可被插件扩展)
|
|
139
|
+
* - 数据与选项:`data`、`options`
|
|
140
|
+
* - 查询与遍历:`items()`、`getNode(id)`
|
|
141
|
+
* - 展开控制:`expand/collapse/toggleExpand` 及其全量版本
|
|
142
|
+
* - 结构操作:`appendChildren` 追加子节点、`moveNode` 移动节点
|
|
143
|
+
* - 事件:`events` 事件总线(`on/emit`)
|
|
144
|
+
*/
|
|
145
|
+
interface SpliceTreeInstance<T = SpliceTreeData> {
|
|
146
|
+
/**
|
|
147
|
+
* 原始数据列表
|
|
148
|
+
*/
|
|
149
|
+
data: T[];
|
|
150
|
+
/**
|
|
151
|
+
* 实例选项
|
|
152
|
+
*/
|
|
153
|
+
options: UseSpliceTreeOptions<T>;
|
|
154
|
+
/**
|
|
155
|
+
* 返回当前可见节点序列(按展开状态计算)
|
|
156
|
+
*/
|
|
157
|
+
items: () => SpliceTreeNode<T>[];
|
|
158
|
+
/**
|
|
159
|
+
* 通过 id 获取节点(不存在时返回 undefined)
|
|
160
|
+
* @param id 节点 id
|
|
161
|
+
*/
|
|
162
|
+
getNode: (id: string) => SpliceTreeNode<T> | undefined;
|
|
163
|
+
/**
|
|
164
|
+
* 事件总线
|
|
165
|
+
*/
|
|
166
|
+
events: SpliceTreeEvents;
|
|
167
|
+
/**
|
|
168
|
+
* 返回当前展开的节点 id 集合
|
|
169
|
+
*/
|
|
170
|
+
expandedKeys: () => string[];
|
|
171
|
+
/**
|
|
172
|
+
* 查询指定节点是否展开
|
|
173
|
+
* @param id 节点 id
|
|
174
|
+
*/
|
|
175
|
+
isExpanded: (id: string) => boolean;
|
|
176
|
+
/**
|
|
177
|
+
* 展开指定节点或节点列表
|
|
178
|
+
* @param id 单个 id 或 id 数组
|
|
179
|
+
*/
|
|
180
|
+
expand: (id: string | string[]) => void;
|
|
181
|
+
/**
|
|
182
|
+
* 收起指定节点或节点列表
|
|
183
|
+
* @param id 单个 id 或 id 数组
|
|
184
|
+
*/
|
|
185
|
+
collapse: (id: string | string[]) => void;
|
|
186
|
+
/**
|
|
187
|
+
* 切换指定节点或节点列表的展开状态
|
|
188
|
+
* @param id 单个 id 或 id 数组
|
|
189
|
+
*/
|
|
190
|
+
toggleExpand: (id: string | string[]) => void;
|
|
191
|
+
/**
|
|
192
|
+
* 展开所有节点
|
|
193
|
+
*/
|
|
194
|
+
expandAll: () => void;
|
|
195
|
+
/**
|
|
196
|
+
* 收起所有节点
|
|
197
|
+
*/
|
|
198
|
+
collapseAll: () => void;
|
|
199
|
+
/**
|
|
200
|
+
* 切换所有节点的展开状态
|
|
201
|
+
*/
|
|
202
|
+
toggleExpandAll: () => void;
|
|
203
|
+
/**
|
|
204
|
+
* 追加子节点
|
|
205
|
+
* @param parentId 父节点 id(传 undefined 表示追加到根)
|
|
206
|
+
* @param children 子节点数据列表
|
|
207
|
+
*/
|
|
208
|
+
appendChildren: (parentId: string | undefined, children: T[]) => void;
|
|
209
|
+
/**
|
|
210
|
+
* 移动节点到新父级并指定插入位置
|
|
211
|
+
* @param id 要移动的节点 id
|
|
212
|
+
* @param newParentId 新父级节点 id(传 undefined 移动到根)
|
|
213
|
+
* @param beforeId 插入到指定兄弟节点之前(不传表示末尾)
|
|
214
|
+
*/
|
|
215
|
+
moveNode: (id: string, newParentId: string | undefined, beforeId?: string) => void;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* 插件上下文
|
|
219
|
+
* - `tree`:核心实例引用
|
|
220
|
+
* - `options`:插件自身选项
|
|
221
|
+
* - `events`:事件总线
|
|
222
|
+
*/
|
|
223
|
+
interface SpliceTreePluginContext<T = SpliceTreeData> {
|
|
224
|
+
/**
|
|
225
|
+
* 核心实例
|
|
226
|
+
*/
|
|
227
|
+
tree: SpliceTreeInstance<T>;
|
|
228
|
+
/**
|
|
229
|
+
* 插件选项(来自用户传入的 `options` 合并)
|
|
230
|
+
*/
|
|
231
|
+
options: Record<string, any>;
|
|
232
|
+
/**
|
|
233
|
+
* 事件总线
|
|
234
|
+
*/
|
|
235
|
+
events: SpliceTreeEvents;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* 插件定义接口
|
|
239
|
+
* - `name`:插件名称
|
|
240
|
+
* - `options`:插件选项(可用于配置行为)
|
|
241
|
+
* - `setup(ctx)`:实例级扩展,返回扩展的字段与方法
|
|
242
|
+
* - `extendNode(node, ctx)`:节点级扩展,直接为节点追加方法/属性
|
|
243
|
+
*/
|
|
244
|
+
interface SpliceTreePlugin<T = SpliceTreeData, TExt = Record<string, any>> {
|
|
245
|
+
/**
|
|
246
|
+
* 插件名称
|
|
247
|
+
*/
|
|
248
|
+
name: string;
|
|
249
|
+
/**
|
|
250
|
+
* 插件选项
|
|
251
|
+
*/
|
|
252
|
+
options?: Record<string, any>;
|
|
253
|
+
/**
|
|
254
|
+
* 设置实例扩展
|
|
255
|
+
* @param ctx 插件上下文
|
|
256
|
+
* @returns 扩展的字段与方法,将合并到实例上
|
|
257
|
+
*/
|
|
258
|
+
setup?: (ctx: SpliceTreePluginContext<T>) => TExt;
|
|
259
|
+
/**
|
|
260
|
+
* 扩展节点
|
|
261
|
+
* @param node 当前节点
|
|
262
|
+
* @param ctx 插件上下文
|
|
263
|
+
*/
|
|
264
|
+
extendNode?: (node: SpliceTreeNode<T>, ctx: SpliceTreePluginContext<T>) => void;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* 工厂函数类型:创建 SpliceTree 实例
|
|
268
|
+
* @param data 原始树数据列表
|
|
269
|
+
* @param options 实例选项(支持插件扩展)
|
|
270
|
+
* @returns SpliceTree 实例
|
|
271
|
+
*/
|
|
272
|
+
type CreateSpliceTree = <T = SpliceTreeData>(data: T[], options?: UseSpliceTreeOptions<T>) => SpliceTreeInstance<T>;
|
|
273
|
+
//#endregion
|
|
274
|
+
//#region src/use.d.ts
|
|
275
|
+
/**
|
|
276
|
+
* 创建 SpliceTree 树实例
|
|
277
|
+
* - 构建缓存结构
|
|
278
|
+
* - 暴露操作方法(展开/收起/追加/移动)
|
|
279
|
+
* - 提供插件扩展点(setup/extendNode)
|
|
280
|
+
*/
|
|
281
|
+
declare function createSpliceTree<T extends SpliceTreeData = SpliceTreeData>(data: T[], options?: UseSpliceTreeOptions<T>): SpliceTreeInstance<T>;
|
|
282
|
+
//#endregion
|
|
283
|
+
export { CreateSpliceTree, Fn, SpliceTreeData, SpliceTreeEventName, SpliceTreeEventPayload, SpliceTreeEventPayloadMap, SpliceTreeEvents, SpliceTreeInstance, SpliceTreeNode, SpliceTreePlugin, SpliceTreePluginContext, UseSpliceTreeOptions, createSpliceTree };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
//#region src/emitter.ts
|
|
2
|
+
/**
|
|
3
|
+
* 轻量事件总线:支持订阅与派发,供核心与插件通信
|
|
4
|
+
*/
|
|
5
|
+
function createEmitter() {
|
|
6
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
7
|
+
const on = (name, handler) => {
|
|
8
|
+
if (!listeners.has(name)) listeners.set(name, /* @__PURE__ */ new Set());
|
|
9
|
+
const set = listeners.get(name);
|
|
10
|
+
set.add(handler);
|
|
11
|
+
return () => set.delete(handler);
|
|
12
|
+
};
|
|
13
|
+
const emit = (payload) => {
|
|
14
|
+
const set = listeners.get(payload.name);
|
|
15
|
+
if (!set) return;
|
|
16
|
+
for (const h of set) h(payload);
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
on,
|
|
20
|
+
emit
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/impl.ts
|
|
26
|
+
/**
|
|
27
|
+
* 创建运行时树节点,并注入必要的 API
|
|
28
|
+
*/
|
|
29
|
+
function createSpliceTreeNode(id, original, api) {
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
original,
|
|
33
|
+
level: 0,
|
|
34
|
+
isExpanded: api.isExpanded,
|
|
35
|
+
hasChildren: api.hasChildren,
|
|
36
|
+
getParent: api.getParent,
|
|
37
|
+
getChildren: api.getChildren,
|
|
38
|
+
toggleExpand: api.toggleExpand
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 基于扁平数据构建树结构缓存
|
|
43
|
+
* - by id 的节点 map
|
|
44
|
+
* - parent/children 缓存
|
|
45
|
+
* - 根节点列表
|
|
46
|
+
* 并计算每个节点的 level
|
|
47
|
+
*/
|
|
48
|
+
function buildTree(data, keyField, parentField, expandedKeys = /* @__PURE__ */ new Set()) {
|
|
49
|
+
const map = /* @__PURE__ */ new Map();
|
|
50
|
+
const roots = [];
|
|
51
|
+
const parentCache = /* @__PURE__ */ new Map();
|
|
52
|
+
const childrenCache = /* @__PURE__ */ new Map();
|
|
53
|
+
data.forEach((item) => {
|
|
54
|
+
const id = String(Reflect.get(item, keyField || "id"));
|
|
55
|
+
const node = createSpliceTreeNode(id, item, {
|
|
56
|
+
hasChildren: () => !!childrenCache.get(id)?.length,
|
|
57
|
+
getParent: () => parentCache.get(id),
|
|
58
|
+
getChildren: () => childrenCache.get(id) ?? [],
|
|
59
|
+
isExpanded: () => expandedKeys.has(id),
|
|
60
|
+
toggleExpand: () => {
|
|
61
|
+
if (expandedKeys.has(id)) expandedKeys.delete(id);
|
|
62
|
+
else expandedKeys.add(id);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
map.set(id, node);
|
|
66
|
+
});
|
|
67
|
+
for (const id of map.keys()) childrenCache.set(id, []);
|
|
68
|
+
for (const node of map.values()) {
|
|
69
|
+
const parentId = Reflect.get(node.original, parentField || "parentId");
|
|
70
|
+
if (!parentId) {
|
|
71
|
+
parentCache.set(node.id, void 0);
|
|
72
|
+
roots.push(node);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const parentNode = map.get(String(parentId));
|
|
76
|
+
parentCache.set(node.id, parentNode ?? void 0);
|
|
77
|
+
if (parentNode) childrenCache.get(parentNode.id).push(node);
|
|
78
|
+
else roots.push(node);
|
|
79
|
+
}
|
|
80
|
+
const dfs = (node, level) => {
|
|
81
|
+
node.level = level;
|
|
82
|
+
for (const child of childrenCache.get(node.id) ?? []) dfs(child, level + 1);
|
|
83
|
+
};
|
|
84
|
+
for (const r of roots) dfs(r, 0);
|
|
85
|
+
return {
|
|
86
|
+
roots,
|
|
87
|
+
map,
|
|
88
|
+
parentCache,
|
|
89
|
+
childrenCache
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/utils/expand.ts
|
|
95
|
+
function initDefaultExpansion(map, expanded, def, lvl) {
|
|
96
|
+
const expandAll = () => {
|
|
97
|
+
for (const id of map.keys()) expanded.add(id);
|
|
98
|
+
};
|
|
99
|
+
const expandByLevel = (lv) => {
|
|
100
|
+
for (const n of map.values()) if (n.level < lv) expanded.add(n.id);
|
|
101
|
+
};
|
|
102
|
+
if (def === true) {
|
|
103
|
+
expandAll();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (Array.isArray(def)) {
|
|
107
|
+
for (const id of def) expanded.add(id);
|
|
108
|
+
if (lvl === "deepest") {
|
|
109
|
+
expandAll();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (typeof lvl === "number" && Number.isFinite(lvl) && lvl > 0) expandByLevel(lvl);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (lvl === "deepest") {
|
|
116
|
+
expandAll();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (typeof lvl === "number" && Number.isFinite(lvl) && lvl > 0) expandByLevel(lvl);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
//#endregion
|
|
123
|
+
//#region src/utils/reactive.ts
|
|
124
|
+
/**
|
|
125
|
+
* 为 Set/Map/Object 创建响应式代理
|
|
126
|
+
* 在集合或对象发生更改时,触发回调以便同步状态(如可见性)
|
|
127
|
+
*/
|
|
128
|
+
function createReactive(target, callback) {
|
|
129
|
+
const isSet = target instanceof Set;
|
|
130
|
+
const isMap = target instanceof Map;
|
|
131
|
+
return new Proxy(target, {
|
|
132
|
+
get(obj, prop, receiver) {
|
|
133
|
+
if (isSet) {
|
|
134
|
+
if (prop === "size") return Reflect.get(obj, prop, obj);
|
|
135
|
+
const value = Reflect.get(obj, prop, obj);
|
|
136
|
+
if (prop === "add") return (v) => {
|
|
137
|
+
const existed = obj.has(v);
|
|
138
|
+
const r = obj.add(v);
|
|
139
|
+
if (!existed) callback({
|
|
140
|
+
type: "ADD",
|
|
141
|
+
target: obj,
|
|
142
|
+
newValue: v
|
|
143
|
+
});
|
|
144
|
+
return r;
|
|
145
|
+
};
|
|
146
|
+
if (prop === "delete") return (v) => {
|
|
147
|
+
const existed = obj.has(v);
|
|
148
|
+
const old = v;
|
|
149
|
+
const r = obj.delete(v);
|
|
150
|
+
if (existed) callback({
|
|
151
|
+
type: "DELETE",
|
|
152
|
+
target: obj,
|
|
153
|
+
oldValue: old
|
|
154
|
+
});
|
|
155
|
+
return r;
|
|
156
|
+
};
|
|
157
|
+
if (prop === "clear") return () => {
|
|
158
|
+
if (obj.size > 0) callback({
|
|
159
|
+
type: "CLEAR",
|
|
160
|
+
target: obj
|
|
161
|
+
});
|
|
162
|
+
return obj.clear();
|
|
163
|
+
};
|
|
164
|
+
return typeof value === "function" ? value.bind(obj) : value;
|
|
165
|
+
}
|
|
166
|
+
if (isMap) {
|
|
167
|
+
if (prop === "size") return Reflect.get(obj, prop, obj);
|
|
168
|
+
const value = Reflect.get(obj, prop, obj);
|
|
169
|
+
if (prop === "set") return (k, v) => {
|
|
170
|
+
const old = obj.get(k);
|
|
171
|
+
const r = obj.set(k, v);
|
|
172
|
+
callback({
|
|
173
|
+
type: "MAP_SET",
|
|
174
|
+
target: obj,
|
|
175
|
+
property: k,
|
|
176
|
+
oldValue: old,
|
|
177
|
+
newValue: v
|
|
178
|
+
});
|
|
179
|
+
return r;
|
|
180
|
+
};
|
|
181
|
+
if (prop === "delete") return (k) => {
|
|
182
|
+
const existed = obj.has(k);
|
|
183
|
+
const old = obj.get(k);
|
|
184
|
+
const r = obj.delete(k);
|
|
185
|
+
if (existed) callback({
|
|
186
|
+
type: "DELETE",
|
|
187
|
+
target: obj,
|
|
188
|
+
property: k,
|
|
189
|
+
oldValue: old
|
|
190
|
+
});
|
|
191
|
+
return r;
|
|
192
|
+
};
|
|
193
|
+
if (prop === "clear") return () => {
|
|
194
|
+
if (obj.size > 0) callback({
|
|
195
|
+
type: "CLEAR",
|
|
196
|
+
target: obj
|
|
197
|
+
});
|
|
198
|
+
return obj.clear();
|
|
199
|
+
};
|
|
200
|
+
return typeof value === "function" ? value.bind(obj) : value;
|
|
201
|
+
}
|
|
202
|
+
return Reflect.get(obj, prop, receiver);
|
|
203
|
+
},
|
|
204
|
+
set(obj, prop, value, receiver) {
|
|
205
|
+
const oldValue = Reflect.get(obj, prop, receiver);
|
|
206
|
+
const r = Reflect.set(obj, prop, value);
|
|
207
|
+
callback({
|
|
208
|
+
type: "SET",
|
|
209
|
+
target: obj,
|
|
210
|
+
property: prop,
|
|
211
|
+
oldValue,
|
|
212
|
+
newValue: value
|
|
213
|
+
});
|
|
214
|
+
return r;
|
|
215
|
+
},
|
|
216
|
+
deleteProperty(obj, prop) {
|
|
217
|
+
const oldValue = Reflect.get(obj, prop);
|
|
218
|
+
const r = Reflect.deleteProperty(obj, prop);
|
|
219
|
+
callback({
|
|
220
|
+
type: "DELETE",
|
|
221
|
+
target: obj,
|
|
222
|
+
property: prop,
|
|
223
|
+
oldValue
|
|
224
|
+
});
|
|
225
|
+
return r;
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
//#endregion
|
|
231
|
+
//#region src/utils/node.ts
|
|
232
|
+
/**
|
|
233
|
+
* 以 DFS 方式计算当前可见节点序列
|
|
234
|
+
* 仅在父节点展开时展开其子节点
|
|
235
|
+
*/
|
|
236
|
+
function computeVisibleItems(roots) {
|
|
237
|
+
const result = [];
|
|
238
|
+
const walk = (node) => {
|
|
239
|
+
result.push(node);
|
|
240
|
+
if (node.isExpanded() && node.hasChildren()) for (const child of node.getChildren()) walk(child);
|
|
241
|
+
};
|
|
242
|
+
for (const root of roots) walk(root);
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* 递归设置节点层级
|
|
247
|
+
*/
|
|
248
|
+
function setLevelRecursively(node, childrenCache, startLevel) {
|
|
249
|
+
node.level = startLevel ?? node.level;
|
|
250
|
+
for (const c of childrenCache.get(node.id) ?? []) setLevelRecursively(c, childrenCache, (node.level ?? 0) + 1);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* 追加子节点到指定父节点(或根)
|
|
254
|
+
* 自动维护缓存与层级,并触发通知
|
|
255
|
+
*/
|
|
256
|
+
function appendChildren(ctx, parentId, children) {
|
|
257
|
+
const parent = parentId ? ctx.map.get(parentId) : void 0;
|
|
258
|
+
for (const item of children) {
|
|
259
|
+
const id = String(Reflect.get(item, ctx.keyField || "id"));
|
|
260
|
+
if (ctx.map.has(id)) continue;
|
|
261
|
+
const node = {
|
|
262
|
+
id,
|
|
263
|
+
original: item,
|
|
264
|
+
level: parent ? parent.level + 1 : 0,
|
|
265
|
+
hasChildren: () => !!ctx.childrenCache.get(id)?.length,
|
|
266
|
+
getParent: () => ctx.parentCache.get(id),
|
|
267
|
+
getChildren: () => ctx.childrenCache.get(id) ?? [],
|
|
268
|
+
isExpanded: () => ctx.expandedKeys.has(id),
|
|
269
|
+
toggleExpand: (expand) => {
|
|
270
|
+
if (expand === void 0) ctx.tree.toggleExpand(id);
|
|
271
|
+
else if (expand) ctx.tree.expand(id);
|
|
272
|
+
else ctx.tree.collapse(id);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
ctx.map.set(id, node);
|
|
276
|
+
ctx.childrenCache.set(id, []);
|
|
277
|
+
ctx.parentCache.set(id, parent);
|
|
278
|
+
if (parent) ctx.childrenCache.get(parent.id).push(node);
|
|
279
|
+
else ctx.roots.push(node);
|
|
280
|
+
}
|
|
281
|
+
if (parent) setLevelRecursively(parent, ctx.childrenCache, parent.level);
|
|
282
|
+
else ctx.roots.forEach((r) => setLevelRecursively(r, ctx.childrenCache, 0));
|
|
283
|
+
ctx.notify();
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* 移动节点到新父级,并支持在某个兄弟节点之前插入
|
|
287
|
+
* 自动维护缓存与层级,并触发通知
|
|
288
|
+
*/
|
|
289
|
+
function moveNode(ctx, id, newParentId, beforeId) {
|
|
290
|
+
const node = ctx.map.get(id);
|
|
291
|
+
if (!node) return;
|
|
292
|
+
const oldParent = ctx.parentCache.get(id);
|
|
293
|
+
const newParent = newParentId ? ctx.map.get(newParentId) : void 0;
|
|
294
|
+
if (oldParent) {
|
|
295
|
+
const arr = ctx.childrenCache.get(oldParent.id) ?? [];
|
|
296
|
+
const idx = arr.findIndex((n) => n.id === id);
|
|
297
|
+
if (idx >= 0) arr.splice(idx, 1);
|
|
298
|
+
} else {
|
|
299
|
+
const idx = ctx.roots.findIndex((n) => n.id === id);
|
|
300
|
+
if (idx >= 0) ctx.roots.splice(idx, 1);
|
|
301
|
+
}
|
|
302
|
+
ctx.parentCache.set(id, newParent);
|
|
303
|
+
node.level = newParent ? newParent.level + 1 : 0;
|
|
304
|
+
if (newParent) {
|
|
305
|
+
const arr = ctx.childrenCache.get(newParent.id) ?? [];
|
|
306
|
+
if (!ctx.childrenCache.has(newParent.id)) ctx.childrenCache.set(newParent.id, arr);
|
|
307
|
+
if (beforeId) {
|
|
308
|
+
const idx = arr.findIndex((n) => n.id === beforeId);
|
|
309
|
+
if (idx >= 0) arr.splice(idx, 0, node);
|
|
310
|
+
else arr.push(node);
|
|
311
|
+
} else arr.push(node);
|
|
312
|
+
} else if (beforeId) {
|
|
313
|
+
const idx = ctx.roots.findIndex((n) => n.id === beforeId);
|
|
314
|
+
if (idx >= 0) ctx.roots.splice(idx, 0, node);
|
|
315
|
+
else ctx.roots.push(node);
|
|
316
|
+
} else ctx.roots.push(node);
|
|
317
|
+
setLevelRecursively(node, ctx.childrenCache, node.level);
|
|
318
|
+
ctx.notify();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
//#endregion
|
|
322
|
+
//#region src/use.ts
|
|
323
|
+
/**
|
|
324
|
+
* 创建 SpliceTree 树实例
|
|
325
|
+
* - 构建缓存结构
|
|
326
|
+
* - 暴露操作方法(展开/收起/追加/移动)
|
|
327
|
+
* - 提供插件扩展点(setup/extendNode)
|
|
328
|
+
*/
|
|
329
|
+
function createSpliceTree(data, options = {}) {
|
|
330
|
+
const keyField = options.keyField ?? "id";
|
|
331
|
+
const parentField = options.parentField ?? "parent";
|
|
332
|
+
const events = createEmitter();
|
|
333
|
+
const expandedKeys = createReactive(/* @__PURE__ */ new Set(), (payload) => {
|
|
334
|
+
events.emit({
|
|
335
|
+
name: "visibility",
|
|
336
|
+
keys: Array.from(payload.target)
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
const { roots, map, parentCache, childrenCache } = buildTree(data, keyField, parentField, expandedKeys);
|
|
340
|
+
initDefaultExpansion(map, expandedKeys, options.defaultExpanded, options.defaultExpandedLevel);
|
|
341
|
+
const emitVisibility = () => {
|
|
342
|
+
events.emit({
|
|
343
|
+
name: "visibility",
|
|
344
|
+
keys: Array.from(expandedKeys)
|
|
345
|
+
});
|
|
346
|
+
};
|
|
347
|
+
const tree = {
|
|
348
|
+
data,
|
|
349
|
+
options,
|
|
350
|
+
items: () => computeVisibleItems(roots),
|
|
351
|
+
getNode: (id) => map.get(id),
|
|
352
|
+
events,
|
|
353
|
+
expandedKeys: () => Array.from(expandedKeys),
|
|
354
|
+
isExpanded: (id) => expandedKeys.has(id),
|
|
355
|
+
expand(ids) {
|
|
356
|
+
const list = Array.isArray(ids) ? ids : [ids];
|
|
357
|
+
for (const id of list) if (!expandedKeys.has(id)) expandedKeys.add(id);
|
|
358
|
+
emitVisibility();
|
|
359
|
+
},
|
|
360
|
+
collapse(ids) {
|
|
361
|
+
const list = Array.isArray(ids) ? ids : [ids];
|
|
362
|
+
for (const id of list) if (expandedKeys.has(id)) expandedKeys.delete(id);
|
|
363
|
+
emitVisibility();
|
|
364
|
+
},
|
|
365
|
+
toggleExpand(ids) {
|
|
366
|
+
const list = Array.isArray(ids) ? ids : [ids];
|
|
367
|
+
const toExpand = [];
|
|
368
|
+
const toCollapse = [];
|
|
369
|
+
for (const id of list) if (tree.isExpanded(id)) toCollapse.push(id);
|
|
370
|
+
else toExpand.push(id);
|
|
371
|
+
if (toExpand.length) tree.expand(toExpand);
|
|
372
|
+
if (toCollapse.length) tree.collapse(toCollapse);
|
|
373
|
+
},
|
|
374
|
+
expandAll() {
|
|
375
|
+
for (const id of map.keys()) expandedKeys.add(id);
|
|
376
|
+
emitVisibility();
|
|
377
|
+
},
|
|
378
|
+
collapseAll() {
|
|
379
|
+
expandedKeys.clear();
|
|
380
|
+
emitVisibility();
|
|
381
|
+
},
|
|
382
|
+
toggleExpandAll() {
|
|
383
|
+
if (expandedKeys.size > 0) tree.collapseAll();
|
|
384
|
+
else tree.expandAll();
|
|
385
|
+
},
|
|
386
|
+
appendChildren(parentId, children) {
|
|
387
|
+
appendChildren({
|
|
388
|
+
map,
|
|
389
|
+
tree,
|
|
390
|
+
roots,
|
|
391
|
+
keyField,
|
|
392
|
+
parentCache,
|
|
393
|
+
childrenCache,
|
|
394
|
+
expandedKeys,
|
|
395
|
+
notify: emitVisibility
|
|
396
|
+
}, parentId, children);
|
|
397
|
+
},
|
|
398
|
+
moveNode(id, newParentId, beforeId) {
|
|
399
|
+
moveNode({
|
|
400
|
+
map,
|
|
401
|
+
tree,
|
|
402
|
+
roots,
|
|
403
|
+
keyField,
|
|
404
|
+
parentCache,
|
|
405
|
+
childrenCache,
|
|
406
|
+
expandedKeys,
|
|
407
|
+
notify: emitVisibility
|
|
408
|
+
}, id, newParentId, beforeId);
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
const pluginCtx = {
|
|
412
|
+
tree,
|
|
413
|
+
options,
|
|
414
|
+
events
|
|
415
|
+
};
|
|
416
|
+
options?.plugins?.forEach((plugin) => {
|
|
417
|
+
const api = plugin.setup?.(pluginCtx);
|
|
418
|
+
Object.assign(tree, api);
|
|
419
|
+
});
|
|
420
|
+
if (options?.plugins?.length) for (const n of map.values()) for (const plugin of options.plugins) plugin.extendNode?.(n, pluginCtx);
|
|
421
|
+
for (const node of map.values()) {
|
|
422
|
+
node.isExpanded = () => tree.isExpanded(node.id);
|
|
423
|
+
node.toggleExpand = (expand) => {
|
|
424
|
+
if (expand === void 0) tree.toggleExpand(node.id);
|
|
425
|
+
else if (expand) tree.expand(node.id);
|
|
426
|
+
else tree.collapse(node.id);
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
return tree;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
//#endregion
|
|
433
|
+
export { createSpliceTree };
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@splicetree/core",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"author": {
|
|
6
|
+
"email": "michael.cocova@gmail.com",
|
|
7
|
+
"name": "Michael Cocova"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"homepage": "https://www.splicetree.dev",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/michaelcocova/splicetree"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./dist/index.js",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"module": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"devDependencies": {},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"dev": "tsdown --watch",
|
|
31
|
+
"build": "tsdown"
|
|
32
|
+
}
|
|
33
|
+
}
|