├── .babelrc ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── _config.yml ├── dist ├── expand.svg ├── leaf.svg ├── oval.svg ├── parent.svg ├── vs-tree.css ├── vs-tree.esm.browser.js └── vs-tree.js ├── package.json ├── public ├── .DS_Store ├── index.html ├── static │ ├── css │ │ ├── icon1.svg │ │ ├── icon2.svg │ │ └── index.css │ ├── data.txt │ ├── js │ │ └── work.js │ └── qq-group.jpg ├── 初始数据为数组.html ├── 加载动画.html ├── 单选示例.html ├── 基础示例.html ├── 复杂示例.html ├── 复选示例.html ├── 大数据量.html ├── 展开收起图标.html ├── 展示图标.html ├── 开启动画.html ├── 异步加载.html ├── 手风琴模式.html ├── 拖拽节点.html ├── 显示连接线.html ├── 最大可选.html ├── 格式化数据.html ├── 清空选中节点.html ├── 结合vue.html ├── 结合worker+indexDb.html ├── 自定义搜索节点.html ├── 自定义节点内容.html ├── 自定义节点内容2.html ├── 节点过滤.html ├── 获取选中节点.html ├── 面包屑.html ├── 项目实战.html └── 鼠标右键事件.html ├── rollup.config.js ├── src ├── breadcrumb │ ├── breadcrumb-item.js │ └── index.ts ├── core │ ├── index.js │ ├── node.js │ ├── store.js │ └── utils.ts ├── less │ └── vs-tree.less ├── main.js ├── virtual-list │ ├── index.js │ └── virtual.js └── vue-plugin │ └── index.js ├── tsconfig.json ├── types └── src │ ├── breadcrumb │ ├── breadcrumb-item.d.ts │ └── index.d.ts │ ├── core │ ├── index.d.ts │ ├── node.d.ts │ ├── store.d.ts │ └── utils.d.ts │ ├── main.d.ts │ ├── virtual-list │ ├── index.d.ts │ └── virtual.d.ts │ └── vue-plugin │ └── index.d.ts └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-typescript" 5 | ] 6 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": [ 7 | "standard" 8 | ], 9 | "globals": { 10 | "Atomics": "readonly", 11 | "SharedArrayBuffer": "readonly" 12 | }, 13 | "parserOptions": { 14 | "ecmaVersion": 2020, 15 | "sourceType": "module" 16 | }, 17 | "rules": { 18 | "space-before-function-paren": "off" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | .DS_Store -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src 3 | public 4 | # yarn.lock should be ignored -> https://github.com/yarnpkg/yarn/issues/5406 5 | yarn.lock 6 | yarn-error.log 7 | .editorconfig 8 | .DS_Store 9 | .babelrc 10 | .eslintrc.json 11 | .eslintignore 12 | .gitignore 13 | CHANGELOG.md 14 | 15 | # GitHub pages 16 | CNAME 17 | _config.yml 18 | 19 | # build 20 | rollup.config.js 21 | tsconfig.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": true, 10 | "arrowParens": "avoid", 11 | "proseWrap": "never", 12 | "format": { 13 | "insertSpaceBeforeFunctionParenthesis": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 yangjingyu 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vs-tree2.0 2 | 3 | 极简树组件, 无任何依赖【麻雀虽小,五脏俱全】 4 | 5 | ## 浏览器支持 6 | 7 | | ![Edge](https://raw.github.com/alrra/browser-logos/master/src/edge/edge_48x48.png) | ![Chrome](https://raw.github.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png) | ![Firefox](https://raw.github.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png) | ![Opera](https://raw.github.com/alrra/browser-logos/master/src/opera/opera_48x48.png) | ![Safari](https://raw.github.com/alrra/browser-logos/master/src/safari/safari_48x48.png) | 8 | | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | 9 | | Latest ✔ | Latest ✔ | Latest ✔ | Latest ✔ | Latest ✔ | 10 | 11 | ## 功能点 12 | 13 | * [x] 基础树组件 14 | * [x] 层级面包屑 15 | * [x] 复选框 16 | * [x] 单选框 17 | * [x] 异步加载数据 18 | * [x] 虚拟列表 19 | * [x] 拖拽节点 20 | * [x] 手风琴 21 | * [x] 树内搜索 22 | * [x] 自定义图标 23 | * [x] 连接线 24 | * [x] 最大可选 25 | * [x] 节点右键事件 26 | * [x] 自定义格式化数据 27 | * [x] 支持Vue组件 28 | 29 | ## DEMO 30 | 31 | [跳转到DEMO](https://yangjingyu.github.io/vs-tree/public/index.html) 32 | 33 | ## 安装 34 | 35 | ```shell 36 | npm install vs-tree 37 | ``` 38 | 39 | 或 40 | 41 | ```shell 42 | yarn add vs-tree 43 | ``` 44 | 45 | ## use 46 | 47 | ```html 48 |
49 | ``` 50 | 51 | ```js 52 | import vsTree from 'vs-tree' 53 | import 'vs-tree/dist/vs-tree.css' 54 | 55 | const tree = new vsTree('#tree', { 56 | data: {id: 1, name: 'tree1', children: []} // [{id, name}, {id, name, children}] 57 | }); 58 | ``` 59 | 60 | --- 61 | 62 | 直接引入js 63 | 64 | ```html 65 | 66 |
67 | 68 | ``` 69 | 70 | ```js 71 | const tree = new vsTree.default('#tree', { 72 | data: {id: 1, name: 'tree1', children: []} // [{id, name}, {id, name, children}] 73 | }); 74 | ``` 75 | 76 | --- 77 | 78 | 支持浏览器模块 79 | 80 | ```html 81 | 87 | ``` 88 | 89 | ## Vue2.x use 90 | 91 | ```js 92 | // main.js 93 | import { install } from 'vs-tree' 94 | import 'vs-tree/dist/vs-tree.css' 95 | 96 | Vue.use(install) 97 | ``` 98 | 99 | ```html 100 | 105 | 106 | 141 | ``` 142 | 143 | ### Options 144 | 145 | | Input | Desc | Type | Default | 146 | | ---------------- | ---------------------------------------------------- | --------------------- | ------------------- | 147 | | el | 选择器, 或 HTMLElement | string 或 HTMLElement | | 148 | | data | 展示数据 | Object、 Array | | 149 | | async | 延时渲染 | Boolean | false | 150 | | hideRoot | 是否展示根节点 | Boolean | false | 151 | | showLine | 是否展示连接线 | Boolean | false | 152 | | showIcon | 是否显示图标 | Boolean | false | 153 | | onlyShowLeafIcon | 是否仅显示叶子节点图标 | Boolean | false | 154 | | showCheckbox | 是否显示复选框 | Boolean | false | 155 | | checkboxType | 父子节点关联关系 | Object | checkboxTypeOptions | 156 | | checkInherit | 新加入节点时自动继承父节点选中状态 | Boolean | false | 157 | | showRadio | 是否显示单选框,会覆盖复选框 | Boolean | false | 158 | | radioType | 分组范围 | String | 'all' | 159 | | disabledInherit | 新加入节点时自动继承父节点禁用状态 | Boolean | false | 160 | | highlightCurrent | 是否高亮选中当前项 | Boolean | false | 161 | | accordion | 手风琴模式 | Boolean | false | 162 | | animation | 开启动画 | Boolean | false | 163 | | draggable | 开启拖拽 | Boolean | false | 164 | | dropable | 允许放置 | Boolean | false | 165 | | nocheckParent | 禁止父节点选中 | Boolean | false | 166 | | sort | 对选中列表排序 | Boolean | false | 167 | | checkOnClickNode | 是否在点击节点的时候选中节点 | Boolean | false | 168 | | lazy | 异步加载节点 | Boolean | false | 169 | | strictLeaf | 严格依赖isLeaf,不提供时如无子节点则不渲染展开图标 | Boolean | false | 170 | | max | 最大可选数量 | Number | 0 | 171 | | checkFilterLeaf | 选中结果过滤掉叶子节点, 异步加载时需手需提供 isLeaf | Boolean | false | 172 | | rootName | 根节点名称,仅 data 为数组时有效,此时不会默认 | String | null | 173 | | expandClass | 展开收起图标class | String | vs-expand-icon | 174 | | theme | 皮肤风格,仅支持 'element' | String | null | 175 | | breadcrumb | 面包屑功能,只展示一层节点 | Object | null | 176 | | disabledKeys | 禁止操作 | Array | null | 177 | | checkedKeys | 默认选中 | Array | null | 178 | | expandKeys | 默认展开 | Array | null | 179 | | expandLevel | 默认展开级数, 0 不展开 -1 全部展开 | Number | 1 | 180 | | indent | 缩进 | Number | 10 | 181 | | virtual | 虚拟列表配置信息 | Object | virtualOptions | 182 | | maxHeight | 组件最大高度 | String、Number | 400px | 183 | | minHeight | 组件最大高度 | String、Number | 0px | 184 | 185 | ### checkboxTypeOptions 186 | 187 | | options | Desc | 默认 | 188 | | ------- | ------------ | ---- | 189 | | Y | 勾选后情况 | 'ps' | 190 | | N | 取消勾选情况 | 'ps' | 191 | 192 | > p 表示操作影响父节点 193 | > s 表示操作影响子节点 194 | 195 | ### radioType 196 | 197 | > all 表示全局范围内分组 198 | > level 表示每级节点内分组 199 | 200 | ### virtualOptions 201 | 202 | | options | Desc | 默认 | 203 | | ---------- | -------------------- | ---- | 204 | | showCount | 视图内展示多少条数据 | 20 | 205 | | itemHeight | 每条的高度 | 26 | 206 | 207 | ### breadcrumb 208 | 209 | 210 | | options | Desc | 默认 | 211 | | --------- | ------------------------- | -------------------- | 212 | | el | Selector, HtmlElement | 内部创建根节点 | 213 | | icon | string, ELement, Function | null | 214 | | link | string, ELement, Function | null | 215 | | separator | string, ELement, Function | null | 216 | | change | Event | dom, node[], current | 217 | 218 | ### 方法 219 | 220 | `Tree` 内部使用了 Node 类型的对象来包装用户传入的数据,用来保存目前节点的状态。 221 | `Tree` 拥有如下方法: 222 | 223 | | Methods | 说明 | 参数 | 224 | | ----------------- | ---------------------- | ----------------------- | 225 | | getCheckedNodes | 获取选中节点 | - | 226 | | getNodeById | 根据 ID 获取 Node 节点 | id | 227 | | setMaxValue | 设置最大可选 | number | 228 | | scrollToIndex | 滚动到索引位置 | number | 229 | | clearCheckedNodes | 清除选中节点 | - | 230 | | filter | 过滤节点 | keyword, onlySearchLeaf | 231 | 232 | > onlySearchLeaf 只过滤叶子节点 233 | 234 | ### Node 方法 235 | 236 | `Node` 拥有如下方法: 237 | 238 | | Methods | 说明 | 参数 | 239 | | ----------- | ------------ | ---------- | 240 | | setChecked | 设置是否选中 | true,false | 241 | | setDisabled | 设置禁止操作 | true,false | 242 | | remove | 删除当前节点 | - | 243 | | append | 追加节点 | data | 244 | 245 | ### Events 246 | 247 | | 事件名称 | 说明 | 回调参数 | 返回值 | 248 | | ------------- | ---------------------- | ------------------- | --------------------------- | 249 | | click | 节点点击事件 | event, node | void | 250 | | beforeCheck | 节点选择前触发 | node | true,false | 251 | | check | 复选框被点击时触发 | event, node | void | 252 | | change | 复选框改变时触发 | [ node ] | void | 253 | | limitAlert | 超过 max 配置时触发 | - | void | 254 | | renderContent | 自定义节点内容 | h,node | h() 或 Dom | 255 | | load | lazy=true 时有效 | node, resolve | void | 256 | | checkFilter | 过滤掉的节点不计入统计 | node | true, false | 257 | | format | 格式化数据 | data | {name,children,isLeaf,icon} | 258 | | contextmenu | 鼠标右键事件 | event, node | void | 259 | | searchFilter | 搜索过滤 | keyword, node, data | node[] | 260 | | searchRender | 搜索渲染 | node, cloneNode | Element | 261 | | onDragstart | 开始拖拽 | e, node | void | 262 | | onDragenter | 进入放置目标 | e, node, dragPos | void | 263 | | onDrop | 放置目标 | e, node, dragPos | void | 264 | 265 | > searchRender 返回的 Element 不会影响原有dom 266 | 267 | #### renderContent 268 | 269 | h: 生成简单 dom 节点,当前仅支持以下配置 270 | 271 | ```js 272 | renderContent: function (h, node) { 273 | return h("div", { 274 | className: "tree-action", 275 | children: [ 276 | h("a", { 277 | text: 'append', 278 | click: function (e, node) { 279 | node.append({ 280 | id: id++, 281 | name: 'append' 282 | }) 283 | } 284 | }), 285 | h("a", { 286 | text: 'remove', 287 | click: function (e, node) { 288 | node.remove() 289 | } 290 | }) 291 | ] 292 | }) 293 | } 294 | ``` 295 | 或 296 | 297 | ```js 298 | renderContent: function(h, node) { 299 | const append = document.createElement('a') 300 | append.innerText = 'append' 301 | dom.appendChild(append) 302 | append.onclick = () => { 303 | node.append({ 304 | id: id++, 305 | name: 'append' 306 | }) 307 | } 308 | return append 309 | } 310 | ``` 311 | 312 | ### load 313 | 314 | resolve 异步加载完成后回调 315 | 316 | ```js 317 | lazy: true, 318 | load: function (node, resolve) { 319 | setTimeout(() => { 320 | resolve([{ 321 | id: id++, 322 | name: '新叶子节点' + id, 323 | isLeaf: true 324 | }]) 325 | }, 1000) 326 | } 327 | ``` 328 | 329 | ### format 330 | 331 | 目前仅支持,id, name、children、isLeaf、icon、extra 332 | 333 | ```js 334 | format: function(data) { 335 | return { 336 | name: data.title, 337 | children: data.child, 338 | isLeaf: !data.child, 339 | icon: 'custom-icon' || document.createElement 340 | } 341 | } 342 | ``` 343 | 344 | 345 | ## Tips 346 | 347 | 1. maxHeight 高度变大后 `showCount` 也要相应变大,不然滑动到底部后数据展示不全,会出现空白. 348 | 2. minHeight 可以配置最小高度,当 minHeight 和 maxHeight 配置相同的高度时,可以固定高度 349 | 3. 如果发现vs-tree组件不显示数据渲染结果为空,则在vs-tree组件上加v-if="list.length > 0" 判断下等数据加载完毕后进行渲染 350 | 4. itemHeight 是用于内部计算,dom元素真是高度需要用css指定 351 | 5. lazy为true时需手动添加isLeaf标识 352 | 353 | ## License 354 | 355 | [MIT License](https://github.com/yangjingyu/vs-tree/blob/master/LICENSE). 356 | 357 | ## QQ交流群(860150548) 358 | 359 | 860150548 360 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /dist/expand.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dist/leaf.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /dist/oval.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /dist/parent.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /dist/vs-tree.css: -------------------------------------------------------------------------------- 1 | .vs-loading { 2 | min-height: 100px; 3 | background-image: url(./oval.svg); 4 | background-position: center center; 5 | background-repeat: no-repeat; 6 | } 7 | .vs-tree-node { 8 | height: 26px; 9 | cursor: pointer; 10 | color: #606266; 11 | font-size: 14px; 12 | display: -webkit-box; 13 | display: -ms-flexbox; 14 | display: flex; 15 | -webkit-box-align: center; 16 | -ms-flex-align: center; 17 | align-items: center; 18 | -webkit-box-pack: justify; 19 | -ms-flex-pack: justify; 20 | justify-content: space-between; 21 | white-space: nowrap; 22 | padding: 0 0 2px; 23 | -webkit-box-sizing: border-box; 24 | box-sizing: border-box; 25 | } 26 | .vs-tree-node:hover { 27 | background-color: #eee; 28 | } 29 | .vs-tree-node:first-child .expand::before { 30 | height: 0; 31 | } 32 | .vs-indent-unit { 33 | position: relative; 34 | display: inline-block; 35 | width: 14px; 36 | height: 14px; 37 | vertical-align: middle; 38 | } 39 | .vs-indent-unit::after { 40 | content: ""; 41 | width: 0; 42 | height: 160%; 43 | position: absolute; 44 | left: 50%; 45 | border-left: 1px dashed #ddd; 46 | top: -8px; 47 | } 48 | .vs-loading-unit.is-loading { 49 | width: 14px; 50 | height: 14px; 51 | margin-right: 5px; 52 | display: inline-block; 53 | vertical-align: middle; 54 | } 55 | .vs-loading-unit.is-loading::after { 56 | content: ""; 57 | width: 14px; 58 | height: 14px; 59 | display: inline-block; 60 | vertical-align: top; 61 | } 62 | .expand, 63 | .expand-empty { 64 | width: 14px; 65 | height: 14px; 66 | line-height: 10px; 67 | display: inline-block; 68 | margin-right: 5px; 69 | color: #bbb; 70 | text-align: center; 71 | -webkit-box-sizing: border-box; 72 | box-sizing: border-box; 73 | vertical-align: middle; 74 | } 75 | .expand { 76 | position: relative; 77 | cursor: pointer; 78 | } 79 | .expand.vs-expand-icon::after { 80 | content: ""; 81 | width: 14px; 82 | height: 14px; 83 | display: inline-block; 84 | background-image: url(./expand.svg); 85 | background-size: 10px 10px; 86 | background-repeat: no-repeat; 87 | background-position: center center; 88 | -webkit-transform: rotate(-90deg); 89 | transform: rotate(-90deg); 90 | -webkit-transition: -webkit-transform 0.3s; 91 | transition: -webkit-transform 0.3s; 92 | transition: transform 0.3s; 93 | transition: transform 0.3s, -webkit-transform 0.3s; 94 | } 95 | .expanded { 96 | color: #bbb; 97 | } 98 | .expanded.vs-expand-icon::after { 99 | -webkit-transform: rotate(0); 100 | transform: rotate(0); 101 | } 102 | .expand.is-loading::after, 103 | .vs-loading-unit.is-loading::after { 104 | background-image: url(./oval.svg); 105 | background-repeat: no-repeat; 106 | background-size: 14px 14px; 107 | border: none; 108 | color: transparent; 109 | } 110 | .vs-indent-unit ~ .expand::before { 111 | content: ""; 112 | position: absolute; 113 | top: -50%; 114 | left: 50%; 115 | width: 0; 116 | height: 50%; 117 | margin-top: -25%; 118 | border-left: 1px dashed #ddd; 119 | } 120 | .vs-tree-node:not([vs-child]) + .vs-tree-node .vs-indent-unit ~ .expand::before { 121 | display: none; 122 | } 123 | .vs-indent-unit ~ .expand-empty { 124 | position: relative; 125 | } 126 | .vs-indent-unit ~ .expand-empty::after { 127 | content: ""; 128 | position: absolute; 129 | top: 50%; 130 | left: 50%; 131 | width: 50%; 132 | margin-top: -1px; 133 | border-bottom: 1px dashed #ddd; 134 | } 135 | .vs-indent-unit ~ .expand-empty::before { 136 | content: ""; 137 | position: absolute; 138 | top: -50%; 139 | left: 50%; 140 | height: 200%; 141 | border-left: 1px dashed #ddd; 142 | } 143 | .selected { 144 | background-color: #eee; 145 | } 146 | .vs-checkbox, 147 | .vs-radio { 148 | position: relative; 149 | color: #606266; 150 | font-weight: 500; 151 | cursor: pointer; 152 | display: inline-block; 153 | white-space: nowrap; 154 | -webkit-user-select: none; 155 | -moz-user-select: none; 156 | -ms-user-select: none; 157 | user-select: none; 158 | margin-right: 8px; 159 | vertical-align: middle; 160 | font-size: 0; 161 | } 162 | .vs-checkbox__input, 163 | .vs-radio__input { 164 | white-space: nowrap; 165 | cursor: pointer; 166 | outline: none; 167 | display: inline-block; 168 | line-height: 1; 169 | position: relative; 170 | vertical-align: middle; 171 | } 172 | .vs-checkbox__inner, 173 | .vs-radio__inner { 174 | display: inline-block; 175 | position: relative; 176 | border: 1px solid #d9d9d9; 177 | border-radius: 2px; 178 | -webkit-box-sizing: border-box; 179 | box-sizing: border-box; 180 | width: 14px; 181 | height: 14px; 182 | background-color: #FFFFFF; 183 | z-index: 1; 184 | -webkit-transition: border-color 0.25s cubic-bezier(0.71, -0.46, 0.29, 1.46), background-color 0.25s cubic-bezier(0.71, -0.46, 0.29, 1.46); 185 | transition: border-color 0.25s cubic-bezier(0.71, -0.46, 0.29, 1.46), background-color 0.25s cubic-bezier(0.71, -0.46, 0.29, 1.46); 186 | } 187 | .is-indeterminate .vs-checkbox__inner::before { 188 | content: ''; 189 | position: absolute; 190 | display: block; 191 | background-color: #1989fa; 192 | height: 12px; 193 | -webkit-transform: scale(0.6); 194 | transform: scale(0.6); 195 | left: 0; 196 | right: 0; 197 | top: 0; 198 | border-radius: 2px; 199 | } 200 | .vs-checkbox__original:checked ~ .vs-checkbox__inner { 201 | background-color: #1989fa; 202 | border-color: #1989fa; 203 | } 204 | .vs-checkbox__original:checked ~ .vs-checkbox__inner::after { 205 | -webkit-transform: rotate(45deg) scaleY(1); 206 | transform: rotate(45deg) scaleY(1); 207 | } 208 | .vs-checkbox__inner::after { 209 | -webkit-box-sizing: content-box; 210 | box-sizing: content-box; 211 | content: ""; 212 | border: 1px solid #FFFFFF; 213 | border-left: 0; 214 | border-top: 0; 215 | height: 7px; 216 | left: 4px; 217 | position: absolute; 218 | top: 1px; 219 | -webkit-transform: rotate(45deg) scaleY(0); 220 | transform: rotate(45deg) scaleY(0); 221 | width: 3px; 222 | -webkit-transition: all 0.2s cubic-bezier(0.12, 0.4, 0.29, 1.46) 0.1s; 223 | transition: all 0.2s cubic-bezier(0.12, 0.4, 0.29, 1.46) 0.1s; 224 | -webkit-transform-origin: center; 225 | transform-origin: center; 226 | } 227 | .vs-checkbox__original:disabled ~ .vs-checkbox__inner { 228 | background-color: #edf2fc; 229 | border-color: #dcdfe6; 230 | cursor: not-allowed; 231 | } 232 | .vs-checkbox__original:checked:disabled ~ .vs-checkbox__inner:after { 233 | border-color: #c0c4cc; 234 | } 235 | .vs-checkbox__original, 236 | .vs-radio__original { 237 | opacity: 0; 238 | outline: none; 239 | position: absolute; 240 | margin: 0; 241 | width: 0; 242 | height: 0; 243 | z-index: -1; 244 | } 245 | .vs-radio__inner { 246 | border-radius: 100%; 247 | } 248 | .vs-radio__inner:after { 249 | -webkit-box-sizing: content-box; 250 | box-sizing: content-box; 251 | content: ""; 252 | left: 3px; 253 | position: absolute; 254 | top: 3px; 255 | width: 6px; 256 | height: 6px; 257 | -webkit-transform: scale(0); 258 | transform: scale(0); 259 | -webkit-transition: -webkit-transform 0.15s ease-in 0.05s; 260 | transition: -webkit-transform 0.15s ease-in 0.05s; 261 | transition: transform 0.15s ease-in 0.05s; 262 | transition: transform 0.15s ease-in 0.05s, -webkit-transform 0.15s ease-in 0.05s; 263 | -webkit-transform-origin: center; 264 | transform-origin: center; 265 | border-radius: 100%; 266 | } 267 | .vs-radio__original:checked ~ .vs-radio__inner { 268 | border-color: #1989fa; 269 | } 270 | .vs-radio__original:checked ~ .vs-radio__inner:after { 271 | background-color: #1989fa; 272 | -webkit-transform: scale(1); 273 | transform: scale(1); 274 | } 275 | .vs-radio__original:checked:disabled ~ .vs-radio__inner:after { 276 | border-color: #c0c4cc; 277 | } 278 | .vs-icon-leaf, 279 | .vs-icon-parent { 280 | width: 14px; 281 | height: 14px; 282 | margin-right: 5px; 283 | display: inline-block; 284 | vertical-align: middle; 285 | background-image: url(./leaf.svg); 286 | background-size: 12px 12px; 287 | background-repeat: no-repeat; 288 | background-position: center; 289 | } 290 | .vs-icon-leaf > img, 291 | .vs-icon-parent > img { 292 | width: 100%; 293 | height: 100%; 294 | } 295 | .vs-icon-parent { 296 | background-image: url(./parent.svg); 297 | } 298 | .vs-transition { 299 | height: 0; 300 | -webkit-transition: all 0.3s ease; 301 | transition: all 0.3s ease; 302 | overflow-y: hidden; 303 | } 304 | .vs-tree-node.vs-drag-enter { 305 | background-color: rgba(25, 137, 250, 0.8); 306 | color: #fff; 307 | } 308 | .vs-tree-node.vs-drag-over-gap-top, 309 | .vs-tree-node.vs-drag-over-gap-bottom { 310 | position: relative; 311 | } 312 | .vs-tree-node.vs-drag-over-gap-top::before, 313 | .vs-tree-node.vs-drag-over-gap-bottom::before { 314 | content: ''; 315 | position: absolute; 316 | left: 0; 317 | width: 100%; 318 | height: 2px; 319 | background-color: #1989fa; 320 | } 321 | .vs-tree-node.vs-drag-over-gap-top::before { 322 | top: 0; 323 | } 324 | .vs-tree-node.vs-drag-over-gap-bottom::before { 325 | bottom: 0; 326 | } 327 | .vs-search-only-leaf .vs-tree-inner { 328 | padding-left: 0!important; 329 | } 330 | .vs-search-only-leaf .vs-tree-inner .expand-empty { 331 | display: none; 332 | } 333 | .vs-theme-element .is-indeterminate .vs-checkbox__inner { 334 | background-color: #1989fa; 335 | border-color: #1989fa; 336 | } 337 | .vs-theme-element .is-indeterminate .vs-checkbox__inner::before { 338 | background-color: #fff; 339 | height: 1px; 340 | width: 50%; 341 | top: 50%; 342 | left: 50%; 343 | -webkit-transform: translate(-50%, -50%) scale(1); 344 | transform: translate(-50%, -50%) scale(1); 345 | } 346 | .vs-breadcrumb { 347 | -webkit-box-sizing: border-box; 348 | box-sizing: border-box; 349 | margin: 0; 350 | padding: 0; 351 | font-variant: tabular-nums; 352 | line-height: 1.5715; 353 | list-style: none; 354 | -webkit-font-feature-settings: "tnum"; 355 | font-feature-settings: "tnum"; 356 | color: rgba(0, 0, 0, 0.45); 357 | font-size: 14px; 358 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; 359 | } 360 | .vs-breadcrumb > span { 361 | display: inline-block; 362 | } 363 | .vs-breadcrumb > span:not(:last-child) { 364 | cursor: pointer; 365 | color: #1989fa; 366 | } 367 | .vs-breadcrumb > span:not(:last-child):hover { 368 | color: rgba(25, 137, 250, 0.8); 369 | } 370 | .vs-breadcrumb > span:last-child { 371 | color: rgba(0, 0, 0, 0.85); 372 | } 373 | .vs-breadcrumb-separator { 374 | margin: 0 8px; 375 | color: rgba(0, 0, 0, 0.45); 376 | } 377 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vs-tree", 3 | "version": "2.1.14", 4 | "license": "MIT", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/yangjingyu/vs-tree" 8 | }, 9 | "homepage": "https://yangjingyu.github.io/vs-tree/", 10 | "bugs": "https://github.com/yangjingyu/vs-tree/issues", 11 | "author": { 12 | "name": "Yangjingyu", 13 | "url": "https://github.com/yangjingyu" 14 | }, 15 | "main": "./dist/vs-tree.js", 16 | "types": "./types/src/main.d.ts", 17 | "module": "./dist/vs-tree.js", 18 | "unpkg": "./dist/vs-tree.js", 19 | "style": "./dist/vs-tree.css", 20 | "scripts": { 21 | "dev": "rollup -c --watch", 22 | "build": "NODE_ENV=production rollup -c", 23 | "lint": "eslint ./ --fix" 24 | }, 25 | "devDependencies": { 26 | "@babel/core": "^7.12.9", 27 | "@babel/preset-env": "^7.12.7", 28 | "@babel/preset-typescript": "^7.12.7", 29 | "@rollup/plugin-babel": "^5.2.2", 30 | "@rollup/plugin-node-resolve": "^11.0.1", 31 | "autoprefixer": "8.0.0", 32 | "cssnano": "^4.1.10", 33 | "eslint-config-standard": "14.1.1", 34 | "eslint-plugin-standard": "^5.0.0", 35 | "less": "^3.12.2", 36 | "rollup": "^2.34.1", 37 | "rollup-plugin-commonjs": "^10.1.0", 38 | "rollup-plugin-eslint": "^7.0.0", 39 | "rollup-plugin-json": "^4.0.0", 40 | "rollup-plugin-postcss": "^4.0.0", 41 | "rollup-plugin-terser": "^7.0.2", 42 | "rollup-plugin-typescript2": "^0.29.0", 43 | "standard": "^16.0.3", 44 | "typescript": "^4.1.3" 45 | }, 46 | "browserslist": [ 47 | "last 2 version", 48 | "> 1%", 49 | "IE 10" 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /public/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangjingyu/vs-tree/7550e78ba2f3b5851e55055e70bbd7317cfe04d4/public/.DS_Store -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0 8 | 9 | 10 | 11 | 12 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /public/static/css/icon1.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/static/css/icon2.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/static/css/index.css: -------------------------------------------------------------------------------- 1 | .menu a { 2 | line-height: 30px; 3 | color: #2a8ae2; 4 | } 5 | 6 | .box { 7 | height: 400px; 8 | display: flex; 9 | overflow: hidden; 10 | justify-content: space-between; 11 | } 12 | 13 | #list { 14 | height: 100%; 15 | padding: 0 20px; 16 | overflow-y: auto; 17 | } 18 | 19 | #search { 20 | width: 400px; 21 | height: 28px; 22 | padding: 0 12px; 23 | margin: 0 0 10px; 24 | font-size: 12px; 25 | box-sizing: border-box; 26 | } 27 | 28 | #popup { 29 | position: fixed; 30 | width: 100%; 31 | height: 100%; 32 | top: 0; 33 | left: 0; 34 | display: none; 35 | background-color: transparent; 36 | z-index: 9999; 37 | } 38 | 39 | .content { 40 | position: absolute; 41 | width: 300px; 42 | height: 500px; 43 | background-color: #333; 44 | } 45 | 46 | p { 47 | font-size: 12px; 48 | color: #333; 49 | } 50 | 51 | .custom-icon { 52 | background-image: url(./icon1.svg); 53 | } 54 | 55 | .tree-demo .vs-tree-node { 56 | justify-content: start; 57 | } 58 | 59 | .custom-expand { 60 | margin-right: 8px; 61 | } 62 | 63 | .custom-expand.expand::after { 64 | content: "+"; 65 | border: 1px solid #bbb; 66 | width: 14px; 67 | height: 14px; 68 | display: inline-block; 69 | box-sizing: border-box; 70 | } 71 | 72 | .custom-expand.expanded::after { 73 | content: "-"; 74 | } -------------------------------------------------------------------------------- /public/static/js/work.js: -------------------------------------------------------------------------------- 1 | let list = [] 2 | const umap = {} 3 | const infomap = {} 4 | const depts = [] 5 | const root = [] 6 | var xhr = new XMLHttpRequest() 7 | xhr.open('GET', typeof window === 'object' ? './static/data.txt' : '../data.txt', true) 8 | xhr.send() 9 | xhr.onload = function (e) { 10 | list = xhr.response.split('\r\n').map(v => v && JSON.parse(v)) 11 | for (let i = 0, len = list.length; i < len; i++) { 12 | const v = list[i] 13 | if (v.obj === 'department_user') { 14 | if (umap[v.data.did]) { 15 | umap[v.data.did].push(v.data) 16 | } else { 17 | umap[v.data.did] = [v.data] 18 | } 19 | } else if (v.obj === 'user') { 20 | infomap[v.data.uid] = v.data 21 | } else if (v.obj === 'department') { 22 | if (v.data.pdid === '-1') { 23 | root.push(v) 24 | } else { 25 | depts.push(v) 26 | } 27 | } 28 | } 29 | console.log(infomap['100002955460']) 30 | postMessage && postMessage({ 31 | id: 1, 32 | list: list, 33 | depts: depts, 34 | root: root, 35 | umap: umap, 36 | infomap: infomap 37 | }) 38 | typeof window !== 'object' && close() 39 | } 40 | -------------------------------------------------------------------------------- /public/static/qq-group.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangjingyu/vs-tree/7550e78ba2f3b5851e55055e70bbd7317cfe04d4/public/static/qq-group.jpg -------------------------------------------------------------------------------- /public/初始数据为数组.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_初始数据为数组 8 | 9 | 10 | 11 | 12 | 13 |

原始数据为数组时,自动为数据包裹一层根节点,如果不设置rootName则showRoot默认设置为false

14 |
15 | 16 | 17 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /public/加载动画.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_加载动画Loading 8 | 9 | 10 | 11 | 12 | 13 |
20万+用时:
14 |
15 | 16 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /public/单选示例.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_单选示例 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 | 18 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /public/基础示例.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_基础示例 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 20 |
21 | 22 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /public/复杂示例.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_复杂示例 8 | 9 | 10 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /public/复选示例.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_复选示例 8 | 9 | 10 | 11 | 12 | 13 |
14 |     checkboxType: {
15 |       Y: '', // p 选中操作只影响父节点
16 |       N: 'p' // s 取消操作只影响子节点
17 |     }
18 |   
19 |
20 | 21 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /public/大数据量.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_大数据量 8 | 9 | 10 | 11 | 12 | 13 |
20万+用时:
14 |
15 | 16 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /public/展开收起图标.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_展示图标 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /public/展示图标.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_展示图标 8 | 9 | 10 | 11 | 12 | 13 |

showIcon: true 显示图标

14 |

onlyShowLeafIcon: true 仅叶子节点显示图标,showIcon:true时有效

15 |
16 | 17 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /public/开启动画.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_基础示例 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /public/异步加载.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_异步加载 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /public/手风琴模式.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_手风琴模式 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /public/拖拽节点.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_基础示例 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /public/显示连接线.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_显示连接线 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /public/最大可选.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_最大可选 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /public/格式化数据.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_格式化数据 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /public/清空选中节点.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_清空选中节点 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 |
18 | 19 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /public/结合vue.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_基础示例 8 | 9 | 10 | 11 | 12 | 13 |

配置项优先级,全局 < props < options

14 |
15 | 30 | 31 | 32 | #[[ node.data.id]]-#[[1+1]] 33 | 34 | 35 |
36 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /public/结合worker+indexDb.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_结合worker+indexDb 8 | 9 | 10 | 11 | 12 | 13 |

此示例在github中速度受限,请下载代码到本地试验!!!,其中用时为忽略下载时间

14 |
用时:
15 | 16 |
17 | 18 | 57 | 58 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /public/自定义搜索节点.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_自定义搜索节点 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /public/自定义节点内容.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_自定义节点内容 8 | 9 | 10 | 17 | 18 | 19 | 20 |
21 | 22 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /public/自定义节点内容2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_自定义节点内容 8 | 9 | 10 | 17 | 18 | 19 | 20 |
21 | 22 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /public/节点过滤.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_节点过滤 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /public/获取选中节点.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_获取选中节点 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 | 18 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /public/面包屑.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_面包屑 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /public/项目实战.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_项目实战 8 | 9 | 10 | 11 | 12 | 13 |

此示例在github中速度受限,请下载代码到本地试验!!!,其中用时为忽略下载时间

14 |
用时:
15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 143 | 144 | 145 | -------------------------------------------------------------------------------- /public/鼠标右键事件.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vs-tree2.0_基础示例 8 | 9 | 10 | 11 | 12 | 13 |
14 | 17 | 18 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import json from 'rollup-plugin-json' 2 | import commonjs from 'rollup-plugin-commonjs' 3 | import { terser } from 'rollup-plugin-terser' 4 | import babel from '@rollup/plugin-babel' 5 | import postcss from 'rollup-plugin-postcss' 6 | import autoprefixer from 'autoprefixer' 7 | import cssnano from 'cssnano' 8 | import { eslint } from 'rollup-plugin-eslint' 9 | import ts from 'rollup-plugin-typescript2' 10 | import resolve from '@rollup/plugin-node-resolve' 11 | 12 | const isDev = process.env.NODE_ENV !== 'production' 13 | 14 | const postcssPlugin = [ 15 | autoprefixer 16 | ] 17 | 18 | const extensions = [ 19 | '.js', 20 | '.ts', 21 | '.tsx' 22 | ] 23 | 24 | const tsPlugin = ts({ 25 | tsconfig: './tsconfig.json', 26 | useTsconfigDeclarationDir: true, 27 | extensions 28 | }) 29 | 30 | if (!isDev) { 31 | postcssPlugin.push(cssnano) 32 | } 33 | 34 | export default { 35 | input: 'src/main.js', 36 | output: [ 37 | { 38 | file: 'dist/vs-tree.esm.browser.js', 39 | format: 'es' 40 | }, 41 | { 42 | file: 'dist/vs-tree.js', 43 | format: 'umd', 44 | name: 'vsTree', 45 | exports: 'named' 46 | } 47 | ], 48 | plugins: [ 49 | resolve({ 50 | extensions, 51 | modulesOnly: true 52 | }), 53 | json(), 54 | tsPlugin, 55 | commonjs(), 56 | eslint({ 57 | // fix: true, 58 | include: ['./src/**/**.js'], 59 | exclude: ['node_modules/**', 'src/less/**'] 60 | }), 61 | babel({ 62 | babelHelpers: 'bundled', 63 | extensions: extensions 64 | }), 65 | postcss({ 66 | plugins: postcssPlugin, 67 | extract: 'vs-tree.css' 68 | }), 69 | !isDev && terser() 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /src/breadcrumb/breadcrumb-item.js: -------------------------------------------------------------------------------- 1 | export default class BreadcrumbItem { 2 | constructor (node, parent) { 3 | this.node = node 4 | this.data = node.data 5 | this.store = node.store 6 | this.parent = parent 7 | 8 | const { icon, link, separator = '/' } = this.parent.options 9 | this.renderIcon = icon 10 | this.renderLink = link 11 | this.renderSeparator = separator 12 | } 13 | 14 | createDom () { 15 | const breads = this.parent.list 16 | const index = breads.findIndex(v => v === this.node) 17 | const last = index === breads.length - 1 18 | const dom = document.createElement('span') 19 | 20 | if (this.renderIcon) { 21 | const icon = this.createIcon() 22 | icon && dom.appendChild(icon) 23 | } 24 | 25 | dom.appendChild(this.createLink(breads, index, last)) 26 | 27 | if (!last) { 28 | dom.appendChild(this.createSeparator()) 29 | } 30 | 31 | return dom 32 | } 33 | 34 | createIcon () { 35 | let _iconInner 36 | if (typeof this.renderIcon === 'function') { 37 | _iconInner = this.renderIcon(this.node, this.data) 38 | } else { 39 | _iconInner = this.renderIcon 40 | } 41 | if (!_iconInner) return false 42 | 43 | const icon = document.createElement('span') 44 | icon.className = 'vs-breadcrumb-icon' 45 | if (typeof this.renderIcon === 'function') { 46 | if (_iconInner instanceof HTMLElement) { 47 | icon.appendChild(_iconInner) 48 | } else { 49 | icon.innerHTML = _iconInner 50 | } 51 | } else { 52 | icon.innerHTML = this.renderIcon 53 | } 54 | return icon 55 | } 56 | 57 | createLink (breads, index, last) { 58 | const link = document.createElement('span') 59 | link.className = 'vs-breadcrumb-link' 60 | 61 | if (typeof this.renderLink === 'function') { 62 | const _linkR = this.renderLink(this.node, this.data) 63 | if (_linkR instanceof HTMLElement) { 64 | link.appendChild(_linkR) 65 | } else { 66 | link.innerHTML = _linkR 67 | } 68 | } else { 69 | link.innerHTML = this.data.name 70 | } 71 | 72 | link.addEventListener('click', (e) => { 73 | e.preventDefault() 74 | e.stopPropagation() 75 | if (last) return 76 | breads.splice(index + 1) 77 | this.store.update() 78 | }) 79 | return link 80 | } 81 | 82 | createSeparator () { 83 | const separator = document.createElement('span') 84 | separator.className = 'vs-breadcrumb-separator' 85 | if (typeof this.renderSeparator === 'function') { 86 | separator.innerHTML = this.renderSeparator(this.node, this.data) 87 | } else { 88 | separator.innerHTML = this.renderSeparator 89 | } 90 | return separator 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/breadcrumb/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import BreadcrumbItem from "./breadcrumb-item" 3 | 4 | export interface BreadcrumbOptions { 5 | el: string | HTMLElement, 6 | icon?: Function | string, 7 | link?: Function | string, 8 | separator?: Function | string, 9 | change?: Function 10 | } 11 | 12 | export default class Breadcrumb { 13 | store: any 14 | list = [] 15 | options: BreadcrumbOptions 16 | constructor(options: BreadcrumbOptions) { 17 | this.options = options 18 | } 19 | 20 | get current(): any { 21 | return this.list[this.list.length - 1] 22 | } 23 | 24 | renderBreadcrumb() { 25 | this.store = this.current.store 26 | const { el, change = () => { } } = this.options 27 | let _el: any 28 | if (el instanceof HTMLElement) { 29 | _el = el 30 | } else if (el && typeof el === 'string') { 31 | _el = document.querySelector(el) 32 | } 33 | if (!_el) { 34 | _el = document.createElement('section') 35 | } 36 | _el.classList.add('vs-breadcrumb') 37 | 38 | const bs = this.list.map((node: any) => { 39 | return new BreadcrumbItem(node, this).createDom() 40 | }) 41 | 42 | _el.innerHTML = '' 43 | bs.forEach((html: HTMLElement) => { 44 | _el.appendChild(html) 45 | }) 46 | change(_el, this.list, this.current) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/core/index.js: -------------------------------------------------------------------------------- 1 | import TreeStore from './store' 2 | import Vlist from '../virtual-list' 3 | import Breadcrumb from '../breadcrumb' 4 | 5 | const noop = () => { } 6 | export default class Tree { 7 | constructor(selector, ops) { 8 | if (typeof selector === 'string') { 9 | this.$el = document.querySelector(selector) 10 | } else { 11 | this.$el = selector 12 | } 13 | 14 | if (!(this.$el instanceof HTMLElement)) { 15 | throw Error('请为组件提供根节点') 16 | } 17 | 18 | this.$el.classList.add('vs-tree') 19 | 20 | const delimiters = ['#\\[\\[', '\\]\\]'] 21 | 22 | const [open, close] = delimiters 23 | var interpolate = open + '([\\s\\S]+?)' + close 24 | this.interpolate = new RegExp(interpolate, 'igm') 25 | const slotsMap = {} 26 | const slots = this.$el.querySelectorAll('[tree-slot]') 27 | if (slots && slots.length) { 28 | slots.forEach(v => { 29 | const name = v.attributes['tree-slot'].value 30 | const scope = v.attributes['tree-slot-scope'].value 31 | 32 | slotsMap[name] = { 33 | scope, 34 | node: v, 35 | interpolate: this.interpolate, 36 | text: v.innerText, 37 | inner: v.outerHTML 38 | } 39 | 40 | v.parentNode.removeChild(v) 41 | }) 42 | } 43 | 44 | // 默认清空根节点 45 | // this.$el.innerHTML = '' 46 | 47 | if (ops.theme) { 48 | this.$el.classList.add('vs-theme-' + ops.theme) 49 | } 50 | 51 | if (Array.isArray(ops.data)) { 52 | this._data = { 53 | _vsroot: true, 54 | name: ops.rootName || '---', 55 | children: ops.data 56 | } 57 | if (!ops.rootName) { 58 | ops.hideRoot = true 59 | } 60 | } else if (typeof ops.data === 'object') { 61 | this._data = ops.data 62 | } else { 63 | throw Error('参数data仅支持对象或数组!') 64 | } 65 | 66 | this.nodes = [] 67 | const { showCount = 20, itemHeight = 26, maxHeight = '400px', minHeight = '0px' } = ops.virtual || {} 68 | // 每一项的高度 69 | this.itemHeight = itemHeight 70 | // 当前可见数量 71 | this.showCount = showCount 72 | // 最大高度 73 | this.maxHeight = ops.maxHeight || maxHeight 74 | // 最小高度 75 | this.minHeight = ops.minHeight || minHeight 76 | // 当前可见列表 77 | this.data = [] 78 | // 关键字过滤 79 | this.keyword = '' 80 | this.searchFilter = ops.searchFilter 81 | this.ready = ops.ready || noop 82 | 83 | if (Object.prototype.toString.call(ops.breadcrumb) === '[object Object]') { 84 | this.$$breadcrumb = new Breadcrumb(ops.breadcrumb) 85 | } 86 | 87 | const start = () => { 88 | this.store = new TreeStore({ 89 | data: this._data, 90 | max: ops.max, 91 | slots: slotsMap, 92 | breadcrumb: this.$$breadcrumb || null, 93 | strictLeaf: ops.strictLeaf || false, 94 | showCount: this.showCount, 95 | itemHeight: this.itemHeight, 96 | hideRoot: ops.hideRoot || false, 97 | animation: ops.animation || false, // 动画 98 | expandLevel: typeof ops.expandLevel === 'number' ? ops.expandLevel : 1, // 默认展开1级节点 99 | beforeCheck: ops.beforeCheck || null, 100 | showLine: ops.showLine || false, // 是否显示连接线 101 | showIcon: ops.showIcon || false, 102 | onlyShowLeafIcon: ops.onlyShowLeafIcon || false, 103 | showCheckbox: ops.showCheckbox || false, 104 | checkboxType: ops.checkboxType || { Y: 'ps', N: 'ps' }, 105 | checkInherit: ops.checkInherit || false, // 新加入节点时自动继承父节点选中状态 106 | disabledInherit: ops.disabledInherit || false, // 新加入节点时自动继承父节点禁用状态 107 | showRadio: ops.showRadio || false, 108 | highlightCurrent: ops.highlightCurrent || false, 109 | checkFilterLeaf: ops.checkFilterLeaf || false, // 过滤非叶子节点 110 | checkFilter: ops.checkFilter || null, // 过滤选中节点 111 | accordion: ops.accordion || false, // 手风琴模式 112 | draggable: ops.draggable || false, 113 | dropable: ops.dropable || false, 114 | lazy: ops.lazy || false, 115 | sort: ops.sort || false, 116 | indent: ops.indent || 10, 117 | checkedKeys: ops.checkedKeys || [], 118 | expandKeys: ops.expandKeys || [], 119 | disabledKeys: ops.disabledKeys || [], 120 | limitAlert: ops.limitAlert || noop, 121 | click: ops.click || noop, 122 | check: ops.check || noop, // 复选框被点击时出发 123 | change: ops.change || noop, 124 | load: ops.load || noop, 125 | contextmenu: ops.contextmenu || null, 126 | radioParentoOnly: ops.radioType === 'level' ? 'level' : 'all', // 每个父节点下唯一,仅raido模式有效 127 | renderContent: ops.renderContent || null, 128 | nocheckParent: ops.nocheckParent || false, // 只允许叶子节点选中 129 | checkOnClickNode: ops.checkOnClickNode || false, 130 | format: ops.format || null, 131 | searchRender: ops.searchRender || null, 132 | searchDisabledChecked: ops.searchDisabledChecked || false, 133 | expandClass: ops.expandClass || 'vs-expand-icon', 134 | onDragstart: ops.onDragstart || noop, 135 | onDragenter: ops.onDragenter || noop, 136 | onDrop: ops.onDrop || noop, 137 | update: () => { 138 | this._render() 139 | }, 140 | nodesChange: (nodes) => { 141 | this.nodes = nodes 142 | this.vlist && this._render() 143 | } 144 | }) 145 | 146 | if (this.store.hideRoot) { 147 | // 根节点创建dom 148 | this.store.root.createNode() 149 | } 150 | 151 | this._init() 152 | 153 | // 设置默认选中 154 | this.store.setDefaultChecked() 155 | } 156 | 157 | if (ops.async) { 158 | setTimeout(() => { 159 | start() 160 | }, 0) 161 | } else { 162 | start() 163 | } 164 | } 165 | 166 | _init() { 167 | this.vlist = new Vlist({ 168 | root: this.$el, 169 | data: [], 170 | maxHeight: this.maxHeight, 171 | minHeight: this.minHeight, 172 | estimateSize: this.itemHeight, 173 | keeps: this.showCount 174 | }) 175 | this._render() 176 | this.ready && this.ready(this) 177 | } 178 | 179 | _render(update = true) { 180 | if (this.$$breadcrumb) { 181 | const { current } = this.$$breadcrumb 182 | this.data = this.nodes.filter(v => v.parent && v.parent.id === current.id) 183 | // 当前仅过滤面包屑当前层级 184 | this._keywordFilter(this.data) 185 | this.$$breadcrumb.renderBreadcrumb() 186 | } else { 187 | this._keywordFilter(this.nodes) 188 | } 189 | update && this.vlist.update(this.data) 190 | } 191 | 192 | _keywordFilter(data) { 193 | this.data = data.filter(v => { 194 | // 过滤隐藏节点 | 隐藏root节点 195 | return this._hasKeyword(v) && v.visbile && !(this.store.hideRoot && v.level === 0) 196 | }) 197 | } 198 | 199 | _hasKeyword(v) { 200 | if (!this.keyword) return true 201 | let boo = this._checkFilter(v) 202 | if (!boo) { 203 | v.childNodes.forEach(node => { 204 | if (!boo) { 205 | boo = this._hasKeyword(node) 206 | } 207 | }) 208 | } else { 209 | v.parent && (v.parent.requireExpand = true) 210 | } 211 | return boo 212 | } 213 | 214 | _checkFilter(v) { 215 | if (!this.keyword) return 216 | if (typeof this.searchFilter === 'function') { 217 | return this.searchFilter(this.keyword, v, v.data) 218 | } 219 | return v.data.name && v.data.name.includes(this.keyword) 220 | } 221 | 222 | // 过滤节点 223 | filter(keyword = '', onlySearchLeaf) { 224 | this.keyword = keyword 225 | this.store.onlySearchLeaf = onlySearchLeaf && !!keyword 226 | this.store.isSearch = !!keyword 227 | if (this.store.onlySearchLeaf) { 228 | const data = this.nodes.filter(v => !v.childNodes.length && this._checkFilter(v) && !(this.store.hideRoot && v.level === 0)) 229 | this.vlist.update(data) 230 | return data 231 | } 232 | 233 | this._render(false) 234 | for (let i = 0, len = this.data.length; i < len; i++) { 235 | const v = this.data[i] 236 | if (v.requireExpand) { 237 | v.requireExpand = false 238 | v.setExpand(true, true) 239 | } 240 | } 241 | this._render() 242 | return this.data 243 | } 244 | 245 | // 根据ID获取节点 246 | getNodeById(id) { 247 | return this.store.getNodeById(id) 248 | } 249 | 250 | // 获取选中节点 251 | getCheckedNodes() { 252 | return this.store.getCheckedNodes(...arguments) 253 | } 254 | 255 | // 设置最大可选 256 | setMaxValue(value = 0) { 257 | this.store.max = value 258 | } 259 | 260 | // 滚动到索引位置 261 | scrollToIndex(index = 0) { 262 | this.vlist.scrollToIndex(index) 263 | } 264 | 265 | // 清空选中元素 266 | clearCheckedNodes() { 267 | const nodes = this.getCheckedNodes(true) 268 | nodes.forEach(node => { 269 | node.setChecked(false) 270 | }) 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /src/core/node.js: -------------------------------------------------------------------------------- 1 | 2 | import { insterAfter, onDragEnterGap, parseTemplate } from './utils.ts' 3 | 4 | let setepId = 0 5 | 6 | export default class Node { 7 | constructor (ops) { 8 | this.id = setepId++ 9 | this.checked = false 10 | this.expanded = false 11 | this.indeterminate = false 12 | this.visbile = false 13 | this.disabled = false 14 | this.loaded = false 15 | this.isLeaf = false 16 | 17 | this.level = 0 18 | this.childNodes = [] 19 | 20 | this.store = ops.store 21 | 22 | this.parent = ops.parent 23 | 24 | this.originData = ops.data 25 | 26 | this.__buffer = {} 27 | 28 | this.data = Object.assign({}, ops.data) 29 | if (typeof this.store.format === 'function' && !ops.data._vsroot) { 30 | const _data = this.store.format(Object.assign({}, ops.data), this) 31 | if (typeof _data !== 'object') { 32 | throw new Error('format must return object! \nformat: function(data) {\n return {id, name, children, isLeaf}\n}') 33 | } 34 | const props = ['id', 'name', 'children', 'isLeaf', 'icon', 'extra'] 35 | for (let i = 0, len = props.length; i < len; i++) { 36 | if (Object.prototype.hasOwnProperty.call(_data, props[i])) { 37 | this.data[props[i]] = _data[props[i]] 38 | } 39 | } 40 | } 41 | 42 | if (this.store.checkInherit && this.parent) { 43 | this.checked = this.parent.checked 44 | } 45 | 46 | if (this.store.disabledInherit && this.parent) { 47 | this.disabled = this.parent.disabled 48 | } 49 | 50 | if (this.store.expandKeys.includes(this.data.id)) { 51 | this.expanded = true 52 | } 53 | 54 | if (this.store.disabledKeys.includes(this.data.id)) { 55 | this.disabled = true 56 | } 57 | 58 | if (this.parent) { 59 | this.level = this.parent.level + 1 60 | } 61 | 62 | if (this.data) { 63 | this.setData(this.data) 64 | } 65 | 66 | this.initData() 67 | } 68 | 69 | initData () { 70 | if (this.level > this.store.expandLevel && this.store.expandLevel !== -1 && !(this.parent?.expanded)) { 71 | this.visbile = false 72 | return 73 | } 74 | this.visbile = true 75 | } 76 | 77 | createNode () { 78 | if (this.dom) { 79 | this.checkboxNode && (this.checkboxNode.checked = this.checked) 80 | this.radioNode && (this.radioNode.checked = this.checked) 81 | if (this.indeterminate) this.dom.classList.add('is-indeterminate') 82 | return this.dom 83 | } 84 | 85 | const dom = document.createElement('div') 86 | dom.className = 'vs-tree-node' 87 | dom.setAttribute('vs-index', this.id) 88 | if (this.indeterminate) dom.classList.add('is-indeterminate') 89 | 90 | !this.isLeaf && this.childNodes.length && dom.setAttribute('vs-child', true) 91 | 92 | dom.appendChild(this.createInner()) 93 | 94 | const slotAppend = parseTemplate('append', this) 95 | if (slotAppend) { 96 | dom.appendChild(slotAppend) 97 | } else if (this.store.renderContent) { 98 | dom.appendChild(this.createContent()) 99 | } 100 | 101 | dom.addEventListener('click', (e) => { 102 | e.stopPropagation() 103 | if (this.store.highlightCurrent) { 104 | if (this.store.selectedCurrent) { 105 | this.store.selectedCurrent.dom.classList.remove('selected') 106 | } 107 | dom.classList.add('selected') 108 | } 109 | 110 | if (this.store.checkOnClickNode && !this.disabled && !(this.store.breadcrumb && !this.isLeaf)) { 111 | this.handleCheckChange({ 112 | target: { checked: !this.checked } 113 | }) 114 | } 115 | 116 | this.store.selectedCurrent = this 117 | 118 | if (this.store.breadcrumb && !this.isLeaf) { 119 | this.store.breadcrumb.list.push(this) 120 | this.setExpand(true) 121 | } 122 | 123 | this.store.click(e, this) 124 | }, { 125 | passive: false 126 | }) 127 | 128 | dom.addEventListener('contextmenu', (e) => { 129 | if (this.store.contextmenu && typeof this.store.contextmenu === 'function') { 130 | e.stopPropagation() 131 | e.preventDefault() 132 | this.store.contextmenu(e, this) 133 | } 134 | }) 135 | if (this.store.draggable) { 136 | this.createDragable(dom) 137 | } 138 | 139 | this.dom = dom 140 | return dom 141 | } 142 | 143 | createInner () { 144 | const dom = document.createElement('div') 145 | dom.className = 'vs-tree-inner' 146 | // 当隐藏根节点时减少一级缩进 147 | let level = this.level + (this.store.hideRoot ? -1 : 0) 148 | 149 | if (this.store.breadcrumb) { 150 | level = 0 151 | } 152 | 153 | if (this.store.showLine) { 154 | for (let i = 0; i < level; i++) { 155 | const indent = document.createElement('span') 156 | indent.className = 'vs-indent-unit' 157 | dom.appendChild(indent) 158 | } 159 | } else { 160 | dom.style.paddingLeft = level * this.store.indent + 'px' 161 | } 162 | 163 | let expandDom 164 | if (!this.store.breadcrumb) { 165 | if (this.store.strictLeaf) { 166 | expandDom = !this.isLeaf ? this.createExpand() : this.createExpandEmpty() 167 | } else { 168 | expandDom = (this.childNodes?.length || this.store.lazy) && !this.isLeaf ? this.createExpand() : this.createExpandEmpty() 169 | } 170 | dom.appendChild(expandDom) 171 | } else { 172 | this.loadingEl = document.createElement('span') 173 | this.loadingEl.className = 'vs-loading-unit' 174 | dom.appendChild(this.loadingEl) 175 | } 176 | 177 | if (this.store.showCheckbox || this.store.showRadio) { 178 | if ((!this.store.nocheckParent) || (this.isLeaf && !this.childNodes.length)) { 179 | dom.appendChild(this.createCheckbox()) 180 | } 181 | } 182 | 183 | if (this.store.showIcon) { 184 | if (!this.store.onlyShowLeafIcon || (!this.childNodes.length || this.isLeaf)) { 185 | dom.appendChild(this.createIcon()) 186 | } 187 | } 188 | 189 | dom.appendChild(this.createText()) 190 | return dom 191 | } 192 | 193 | // 自定义Dom 节点 194 | cusmtomNode (name, info) { 195 | const box = document.createElement(name) 196 | info.text && (box.innerText = info.text) 197 | info.className && (box.className = info.className) 198 | if (info.children) { 199 | info.children.forEach(v => { 200 | box.appendChild(v) 201 | }) 202 | } 203 | if (typeof info.click === 'function') { 204 | box.addEventListener('click', (e) => { 205 | e.stopPropagation() 206 | info.click(e, this) 207 | }, { passive: false }) 208 | } 209 | return box 210 | } 211 | 212 | // 自定义内容 213 | createContent () { 214 | const tpl = this.store.renderContent(this.cusmtomNode.bind(this), this) 215 | if (!tpl) { 216 | return document.createElement('span') 217 | } 218 | tpl.addEventListener('click', (e) => { 219 | e.stopPropagation() 220 | }, { passive: false }) 221 | return tpl 222 | } 223 | 224 | // 叶子节点-无需展开 225 | createExpandEmpty () { 226 | const dom = document.createElement('span') 227 | dom.className = 'expand-empty ' + this.store.expandClass 228 | return dom 229 | } 230 | 231 | // 有子元素-需要展开 232 | createExpand () { 233 | const dom = document.createElement('span') 234 | dom.className = 'expand ' + this.store.expandClass 235 | 236 | if (this.level < this.store.expandLevel || this.store.expandLevel === -1 || this.expanded) { 237 | dom.classList.add('expanded') 238 | this.expanded = true 239 | } 240 | 241 | dom.addEventListener('click', (e) => { 242 | e.stopPropagation() 243 | if (this.loading) return 244 | const expand = !dom.classList.contains('expanded') 245 | // dom.classList.toggle('expanded') 246 | this.setExpand(expand) 247 | }, { 248 | passive: false 249 | }) 250 | this.expandEl = dom 251 | return dom 252 | } 253 | 254 | createCheckbox () { 255 | let label = 'checkbox' 256 | if (this.store.showRadio) { 257 | label = 'radio' 258 | } 259 | const dom = document.createElement('label') 260 | dom.className = `vs-${label}` 261 | const inner = document.createElement('span') 262 | inner.className = `vs-${label}__inner` 263 | const checkbox = document.createElement('input') 264 | checkbox.type = label 265 | checkbox.checked = this.checked 266 | checkbox.disabled = this.disabled 267 | checkbox.className = `vs-${label}__original` 268 | checkbox.name = label === 'radio' ? 'vs-radio' + (this.store.radioParentoOnly && this.parent ? this.parent.id : '') : 'vs-checkbox' 269 | 270 | if (label === 'radio') { 271 | if (this.store.radioParentoOnly === 'level') { 272 | checkbox.name = 'vs-radio' + (this.store.radioParentoOnly && this.parent ? this.parent.id : '') 273 | } else { 274 | checkbox.name = 'vs-radio' 275 | } 276 | this.radioNode = checkbox 277 | } else { 278 | checkbox.name = 'vs-checkbox' 279 | this.checkboxNode = checkbox 280 | } 281 | 282 | dom.appendChild(checkbox) 283 | dom.appendChild(inner) 284 | 285 | // label 点击会出发两次 286 | dom.addEventListener('click', (e) => { 287 | e.stopPropagation() 288 | }, { passive: false }) 289 | 290 | // 点击回调 291 | checkbox.addEventListener('click', (e) => { 292 | console.log('==========') 293 | this.store.check(e, this) 294 | }, { passive: false }) 295 | 296 | checkbox.addEventListener('change', (e) => { 297 | e.stopPropagation() 298 | console.log('handleCheckChange', e) 299 | this.handleCheckChange(e) 300 | }) 301 | 302 | this.checkboxEl = checkbox 303 | 304 | return dom 305 | } 306 | 307 | handleCheckChange (e) { 308 | const checked = e.target.checked 309 | 310 | if (typeof this.store.beforeCheck === 'function') { 311 | if (!this.store.beforeCheck(this)) { 312 | e.target.checked = !checked 313 | return 314 | } 315 | } 316 | 317 | if (checked && this.store.checkMaxNodes(this)) { 318 | this.store.limitAlert() 319 | e.target.checked = false 320 | return 321 | } 322 | 323 | if (this.store.showRadio) { 324 | this.updateRadioChecked(checked) 325 | } else { 326 | this.updateChecked(checked) 327 | this.updateCheckedParent(checked) 328 | } 329 | this.store._change(this) 330 | } 331 | 332 | createText () { 333 | const slot = parseTemplate('name', this) 334 | if (slot) { 335 | return slot 336 | } 337 | 338 | const dom = document.createElement('span') 339 | dom.innerText = this.data.name 340 | dom.className = 'vs-tree-text' 341 | return dom 342 | } 343 | 344 | createIcon () { 345 | const icon = document.createElement('span') 346 | icon.className = (this.isLeaf && !this.childNodes.length) ? 'vs-icon-leaf' : 'vs-icon-parent' 347 | if (this.data.icon) { 348 | if (this.data.icon instanceof HTMLElement) { 349 | icon.style.backgroundImage = 'none' 350 | icon.appendChild(this.data.icon) 351 | } else { 352 | icon.classList.add(this.data.icon) 353 | } 354 | } 355 | return icon 356 | } 357 | 358 | setData (data) { 359 | this.store.dataMap.set(data.id, this) 360 | this.store.nodeMap.set(this.id, this) 361 | this.data = data 362 | this.childNodes = [] 363 | 364 | if (typeof data.isLeaf === 'boolean') { 365 | this.isLeaf = data.isLeaf 366 | } else if (!data.children && !this.store.lazy) { 367 | this.isLeaf = true 368 | } 369 | 370 | let children 371 | if (this.level === 0 && this.data instanceof Node) { 372 | children = this.data 373 | } else { 374 | children = this.data.children || [] 375 | } 376 | 377 | if (children.length) { 378 | this.loaded = true 379 | } 380 | 381 | for (let i = 0, j = children.length; i < j; i++) { 382 | this.insertChild({ data: children[i] }) 383 | } 384 | } 385 | 386 | insertChild (child, index) { 387 | if (!(child instanceof Node)) { 388 | Object.assign(child, { 389 | parent: this, 390 | store: this.store 391 | }) 392 | child = new Node(child) 393 | } 394 | 395 | child.level = this.level + 1 396 | 397 | if (typeof index === 'undefined' || index < 0) { 398 | this.childNodes.push(child) 399 | } else { 400 | this.childNodes.splice(index, 0, child) 401 | } 402 | return child 403 | } 404 | 405 | insertBefore (child, ref) { 406 | let index 407 | if (ref) { 408 | index = this.childNodes.indexOf(ref) 409 | } 410 | this.insertChild(child, index) 411 | } 412 | 413 | insertAfter (child, ref) { 414 | let index 415 | if (ref) { 416 | index = this.childNodes.indexOf(ref) 417 | if (index !== -1) index += 1 418 | } 419 | this.insertChild(child, index) 420 | } 421 | 422 | // 设置展开状态 423 | updateExpand (expand) { 424 | if (this.childNodes.length) { 425 | this.childNodes.forEach(v => { 426 | if (expand && this.expanded) { 427 | v.visbile = true 428 | } else { 429 | v.visbile = false 430 | } 431 | v.updateExpand(expand) 432 | }) 433 | } 434 | } 435 | 436 | // 更新本身及子节点状态 437 | updateChecked (check, isInitDefault) { 438 | if ((!isInitDefault && this.disabled)) return 439 | if (!this.store.showCheckbox) return 440 | // if (this.disabled) return 441 | this.checked = check 442 | this.sortId = Date.now() 443 | this.checkboxNode && (this.checkboxNode.checked = check) 444 | this.dom && this.dom.classList.remove('is-indeterminate') 445 | 446 | // 验证关联关系 447 | if (this.store.allowEmit(check, 'p')) { 448 | this.parent && (this.parent.indeterminate = false) 449 | } 450 | 451 | if (!this.store.allowEmit(check, 's')) { 452 | return 453 | } 454 | 455 | if (this.childNodes.length) { 456 | this.childNodes.forEach(v => { 457 | v.updateChecked(check) 458 | }) 459 | } 460 | } 461 | 462 | // 更新父节点状态 463 | updateCheckedParent (_checked, isInitDefault) { 464 | if ((!isInitDefault && this.disabled)) return 465 | if (!this.store.showCheckbox) return 466 | if (!this.store.allowEmit(_checked, 'p')) { 467 | return 468 | } 469 | 470 | if (!this.parent || this.store.nocheckParent) return 471 | const allChecked = this.parent.childNodes.every(v => v.checked) 472 | const someChecked = this.parent.childNodes.some(v => v.checked || v.indeterminate) 473 | if (allChecked) { 474 | this.parent.checked = true 475 | this.parent.indeterminate = false 476 | this.parent.checkboxNode && (this.parent.checkboxNode.checked = true) 477 | this.parent.dom && this.parent.dom.classList.remove('is-indeterminate') 478 | } else if (someChecked) { 479 | this.parent.checked = false 480 | this.parent.indeterminate = true 481 | this.parent.checkboxNode && (this.parent.checkboxNode.checked = false) 482 | this.parent.dom && this.parent.dom.classList.add('is-indeterminate') 483 | } else { 484 | this.parent.checked = false 485 | this.parent.indeterminate = false 486 | this.parent.checkboxNode && (this.parent.checkboxNode.checked = false) 487 | this.parent.dom && this.parent.dom.classList.remove('is-indeterminate') 488 | } 489 | 490 | this.parent.updateCheckedParent() 491 | } 492 | 493 | // 更新单选节点选中 494 | updateRadioChecked (checked, isInitDefault) { 495 | if ((!isInitDefault && this.disabled)) return 496 | 497 | if (this.store.nocheckParent && (this.childNodes.length || !this.isLeaf)) return 498 | // 父节点下唯一 499 | if (this.store.radioParentoOnly === 'level') { 500 | if (this.store.radioMap[this.parent.id]) { 501 | this.store.radioMap[this.parent.id].checked = false 502 | } 503 | this.store.radioMap[this.parent.id] = this 504 | } else { 505 | if (this.store.radioNode) { 506 | this.store.radioNode.checked = false 507 | this.store.radioNode = false 508 | } 509 | this.store.radioNode = this 510 | } 511 | 512 | this.checked = checked 513 | this.radioNode && (this.radioNode.checked = checked) 514 | } 515 | 516 | // 设置是否选中 517 | setChecked (checked, isInitDefault) { 518 | if (checked && this.store.checkMaxNodes(this)) { 519 | this.store.limitAlert() 520 | return 521 | } 522 | 523 | if (this.store.showRadio) { 524 | this.updateRadioChecked(checked, isInitDefault) 525 | return 526 | } 527 | if (!this.store.showCheckbox) return 528 | 529 | this.updateChecked(checked, isInitDefault) 530 | this.updateCheckedParent(checked, isInitDefault) 531 | 532 | this.store._change(this) 533 | } 534 | 535 | // 设置禁止选中 536 | setDisabled (disabled = true) { 537 | this.disabled = disabled 538 | this.checkboxEl && (this.checkboxEl.disabled = disabled) 539 | } 540 | 541 | // 设置默认展开 542 | setExpand (expand, noUpdate) { 543 | this.expanded = expand 544 | this.updateExpand(this.expanded) 545 | this.setAccordion(expand) 546 | 547 | if (this.expandEl) { 548 | if (expand) { 549 | this.expandEl.classList.add('expanded') 550 | } else { 551 | this.expandEl.classList.remove('expanded') 552 | } 553 | } 554 | 555 | if (this.store.lazy && !this.loaded) { 556 | this.loadData((data) => { 557 | if (data) { 558 | !noUpdate && this.storeUpdate() 559 | } 560 | }) 561 | } else { 562 | !noUpdate && this.storeUpdate() 563 | } 564 | } 565 | 566 | storeUpdate () { 567 | if (this.store.animation) { 568 | this.createAnimation() 569 | } else { 570 | this.store.update() 571 | } 572 | } 573 | 574 | // 创建动画 575 | createAnimation () { 576 | this.transitionNode && this.transitionNode.parentNode && this.transitionNode.parentNode.removeChild(this.transitionNode) 577 | const tg = document.createElement('div') 578 | tg.className = 'vs-transition' 579 | 580 | if (this.childNodes.length > this.store.showCount) { 581 | for (let i = 0; i < this.store.showCount - 1; i++) { 582 | const _v = this.childNodes[i] 583 | tg.appendChild(_v.dom || _v.createNode()) 584 | } 585 | } else { 586 | this.childNodes.forEach((_v) => { 587 | tg.appendChild(_v.dom || _v.createNode()) 588 | }) 589 | } 590 | 591 | insterAfter(tg, this.dom) 592 | 593 | const animatHeight = ((this.childNodes.length > this.store.showCount ? this.store.showCount : this.childNodes.length) * this.store.itemHeight) + 'px' 594 | if (this.expanded) { 595 | setTimeout(() => { 596 | tg.style.height = animatHeight 597 | }, 0) 598 | } else { 599 | tg.style.height = animatHeight 600 | setTimeout(() => { 601 | tg.style.height = 0 602 | }, 0) 603 | } 604 | 605 | const transend = () => { 606 | tg.removeEventListener('transitionend', transend) 607 | tg.parentNode && tg.parentNode.removeChild(tg) 608 | tg.removeEventListener('transitionend', transend) 609 | this.store.update() 610 | } 611 | 612 | tg.addEventListener('transitionend', transend) 613 | 614 | this.transitionNode = tg 615 | } 616 | 617 | // 创建拖拽 618 | createDragable (dom) { 619 | dom.draggable = true 620 | 621 | dom.addEventListener('dragstart', (e) => { 622 | e.stopPropagation() 623 | this.store.dragNode = this 624 | this.store.onDragstart(e, this) 625 | // wrap in try catch to address IE's error when first param is 'text/plain' 626 | try { 627 | // setData is required for draggable to work in FireFox 628 | // the content has to be '' so dragging a node out of the tree won't open a new tab in FireFox 629 | e.dataTransfer.setData('text/plain', '') 630 | } catch (e) { } 631 | }) 632 | 633 | // Chorme下,拖拽必须禁止默认事件否则drop事件不会触发 634 | dom.addEventListener('dragover', (e) => { 635 | e.preventDefault() 636 | }) 637 | 638 | dom.addEventListener('dragenter', (e) => { 639 | e.stopPropagation() 640 | e.preventDefault() 641 | 642 | removeClass(this.store.dropNode) 643 | 644 | const dropNode = this.dom 645 | if (!dropNode) return 646 | 647 | const enterGap = onDragEnterGap(e, dropNode) 648 | if (this.store.dragNode.dom === dropNode && enterGap === 0) return 649 | 650 | this.store.dropPostion = enterGap 651 | 652 | this.store.dropNode = dropNode 653 | 654 | this.store.onDragenter(e, this, dropNode, enterGap) 655 | 656 | if (this.store.dropable) { 657 | if (!this.expanded && !this.isLeaf) { 658 | this.setExpand(true) 659 | } 660 | if (enterGap === -1) { 661 | dropNode.classList.add('vs-drag-over-gap-top') 662 | return 663 | } 664 | 665 | if (enterGap === 1) { 666 | dropNode.classList.add('vs-drag-over-gap-bottom') 667 | return 668 | } 669 | if (!this.isLeaf) { 670 | dropNode.classList.add('vs-drag-enter') 671 | } 672 | } 673 | }) 674 | 675 | function removeClass (dom) { 676 | if (!dom) return 677 | dom.classList.remove('vs-drag-enter') 678 | dom.classList.remove('vs-drag-over-gap-bottom') 679 | dom.classList.remove('vs-drag-over-gap-top') 680 | } 681 | 682 | dom.addEventListener('dragleave', (e) => { 683 | if (this.store.dropable) { 684 | removeClass(e.target) 685 | } 686 | }) 687 | 688 | dom.addEventListener('drop', (e) => { 689 | e.stopPropagation() 690 | this.store.onDrop(e, this, this.store.dropPostion) 691 | if (this.store.dropable) { 692 | removeClass(this.store.dropNode) 693 | const dragNode = this.store.dragNode 694 | if (dragNode && this.parent) { 695 | const data = Object.assign({}, dragNode.data) 696 | dragNode.remove() 697 | if (!data) return 698 | if (this.store.dropPostion === -1) { 699 | this.parent.insertBefore({ data }, this) 700 | this.updateCheckedParent() 701 | this.store.updateNodes() 702 | } else if (this.store.dropPostion === 1) { 703 | this.parent.insertAfter({ data }, this) 704 | this.updateCheckedParent() 705 | this.store.updateNodes() 706 | } else if (!this.isLeaf) { 707 | this.append(data) 708 | } 709 | } 710 | } 711 | }) 712 | } 713 | 714 | // 更新手风琴状态 715 | setAccordion (expand) { 716 | if (this.store.accordion && this.parent && expand) { 717 | const preExpand = this.store.expandMap[this.parent.id] 718 | if (preExpand === this) return 719 | if (preExpand) { 720 | preExpand.setExpand(false) 721 | } 722 | this.store.expandMap[this.parent.id] = this 723 | } 724 | } 725 | 726 | // 加载数据 727 | loadData (callback) { 728 | if (this.loading) return 729 | this.loading = true 730 | if (this.expandEl) { 731 | this.expandEl.classList.add('is-loading') 732 | } else if (this.loadingEl) { 733 | this.loadingEl.classList.add('is-loading') 734 | } 735 | 736 | const resolve = (children = []) => { 737 | this.loaded = true 738 | this.loading = false 739 | if (this.expandEl) { 740 | this.expandEl.classList.remove('is-loading') 741 | } else if (this.loadingEl) { 742 | this.loadingEl.classList.remove('is-loading') 743 | } 744 | 745 | if (children.length) { 746 | children.forEach(data => { 747 | this.insertChild({ 748 | data: data, 749 | store: this.store 750 | }) 751 | }) 752 | this.childNodes[0].updateCheckedParent() 753 | this.store.updateNodes() 754 | } 755 | 756 | if (callback) { 757 | callback.call(this, children) 758 | } 759 | } 760 | 761 | this.store.load(this, resolve) 762 | } 763 | 764 | // 删除节点 765 | remove () { 766 | const parent = this.parent 767 | if (!parent) return 768 | const children = parent.childNodes || [] 769 | const index = children.findIndex(d => d.id === this.id) 770 | if (index > -1) { 771 | children.splice(index, 1) 772 | } 773 | this.store.updateNodes() 774 | } 775 | 776 | // 添加节点 777 | append (data) { 778 | if (!data || typeof data !== 'object') return 779 | let olddom = this.dom 780 | if (this.childNodes.length !== 0) { 781 | olddom = null 782 | } 783 | const node = this.insertChild({ 784 | data: data, 785 | store: this.store 786 | }) 787 | this.data.children ? this.data.children.push(data) : this.data.children = [data] 788 | this.isLeaf = false 789 | if (olddom) { 790 | delete this.dom 791 | olddom.parentNode.replaceChild(this.createNode(), olddom) 792 | } 793 | node.updateCheckedParent() 794 | this.store.updateNodes() 795 | } 796 | } 797 | -------------------------------------------------------------------------------- /src/core/store.js: -------------------------------------------------------------------------------- 1 | import Node from './node' 2 | export default class TreeStore { 3 | constructor (options) { 4 | for (const option in options) { 5 | if (Object.prototype.hasOwnProperty.call(options, option)) { 6 | this[option] = options[option] 7 | } 8 | } 9 | 10 | this.nodes = [] 11 | 12 | this.dataMap = new Map() 13 | this.nodeMap = new Map() 14 | 15 | // 当前选中节点 16 | this.radioMap = {} 17 | 18 | // 当前展开节点 19 | this.expandMap = {} 20 | 21 | this.root = new Node({ 22 | data: this.data, 23 | store: this 24 | }) 25 | 26 | this.updateNodes() 27 | 28 | // 面包屑 29 | if (this.breadcrumb) { 30 | this.breadcrumb.list.push(this.root) 31 | } 32 | 33 | this.changeNodes = [] 34 | } 35 | 36 | setData (val) { 37 | this.root.childNodes = [] 38 | this.root.setData(val) 39 | this.updateNodes() 40 | } 41 | 42 | // 更新节点列表 43 | updateNodes () { 44 | this.nodes = this.flattenTreeData() 45 | this.nodesChange(this.nodes) 46 | } 47 | 48 | // 获取节点列表 49 | flattenTreeData () { 50 | const nodes = [] 51 | const dig = (val) => { 52 | nodes.push(val) 53 | if (val.childNodes && val.childNodes.length) { 54 | for (let i = 0, len = val.childNodes.length; i < len; i++) { 55 | dig(val.childNodes[i]) 56 | } 57 | } 58 | } 59 | dig(this.root) 60 | return nodes 61 | } 62 | 63 | // 根据ID获取节点 64 | getNodeById (id) { 65 | return this.dataMap.get(id) 66 | } 67 | 68 | // 获取选中节点 69 | getCheckedNodes (isTreeNode = false) { 70 | const nodes = this.nodes.filter(v => v.checked && !v.data._vsroot && this._checkVerify(v) && (!this.nocheckParent || !v.childNodes.length)) 71 | if (this.sort) { 72 | const sortNodes = nodes.sort((a, b) => a.sortId - b.sortId) 73 | if (isTreeNode) { 74 | return sortNodes 75 | } 76 | return sortNodes.map(v => v.data) 77 | } 78 | if (isTreeNode) { 79 | return nodes 80 | } 81 | return nodes.map(v => v.data) 82 | } 83 | 84 | // 设置默认选中 85 | setDefaultChecked () { 86 | this.checkedKeys.forEach(id => { 87 | const node = this.getNodeById(id) 88 | if (node) { 89 | node.setChecked(true, true) 90 | } else { 91 | console.warn('not found node by ' + id) 92 | } 93 | }) 94 | } 95 | 96 | // 验证是否已经选到最大 97 | checkMaxNodes (node) { 98 | if (!this.max) { 99 | return false 100 | } 101 | 102 | if (!node.checked && node.hasChildCount > this.max) { 103 | return true 104 | } 105 | 106 | const len = this.getCheckedNodes().length 107 | 108 | if (!node.checked && len + (node.isLeaf ? 1 : this.getUnCheckLeafsCount(node)) > this.max) { 109 | return true 110 | } 111 | 112 | return false 113 | } 114 | 115 | getUnCheckLeafsCount (node) { 116 | let count = this._checkVerify(node) && !node.checked ? 1 : 0 117 | node.childNodes.forEach(v => { 118 | count += this.getUnCheckLeafsCount(v) 119 | }) 120 | return count 121 | } 122 | 123 | // 关联判断 124 | allowEmit (check, type) { 125 | const { Y, N } = this.checkboxType 126 | if (check) { 127 | if (!Y.includes(type)) { 128 | return false 129 | } 130 | } else { 131 | if (!N.includes(type)) { 132 | return false 133 | } 134 | } 135 | return true 136 | } 137 | 138 | _checkVerify (node) { 139 | if (typeof this.checkFilter === 'function') { 140 | return this.checkFilter(node) 141 | } else if (this.checkFilterLeaf) { 142 | return node.isLeaf 143 | } else { 144 | return true 145 | } 146 | } 147 | 148 | // 节点切换选中时触发 149 | _change(node) { 150 | this.changeNodes.push(node) 151 | if (this._changeTimer) clearTimeout(this._changeTimer) 152 | this._changeTimer = setTimeout(() => { 153 | this.change(this.changeNodes) 154 | this.changeNodes = [] 155 | }, 0) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/core/utils.ts: -------------------------------------------------------------------------------- 1 | import Node from "./node" 2 | 3 | export function insterAfter(newElement: HTMLElement, targetElement: HTMLElement) { 4 | const parent = targetElement.parentNode 5 | if (!parent) { return } 6 | if (parent.lastChild === targetElement) { 7 | parent.appendChild(newElement) 8 | } else { 9 | parent.insertBefore(newElement, targetElement.nextSibling) 10 | } 11 | } 12 | 13 | export function onDragEnterGap(e: MouseEvent, treeNode: HTMLElement) { 14 | var offsetTop = treeNode.getBoundingClientRect().top 15 | var offsetHeight = treeNode.offsetHeight 16 | var pageY = e.pageY 17 | var gapHeight = 2 18 | if (pageY > offsetTop + offsetHeight - offsetHeight) { 19 | return 1 // bottom 20 | } 21 | if (pageY < offsetTop + gapHeight) { 22 | return -1 // top 23 | } 24 | return 0 25 | } 26 | 27 | export const findNearestNode = (element: HTMLElement, name: string) => { 28 | let target: any = element 29 | while (target && target?.tagName !== 'BODY') { 30 | if (target.className && target.className.includes(name)) { 31 | return target 32 | } 33 | target = target.parentNode 34 | } 35 | return null 36 | } 37 | 38 | 39 | export const parseTemplate = (name: string, ctx: Node) => { 40 | const slotName = ctx.store.slots[name] 41 | if (slotName) { 42 | const node = slotName.node.cloneNode(true) 43 | node.classList.add('vs-tree-text') 44 | node.setAttribute('tree-node-id', ctx.id) 45 | ctx.__buffer = {} 46 | 47 | var prefix = ` 48 | var ${slotName.scope} = _; 49 | ` 50 | slotName.text 51 | .replace(slotName.interpolate, (a: string, b: string) => { 52 | prefix += `_.__buffer['${a}'] = ${b};` 53 | }) 54 | 55 | // eslint-disable-next-line no-new-func 56 | const render = new Function('_', prefix) 57 | 58 | render.call(ctx, ctx) 59 | 60 | node.innerText = node.innerText.replace(slotName.interpolate, (a: any) => { 61 | return (ctx as any).__buffer[a] 62 | }).replace(/\n/g, '') 63 | 64 | return node 65 | } 66 | return false 67 | } -------------------------------------------------------------------------------- /src/less/vs-tree.less: -------------------------------------------------------------------------------- 1 | .vs-loading { 2 | min-height: 100px; 3 | background-image: url(./oval.svg); 4 | // background-size: 30px 30px; 5 | background-position: center center; 6 | background-repeat: no-repeat; 7 | } 8 | 9 | .vs-tree-node { 10 | height: 26px; 11 | cursor: pointer; 12 | color: #606266; 13 | font-size: 14px; 14 | display: flex; 15 | align-items: center; 16 | justify-content: space-between; 17 | white-space: nowrap; 18 | padding: 0 0 2px; 19 | box-sizing: border-box; 20 | 21 | &:hover { 22 | background-color: #eee; 23 | } 24 | 25 | &:first-child { 26 | .expand { 27 | &::before { 28 | height: 0; 29 | } 30 | } 31 | } 32 | } 33 | 34 | .vs-indent-unit { 35 | position: relative; 36 | display: inline-block; 37 | width: 14px; 38 | height: 14px; 39 | vertical-align: middle; 40 | 41 | &::after { 42 | content: ""; 43 | width: 0; 44 | height: 160%; 45 | position: absolute; 46 | left: 50%; 47 | border-left: 1px dashed #ddd; 48 | top: -8px; 49 | } 50 | } 51 | 52 | .vs-loading-unit.is-loading { 53 | width: 14px; 54 | height: 14px; 55 | margin-right: 5px; 56 | display: inline-block; 57 | vertical-align: middle; 58 | 59 | &::after { 60 | content: ""; 61 | width: 14px; 62 | height: 14px; 63 | display: inline-block; 64 | vertical-align: top; 65 | } 66 | } 67 | 68 | .expand, 69 | .expand-empty { 70 | width: 14px; 71 | height: 14px; 72 | line-height: 10px; 73 | display: inline-block; 74 | margin-right: 5px; 75 | color: #bbb; 76 | text-align: center; 77 | box-sizing: border-box; 78 | vertical-align: middle; 79 | } 80 | 81 | .expand { 82 | position: relative; 83 | cursor: pointer; 84 | 85 | &.vs-expand-icon { 86 | &::after { 87 | content: ""; 88 | width: 14px; 89 | height: 14px; 90 | display: inline-block; 91 | background-image: url(./expand.svg); 92 | background-size: 10px 10px; 93 | background-repeat: no-repeat; 94 | background-position: center center; 95 | transform: rotate(-90deg); 96 | transition: transform .3s; 97 | } 98 | } 99 | } 100 | 101 | .expanded { 102 | color: #bbb; 103 | 104 | &.vs-expand-icon::after { 105 | transform: rotate(0); 106 | } 107 | } 108 | 109 | .expand.is-loading, .vs-loading-unit.is-loading { 110 | &::after { 111 | background-image: url(./oval.svg); 112 | background-repeat: no-repeat; 113 | background-size: 14px 14px; 114 | border: none; 115 | color: transparent; 116 | } 117 | } 118 | 119 | .vs-indent-unit ~ .expand { 120 | &::before { 121 | content: ""; 122 | position: absolute; 123 | top: -50%; 124 | left: 50%; 125 | width: 0; 126 | height: 50%; 127 | margin-top: -25%; 128 | border-left: 1px dashed #ddd; 129 | } 130 | } 131 | 132 | .vs-tree-node:not([vs-child]) + .vs-tree-node{ 133 | .vs-indent-unit ~ .expand { 134 | &::before { 135 | display: none; 136 | } 137 | } 138 | } 139 | 140 | .vs-indent-unit ~ .expand-empty { 141 | position: relative; 142 | 143 | &::after { 144 | content: ""; 145 | position: absolute; 146 | top: 50%; 147 | left: 50%; 148 | width: 50%; 149 | margin-top: -1px; 150 | border-bottom: 1px dashed #ddd; 151 | } 152 | 153 | &::before { 154 | content: ""; 155 | position: absolute; 156 | top: -50%; 157 | left: 50%; 158 | height: 200%; 159 | border-left: 1px dashed #ddd; 160 | } 161 | } 162 | 163 | .selected { 164 | background-color: #eee; 165 | } 166 | 167 | .vs-checkbox, .vs-radio { 168 | position: relative; 169 | color: #606266; 170 | font-weight: 500; 171 | cursor: pointer; 172 | display: inline-block; 173 | white-space: nowrap; 174 | user-select: none; 175 | margin-right: 8px; 176 | vertical-align: middle; 177 | font-size: 0; 178 | } 179 | 180 | .vs-checkbox__input, .vs-radio__input { 181 | white-space: nowrap; 182 | cursor: pointer; 183 | outline: none; 184 | display: inline-block; 185 | line-height: 1; 186 | position: relative; 187 | vertical-align: middle; 188 | } 189 | 190 | .vs-checkbox__inner, .vs-radio__inner { 191 | display: inline-block; 192 | position: relative; 193 | border: 1px solid #d9d9d9; 194 | border-radius: 2px; 195 | box-sizing: border-box; 196 | width: 14px; 197 | height: 14px; 198 | background-color: #FFFFFF; 199 | z-index: 1; 200 | transition: border-color 0.25s cubic-bezier(0.71, -0.46, 0.29, 1.46), background-color 0.25s cubic-bezier(0.71, -0.46, 0.29, 1.46); 201 | } 202 | 203 | .is-indeterminate .vs-checkbox__inner::before { 204 | content: ''; 205 | position: absolute; 206 | display: block; 207 | background-color: #1989fa; 208 | height: 12px; 209 | transform: scale(0.6); 210 | left: 0; 211 | right: 0; 212 | top: 0; 213 | border-radius: 2px; 214 | } 215 | 216 | .vs-checkbox__original:checked ~ .vs-checkbox__inner { 217 | background-color: #1989fa; 218 | border-color: #1989fa; 219 | } 220 | 221 | .vs-checkbox__original:checked ~ .vs-checkbox__inner::after { 222 | transform: rotate(45deg) scaleY(1); 223 | } 224 | 225 | .vs-checkbox__inner::after { 226 | box-sizing: content-box; 227 | content: ""; 228 | border: 1px solid #FFFFFF; 229 | border-left: 0; 230 | border-top: 0; 231 | height: 7px; 232 | left: 4px; 233 | position: absolute; 234 | top: 1px; 235 | transform: rotate(45deg) scaleY(0); 236 | width: 3px; 237 | transition: all .2s cubic-bezier(.12,.4,.29,1.46) .1s; 238 | transform-origin: center; 239 | } 240 | 241 | .vs-checkbox__original:disabled ~ .vs-checkbox__inner { 242 | background-color: #edf2fc; 243 | border-color: #dcdfe6; 244 | cursor: not-allowed; 245 | } 246 | 247 | .vs-checkbox__original:checked:disabled ~ .vs-checkbox__inner:after { 248 | border-color: #c0c4cc; 249 | } 250 | 251 | .vs-checkbox__original, .vs-radio__original { 252 | opacity: 0; 253 | outline: none; 254 | position: absolute; 255 | margin: 0; 256 | width: 0; 257 | height: 0; 258 | z-index: -1; 259 | } 260 | 261 | .vs-radio__inner { 262 | border-radius: 100%; 263 | } 264 | 265 | .vs-radio__inner:after { 266 | box-sizing: content-box; 267 | content: ""; 268 | left: 3px; 269 | position: absolute; 270 | top: 3px; 271 | width: 6px; 272 | height: 6px; 273 | transform: scale(0); 274 | transition: transform .15s ease-in .05s; 275 | transform-origin: center; 276 | border-radius: 100%; 277 | } 278 | 279 | .vs-radio__original:checked ~ .vs-radio__inner { 280 | border-color: #1989fa; 281 | } 282 | 283 | .vs-radio__original:checked ~ .vs-radio__inner:after { 284 | background-color: #1989fa; 285 | transform: scale(1); 286 | } 287 | 288 | .vs-radio__original:checked:disabled ~ .vs-radio__inner:after { 289 | border-color: #c0c4cc; 290 | } 291 | 292 | .vs-icon-leaf, .vs-icon-parent { 293 | width: 14px; 294 | height: 14px; 295 | margin-right: 5px; 296 | display: inline-block; 297 | vertical-align: middle; 298 | background-image: url(./leaf.svg); 299 | background-size: 12px 12px; 300 | background-repeat: no-repeat; 301 | background-position: center; 302 | 303 | > img { 304 | width: 100%; 305 | height: 100%; 306 | } 307 | } 308 | 309 | .vs-icon-parent { 310 | background-image: url(./parent.svg); 311 | } 312 | 313 | .vs-transition { 314 | height: 0; 315 | transition: all .3s ease; 316 | overflow-y: hidden; 317 | } 318 | 319 | .vs-tree-node { 320 | &.vs-drag-enter { 321 | background-color: rgba(#1989fa, .8); 322 | color: #fff; 323 | } 324 | &.vs-drag-over-gap-top, 325 | &.vs-drag-over-gap-bottom { 326 | position: relative; 327 | &::before { 328 | content: ''; 329 | position: absolute; 330 | left: 0; 331 | width: 100%; 332 | height: 2px; 333 | background-color: #1989fa; 334 | } 335 | } 336 | 337 | &.vs-drag-over-gap-top { 338 | &::before { 339 | top: 0; 340 | } 341 | } 342 | 343 | &.vs-drag-over-gap-bottom { 344 | &::before { 345 | bottom: 0; 346 | } 347 | } 348 | } 349 | 350 | 351 | .vs-search-only-leaf { 352 | .vs-tree-inner { 353 | padding-left: 0!important; 354 | .expand-empty { 355 | display: none; 356 | } 357 | } 358 | } 359 | 360 | .vs-theme-element { 361 | .is-indeterminate .vs-checkbox__inner { 362 | background-color: #1989fa; 363 | border-color: #1989fa; 364 | } 365 | .is-indeterminate .vs-checkbox__inner::before { 366 | background-color: #fff; 367 | height: 1px; 368 | width: 50%; 369 | top: 50%; 370 | left: 50%; 371 | transform: translate(-50%, -50%) scale(1); 372 | } 373 | } 374 | 375 | // 面包屑 376 | .vs-breadcrumb { 377 | box-sizing: border-box; 378 | margin: 0; 379 | padding: 0; 380 | font-variant: tabular-nums; 381 | line-height: 1.5715; 382 | list-style: none; 383 | font-feature-settings: "tnum"; 384 | color: rgba(0,0,0,.45); 385 | font-size: 14px; 386 | font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji; 387 | 388 | & > span { 389 | display: inline-block; 390 | 391 | &:not(:last-child) { 392 | cursor: pointer; 393 | color: #1989fa; 394 | &:hover { 395 | color: rgba(#1989fa, .8); 396 | } 397 | } 398 | 399 | &:last-child { 400 | color: rgba(0,0,0,.85); 401 | } 402 | } 403 | 404 | &-separator { 405 | margin: 0 8px; 406 | color: rgba(0,0,0,.45); 407 | } 408 | } -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { version as _v } from '../package.json' 2 | import './less/vs-tree.less' 3 | import vsTree from './core' 4 | import plugin from './vue-plugin' 5 | 6 | export default vsTree 7 | 8 | // 版本号 9 | export const version = _v 10 | 11 | // Vue 插件 12 | export const install = plugin(vsTree) 13 | -------------------------------------------------------------------------------- /src/virtual-list/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * virtual list default component 3 | */ 4 | 5 | import Virtual from './virtual' 6 | 7 | export default class Vlist { 8 | constructor (opts) { 9 | this.range = null 10 | 11 | this.$el = opts.root 12 | 13 | this.$el.style.maxHeight = typeof opts.maxHeight === 'number' ? opts.maxHeight + 'px' : opts.maxHeight 14 | this.$el.style.minHeight = typeof opts.minHeight === 'number' ? opts.minHeight + 'px' : opts.minHeight 15 | this.$el.style.overflowY = 'auto' 16 | 17 | this.dataSources = opts.data 18 | 19 | this.wrapper = document.createElement('div') 20 | this.wrapper.className = 'vs-virtual-list' 21 | this.$el.appendChild(this.wrapper) 22 | 23 | this.$el.addEventListener('scroll', this.onScroll.bind(this), { 24 | passive: false 25 | }) 26 | 27 | this.keeps = opts.keeps || 20 28 | 29 | this.estimateSize = opts.estimateSize || 26 30 | 31 | this.dataKey = 'id' 32 | 33 | this.installVirtual() 34 | } 35 | 36 | // return current scroll offset 37 | getOffset () { 38 | const root = this.$el 39 | return root ? Math.ceil(root.scrollTop) : 0 40 | } 41 | 42 | // return client viewport size 43 | getClientSize () { 44 | const root = this.$el 45 | return root ? Math.ceil(root.clientHeight) : 0 46 | } 47 | 48 | // return all scroll size 49 | getScrollSize () { 50 | const root = this.$el 51 | return root ? Math.ceil(root.scrollHeight) : 0 52 | } 53 | 54 | // set current scroll position to a expectant index 55 | scrollToIndex (index) { 56 | // scroll to bottom 57 | if (index >= this.dataSources.length - 1) { 58 | this.scrollToBottom() 59 | } else { 60 | const offset = this.virtual.getOffset(index) 61 | this.scrollToOffset(offset) 62 | } 63 | } 64 | 65 | // reset all state back to initial 66 | reset () { 67 | this.virtual.destroy() 68 | this.scrollToOffset(0) 69 | this.installVirtual() 70 | } 71 | 72 | // ----------- public method end ----------- 73 | 74 | installVirtual () { 75 | this.virtual = new Virtual({ 76 | slotHeaderSize: 0, 77 | slotFooterSize: 0, 78 | keeps: this.keeps, 79 | estimateSize: this.estimateSize, 80 | buffer: Math.round(this.keeps / 3), // recommend for a third of keeps 81 | uniqueIds: this.getUniqueIdFromDataSources() 82 | }, this.onRangeChanged.bind(this)) 83 | 84 | // sync initial range 85 | this.range = this.virtual.getRange() 86 | this.render() 87 | } 88 | 89 | getUniqueIdFromDataSources () { 90 | const { dataKey } = this 91 | return this.dataSources.map((dataSource) => typeof dataKey === 'function' ? dataKey(dataSource) : dataSource[dataKey]) 92 | } 93 | 94 | // here is the rerendering entry 95 | onRangeChanged (range) { 96 | this.range = range 97 | this.render() 98 | } 99 | 100 | onScroll () { 101 | const offset = this.getOffset() 102 | const clientSize = this.getClientSize() 103 | const scrollSize = this.getScrollSize() 104 | 105 | // iOS scroll-spring-back behavior will make direction mistake 106 | if (offset < 0 || (offset + clientSize > scrollSize + 1) || !scrollSize) { 107 | return 108 | } 109 | 110 | this.virtual.handleScroll(offset) 111 | } 112 | 113 | getRenderSlots () { 114 | const { start, end } = this.range 115 | const { dataSources, dataKey } = this 116 | this.wrapper.innerHTML = '' 117 | for (let index = start; index <= end; index++) { 118 | const dataSource = dataSources[index] 119 | if (dataSource) { 120 | const uniqueKey = typeof dataKey === 'function' ? dataKey(dataSource) : dataSource[dataKey] 121 | if (typeof uniqueKey === 'string' || typeof uniqueKey === 'number') { 122 | const dom = dataSource.createNode() 123 | if (dataSource.store.onlySearchLeaf) { 124 | dom.classList.add('vs-search-only-leaf') 125 | } else { 126 | dom.classList.remove('vs-search-only-leaf') 127 | } 128 | 129 | if (dataSource.store.isSearch && dataSource.store.searchRender) { 130 | const searchNode = dataSource.store.searchRender(dataSource, dom.cloneNode(true)) 131 | if (!(searchNode instanceof HTMLElement)) { 132 | throw Error('searchRender must return HTMLElement') 133 | } 134 | this.wrapper.appendChild(searchNode) 135 | } else { 136 | this.wrapper.appendChild(dom) 137 | } 138 | } else { 139 | console.warn(`Cannot get the data-key '${dataKey}' from data-sources.`) 140 | } 141 | } else { 142 | console.warn(`Cannot get the index '${index}' from data-sources.`) 143 | } 144 | } 145 | } 146 | 147 | update (data) { 148 | this.dataSources = data 149 | this.wrapper.innerHTML = '' 150 | this.virtual.updateParam('uniqueIds', this.getUniqueIdFromDataSources()) 151 | this.virtual.handleDataSourcesChange() 152 | } 153 | 154 | render () { 155 | const { padFront, padBehind } = this.range 156 | 157 | const paddingStyle = `${padFront}px 0px ${padBehind}px` 158 | 159 | this.wrapper.style.padding = paddingStyle 160 | 161 | this.getRenderSlots() 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/virtual-list/virtual.js: -------------------------------------------------------------------------------- 1 | /** 2 | * virtual list core calculating center 3 | */ 4 | 5 | const DIRECTION_TYPE = { 6 | FRONT: 'FRONT', // scroll up or left 7 | BEHIND: 'BEHIND' // scroll down or right 8 | } 9 | const CALC_TYPE = { 10 | INIT: 'INIT', 11 | FIXED: 'FIXED', 12 | DYNAMIC: 'DYNAMIC' 13 | } 14 | const LEADING_BUFFER = 2 15 | 16 | export default class Virtual { 17 | constructor (param, callUpdate) { 18 | this.init(param, callUpdate) 19 | } 20 | 21 | init (param, callUpdate) { 22 | // param data 23 | this.param = param 24 | this.callUpdate = callUpdate 25 | 26 | // size data 27 | this.sizes = new Map() 28 | this.firstRangeTotalSize = 0 29 | this.firstRangeAverageSize = 0 30 | this.lastCalcIndex = 0 31 | this.fixedSizeValue = 0 32 | this.calcType = CALC_TYPE.INIT 33 | 34 | // scroll data 35 | this.offset = 0 36 | this.direction = '' 37 | 38 | // range data 39 | this.range = Object.create(null) 40 | if (param) { 41 | this.checkRange(0, param.keeps - 1) 42 | } 43 | 44 | // benchmark test data 45 | // this.__bsearchCalls = 0 46 | // this.__getIndexOffsetCalls = 0 47 | } 48 | 49 | destroy () { 50 | this.init(null, null) 51 | } 52 | 53 | // return current render range 54 | getRange () { 55 | const range = Object.create(null) 56 | range.start = this.range.start 57 | range.end = this.range.end 58 | range.padFront = this.range.padFront 59 | range.padBehind = this.range.padBehind 60 | return range 61 | } 62 | 63 | isBehind () { 64 | return this.direction === DIRECTION_TYPE.BEHIND 65 | } 66 | 67 | isFront () { 68 | return this.direction === DIRECTION_TYPE.FRONT 69 | } 70 | 71 | // return start index offset 72 | getOffset (start) { 73 | return (start < 1 ? 0 : this.getIndexOffset(start)) + this.param.slotHeaderSize 74 | } 75 | 76 | updateParam (key, value) { 77 | if (this.param && (key in this.param)) { 78 | // if uniqueIds change, find out deleted id and remove from size map 79 | if (key === 'uniqueIds') { 80 | this.sizes.forEach((v, key) => { 81 | if (!value.includes(key)) { 82 | this.sizes.delete(key) 83 | } 84 | }) 85 | } 86 | this.param[key] = value 87 | } 88 | } 89 | 90 | // in some special situation (e.g. length change) we need to update in a row 91 | // try goiong to render next range by a leading buffer according to current direction 92 | handleDataSourcesChange () { 93 | let start = this.range.start 94 | 95 | if (this.isFront()) { 96 | start = start - LEADING_BUFFER 97 | } else if (this.isBehind()) { 98 | start = start + LEADING_BUFFER 99 | } 100 | 101 | start = Math.max(start, 0) 102 | 103 | this.updateRange(this.range.start, this.getEndByStart(start)) 104 | } 105 | 106 | // when slot size change, we also need force update 107 | handleSlotSizeChange () { 108 | this.handleDataSourcesChange() 109 | } 110 | 111 | // calculating range on scroll 112 | handleScroll (offset) { 113 | this.direction = offset < this.offset ? DIRECTION_TYPE.FRONT : DIRECTION_TYPE.BEHIND 114 | this.offset = offset 115 | 116 | if (!this.param) { 117 | return 118 | } 119 | 120 | if (this.direction === DIRECTION_TYPE.FRONT) { 121 | this.handleFront() 122 | } else if (this.direction === DIRECTION_TYPE.BEHIND) { 123 | this.handleBehind() 124 | } 125 | } 126 | 127 | // ----------- public method end ----------- 128 | 129 | handleFront () { 130 | const overs = this.getScrollOvers() 131 | // should not change range if start doesn't exceed overs 132 | if (overs > this.range.start) { 133 | return 134 | } 135 | 136 | // move up start by a buffer length, and make sure its safety 137 | const start = Math.max(overs - this.param.buffer, 0) 138 | this.checkRange(start, this.getEndByStart(start)) 139 | } 140 | 141 | handleBehind () { 142 | const overs = this.getScrollOvers() 143 | // range should not change if scroll overs within buffer 144 | if (overs < this.range.start + this.param.buffer) { 145 | return 146 | } 147 | 148 | this.checkRange(overs, this.getEndByStart(overs)) 149 | } 150 | 151 | // return the pass overs according to current scroll offset 152 | getScrollOvers () { 153 | // if slot header exist, we need subtract its size 154 | const offset = this.offset - this.param.slotHeaderSize 155 | if (offset <= 0) { 156 | return 0 157 | } 158 | 159 | // if is fixed type, that can be easily 160 | if (this.isFixedType()) { 161 | return Math.floor(offset / this.fixedSizeValue) 162 | } 163 | 164 | let low = 0 165 | let middle = 0 166 | let middleOffset = 0 167 | let high = this.param.uniqueIds.length 168 | 169 | while (low <= high) { 170 | // this.__bsearchCalls++ 171 | middle = low + Math.floor((high - low) / 2) 172 | middleOffset = this.getIndexOffset(middle) 173 | 174 | if (middleOffset === offset) { 175 | return middle 176 | } else if (middleOffset < offset) { 177 | low = middle + 1 178 | } else if (middleOffset > offset) { 179 | high = middle - 1 180 | } 181 | } 182 | 183 | return low > 0 ? --low : 0 184 | } 185 | 186 | // return a scroll offset from given index, can efficiency be improved more here? 187 | // although the call frequency is very high, its only a superposition of numbers 188 | getIndexOffset (givenIndex) { 189 | if (!givenIndex) { 190 | return 0 191 | } 192 | 193 | let offset = 0 194 | let indexSize = 0 195 | for (let index = 0; index < givenIndex; index++) { 196 | // this.__getIndexOffsetCalls++ 197 | indexSize = this.sizes.get(this.param.uniqueIds[index]) 198 | offset = offset + (typeof indexSize === 'number' ? indexSize : this.getEstimateSize()) 199 | } 200 | 201 | // remember last calculate index 202 | this.lastCalcIndex = Math.max(this.lastCalcIndex, givenIndex - 1) 203 | this.lastCalcIndex = Math.min(this.lastCalcIndex, this.getLastIndex()) 204 | 205 | return offset 206 | } 207 | 208 | // is fixed size type 209 | isFixedType () { 210 | return this.calcType === CALC_TYPE.FIXED 211 | } 212 | 213 | // return the real last index 214 | getLastIndex () { 215 | return this.param.uniqueIds.length - 1 216 | } 217 | 218 | // in some conditions range is broke, we need correct it 219 | // and then decide whether need update to next range 220 | checkRange (start, end) { 221 | const keeps = this.param.keeps 222 | const total = this.param.uniqueIds.length 223 | 224 | // datas less than keeps, render all 225 | if (total <= keeps) { 226 | start = 0 227 | end = this.getLastIndex() 228 | } else if (end - start < keeps - 1) { 229 | // if range length is less than keeps, corrent it base on end 230 | start = end - keeps + 1 231 | } 232 | 233 | if (this.range.start !== start) { 234 | this.updateRange(start, end) 235 | } 236 | } 237 | 238 | // setting to a new range and rerender 239 | updateRange (start, end) { 240 | this.range.start = start 241 | this.range.end = end 242 | this.range.padFront = this.getPadFront() 243 | this.range.padBehind = this.getPadBehind() 244 | this.callUpdate(this.getRange()) 245 | } 246 | 247 | // return end base on start 248 | getEndByStart (start) { 249 | const theoryEnd = start + this.param.keeps - 1 250 | const truelyEnd = Math.min(theoryEnd, this.getLastIndex()) 251 | return truelyEnd 252 | } 253 | 254 | // return total front offset 255 | getPadFront () { 256 | if (this.isFixedType()) { 257 | return this.fixedSizeValue * this.range.start 258 | } else { 259 | return this.getIndexOffset(this.range.start) 260 | } 261 | } 262 | 263 | // return total behind offset 264 | getPadBehind () { 265 | const end = this.range.end 266 | const lastIndex = this.getLastIndex() 267 | 268 | if (this.isFixedType()) { 269 | return (lastIndex - end) * this.fixedSizeValue 270 | } 271 | 272 | // if it's all calculated, return the exactly offset 273 | if (this.lastCalcIndex === lastIndex) { 274 | return this.getIndexOffset(lastIndex) - this.getIndexOffset(end) 275 | } else { 276 | // if not, use a estimated value 277 | return (lastIndex - end) * this.getEstimateSize() 278 | } 279 | } 280 | 281 | // get the item estimate size 282 | getEstimateSize () { 283 | return this.isFixedType() ? this.fixedSizeValue : (this.firstRangeAverageSize || this.param.estimateSize) 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/vue-plugin/index.js: -------------------------------------------------------------------------------- 1 | export default (VsTree) => { 2 | return (Vue, options = {}) => { 3 | Vue.component('vs-tree', { 4 | props: { 5 | data: Array | Object, 6 | options: Object, 7 | async: Boolean, 8 | animation: Boolean, 9 | draggable: Boolean, 10 | dropable: Boolean, 11 | hideRoot: Boolean, 12 | showCheckbox: Boolean, 13 | checkboxType: Object, 14 | showRadio: Boolean, 15 | radioType: String, 16 | showLine: Boolean, 17 | showIcon: Boolean, 18 | onlyShowLeafIcon: Boolean, 19 | highlightCurrent: Boolean, 20 | accordion: Boolean, 21 | nocheckParent: Boolean, 22 | sort: Boolean, 23 | checkOnClickNode: Boolean, 24 | checkFilterLeaf: Boolean, 25 | strictLeaf: Boolean, 26 | rootName: String, 27 | max: Number, 28 | lazy: Boolean, 29 | load: Function, 30 | format: Function, 31 | disabledKeys: Array, 32 | checkedKeys: Array, 33 | expandKeys: Array, 34 | keyword: String, 35 | expandClass: String, 36 | theme: String, 37 | breadcrumb: Object, 38 | virtual: Object, 39 | expandLevel: { 40 | type: Number, 41 | default: 1 42 | }, 43 | indent: { 44 | type: Number, 45 | default: 10 46 | }, 47 | showCount: { 48 | type: Number, 49 | default: 20 50 | }, 51 | itemHeight: { 52 | type: Number, 53 | default: 26 54 | }, 55 | 56 | maxHeight: String, 57 | minHeight: String, 58 | 59 | beforeCheck: Function, 60 | renderContent: Function, 61 | checkFilter: Function, 62 | searchFilter: Function, 63 | searchRender: Function, 64 | onDragstart: Function, 65 | onDragenter: Function, 66 | onDrop: Function 67 | }, 68 | data () { 69 | return { 70 | tree: {} 71 | } 72 | }, 73 | watch: { 74 | max (newVal = 0) { 75 | this.setMaxValue(newVal) 76 | }, 77 | keyword (newVal) { 78 | this.filter(newVal) 79 | } 80 | }, 81 | mounted () { 82 | this.$nextTick(() => { 83 | this._vsinit() 84 | }) 85 | }, 86 | methods: { 87 | _vsinit () { 88 | console.time('render:tree') 89 | this.tree.tree = new VsTree(this.$refs.tree, Object.assign({}, options, this.$props, { 90 | ...this.options, 91 | data: this.data, 92 | click: (event, node) => { 93 | this.$emit('click', event, node) 94 | }, 95 | check: (event, node) => { 96 | this.$emit('check', event, node) 97 | }, 98 | change: (node) => { 99 | this.$emit('change', node) 100 | }, 101 | contextmenu: (event, node) => { 102 | this.$emit('node-contextmenu', event, node) 103 | }, 104 | limitAlert: () => { 105 | this.$emit('limit-alert') 106 | } 107 | })) 108 | console.timeEnd('render:tree') 109 | }, 110 | getNodeById (id) { 111 | return this.tree.tree.getNodeById(id) 112 | }, 113 | getCheckedNodes () { 114 | return this.tree.tree.getCheckedNodes() 115 | }, 116 | filter (value) { 117 | return this.tree.tree.filter(value) 118 | }, 119 | setMaxValue (value = 0) { 120 | this.tree.tree.setMaxValue(value) 121 | } 122 | }, 123 | render (h) { 124 | return h('div', { 125 | ref: 'tree' 126 | }, this.$slots.default) 127 | } 128 | }) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "outDir": "./lib", // 输出目录 5 | "sourceMap": false, // 是否生成sourceMap 6 | "target": "esnext", // 编译目标 7 | "module": "esnext", // 模块类型 8 | "moduleResolution": "node", 9 | "allowJs": true, // 是否编辑js文件 10 | "strict": true, // 严格模式 11 | "noUnusedLocals": true, // 未使用变量报错 12 | "experimentalDecorators": true, // 启动装饰器 13 | "resolveJsonModule": true, // 加载json 14 | "esModuleInterop": true, 15 | "removeComments": false, // 删除注释 16 | "declaration": true, // 生成定义文件 17 | "declarationMap": false, // 生成定义sourceMap 18 | "declarationDir": "./types", // 定义文件输出目录 19 | "lib": ["esnext", "dom"], // 导入库类型定义 20 | "types": ["node"] // 导入指定类型包 21 | }, 22 | "include": [ 23 | "src/" // 导入目录 24 | ] 25 | } -------------------------------------------------------------------------------- /types/src/breadcrumb/breadcrumb-item.d.ts: -------------------------------------------------------------------------------- 1 | export default class BreadcrumbItem { 2 | constructor(node: any, parent: any); 3 | node: any; 4 | data: any; 5 | store: any; 6 | parent: any; 7 | renderIcon: any; 8 | renderLink: any; 9 | renderSeparator: any; 10 | createDom(): HTMLSpanElement; 11 | createIcon(): false | HTMLSpanElement; 12 | createLink(breads: any, index: any, last: any): HTMLSpanElement; 13 | createSeparator(): HTMLSpanElement; 14 | } 15 | -------------------------------------------------------------------------------- /types/src/breadcrumb/index.d.ts: -------------------------------------------------------------------------------- 1 | export interface BreadcrumbOptions { 2 | el: string | HTMLElement; 3 | icon?: Function | string; 4 | link?: Function | string; 5 | separator?: Function | string; 6 | change?: Function; 7 | } 8 | export default class Breadcrumb { 9 | store: any; 10 | list: never[]; 11 | options: BreadcrumbOptions; 12 | constructor(options: BreadcrumbOptions); 13 | get current(): any; 14 | renderBreadcrumb(): void; 15 | } 16 | -------------------------------------------------------------------------------- /types/src/core/index.d.ts: -------------------------------------------------------------------------------- 1 | export default class Tree { 2 | constructor(selector: any, ops: any); 3 | $el: HTMLElement; 4 | interpolate: RegExp; 5 | _data: any; 6 | nodes: any[]; 7 | itemHeight: any; 8 | showCount: any; 9 | maxHeight: any; 10 | minHeight: any; 11 | data: any[]; 12 | keyword: string; 13 | searchFilter: any; 14 | ready: any; 15 | $$breadcrumb: Breadcrumb | undefined; 16 | store: TreeStore; 17 | _init(): void; 18 | vlist: Vlist | undefined; 19 | _render(update?: boolean): void; 20 | _keywordFilter(data: any): void; 21 | _hasKeyword(v: any): any; 22 | _checkFilter(v: any): any; 23 | filter(keyword: string | undefined, onlySearchLeaf: any): any[]; 24 | getNodeById(id: any): any; 25 | getCheckedNodes(...args: any[]): any[]; 26 | setMaxValue(value?: number): void; 27 | scrollToIndex(index?: number): void; 28 | clearCheckedNodes(): void; 29 | } 30 | import Breadcrumb from "../breadcrumb"; 31 | import TreeStore from "./store"; 32 | import Vlist from "../virtual-list"; 33 | -------------------------------------------------------------------------------- /types/src/core/node.d.ts: -------------------------------------------------------------------------------- 1 | export default class Node { 2 | constructor(ops: any); 3 | id: number; 4 | checked: any; 5 | expanded: boolean; 6 | indeterminate: boolean; 7 | visbile: boolean; 8 | disabled: any; 9 | loaded: boolean; 10 | isLeaf: boolean; 11 | level: any; 12 | childNodes: any[]; 13 | store: any; 14 | parent: any; 15 | originData: any; 16 | __buffer: {}; 17 | data: any; 18 | initData(): void; 19 | createNode(): HTMLDivElement; 20 | dom: HTMLDivElement | undefined; 21 | createInner(): HTMLDivElement; 22 | loadingEl: HTMLSpanElement | undefined; 23 | cusmtomNode(name: any, info: any): any; 24 | createContent(): any; 25 | createExpandEmpty(): HTMLSpanElement; 26 | createExpand(): HTMLSpanElement; 27 | expandEl: HTMLSpanElement | undefined; 28 | createCheckbox(): HTMLLabelElement; 29 | radioNode: HTMLInputElement | undefined; 30 | checkboxNode: HTMLInputElement | undefined; 31 | checkboxEl: HTMLInputElement | undefined; 32 | handleCheckChange(e: any): void; 33 | createText(): any; 34 | createIcon(): HTMLSpanElement; 35 | setData(data: any): void; 36 | insertChild(child: any, index: any): any; 37 | insertBefore(child: any, ref: any): void; 38 | insertAfter(child: any, ref: any): void; 39 | updateExpand(expand: any): void; 40 | updateChecked(check: any, isInitDefault: any): void; 41 | sortId: number | undefined; 42 | updateCheckedParent(_checked: any, isInitDefault: any): void; 43 | updateRadioChecked(checked: any, isInitDefault: any): void; 44 | setChecked(checked: any, isInitDefault: any): void; 45 | setDisabled(disabled?: boolean): void; 46 | setExpand(expand: any, noUpdate: any): void; 47 | storeUpdate(): void; 48 | createAnimation(): void; 49 | transitionNode: HTMLDivElement | undefined; 50 | createDragable(dom: any): void; 51 | setAccordion(expand: any): void; 52 | loadData(callback: any): void; 53 | loading: boolean | undefined; 54 | remove(): void; 55 | append(data: any): void; 56 | } 57 | -------------------------------------------------------------------------------- /types/src/core/store.d.ts: -------------------------------------------------------------------------------- 1 | export default class TreeStore { 2 | constructor(options: any); 3 | nodes: any[]; 4 | dataMap: Map; 5 | nodeMap: Map; 6 | radioMap: {}; 7 | expandMap: {}; 8 | root: Node; 9 | changeNodes: any[]; 10 | setData(val: any): void; 11 | updateNodes(): void; 12 | flattenTreeData(): any[]; 13 | getNodeById(id: any): any; 14 | getCheckedNodes(isTreeNode?: boolean): any[]; 15 | setDefaultChecked(): void; 16 | checkMaxNodes(node: any): boolean; 17 | getUnCheckLeafsCount(node: any): number; 18 | allowEmit(check: any, type: any): boolean; 19 | _checkVerify(node: any): any; 20 | _change(node: any): void; 21 | _changeTimer: NodeJS.Timeout | undefined; 22 | } 23 | import Node from "./node"; 24 | -------------------------------------------------------------------------------- /types/src/core/utils.d.ts: -------------------------------------------------------------------------------- 1 | import Node from "./node"; 2 | export declare function insterAfter(newElement: HTMLElement, targetElement: HTMLElement): void; 3 | export declare function onDragEnterGap(e: MouseEvent, treeNode: HTMLElement): 1 | -1 | 0; 4 | export declare const findNearestNode: (element: HTMLElement, name: string) => any; 5 | export declare const parseTemplate: (name: string, ctx: Node) => any; 6 | -------------------------------------------------------------------------------- /types/src/main.d.ts: -------------------------------------------------------------------------------- 1 | export default vsTree; 2 | export const version: string; 3 | export const install: (Vue: any, options?: {}) => void; 4 | import vsTree from "./core"; 5 | -------------------------------------------------------------------------------- /types/src/virtual-list/index.d.ts: -------------------------------------------------------------------------------- 1 | export default class Vlist { 2 | constructor(opts: any); 3 | range: any; 4 | $el: any; 5 | dataSources: any; 6 | wrapper: HTMLDivElement; 7 | keeps: any; 8 | estimateSize: any; 9 | dataKey: string; 10 | getOffset(): number; 11 | getClientSize(): number; 12 | getScrollSize(): number; 13 | scrollToIndex(index: any): void; 14 | reset(): void; 15 | installVirtual(): void; 16 | virtual: Virtual | undefined; 17 | getUniqueIdFromDataSources(): any; 18 | onRangeChanged(range: any): void; 19 | onScroll(): void; 20 | getRenderSlots(): void; 21 | update(data: any): void; 22 | render(): void; 23 | } 24 | import Virtual from "./virtual"; 25 | -------------------------------------------------------------------------------- /types/src/virtual-list/virtual.d.ts: -------------------------------------------------------------------------------- 1 | export default class Virtual { 2 | constructor(param: any, callUpdate: any); 3 | init(param: any, callUpdate: any): void; 4 | param: any; 5 | callUpdate: any; 6 | sizes: Map | undefined; 7 | firstRangeTotalSize: number | undefined; 8 | firstRangeAverageSize: number | undefined; 9 | lastCalcIndex: any; 10 | fixedSizeValue: number | undefined; 11 | calcType: string | undefined; 12 | offset: any; 13 | direction: string | undefined; 14 | range: any; 15 | destroy(): void; 16 | getRange(): any; 17 | isBehind(): boolean; 18 | isFront(): boolean; 19 | getOffset(start: any): any; 20 | updateParam(key: any, value: any): void; 21 | handleDataSourcesChange(): void; 22 | handleSlotSizeChange(): void; 23 | handleScroll(offset: any): void; 24 | handleFront(): void; 25 | handleBehind(): void; 26 | getScrollOvers(): number; 27 | getIndexOffset(givenIndex: any): number; 28 | isFixedType(): boolean; 29 | getLastIndex(): number; 30 | checkRange(start: any, end: any): void; 31 | updateRange(start: any, end: any): void; 32 | getEndByStart(start: any): number; 33 | getPadFront(): number; 34 | getPadBehind(): number; 35 | getEstimateSize(): any; 36 | } 37 | -------------------------------------------------------------------------------- /types/src/vue-plugin/index.d.ts: -------------------------------------------------------------------------------- 1 | declare function _default(VsTree: any): (Vue: any, options?: {}) => void; 2 | export default _default; 3 | --------------------------------------------------------------------------------